From 2df43a7620e20dd786ae8d33e5830d79a18e1e56 Mon Sep 17 00:00:00 2001 From: Bryant Date: Sun, 5 Jul 2026 21:15:45 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=F0=9F=94=92=20(core):=20Fail=20closed=20on?= =?UTF-8?q?=20UNSPECIFIED=20policy=20verdict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The proto3 zero-value decision "unspecified" ("no decision rendered") was in _ALLOW_DECISIONS, so a non-authoritative verdict was treated as an authoritative allow and proceeded under enforce — Python failed open where Node fails closed. Drop "unspecified" from _ALLOW_DECISIONS so it falls through to the existing fail-closed path (deny under enforce, allow under observe), matching the Node SDK (AAASM-4166). Update the known-good-under-enforce assertion that codified the old behavior. Refs AAASM-4166 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R7vqjjo5nrebYNt8WnCNbz --- agent_assembly/core/runtime_interceptor.py | 21 ++++++++++++--------- test/unit/core/test_runtime_interceptor.py | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/agent_assembly/core/runtime_interceptor.py b/agent_assembly/core/runtime_interceptor.py index 4eb7ded..819d3de 100644 --- a/agent_assembly/core/runtime_interceptor.py +++ b/agent_assembly/core/runtime_interceptor.py @@ -55,10 +55,11 @@ # Native decisions that authoritatively permit the tool to proceed. ``deny`` and # ``pending`` are handled explicitly; only these are treated as an allow. Anything -# else — an unknown string, an empty value, or a missing ``decision`` key — is not -# an authoritative allow and must fail closed under ``enforce`` (AAASM-4014); the -# runtime remains the authority on redaction, so ``redact`` proceeds here. -_ALLOW_DECISIONS = frozenset({"allow", "redact", "unspecified"}) +# else — the proto3 zero value ``unspecified`` ("no decision rendered"), an unknown +# string, an empty value, or a missing ``decision`` key — is not an authoritative +# allow and must fail closed under ``enforce`` (AAASM-4014, AAASM-4166), matching the +# Node SDK; the runtime remains the authority on redaction, so ``redact`` proceeds. +_ALLOW_DECISIONS = frozenset({"allow", "redact"}) def _local_posture_is_enforce(enforcement_mode: str | None) -> bool: @@ -247,11 +248,13 @@ def check_tool_start( * ``"deny"`` → ``{"status": "deny", "reason": ...}``. * ``"pending"`` → ``{"status": "pending", "reason": ...}`` so the adapter's existing approval path runs. - * ``"allow"`` / ``"redact"`` / ``"unspecified"`` → ``{"status": "allow"}``. - The runtime redacts authoritatively; this layer never redacts. - * A raising ``query_policy`` or an error-sentinel ``decision`` - (``query_failed`` / ``channel_closed`` / ``shutdown``) → ``deny`` under - ``enforce`` (fail closed, AAASM-3106), else ``allow`` (fail open). + * ``"allow"`` / ``"redact"`` → ``{"status": "allow"}``. The runtime + redacts authoritatively; this layer never redacts. + * A raising ``query_policy``, the proto3 zero value ``"unspecified"`` + ("no decision rendered"), an error-sentinel ``decision`` + (``query_failed`` / ``channel_closed`` / ``shutdown``), or any + unrecognized decision → ``deny`` under ``enforce`` (fail closed, + AAASM-3106 / AAASM-4166, matching Node), else ``allow`` (fail open). Before any of the above, the live op-control kill switch (AAASM-3491) is consulted when an ``op_id`` is supplied and a subscriber is wired: a diff --git a/test/unit/core/test_runtime_interceptor.py b/test/unit/core/test_runtime_interceptor.py index d2c7603..e19a4a2 100644 --- a/test/unit/core/test_runtime_interceptor.py +++ b/test/unit/core/test_runtime_interceptor.py @@ -615,10 +615,11 @@ def query_policy(self, *_args: Any, **_kwargs: Any) -> dict[str, str]: assert result["status"] == "deny" -@pytest.mark.parametrize("decision", ["allow", "redact", "unspecified"]) +@pytest.mark.parametrize("decision", ["allow", "redact"]) def test_known_good_decisions_allow_under_enforce(decision: str) -> None: """Authoritative allow verdicts still proceed under enforce; the runtime - remains the authority on redaction, so ``redact`` proceeds here.""" + remains the authority on redaction, so ``redact`` proceeds here. ``unspecified`` + is no longer among them — it fails closed (AAASM-4166).""" interceptor = RuntimeQueryInterceptor(_FakeGatewayClient(), _FakeRuntimeClient(decision), "agent-001", enforce=True) result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i") From a203a2ddbe6346d8ee8922c518b461d46780e293 Mon Sep 17 00:00:00 2001 From: Bryant Date: Sun, 5 Jul 2026 21:18:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E2=9C=85=20(core):=20Assert=20UNSPECIFIED?= =?UTF-8?q?=20verdict=20fails=20closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression test for AAASM-4166: an "unspecified" runtime decision denies under enforce (fail-closed, was allow) and still proceeds under observe (fail-open), matching the treatment of any other non-authoritative verdict. Refs AAASM-4166 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01R7vqjjo5nrebYNt8WnCNbz --- test/unit/core/test_runtime_interceptor.py | 25 ++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/test/unit/core/test_runtime_interceptor.py b/test/unit/core/test_runtime_interceptor.py index e19a4a2..77eff0e 100644 --- a/test/unit/core/test_runtime_interceptor.py +++ b/test/unit/core/test_runtime_interceptor.py @@ -627,6 +627,31 @@ def test_known_good_decisions_allow_under_enforce(decision: str) -> None: assert result == {"status": "allow"} +def test_unspecified_decision_denies_under_enforce() -> None: + """AAASM-4166: the proto3 zero value ``unspecified`` ("no decision rendered") + is not an authoritative allow — it must fail closed under enforce (deny), + matching the Node SDK, rather than being folded onto allow as before.""" + interceptor = RuntimeQueryInterceptor( + _FakeGatewayClient(), _FakeRuntimeClient("unspecified"), "agent-001", enforce=True + ) + + result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i") + + assert result["status"] == "deny" + + +def test_unspecified_decision_allows_under_observe() -> None: + """Under observe the ``unspecified`` verdict still proceeds (fail open), like + any other non-authoritative decision.""" + interceptor = RuntimeQueryInterceptor( + _FakeGatewayClient(), _FakeRuntimeClient("unspecified"), "agent-001", enforce=False + ) + + result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i") + + assert result == {"status": "allow"} + + def test_unknown_decision_allows_under_observe() -> None: """Under observe the unknown-decision path still proceeds (fail open).""" interceptor = RuntimeQueryInterceptor(