Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion agent_assembly/adapters/openai_agents/patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -440,6 +444,37 @@
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.

Expand Down Expand Up @@ -475,7 +510,7 @@
return None


def _wrap_on_invoke_tool(tool_obj: Any, callback_handler: Any) -> None:

Check failure on line 513 in agent_assembly/adapters/openai_agents/patch.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this function to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=ai-agent-assembly_python-sdk&issues=AZ9vMpxgz61ewOm5W2HR&open=AZ9vMpxgz61ewOm5W2HR&pullRequest=273
"""Wrap a single tool instance's ``on_invoke_tool`` coroutine with governance."""
original_invoke = getattr(tool_obj, "on_invoke_tool", None)
if not callable(original_invoke):
Expand Down Expand Up @@ -520,7 +555,9 @@
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,
Expand All @@ -533,6 +570,18 @@
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):
Expand Down
104 changes: 104 additions & 0 deletions test/unit/adapters/openai_agents/test_patch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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