Skip to content

Commit ba9e9fc

Browse files
authored
Merge pull request #138 from ai-agent-assembly/v0.0.1/AAASM-3106/fail_closed_when_unreachable
[AAASM-3106] 🔒 (runtime): Fail closed when runtime unreachable or query errors
2 parents d876072 + 3bbb259 commit ba9e9fc

3 files changed

Lines changed: 218 additions & 31 deletions

File tree

agent_assembly/core/assembly.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,7 @@ def init_assembly(
213213
registered_adapters = _register_adapters(
214214
client=client,
215215
process_agent_id=resolved_agent_id,
216+
enforcement_mode=enforcement_mode,
216217
)
217218
network_mode, network_shutdown = _start_network_layer(client=client, mode=mode)
218219
except Exception as error:
@@ -263,6 +264,7 @@ def _validate_inputs(
263264
def _register_adapters(
264265
client: GatewayClient,
265266
process_agent_id: str,
267+
enforcement_mode: EnforcementMode | None = None,
266268
) -> list[FrameworkAdapter]:
267269
"""Detect available frameworks via AdapterRegistry and register hooks.
268270
@@ -272,14 +274,15 @@ def _register_adapters(
272274
273275
When the native runtime is reachable, the bare ``GatewayClient`` is wrapped
274276
in a ``RuntimeQueryInterceptor`` so a runtime ``deny`` blocks the tool via
275-
``check_tool_start``; otherwise the bare client is used unchanged
276-
(fail-open).
277+
``check_tool_start``. ``enforcement_mode`` decides the failure posture: under
278+
``enforce`` an unreachable runtime or a failed query blocks (fail closed,
279+
AAASM-3106); under ``observe`` / ``disabled`` it proceeds (fail open).
277280
"""
278281
registry = AdapterRegistry()
279282
adapters = registry.get_available_adapters_by_priority()
280283

281284
registered: list[FrameworkAdapter] = []
282-
interceptor: Any = build_governance_interceptor(client, process_agent_id)
285+
interceptor: Any = build_governance_interceptor(client, process_agent_id, enforcement_mode)
283286

284287
for adapter in adapters:
285288
adapter.set_process_agent_id(process_agent_id)

agent_assembly/core/runtime_interceptor.py

Lines changed: 88 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -12,15 +12,22 @@
1212
``query_policy``. The runtime is the authority: it redacts in place, so this
1313
layer 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

2633
from __future__ import annotations
@@ -31,6 +38,12 @@
3138

3239
ENV_RUNTIME_SOCKET = "AA_RUNTIME_SOCKET"
3340
ACTION_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

3649
def _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)

test/unit/core/test_runtime_interceptor.py

Lines changed: 124 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
"""Unit tests for the runtime-backed pre-execution check (AAASM-3049).
1+
"""Unit tests for the runtime-backed pre-execution check (AAASM-3049, AAASM-3106).
22
3-
Wave 3 of AAASM-3021: a reachable runtime's ``deny`` must block a tool via the
4-
adapter ``check_tool_start`` contract, while every other path (allow / redact /
5-
pending / unreachable runtime / missing native extension) proceeds (fail-open).
3+
Wave 3 of AAASM-3021: a reachable runtime's ``deny`` blocks a tool via the
4+
adapter ``check_tool_start`` contract.
5+
6+
AAASM-3106 adds the failure posture: under ``enforce`` an unreachable runtime, a
7+
raising ``query_policy``, or an error-sentinel ``decision`` must deny (fail
8+
closed); under ``observe`` / ``disabled`` those paths still proceed (fail open).
69
"""
710

811
from __future__ import annotations
@@ -209,6 +212,123 @@ def connect(_socket_path: str) -> Any:
209212
}
210213

211214

215+
def test_enforce_query_raising_fails_closed() -> None:
216+
"""AAASM-3106: under enforce a raising query_policy denies, not allows."""
217+
218+
class _Raising:
219+
def query_policy(self, *_args: Any, **_kwargs: Any) -> dict[str, str]:
220+
raise RuntimeError("native boom")
221+
222+
interceptor = RuntimeQueryInterceptor(_FakeGatewayClient(), _Raising(), "agent-001", enforce=True)
223+
224+
result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i")
225+
226+
assert result["status"] == "deny"
227+
228+
229+
@pytest.mark.parametrize("decision", ["query_failed", "channel_closed", "shutdown"])
230+
def test_enforce_error_decision_fails_closed(decision: str) -> None:
231+
"""AAASM-3106: native error-sentinel decisions deny under enforce."""
232+
interceptor = RuntimeQueryInterceptor(_FakeGatewayClient(), _FakeRuntimeClient(decision), "agent-001", enforce=True)
233+
234+
result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i")
235+
236+
assert result["status"] == "deny"
237+
238+
239+
@pytest.mark.parametrize("decision", ["query_failed", "channel_closed"])
240+
def test_observe_error_decision_still_fails_open(decision: str) -> None:
241+
"""Without enforce the error-sentinel decisions keep proceeding."""
242+
interceptor = RuntimeQueryInterceptor(_FakeGatewayClient(), _FakeRuntimeClient(decision), "agent-001")
243+
244+
result = interceptor.check_tool_start(serialized={"name": "t"}, input_str="i")
245+
246+
assert result == {"status": "allow"}
247+
248+
249+
def test_enforce_unreachable_runtime_denies_all(monkeypatch: pytest.MonkeyPatch) -> None:
250+
"""AAASM-3106: native present but runtime unreachable yields a deny-all
251+
interceptor under enforce, not the fail-open bare client."""
252+
253+
class _UnreachableRuntimeClient:
254+
@staticmethod
255+
def connect(_socket_path: str) -> Any:
256+
raise OSError("no such socket")
257+
258+
fake_core = types.ModuleType("agent_assembly._core")
259+
fake_core.RuntimeClient = _UnreachableRuntimeClient # type: ignore[attr-defined]
260+
monkeypatch.setitem(sys.modules, "agent_assembly._core", fake_core)
261+
262+
client = _FakeGatewayClient()
263+
result = build_governance_interceptor(client, "agent-001", "enforce")
264+
265+
assert result is not client
266+
assert result.check_tool_start(serialized={"name": "t"}, input_str="i")["status"] == "deny"
267+
# Non-check attributes still delegate to the wrapped client.
268+
result.close()
269+
assert client.closed is True
270+
271+
272+
def test_observe_unreachable_runtime_returns_bare_client(monkeypatch: pytest.MonkeyPatch) -> None:
273+
"""Without enforce an unreachable runtime keeps the fail-open bare client."""
274+
275+
class _UnreachableRuntimeClient:
276+
@staticmethod
277+
def connect(_socket_path: str) -> Any:
278+
raise OSError("no such socket")
279+
280+
fake_core = types.ModuleType("agent_assembly._core")
281+
fake_core.RuntimeClient = _UnreachableRuntimeClient # type: ignore[attr-defined]
282+
monkeypatch.setitem(sys.modules, "agent_assembly._core", fake_core)
283+
284+
client = _FakeGatewayClient()
285+
286+
assert build_governance_interceptor(client, "agent-001", "observe") is client
287+
288+
289+
def test_enforce_wraps_with_fail_closed_query_path(monkeypatch: pytest.MonkeyPatch) -> None:
290+
"""build_governance_interceptor under enforce wraps a reachable runtime so a
291+
raising query denies (the wrapper carries enforce=True)."""
292+
293+
class _Raising:
294+
def query_policy(self, *_args: Any, **_kwargs: Any) -> dict[str, str]:
295+
raise RuntimeError("boom")
296+
297+
class _ConnectingRuntimeClient:
298+
@staticmethod
299+
def connect(_socket_path: str) -> Any:
300+
return _Raising()
301+
302+
fake_core = types.ModuleType("agent_assembly._core")
303+
fake_core.RuntimeClient = _ConnectingRuntimeClient # type: ignore[attr-defined]
304+
monkeypatch.setitem(sys.modules, "agent_assembly._core", fake_core)
305+
306+
result = build_governance_interceptor(_FakeGatewayClient(), "agent-001", "enforce")
307+
308+
assert isinstance(result, RuntimeQueryInterceptor)
309+
assert result.check_tool_start(serialized={"name": "t"}, input_str="i")["status"] == "deny"
310+
311+
312+
def test_callback_handler_blocks_on_enforce_fail_closed() -> None:
313+
"""End-to-end: under enforce a failing runtime drives on_tool_start to raise."""
314+
315+
class _Raising:
316+
def query_policy(self, *_args: Any, **_kwargs: Any) -> dict[str, str]:
317+
raise RuntimeError("native down")
318+
319+
interceptor = RuntimeQueryInterceptor(_FakeGatewayClient(), _Raising(), "agent-001", enforce=True)
320+
handler = AssemblyCallbackHandler(interceptor)
321+
322+
with pytest.raises(ToolExecutionBlockedError):
323+
handler.on_tool_start(
324+
serialized={"name": "web_search"},
325+
input_str="query",
326+
run_id=uuid4(),
327+
tool_name="web_search",
328+
args={"q": "x"},
329+
)
330+
331+
212332
def test_resolve_socket_path_prefers_env(monkeypatch: pytest.MonkeyPatch) -> None:
213333
monkeypatch.setenv("AA_RUNTIME_SOCKET", "/custom/runtime.sock")
214334
assert runtime_interceptor._resolve_runtime_socket_path("agent-001") == "/custom/runtime.sock"

0 commit comments

Comments
 (0)