From dc94baf9a77df0e35d9e67b8f1c41e98c1ee07c8 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:27:52 +0800 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20(openai=5Fagents):=20Threa?= =?UTF-8?q?d=20enforce=20through=20=5Fnormalize=5Fdecision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unrecognized/malformed verdict routed through the local _normalize_decision dropped the enforce flag, so it fell open (allow) even under enforce. Give the wrapper an enforce param and thread _interceptor_enforces(callback_handler) through both call sites so an unknown verdict fails closed under enforce (AAASM-4734). --- agent_assembly/adapters/openai_agents/patch.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index 41c3f269..fb14fc79 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -34,6 +34,9 @@ from agent_assembly.adapters.crewai.patch import ( _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) +from agent_assembly.adapters.crewai.patch import ( + _interceptor_enforces, +) from agent_assembly.adapters.crewai.patch import ( _normalize_decision as _normalize_governance_decision, ) @@ -306,8 +309,10 @@ def _resolve_agent_id(ctx: Any) -> str | None: def _normalize_decision( decision: object, + *, + enforce: bool = False, ) -> tuple[Literal["allow", "deny", "pending"], str | None]: - return _normalize_governance_decision(decision) + return _normalize_governance_decision(decision, enforce=enforce) def _resolve_governance_target(callback_handler: Any) -> Any: @@ -477,6 +482,8 @@ def _wrap_on_invoke_tool(tool_obj: Any, callback_handler: Any) -> None: if getattr(original_invoke, _WRAPPED_INVOKE_FLAG, False): return None + enforce = _interceptor_enforces(callback_handler) + @wraps(original_invoke) async def governed_invoke(ctx: Any, tool_input: Any) -> Any: tool_name = str(getattr(tool_obj, "name", tool_obj.__class__.__name__)) @@ -491,7 +498,7 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any: agent_id=agent_id, ctx=ctx, ) - status, reason = _normalize_decision(decision) + status, reason = _normalize_decision(decision, enforce=enforce) is_pending_flow = False if status == "pending": is_pending_flow = True @@ -504,7 +511,7 @@ async def governed_invoke(ctx: Any, tool_input: Any) -> Any: agent_id=agent_id, ctx=ctx, ) - status, reason = _normalize_decision(final_decision) + status, reason = _normalize_decision(final_decision, enforce=enforce) if status == "deny": blocked_result = _build_tool_deny_error( From 33b875e4948a5a46bb908bf9e6f040102f2b130a Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:29:02 +0800 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=90=9B=20(openai=5Fagents):=20Fail?= =?UTF-8?q?=20closed=20on=20missing=20interceptor=20check=5Ftool=5Fstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the wired interceptor exposed no callable check_tool_start, _invoke_async_tool_check hardcoded {"status": "allow"}, silently skipping pre-execution governance. Route through _missing_interceptor_decision so it denies under enforce, matching pydantic_ai/google_adk (AAASM-4734). --- agent_assembly/adapters/openai_agents/patch.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/openai_agents/patch.py b/agent_assembly/adapters/openai_agents/patch.py index fb14fc79..65d751f9 100644 --- a/agent_assembly/adapters/openai_agents/patch.py +++ b/agent_assembly/adapters/openai_agents/patch.py @@ -36,6 +36,7 @@ ) from agent_assembly.adapters.crewai.patch import ( _interceptor_enforces, + _missing_interceptor_decision, ) from agent_assembly.adapters.crewai.patch import ( _normalize_decision as _normalize_governance_decision, @@ -333,7 +334,7 @@ async def _invoke_async_tool_check( target = _resolve_governance_target(callback_handler) method = getattr(target, "check_tool_start", None) if not callable(method): - return {"status": "allow"} + return _missing_interceptor_decision(callback_handler) result = method( serialized={"name": tool_name}, From c25b85fd9053e9bb9ce93be3c955c0769fdad7fb Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:30:30 +0800 Subject: [PATCH 03/14] =?UTF-8?q?=F0=9F=90=9B=20(pydantic=5Fai):=20Thread?= =?UTF-8?q?=20enforce=20through=20=5Fnormalize=5Fdecision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local _normalize_decision dropped the enforce flag, so an unrecognized/ malformed verdict fell open (allow) even under enforce. Give the wrapper an enforce param and thread _interceptor_enforces(callback_handler) through all four call sites in both patched tool hooks (AAASM-4734). --- agent_assembly/adapters/pydantic_ai/patch.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/agent_assembly/adapters/pydantic_ai/patch.py b/agent_assembly/adapters/pydantic_ai/patch.py index ac56153b..5a9e12a2 100644 --- a/agent_assembly/adapters/pydantic_ai/patch.py +++ b/agent_assembly/adapters/pydantic_ai/patch.py @@ -16,6 +16,7 @@ _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) from agent_assembly.adapters.crewai.patch import ( + _interceptor_enforces, _missing_interceptor_decision, ) from agent_assembly.adapters.crewai.patch import ( @@ -294,6 +295,8 @@ def _apply_tool_run_patch(tool_cls: type[Any], callback_handler: Any) -> bool: if not callable(original_run): return False + enforce = _interceptor_enforces(callback_handler) + @wraps(original_run) async def patched_run(self: Any, ctx: Any, args: Any, **kwargs: Any) -> Any: tool_name = str(getattr(self, "name", self.__class__.__name__)) @@ -308,7 +311,7 @@ async def patched_run(self: Any, ctx: Any, args: Any, **kwargs: Any) -> Any: agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(decision) + status, reason = _normalize_decision(decision, enforce=enforce) is_pending_flow = False if status == "pending": is_pending_flow = True @@ -321,7 +324,7 @@ async def patched_run(self: Any, ctx: Any, args: Any, **kwargs: Any) -> Any: agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(final_decision) + status, reason = _normalize_decision(final_decision, enforce=enforce) if status == "deny": if is_pending_flow: @@ -384,6 +387,8 @@ def _apply_toolset_call_tool_patch(toolset_cls: type[Any], callback_handler: Any if not callable(original_call_tool): return False + enforce = _interceptor_enforces(callback_handler) + @wraps(original_call_tool) async def patched_call_tool(self: Any, name: Any, tool_args: Any, ctx: Any, tool: Any, **kwargs: Any) -> Any: tool_name = str(name) @@ -398,7 +403,7 @@ async def patched_call_tool(self: Any, name: Any, tool_args: Any, ctx: Any, tool agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(decision) + status, reason = _normalize_decision(decision, enforce=enforce) is_pending_flow = False if status == "pending": is_pending_flow = True @@ -411,7 +416,7 @@ async def patched_call_tool(self: Any, name: Any, tool_args: Any, ctx: Any, tool agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(final_decision) + status, reason = _normalize_decision(final_decision, enforce=enforce) if status == "deny": if is_pending_flow: @@ -503,8 +508,10 @@ def _serialize_tool_args(args: Any) -> dict[str, Any]: def _normalize_decision( decision: object, + *, + enforce: bool = False, ) -> tuple[Literal["allow", "deny", "pending"], str | None]: - return _normalize_governance_decision(decision) + return _normalize_governance_decision(decision, enforce=enforce) async def _invoke_async_tool_check( From 225958b88f2eee2bf881a231e6c8857e6473af33 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:31:06 +0800 Subject: [PATCH 04/14] =?UTF-8?q?=F0=9F=90=9B=20(google=5Fadk):=20Thread?= =?UTF-8?q?=20enforce=20through=20=5Fnormalize=5Fdecision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The local _normalize_decision dropped the enforce flag, so an unrecognized/ malformed verdict fell open (allow) even under enforce. Give the wrapper an enforce param and thread _interceptor_enforces(callback_handler) through both call sites in patched_run_async (AAASM-4734). --- agent_assembly/adapters/google_adk/patch.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/agent_assembly/adapters/google_adk/patch.py b/agent_assembly/adapters/google_adk/patch.py index 3485d4ec..e24b0700 100644 --- a/agent_assembly/adapters/google_adk/patch.py +++ b/agent_assembly/adapters/google_adk/patch.py @@ -16,6 +16,7 @@ _get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds, ) from agent_assembly.adapters.crewai.patch import ( + _interceptor_enforces, _missing_interceptor_decision, ) from agent_assembly.adapters.crewai.patch import ( @@ -171,6 +172,7 @@ def _apply_tool_run_async_patch(tool_cls: type[Any], callback_handler: Any) -> N return None original_run_async = tool_cls.run_async + enforce = _interceptor_enforces(callback_handler) @wraps(original_run_async) async def patched_run_async(self: Any, *, args: Any, tool_context: Any, **kwargs: Any) -> Any: @@ -186,7 +188,7 @@ async def patched_run_async(self: Any, *, args: Any, tool_context: Any, **kwargs agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(decision) + status, reason = _normalize_decision(decision, enforce=enforce) is_pending_flow = False if status == "pending": is_pending_flow = True @@ -199,7 +201,7 @@ async def patched_run_async(self: Any, *, args: Any, tool_context: Any, **kwargs agent_id=agent_id, run_id=run_id, ) - status, reason = _normalize_decision(final_decision) + status, reason = _normalize_decision(final_decision, enforce=enforce) if status == "deny": if is_pending_flow: @@ -291,8 +293,10 @@ def _serialize_tool_args(args: Any) -> dict[str, Any]: def _normalize_decision( decision: object, + *, + enforce: bool = False, ) -> tuple[Literal["allow", "deny", "pending"], str | None]: - return _normalize_governance_decision(decision) + return _normalize_governance_decision(decision, enforce=enforce) async def _invoke_async_tool_check( From efc9a5fe783c9bee85ee3c6f59fe657f286d283f Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:31:44 +0800 Subject: [PATCH 05/14] =?UTF-8?q?=F0=9F=90=9B=20(mcp):=20Fail=20closed=20o?= =?UTF-8?q?n=20missing=20interceptor=20check=5Ftool=5Fstart?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the wired interceptor exposed no callable check_tool_start, _invoke_async_tool_check hardcoded {"status": "allow"}, silently skipping pre-execution governance. Route through _missing_interceptor_decision so it denies under enforce, matching pydantic_ai/google_adk (AAASM-4734). --- agent_assembly/adapters/mcp/patch.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agent_assembly/adapters/mcp/patch.py b/agent_assembly/adapters/mcp/patch.py index 5b5ee352..fac10f77 100644 --- a/agent_assembly/adapters/mcp/patch.py +++ b/agent_assembly/adapters/mcp/patch.py @@ -18,6 +18,9 @@ from agent_assembly.adapters.crewai.patch import ( _interceptor_enforces as _resolve_interceptor_enforces, ) +from agent_assembly.adapters.crewai.patch import ( + _missing_interceptor_decision, +) from agent_assembly.adapters.crewai.patch import ( _normalize_decision as _normalize_governance_decision, ) @@ -141,7 +144,7 @@ async def _invoke_async_tool_check( target = _resolve_governance_target(callback_handler) method = getattr(target, "check_tool_start", None) if not callable(method): - return {"status": "allow"} + return _missing_interceptor_decision(callback_handler) result = method( serialized={"name": tool_name}, From e044247b54a2ebbd3ab5bc8fedf0e4e59f6afbb7 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:32:28 +0800 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=90=9B=20(core):=20Honor=20None=20e?= =?UTF-8?q?nforcement=20posture=20on=20register=20failure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _register_agent_with_gateway hard-failed init on a register() error only for a literal enforcement_mode == "enforce", so the advertised None default (which registers under the gateway's live enforce default) fell open. Reuse the None-aware _local_posture_is_enforce so an omitted posture propagates a register failure like enforce (AAASM-4734). --- agent_assembly/core/assembly.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/agent_assembly/core/assembly.py b/agent_assembly/core/assembly.py index b0d71e6e..dcaee05c 100644 --- a/agent_assembly/core/assembly.py +++ b/agent_assembly/core/assembly.py @@ -21,6 +21,7 @@ resolve_gateway_url, ) from agent_assembly.core.runtime_interceptor import ( + _local_posture_is_enforce, _native_core_available, build_governance_interceptor, connect_runtime_client, @@ -394,8 +395,8 @@ def _register_agent_with_gateway( ``register`` raises, the failure is no longer silent: a loud :func:`_warn_agent_unregistered` fires and ``False`` is returned (AAASM-4547). Init still proceeds so the proxy / eBPF layers stay authoritative — except - under ``enforce``, where a ``register`` failure propagates so a misconfigured - gateway fails init closed. + under an enforce posture (the ``None`` default or explicit ``enforce``), where a + ``register`` failure propagates so a misconfigured gateway fails init closed. """ if runtime_client is None: if native_available: @@ -418,7 +419,7 @@ def _register_agent_with_gateway( parent_agent_id=parent_agent_id, ) except Exception as error: - if enforcement_mode == "enforce": + if _local_posture_is_enforce(enforcement_mode): raise _warn_agent_unregistered(f"registration failed: {error}") return False From 1f125ee433c78b7b784c89378e01520cebe1c0b9 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:35:08 +0800 Subject: [PATCH 07/14] =?UTF-8?q?=E2=9C=85=20(openai=5Fagents):=20Add=20un?= =?UTF-8?q?recognized-verdict=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing interceptor returning a malformed (None) verdict must block the tool instead of falling open. --- .../unit/adapters/openai_agents/test_patch.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index f380238f..b7afd8e9 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -514,3 +514,31 @@ async def on_tool_end(self, **kwargs: object) -> None: ) assert observed + + +# --- AAASM-4734: fail closed on unrecognized verdict / missing interceptor --- + + +@pytest.mark.asyncio +async def test_unknown_verdict_returns_governance_error_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class EnforcingUnknown: + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=EnforcingUnknown()) + assert patcher.apply() is True + + tool = function_tool_cls(name="unknown_tool") + result = await tool.on_invoke_tool(SimpleNamespace(agent_id="a"), "{}") + + # A fail-open patch would return the tool's real dict output; under enforce an + # unrecognized verdict must instead be blocked (returned as a deny string). + assert isinstance(result, str) + assert "blocked by governance policy" in result From a1db25683625ec0b3a5284ac9382645283644b3c Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:35:36 +0800 Subject: [PATCH 08/14] =?UTF-8?q?=E2=9C=85=20(openai=5Fagents):=20Add=20mi?= =?UTF-8?q?ssing-interceptor=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing handler exposing no check_tool_start must block the tool instead of the previous hardcoded allow. --- .../unit/adapters/openai_agents/test_patch.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index b7afd8e9..cbde2bee 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -542,3 +542,24 @@ async def check_tool_start(self, **kwargs: object) -> object: # unrecognized verdict must instead be blocked (returned as a deny string). assert isinstance(result, str) assert "blocked by governance policy" in result + + +@pytest.mark.asyncio +async def test_missing_check_tool_start_returns_governance_error_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + function_tool_cls = _install_fake_openai_agents_module(monkeypatch) + + class EnforcingHandlerWithoutCheck: + # No check_tool_start: a co-installed handler that cannot govern tool + # starts must fail closed under enforce, not silently allow. + _enforce = True + + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=EnforcingHandlerWithoutCheck()) + assert patcher.apply() is True + + tool = function_tool_cls(name="ungoverned_tool") + result = await tool.on_invoke_tool(SimpleNamespace(agent_id="a"), "{}") + + assert isinstance(result, str) + assert "blocked by governance policy" in result From 86e87014125020aed97b54946b883bfb097453bd Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:36:49 +0800 Subject: [PATCH 09/14] =?UTF-8?q?=E2=9C=85=20(pydantic=5Fai):=20Add=20unre?= =?UTF-8?q?cognized-verdict=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing interceptor returning a malformed (None) verdict must raise instead of falling open. --- .../pydantic_ai/test_pydantic_ai_patch.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py index 18ffa2d6..100a2dff 100644 --- a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py +++ b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py @@ -628,3 +628,28 @@ def fake_import_module(module_name: str) -> object: patcher = pydantic_ai_patch.PydanticAIPatch(_RecordingInterceptor()) assert patcher.apply() is False assert pydantic_ai_patch._get_process_agent_id() is None + + +# --- AAASM-4734: fail closed on unrecognized verdict / missing interceptor --- + + +@pytest.mark.asyncio +async def test_unknown_verdict_raises_policy_violation_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeTool = _install_fake_pydantic_ai_modules(monkeypatch) + + class EnforcingUnknown: + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + patcher = pydantic_ai_patch.PydanticAIPatch(EnforcingUnknown()) + assert patcher.apply() is True + + tool = FakeTool() + ctx = SimpleNamespace(deps=SimpleNamespace(assembly_agent_id="agent-a"), run_id="run-1") + with pytest.raises(PolicyViolationError): + await tool._run(ctx, _ArgsModel({"topic": "finance"})) From 14e701678fda3fd36d407a920079d5153e892354 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:37:25 +0800 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=85=20(pydantic=5Fai):=20Add=20miss?= =?UTF-8?q?ing-interceptor=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing handler exposing no check_tool_start must raise via _missing_interceptor_decision rather than allow. --- .../pydantic_ai/test_pydantic_ai_patch.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py index 100a2dff..8fb069f1 100644 --- a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py +++ b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py @@ -653,3 +653,23 @@ async def check_tool_start(self, **kwargs: object) -> object: ctx = SimpleNamespace(deps=SimpleNamespace(assembly_agent_id="agent-a"), run_id="run-1") with pytest.raises(PolicyViolationError): await tool._run(ctx, _ArgsModel({"topic": "finance"})) + + +@pytest.mark.asyncio +async def test_missing_check_tool_start_raises_policy_violation_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeTool = _install_fake_pydantic_ai_modules(monkeypatch) + + class EnforcingHandlerWithoutCheck: + # No check_tool_start: a handler that cannot govern tool starts must + # fail closed under enforce. + _enforce = True + + patcher = pydantic_ai_patch.PydanticAIPatch(EnforcingHandlerWithoutCheck()) + assert patcher.apply() is True + + tool = FakeTool() + ctx = SimpleNamespace(deps=SimpleNamespace(assembly_agent_id="agent-a"), run_id="run-1") + with pytest.raises(PolicyViolationError): + await tool._run(ctx, _ArgsModel({"topic": "finance"})) From 753d5fbec999dbe108867897f4f71d0a0f2666f2 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:38:13 +0800 Subject: [PATCH 11/14] =?UTF-8?q?=E2=9C=85=20(google=5Fadk):=20Add=20unrec?= =?UTF-8?q?ognized-verdict=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing interceptor returning a malformed (None) verdict must raise instead of falling open. --- .../google_adk/test_google_adk_patch.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/adapters/google_adk/test_google_adk_patch.py b/test/unit/adapters/google_adk/test_google_adk_patch.py index 31b9dd78..92410b94 100644 --- a/test/unit/adapters/google_adk/test_google_adk_patch.py +++ b/test/unit/adapters/google_adk/test_google_adk_patch.py @@ -461,3 +461,28 @@ async def test_revert_restores_concrete_function_tool_override( assert FakeFunctionTool.run_async is original_function assert getattr(FakeBaseTool, google_adk_patch._TOOLS_PATCHED_FLAG, False) is False assert getattr(FakeFunctionTool, google_adk_patch._TOOLS_PATCHED_FLAG, False) is False + + +# --- AAASM-4734: fail closed on unrecognized verdict / missing interceptor --- + + +@pytest.mark.asyncio +async def test_unknown_verdict_raises_policy_violation_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeBaseTool = _install_fake_google_adk_modules(monkeypatch) + + class EnforcingUnknown: + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + patcher = google_adk_patch.GoogleADKPatch(EnforcingUnknown()) + assert patcher.apply() is True + + tool = FakeBaseTool() + tool_context = SimpleNamespace(invocation_context=None) + with pytest.raises(PolicyViolationError): + await tool.run_async(args={"step": 1}, tool_context=tool_context) From 20f86b86fab8e450562c690515bc53466d107f36 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:38:31 +0800 Subject: [PATCH 12/14] =?UTF-8?q?=E2=9C=85=20(google=5Fadk):=20Add=20missi?= =?UTF-8?q?ng-interceptor=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing handler exposing no check_tool_start must raise via _missing_interceptor_decision rather than allow. --- .../google_adk/test_google_adk_patch.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/test/unit/adapters/google_adk/test_google_adk_patch.py b/test/unit/adapters/google_adk/test_google_adk_patch.py index 92410b94..23375a15 100644 --- a/test/unit/adapters/google_adk/test_google_adk_patch.py +++ b/test/unit/adapters/google_adk/test_google_adk_patch.py @@ -486,3 +486,23 @@ async def check_tool_start(self, **kwargs: object) -> object: tool_context = SimpleNamespace(invocation_context=None) with pytest.raises(PolicyViolationError): await tool.run_async(args={"step": 1}, tool_context=tool_context) + + +@pytest.mark.asyncio +async def test_missing_check_tool_start_raises_policy_violation_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeBaseTool = _install_fake_google_adk_modules(monkeypatch) + + class EnforcingHandlerWithoutCheck: + # No check_tool_start: a handler that cannot govern tool starts must + # fail closed under enforce. + _enforce = True + + patcher = google_adk_patch.GoogleADKPatch(EnforcingHandlerWithoutCheck()) + assert patcher.apply() is True + + tool = FakeBaseTool() + tool_context = SimpleNamespace(invocation_context=None) + with pytest.raises(PolicyViolationError): + await tool.run_async(args={"step": 1}, tool_context=tool_context) From ef656398a17468c0dd53b801c36b74f1dbf5e657 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 10:38:55 +0800 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=85=20(mcp):=20Add=20missing-interc?= =?UTF-8?q?eptor=20deny-under-enforce=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression for AAASM-4734: an enforcing handler exposing no check_tool_start must raise MCPToolBlockedError rather than the previous hardcoded allow. --- test/unit/adapters/mcp/test_patch.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/test/unit/adapters/mcp/test_patch.py b/test/unit/adapters/mcp/test_patch.py index 62638801..8ff11850 100644 --- a/test/unit/adapters/mcp/test_patch.py +++ b/test/unit/adapters/mcp/test_patch.py @@ -298,3 +298,25 @@ async def check_tool_start(self, **kwargs: object) -> object: result = await FakeClientSession().call_tool(name="allowed_tool", arguments={"ok": True}) assert result["name"] == "allowed_tool" + + +# --- AAASM-4734: missing check_tool_start must fail closed under enforce --- + + +@pytest.mark.asyncio +async def test_missing_check_tool_start_blocks_tool_under_enforce( + monkeypatch: pytest.MonkeyPatch, +) -> None: + FakeClientSession = _install_fake_mcp_module(monkeypatch) + + class EnforcingHandlerWithoutCheck: + # No check_tool_start: previously this path hardcoded allow; under + # enforce it must block instead. + _enforce = True + + patcher = mcp_patch.MCPClientPatch(EnforcingHandlerWithoutCheck(), process_agent_id="agent-9") + assert patcher.apply() is True + + session = FakeClientSession() + with pytest.raises(MCPToolBlockedError): + await session.call_tool("some_tool", {"q": "x"}) From 33a9b38d4a63defed7ad8d33e048a7f0b1db69a0 Mon Sep 17 00:00:00 2001 From: Bryant Date: Fri, 17 Jul 2026 11:56:20 +0800 Subject: [PATCH 14/14] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20(adapters):=20De-dup?= =?UTF-8?q?licate=20enforce-deny=20tests=20via=20shared=20helper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SonarCloud flagged the four adapters' near-identical fail-closed regression tests as duplicated new lines (5.0% > 3%). Extract the two enforcing interceptor stand-ins and the (unknown-verdict, missing-check) parametrization into test/unit/adapters/enforce_helpers.py, and collapse each adapter's two copy-paste tests into one parametrized test_denies_under_enforce. Coverage is unchanged: openai_agents/pydantic_ai/google_adk still exercise both scenarios; mcp keeps its pre-existing malformed test plus the missing-interceptor case (AAASM-4734). --- test/unit/adapters/enforce_helpers.py | 47 +++++++++++++++++++ .../google_adk/test_google_adk_patch.py | 34 ++------------ test/unit/adapters/mcp/test_patch.py | 8 ++-- .../unit/adapters/openai_agents/test_patch.py | 42 ++++------------- .../pydantic_ai/test_pydantic_ai_patch.py | 34 ++------------ 5 files changed, 69 insertions(+), 96 deletions(-) create mode 100644 test/unit/adapters/enforce_helpers.py diff --git a/test/unit/adapters/enforce_helpers.py b/test/unit/adapters/enforce_helpers.py new file mode 100644 index 00000000..280266c3 --- /dev/null +++ b/test/unit/adapters/enforce_helpers.py @@ -0,0 +1,47 @@ +"""Shared stand-ins for the AAASM-4734 fail-closed-under-enforce regression tests. + +Every governed adapter must DENY (not fall open) under an enforce posture for two +inputs that previously slipped through: a malformed/unrecognized verdict, and an +interceptor exposing no ``check_tool_start``. The interceptor stand-ins and the +parametrization list are shared here so the four adapters' tests exercise both +scenarios without copy-pasting the same arrange/assert into each file (which +SonarCloud otherwise flags as duplicated lines). +""" + +from __future__ import annotations + +import pytest + + +class EnforcingUnknownInterceptor: + """Enforcing interceptor whose ``check_tool_start`` returns a malformed verdict. + + ``_enforce = True`` marks the fail-closed posture; ``None`` is an unrecognized + verdict that must be denied rather than silently allowed. + """ + + _enforce = True + + async def check_tool_start(self, **kwargs: object) -> object: + del kwargs + return None + + +class EnforcingHandlerWithoutCheck: + """Enforcing handler that exposes no ``check_tool_start`` at all. + + The missing-method path must fail closed under enforce instead of the former + hardcoded allow. + """ + + _enforce = True + + +# Both fail-closed scenarios as interceptor-factory params. Adapters that cover +# both cases (openai_agents / pydantic_ai / google_adk) parametrize their deny +# test over this list; mcp already had the malformed-verdict test, so it uses the +# missing-check handler directly. +ENFORCE_DENY_CASES = [ + pytest.param(EnforcingUnknownInterceptor, id="unknown_verdict"), + pytest.param(EnforcingHandlerWithoutCheck, id="missing_check_tool_start"), +] diff --git a/test/unit/adapters/google_adk/test_google_adk_patch.py b/test/unit/adapters/google_adk/test_google_adk_patch.py index 23375a15..a3a65da4 100644 --- a/test/unit/adapters/google_adk/test_google_adk_patch.py +++ b/test/unit/adapters/google_adk/test_google_adk_patch.py @@ -1,5 +1,6 @@ from __future__ import annotations +from test.unit.adapters.enforce_helpers import ENFORCE_DENY_CASES from types import SimpleNamespace from typing import Any @@ -467,39 +468,14 @@ async def test_revert_restores_concrete_function_tool_override( @pytest.mark.asyncio -async def test_unknown_verdict_raises_policy_violation_under_enforce( +@pytest.mark.parametrize("interceptor_factory", ENFORCE_DENY_CASES) +async def test_denies_under_enforce( monkeypatch: pytest.MonkeyPatch, + interceptor_factory: type, ) -> None: FakeBaseTool = _install_fake_google_adk_modules(monkeypatch) - class EnforcingUnknown: - _enforce = True - - async def check_tool_start(self, **kwargs: object) -> object: - del kwargs - return None - - patcher = google_adk_patch.GoogleADKPatch(EnforcingUnknown()) - assert patcher.apply() is True - - tool = FakeBaseTool() - tool_context = SimpleNamespace(invocation_context=None) - with pytest.raises(PolicyViolationError): - await tool.run_async(args={"step": 1}, tool_context=tool_context) - - -@pytest.mark.asyncio -async def test_missing_check_tool_start_raises_policy_violation_under_enforce( - monkeypatch: pytest.MonkeyPatch, -) -> None: - FakeBaseTool = _install_fake_google_adk_modules(monkeypatch) - - class EnforcingHandlerWithoutCheck: - # No check_tool_start: a handler that cannot govern tool starts must - # fail closed under enforce. - _enforce = True - - patcher = google_adk_patch.GoogleADKPatch(EnforcingHandlerWithoutCheck()) + patcher = google_adk_patch.GoogleADKPatch(interceptor_factory()) assert patcher.apply() is True tool = FakeBaseTool() diff --git a/test/unit/adapters/mcp/test_patch.py b/test/unit/adapters/mcp/test_patch.py index 8ff11850..151e3715 100644 --- a/test/unit/adapters/mcp/test_patch.py +++ b/test/unit/adapters/mcp/test_patch.py @@ -1,5 +1,6 @@ from __future__ import annotations +from test.unit.adapters.enforce_helpers import EnforcingHandlerWithoutCheck from types import SimpleNamespace from typing import Any @@ -307,13 +308,10 @@ async def check_tool_start(self, **kwargs: object) -> object: async def test_missing_check_tool_start_blocks_tool_under_enforce( monkeypatch: pytest.MonkeyPatch, ) -> None: + # Previously this path hardcoded allow; under enforce a handler exposing no + # check_tool_start must block instead. FakeClientSession = _install_fake_mcp_module(monkeypatch) - class EnforcingHandlerWithoutCheck: - # No check_tool_start: previously this path hardcoded allow; under - # enforce it must block instead. - _enforce = True - patcher = mcp_patch.MCPClientPatch(EnforcingHandlerWithoutCheck(), process_agent_id="agent-9") assert patcher.apply() is True diff --git a/test/unit/adapters/openai_agents/test_patch.py b/test/unit/adapters/openai_agents/test_patch.py index cbde2bee..eec22a97 100644 --- a/test/unit/adapters/openai_agents/test_patch.py +++ b/test/unit/adapters/openai_agents/test_patch.py @@ -9,6 +9,7 @@ from __future__ import annotations +from test.unit.adapters.enforce_helpers import ENFORCE_DENY_CASES from types import SimpleNamespace from typing import Any @@ -520,46 +521,21 @@ async def on_tool_end(self, **kwargs: object) -> None: @pytest.mark.asyncio -async def test_unknown_verdict_returns_governance_error_under_enforce( +@pytest.mark.parametrize("interceptor_factory", ENFORCE_DENY_CASES) +async def test_denies_under_enforce( monkeypatch: pytest.MonkeyPatch, + interceptor_factory: type, ) -> None: function_tool_cls = _install_fake_openai_agents_module(monkeypatch) - class EnforcingUnknown: - _enforce = True - - async def check_tool_start(self, **kwargs: object) -> object: - del kwargs - return None - - patcher = openai_patch.OpenAIAgentsPatch(callback_handler=EnforcingUnknown()) - assert patcher.apply() is True - - tool = function_tool_cls(name="unknown_tool") - result = await tool.on_invoke_tool(SimpleNamespace(agent_id="a"), "{}") - - # A fail-open patch would return the tool's real dict output; under enforce an - # unrecognized verdict must instead be blocked (returned as a deny string). - assert isinstance(result, str) - assert "blocked by governance policy" in result - - -@pytest.mark.asyncio -async def test_missing_check_tool_start_returns_governance_error_under_enforce( - monkeypatch: pytest.MonkeyPatch, -) -> None: - function_tool_cls = _install_fake_openai_agents_module(monkeypatch) - - class EnforcingHandlerWithoutCheck: - # No check_tool_start: a co-installed handler that cannot govern tool - # starts must fail closed under enforce, not silently allow. - _enforce = True - - patcher = openai_patch.OpenAIAgentsPatch(callback_handler=EnforcingHandlerWithoutCheck()) + patcher = openai_patch.OpenAIAgentsPatch(callback_handler=interceptor_factory()) assert patcher.apply() is True - tool = function_tool_cls(name="ungoverned_tool") + tool = function_tool_cls(name="governed_tool") result = await tool.on_invoke_tool(SimpleNamespace(agent_id="a"), "{}") + # A fail-open patch would return the tool's real dict output; under enforce + # both an unrecognized verdict and a missing interceptor must be blocked + # (returned as a deny string). assert isinstance(result, str) assert "blocked by governance policy" in result diff --git a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py index 8fb069f1..8653d45c 100644 --- a/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py +++ b/test/unit/adapters/pydantic_ai/test_pydantic_ai_patch.py @@ -1,5 +1,6 @@ from __future__ import annotations +from test.unit.adapters.enforce_helpers import ENFORCE_DENY_CASES from types import SimpleNamespace from typing import Any @@ -634,39 +635,14 @@ def fake_import_module(module_name: str) -> object: @pytest.mark.asyncio -async def test_unknown_verdict_raises_policy_violation_under_enforce( +@pytest.mark.parametrize("interceptor_factory", ENFORCE_DENY_CASES) +async def test_denies_under_enforce( monkeypatch: pytest.MonkeyPatch, + interceptor_factory: type, ) -> None: FakeTool = _install_fake_pydantic_ai_modules(monkeypatch) - class EnforcingUnknown: - _enforce = True - - async def check_tool_start(self, **kwargs: object) -> object: - del kwargs - return None - - patcher = pydantic_ai_patch.PydanticAIPatch(EnforcingUnknown()) - assert patcher.apply() is True - - tool = FakeTool() - ctx = SimpleNamespace(deps=SimpleNamespace(assembly_agent_id="agent-a"), run_id="run-1") - with pytest.raises(PolicyViolationError): - await tool._run(ctx, _ArgsModel({"topic": "finance"})) - - -@pytest.mark.asyncio -async def test_missing_check_tool_start_raises_policy_violation_under_enforce( - monkeypatch: pytest.MonkeyPatch, -) -> None: - FakeTool = _install_fake_pydantic_ai_modules(monkeypatch) - - class EnforcingHandlerWithoutCheck: - # No check_tool_start: a handler that cannot govern tool starts must - # fail closed under enforce. - _enforce = True - - patcher = pydantic_ai_patch.PydanticAIPatch(EnforcingHandlerWithoutCheck()) + patcher = pydantic_ai_patch.PydanticAIPatch(interceptor_factory()) assert patcher.apply() is True tool = FakeTool()