1212``query_policy``. The runtime is the authority: it redacts in place, so this
1313layer only ever *blocks* on an explicit ``deny`` and otherwise proceeds.
1414
15- Fail-open is preserved at two levels:
16-
17- * When the native extension is missing or no runtime socket is reachable,
18- :func:`build_governance_interceptor` returns the bare ``GatewayClient``
19- unchanged — no ``check_tool_start`` is present and the adapters proceed
20- exactly as before.
21- * When a runtime *is* connected, the native ``query_policy`` already returns
22- ``decision="allow"`` on QueryFailed / ChannelClosed / Shutdown, so a
23- transient runtime outage proceeds rather than blocks.
15+ Failure posture is governed by ``enforcement_mode`` (AAASM-3106):
16+
17+ * Under ``enforce``, the SDK is a security control and **fails closed**. When the
18+ native extension is missing the bare client is returned (no native authority
19+ exists to consult — see :func:`build_governance_interceptor`), but once the
20+ native extension is present every other failure denies: an unreachable runtime
21+ socket yields a deny-all interceptor, a raising ``query_policy`` maps to
22+ ``deny``, and a native ``decision`` that is itself an error sentinel
23+ (``query_failed`` / ``channel_closed``) maps to ``deny`` rather than allow.
24+ * Under ``observe`` / ``disabled`` (or when no mode is supplied), the SDK is a
25+ dry-run / hermetic-test layer and **fails open**: an unreachable runtime
26+ returns the bare client unchanged, a raising or error ``query_policy``
27+ proceeds, exactly as before.
28+
29+ The runtime remains the authority on *redaction* (it redacts in place); this
30+ layer only ever decides allow / deny / pending.
2431"""
2532
2633from __future__ import annotations
3138
3239ENV_RUNTIME_SOCKET = "AA_RUNTIME_SOCKET"
3340ACTION_TYPE_TOOL_CALL = "tool_call"
41+ ENFORCE_MODE = "enforce"
42+
43+ # Native ``query_policy`` decisions that signal the runtime could not produce an
44+ # authoritative verdict (mirrors aa-ffi-python mapping QueryFailed / ChannelClosed
45+ # / Shutdown). Under ``enforce`` these must deny, not allow (AAASM-3106).
46+ _ERROR_DECISIONS = frozenset ({"query_failed" , "channel_closed" , "shutdown" , "error" })
3447
3548
3649def _resolve_runtime_socket_path (agent_id : str ) -> str :
@@ -99,14 +112,20 @@ class RuntimeQueryInterceptor:
99112
100113 Delegates every attribute the adapters look up (event reporting, tool-end
101114 hooks, approval timeout providers, ...) to the wrapped ``GatewayClient`` and
102- only *adds* ``check_tool_start``. The added check is fail-open: any path that
103- cannot produce an explicit ``deny`` proceeds.
115+ only *adds* ``check_tool_start``.
116+
117+ The failure posture of the added check is governed by ``enforce``: when
118+ ``True`` (``enforcement_mode == "enforce"``) any path that cannot obtain an
119+ authoritative allow — a raising ``query_policy`` or an error-sentinel
120+ ``decision`` — maps to ``deny`` (fail closed). When ``False`` those paths
121+ proceed (fail open), preserving the observe / disabled behavior.
104122 """
105123
106- def __init__ (self , client : Any , runtime_client : Any , agent_id : str ) -> None :
124+ def __init__ (self , client : Any , runtime_client : Any , agent_id : str , * , enforce : bool = False ) -> None :
107125 self ._client = client
108126 self ._runtime_client = runtime_client
109127 self ._agent_id = agent_id
128+ self ._enforce = enforce
110129
111130 def __getattr__ (self , name : str ) -> Any :
112131 # Delegate anything not defined here (e.g. report_event, on_tool_end,
@@ -125,12 +144,14 @@ def check_tool_start(
125144
126145 Maps the runtime decision onto the adapter contract:
127146
128- * ``"deny"`` → ``{"status": "deny", "reason": ...}`` (the only block) .
147+ * ``"deny"`` → ``{"status": "deny", "reason": ...}``.
129148 * ``"pending"`` → ``{"status": "pending", "reason": ...}`` so the
130149 adapter's existing approval path runs.
131- * anything else (``"allow"`` / ``"redact"`` / ``"unspecified"`` / an
132- unreachable runtime) → ``{"status": "allow"}``. The runtime redacts
133- authoritatively; this layer never redacts.
150+ * ``"allow"`` / ``"redact"`` / ``"unspecified"`` → ``{"status": "allow"}``.
151+ The runtime redacts authoritatively; this layer never redacts.
152+ * A raising ``query_policy`` or an error-sentinel ``decision``
153+ (``query_failed`` / ``channel_closed`` / ``shutdown``) → ``deny`` under
154+ ``enforce`` (fail closed, AAASM-3106), else ``allow`` (fail open).
134155 """
135156 tool_name = _extract_tool_name (serialized , kwargs )
136157 tool_args_json = _extract_tool_args_json (input_str , kwargs )
@@ -143,8 +164,10 @@ def check_tool_start(
143164 tool_args_json ,
144165 )
145166 except Exception :
146- # Native query raised unexpectedly — fail OPEN, never block.
147- return {"status" : "allow" }
167+ # Native query raised — the runtime gave no verdict. Under enforce the
168+ # SDK is a security control and must block (fail closed); otherwise
169+ # proceed (fail open).
170+ return self ._on_query_failure ("runtime query failed" )
148171
149172 decision = str (result .get ("decision" , "allow" )).strip ().lower ()
150173 reason = str (result .get ("reason" , "" ) or "" )
@@ -153,28 +176,69 @@ def check_tool_start(
153176 return {"status" : "deny" , "reason" : reason }
154177 if decision == "pending" :
155178 return {"status" : "pending" , "reason" : reason }
179+ if decision in _ERROR_DECISIONS :
180+ # Native reported it could not reach an authoritative verdict.
181+ return self ._on_query_failure (reason or f"runtime returned { decision } " )
182+ return {"status" : "allow" }
183+
184+ def _on_query_failure (self , reason : str ) -> dict [str , str ]:
185+ """Map an unauthoritative query to deny (enforce) or allow (observe)."""
186+ if self ._enforce :
187+ return {"status" : "deny" , "reason" : reason }
156188 return {"status" : "allow" }
157189
158190
159- def build_governance_interceptor (client : Any , agent_id : str ) -> Any :
191+ class _FailClosedInterceptor :
192+ """Deny-all interceptor used under ``enforce`` when no runtime is reachable.
193+
194+ The native extension is present (so the SDK is configured as a security
195+ control) but the runtime socket could not be connected, meaning no
196+ authoritative verdict can be obtained. Under ``enforce`` this must block
197+ every tool rather than silently allow it (AAASM-3106). Non-check attributes
198+ delegate to the wrapped ``GatewayClient`` so event reporting still works.
199+ """
200+
201+ def __init__ (self , client : Any , reason : str ) -> None :
202+ self ._client = client
203+ self ._reason = reason
204+
205+ def __getattr__ (self , name : str ) -> Any :
206+ return getattr (self ._client , name )
207+
208+ def check_tool_start (self , ** _kwargs : Any ) -> dict [str , str ]:
209+ return {"status" : "deny" , "reason" : self ._reason }
210+
211+
212+ def build_governance_interceptor (client : Any , agent_id : str , enforcement_mode : str | None = None ) -> Any :
160213 """Return the interceptor adapters should use for pre-execution checks.
161214
162215 When the native extension is importable and a runtime socket is reachable,
163216 wrap ``client`` in a :class:`RuntimeQueryInterceptor` so a runtime ``deny``
164- actually blocks the tool. Otherwise return ``client`` unchanged so the
165- existing fail-open / no-core path is preserved exactly.
217+ actually blocks the tool. The failure posture depends on ``enforcement_mode``
218+ (AAASM-3106):
219+
220+ * The native extension is **missing**: return ``client`` unchanged in every
221+ mode. There is no native authority to consult, so there is nothing to fail
222+ closed *to* — the SDK fast path is simply not engaged.
223+ * The native extension is **present** but the runtime socket is unreachable:
224+ under ``enforce`` return a deny-all :class:`_FailClosedInterceptor`;
225+ otherwise return ``client`` unchanged (fail open).
166226 """
227+ enforce = enforcement_mode == ENFORCE_MODE
167228 try :
168229 from agent_assembly ._core import RuntimeClient
169230 except ImportError :
231+ # No native fast path at all — the SDK control is not engaged in any mode.
170232 return client
171233
172234 socket_path = _resolve_runtime_socket_path (agent_id )
173235 try :
174236 runtime_client = RuntimeClient .connect (socket_path )
175237 except Exception :
176- # No reachable runtime / connect failed — fail OPEN: bare client has no
177- # check_tool_start, so adapters proceed exactly as before.
238+ # Native present but runtime unreachable: deny everything under enforce
239+ # (fail closed); proceed under observe / disabled (fail open).
240+ if enforce :
241+ return _FailClosedInterceptor (client , "runtime unreachable" )
178242 return client
179243
180- return RuntimeQueryInterceptor (client , runtime_client , agent_id )
244+ return RuntimeQueryInterceptor (client , runtime_client , agent_id , enforce = enforce )
0 commit comments