diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 65d751f9..5789d2c8 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -54,6 +54,10 @@ _EDGE_EMITTER: Any = None _MAX_AUDIT_RESULT_CHARS = 2000 _MAX_DELEGATION_REASON_CHARS = 256 +# Deny reason when the pre-execution check itself faults (e.g. GatewayError) +# under enforce: the SDK is a security control, so a governance-layer fault must +# fail closed rather than run the tool ungoverned (AAASM-4782). +_GOVERNANCE_FAULT_DENY_REASON = "Governance check failed; denied under enforce." # The shipped framework is the top-level ``agents`` package (openai-agents), # NOT ``openai.agents`` (which does not exist). See AAASM-3528. _OPENAI_AGENTS_MODULE = "agents" @@ -440,6 +444,37 @@ async def _record_async_tool_result( await recorded +async def _record_denied_tool_result( + callback_handler: Any, + *, + tool_name: str, + tool_input: Any, + result: object, + agent_id: str | None, + ctx: Any, +) -> None: + """Record a denied tool result best-effort, never letting the audit re-enter the run path. + + A decided deny is final and must be returned to the model regardless of + audit outcome. This audit runs *inside* ``governed_invoke``'s governance-error + handler scope, where a raised ``AssemblyError`` would be caught and fall + through to run the very tool that was just denied — silently downgrading the + deny to an allow. Swallow any failure here so the deny always stands + (AAASM-4782). + """ + try: + await _record_async_tool_result( + callback_handler, + tool_name=tool_name, + tool_input=tool_input, + result=result, + agent_id=agent_id, + ctx=ctx, + ) + except Exception: + return None + + def _is_governance_error(error: Exception) -> bool: """Check if error is a governance-specific error that should be handled. @@ -520,7 +555,9 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any: reason=reason, is_pending_rejection=is_pending_flow, ) - await _record_async_tool_result( + # Guard the audit so its failure cannot re-enter this handler and + # downgrade a decided deny to an allow (AAASM-4782). + await _record_denied_tool_result( callback_handler, tool_name=tool_name, tool_input=tool_input, @@ -533,6 +570,18 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any: governance_failed = _is_governance_error(error) if not governance_failed: raise + # A governance-layer fault during the pre-execution check. Under + # enforce the SDK is a security control: fail closed by denying the + # call rather than running the tool ungoverned — matching every other + # adapter, which never swallows a governance error into an allow. + # Under observe/disabled fall through to run the tool (fail-open by + # design), preserving the dry-run/hermetic posture (AAASM-4782). + if enforce: + return _build_tool_deny_error( + tool_name=tool_name, + reason=_GOVERNANCE_FAULT_DENY_REASON, + is_pending_rejection=False, + ) result = original_invoke(ctx, tool_input) if inspect.isawaitable(result): diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index eec22a97..6f1ca5b9 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -539,3 +539,107 @@ async def test_denies_under_enforce( # (returned as a deny string). assert isinstance(result, str) assert "blocked by governance policy" in result + + +# --- AAASM-4782: fail closed on governance error under enforce; never downgrade a deny --- + + +@pytest.mark.asyncio +async def test_governance_error_under_enforce_blocks_tool( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """S1/S2: a governance error (e.g. GatewayError) from ``check_tool_start`` must + BLOCK the tool under enforce, not silently run it ungoverned. + + The former handler swallowed any ``AssemblyError`` and fell through to the + original invoke, so a transient governance fault ran the tool with no policy + check — contradicting every other adapter. Under enforce the SDK is a + security control and must fail closed (AAASM-4782).""" + from agent_assembly.exceptions import GatewayError + + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class EnforcingRaisingInterceptor: + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + raise GatewayError("gateway unavailable") + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=EnforcingRaisingInterceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="enforced_tool") + result = await tool.on_invoke_tool(SimpleNamespace(agent_id="agent-enforce"), '{"x": 1}') + + # A fail-open patch would return the tool's real dict output; under enforce a + # governance fault must be blocked (returned as a deny string). + assert isinstance(result, str) + assert "blocked by governance policy" in result + + +@pytest.mark.asyncio +async def test_governance_error_under_observe_still_fails_open( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A governance error under observe/disabled (no enforce flag) keeps the + existing fail-open behavior: the tool runs. This is the negative control that + proves the enforce gate — not a blanket block — decides the fix (AAASM-4782).""" + from agent_assembly.exceptions import GatewayError + + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class ObservingRaisingInterceptor: + _enforce = False + + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + raise GatewayError("gateway temporarily unavailable") + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=ObservingRaisingInterceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="observe_tool") + result = await tool.on_invoke_tool(SimpleNamespace(agent_id="agent-observe"), '{"x": 1}') + + assert isinstance(result, dict) + assert result["name"] == "observe_tool" + + +@pytest.mark.asyncio +async def test_deny_is_not_downgraded_when_audit_raises_governance_error( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """S6: a decided deny must stand even if recording the deny raises an + ``AssemblyError``. + + The audit ran inside the governance-error handler's scope, so a raised + ``AssemblyError`` from ``record_result`` was caught and fell through to run + the very tool that was just denied — silently downgrading DENY to ALLOW. The + deny is final; the audit is guarded (AAASM-4782).""" + from agent_assembly.exceptions import GatewayError + + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class DenyThenAuditFailsInterceptor: + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> dict[str, str]: + del kwargs + return {"status": "deny", "reason": "policy deny"} + + async def record_result(self, **kwargs: object) -> None: + del kwargs + raise GatewayError("audit backend down") + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=DenyThenAuditFailsInterceptor()) + assert patcher.apply() is True + + tool = function_tool_cls(name="deny_audit_tool") + result = await tool.on_invoke_tool(SimpleNamespace(agent_id="agent-deny-audit"), '{"x": 1}') + + # The deny must survive the audit fault: a downgrade would return the tool's + # real dict output. + assert isinstance(result, str) + assert "policy deny" in result + assert "blocked by governance policy" in result