Skip to content

Commit 0886d44

Browse files
authored
Merge pull request #139 from ai-agent-assembly/v0.0.1/AAASM-3107/normalize_fail_closed
[AAASM-3107] 🔒 (adapters): Fail closed on unknown/None decision under enforce
2 parents ba9e9fc + d81aba6 commit 0886d44

7 files changed

Lines changed: 294 additions & 30 deletions

File tree

agent_assembly/adapters/crewai/patch.py

Lines changed: 52 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -193,31 +193,69 @@ def _format_approval_rejected_message(reason: str | None) -> str:
193193
return f"[APPROVAL REJECTED] Action was reviewed and denied: {reason_text}"
194194

195195

196+
_UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce."
197+
198+
199+
def _interceptor_enforces(callback_handler: Any) -> bool:
200+
"""Return whether the wired interceptor is in fail-closed ``enforce`` posture.
201+
202+
The governance interceptor (``RuntimeQueryInterceptor`` /
203+
``_FailClosedInterceptor``) carries ``_enforce`` set from
204+
``enforcement_mode == "enforce"`` (AAASM-3106). A bare ``GatewayClient`` — used
205+
when no native runtime authority is engaged — has no such attribute and
206+
defaults to fail-open. AAASM-3107 reuses this flag so an unknown / malformed
207+
verdict denies under enforce instead of silently allowing.
208+
209+
Compared strictly against ``True`` so a stub interceptor whose ``__getattr__``
210+
synthesizes truthy values for missing attributes is not mistaken for the
211+
enforce posture; the real flag is always a ``bool``.
212+
"""
213+
target = getattr(callback_handler, "_interceptor", callback_handler)
214+
return getattr(target, "_enforce", False) is True
215+
216+
217+
def _unknown_decision(enforce: bool) -> tuple[Literal["allow", "deny", "pending"], str | None]:
218+
"""Map an unrecognized / malformed verdict, failing closed under ``enforce``.
219+
220+
Under ``enforce`` the SDK is a security control: an unknown, ``None``, or
221+
malformed verdict must not be silently allowed (AAASM-3107), so it denies.
222+
Under ``observe`` / ``disabled`` it proceeds (fail open), preserving the
223+
dry-run / hermetic posture.
224+
"""
225+
if enforce:
226+
return "deny", _UNKNOWN_DECISION_REASON
227+
return "allow", None
228+
229+
196230
def _normalize_decision(
197231
decision: object,
232+
*,
233+
enforce: bool = False,
198234
) -> tuple[Literal["allow", "deny", "pending"], str | None]:
199235
if isinstance(decision, str):
200236
normalized = decision.strip().lower()
237+
if normalized == "allow":
238+
return "allow", None
201239
if normalized == "deny":
202240
return "deny", None
203241
if normalized == "pending":
204242
return "pending", None
205-
return "allow", None
243+
return _unknown_decision(enforce)
206244

207245
if isinstance(decision, Mapping):
208-
raw_status = str(decision.get("status", "allow")).strip().lower()
209-
if raw_status == "deny":
210-
status: Literal["allow", "deny", "pending"] = "deny"
211-
elif raw_status == "pending":
212-
status = "pending"
213-
else:
214-
status = "allow"
215-
246+
raw_status = str(decision.get("status", "")).strip().lower()
216247
reason_value = decision.get("reason")
217248
reason = str(reason_value) if reason_value is not None else None
218-
return status, reason
249+
if raw_status == "allow":
250+
return "allow", reason
251+
if raw_status == "deny":
252+
return "deny", reason
253+
if raw_status == "pending":
254+
return "pending", reason
255+
unknown_status, unknown_reason = _unknown_decision(enforce)
256+
return unknown_status, reason if reason is not None else unknown_reason
219257

220-
return "allow", None
258+
return _unknown_decision(enforce)
221259

222260

223261
def _invoke_sync_tool_check(
@@ -306,6 +344,7 @@ def _apply_basetool_run_patch(base_tool_cls: type[Any], callback_handler: Any) -
306344
return None
307345

308346
original_run = base_tool_cls.run
347+
enforce = _interceptor_enforces(callback_handler)
309348

310349
@wraps(original_run)
311350
def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any:
@@ -318,7 +357,7 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any:
318357
tool_args=tool_args,
319358
agent_id=agent_id,
320359
)
321-
status, reason = _normalize_decision(decision)
360+
status, reason = _normalize_decision(decision, enforce=enforce)
322361
is_pending_flow = False
323362
if status == "pending":
324363
is_pending_flow = True
@@ -330,7 +369,7 @@ def patched_run(self: Any, *args: Any, **kwargs: Any) -> Any:
330369
tool_args=tool_args,
331370
agent_id=agent_id,
332371
)
333-
status, reason = _normalize_decision(final_decision)
372+
status, reason = _normalize_decision(final_decision, enforce=enforce)
334373

335374
if status == "deny":
336375
if is_pending_flow:

agent_assembly/adapters/langchain/callback_handler.py

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,39 @@ class _FallbackBaseCallbackHandler:
2727
class AssemblyCallbackHandler(_CallbackHandlerBase): # type: ignore[valid-type,misc]
2828
"""Callback handler that delegates runtime events to governance interception."""
2929

30+
_UNKNOWN_DECISION_REASON = "Unrecognized governance decision; denied under enforce."
31+
3032
def __init__(self, interceptor: Any) -> None:
3133
self._interceptor = interceptor
3234

35+
@property
36+
def _enforce(self) -> bool:
37+
"""Whether the wired interceptor is in fail-closed ``enforce`` posture.
38+
39+
The governance interceptor (``RuntimeQueryInterceptor`` /
40+
``_FailClosedInterceptor``) carries ``_enforce`` set from
41+
``enforcement_mode == "enforce"`` (AAASM-3106). A bare ``GatewayClient``
42+
— used when no native runtime authority is engaged — lacks it and
43+
defaults to fail-open. AAASM-3107 reuses this flag so an unknown / ``None``
44+
/ malformed verdict denies under enforce instead of silently allowing.
45+
46+
Compared strictly against ``True`` so a stub interceptor whose
47+
``__getattr__`` synthesizes truthy values for missing attributes is not
48+
mistaken for the enforce posture; the real flag is always a ``bool``.
49+
"""
50+
return getattr(self._interceptor, "_enforce", False) is True
51+
52+
def _unknown_decision(self) -> tuple[Literal["allow", "deny", "pending"], str | None]:
53+
"""Map an unrecognized / malformed verdict, failing closed under enforce.
54+
55+
Under ``enforce`` an unknown, ``None``, or malformed verdict must not be
56+
silently allowed (AAASM-3107), so it denies. Under ``observe`` /
57+
``disabled`` it proceeds (fail open).
58+
"""
59+
if self._enforce:
60+
return "deny", self._UNKNOWN_DECISION_REASON
61+
return "allow", None
62+
3363
def _normalize_decision(
3464
self,
3565
decision: object,
@@ -42,24 +72,22 @@ def _normalize_decision(
4272
return "deny", None
4373
if normalized == "pending":
4474
return "pending", None
45-
return "allow", None
75+
return self._unknown_decision()
4676

4777
if isinstance(decision, Mapping):
48-
raw_status = str(decision.get("status", "allow")).strip().lower()
49-
if raw_status == "allow":
50-
status: Literal["allow", "deny", "pending"] = "allow"
51-
elif raw_status == "deny":
52-
status = "deny"
53-
elif raw_status == "pending":
54-
status = "pending"
55-
else:
56-
status = "allow"
57-
78+
raw_status = str(decision.get("status", "")).strip().lower()
5879
reason_value = decision.get("reason")
5980
reason = str(reason_value) if reason_value is not None else None
60-
return status, reason
61-
62-
return "allow", None
81+
if raw_status == "allow":
82+
return "allow", reason
83+
if raw_status == "deny":
84+
return "deny", reason
85+
if raw_status == "pending":
86+
return "pending", reason
87+
unknown_status, unknown_reason = self._unknown_decision()
88+
return unknown_status, reason if reason is not None else unknown_reason
89+
90+
return self._unknown_decision()
6391

6492
def on_tool_start(
6593
self,

agent_assembly/adapters/mcp/patch.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@
1111
from agent_assembly.adapters.crewai.patch import (
1212
_get_pending_tool_approval_timeout_seconds as _resolve_pending_timeout_seconds,
1313
)
14+
from agent_assembly.adapters.crewai.patch import (
15+
_interceptor_enforces as _resolve_interceptor_enforces,
16+
)
1417
from agent_assembly.adapters.crewai.patch import (
1518
_normalize_decision as _normalize_governance_decision,
1619
)
@@ -113,8 +116,10 @@ def _extract_tool_call_inputs(
113116

114117
def _normalize_decision(
115118
decision: object,
119+
*,
120+
enforce: bool = False,
116121
) -> tuple[Literal["allow", "deny", "pending"], str | None]:
117-
return _normalize_governance_decision(decision)
122+
return _normalize_governance_decision(decision, enforce=enforce)
118123

119124

120125
def _get_pending_tool_approval_timeout_seconds(callback_handler: Any) -> int:
@@ -245,6 +250,8 @@ def _apply_client_session_patch(client_session_cls: type[Any], callback_handler:
245250
if not callable(original_call_tool):
246251
return None
247252

253+
enforce = _resolve_interceptor_enforces(callback_handler)
254+
248255
async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any:
249256
tool_name, tool_args = _extract_tool_call_inputs(args, kwargs)
250257
agent_id = _get_process_agent_id()
@@ -257,7 +264,7 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any:
257264
agent_id=agent_id,
258265
server_identifier=server_identifier,
259266
)
260-
status, reason = _normalize_decision(decision)
267+
status, reason = _normalize_decision(decision, enforce=enforce)
261268
is_pending_flow = False
262269
if status == "pending":
263270
is_pending_flow = True
@@ -270,7 +277,7 @@ async def patched_call_tool(self: Any, *args: Any, **kwargs: Any) -> Any:
270277
agent_id=agent_id,
271278
server_identifier=server_identifier,
272279
)
273-
status, reason = _normalize_decision(final_decision)
280+
status, reason = _normalize_decision(final_decision, enforce=enforce)
274281

275282
if status == "deny":
276283
raise _build_blocked_error(

test/unit/adapters/crewai/test_patch.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,53 @@ def on_task_complete(self, **kwargs: object) -> None:
227227
assert lifecycle_events == ["start", "complete"]
228228

229229

230+
# --- AAASM-3107: unknown/None/malformed verdicts must fail closed under enforce ---
231+
232+
233+
@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}])
234+
def test_normalize_decision_denies_unknown_under_enforce(decision: object) -> None:
235+
status, reason = crewai_patch._normalize_decision(decision, enforce=True)
236+
assert status == "deny"
237+
assert reason
238+
239+
240+
@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}])
241+
def test_normalize_decision_allows_unknown_when_not_enforcing(decision: object) -> None:
242+
assert crewai_patch._normalize_decision(decision, enforce=False) == ("allow", None)
243+
244+
245+
def test_normalize_decision_known_verdicts_unchanged_under_enforce() -> None:
246+
assert crewai_patch._normalize_decision("allow", enforce=True) == ("allow", None)
247+
assert crewai_patch._normalize_decision("deny", enforce=True) == ("deny", None)
248+
assert crewai_patch._normalize_decision("pending", enforce=True) == ("pending", None)
249+
assert crewai_patch._normalize_decision({"status": "deny", "reason": "x"}, enforce=True) == ("deny", "x")
250+
251+
252+
def test_interceptor_enforces_reads_enforce_flag() -> None:
253+
assert crewai_patch._interceptor_enforces(SimpleNamespace(_enforce=True)) is True
254+
assert crewai_patch._interceptor_enforces(SimpleNamespace()) is False
255+
assert crewai_patch._interceptor_enforces(SimpleNamespace(_interceptor=SimpleNamespace(_enforce=True))) is True
256+
257+
258+
def test_unknown_verdict_blocks_tool_under_enforce(monkeypatch: pytest.MonkeyPatch) -> None:
259+
FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch)
260+
261+
class EnforcingUnknownInterceptor:
262+
_enforce = True
263+
264+
def check_tool_start(self, **kwargs: object) -> object:
265+
del kwargs
266+
return None
267+
268+
patcher = crewai_patch.CrewAIPatch(EnforcingUnknownInterceptor())
269+
assert patcher.apply() is True
270+
271+
result = FakeBaseTool().run(param="value")
272+
273+
assert isinstance(result, str)
274+
assert "[BLOCKED by governance policy]" in result
275+
276+
230277
def test_blocked_tool_returns_policy_string(monkeypatch: pytest.MonkeyPatch) -> None:
231278
FakeBaseTool, _ = _install_fake_crewai_modules(monkeypatch)
232279

test/unit/adapters/langchain/test_callback_handler_async.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,42 @@ async def test_aon_llm_end_delegates_to_interceptor() -> None:
9393
await handler.aon_llm_end(response={"text": "done"}, run_id=uuid4())
9494

9595
assert interceptor.llm_end_calls == 1
96+
97+
98+
class _EnforcingAsyncInterceptor(AsyncInterceptor):
99+
"""AsyncInterceptor carrying the fail-closed enforce posture (AAASM-3106)."""
100+
101+
_enforce = True
102+
103+
104+
# --- AAASM-3107: unknown/None/malformed verdicts must fail closed under enforce ---
105+
106+
107+
@pytest.mark.asyncio
108+
@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}])
109+
async def test_aon_tool_start_denies_unknown_under_enforce(decision: object) -> None:
110+
handler = AssemblyCallbackHandler(_EnforcingAsyncInterceptor())
111+
112+
with pytest.raises(ToolExecutionBlockedError):
113+
await handler.aon_tool_start(
114+
serialized={"name": "web_search"},
115+
input_str="query",
116+
run_id=uuid4(),
117+
decision=decision,
118+
)
119+
120+
121+
@pytest.mark.asyncio
122+
@pytest.mark.parametrize("decision", [None, "maybe", 12345, {"status": "garbage"}, {}])
123+
async def test_aon_tool_start_allows_unknown_when_not_enforcing(decision: object) -> None:
124+
interceptor = AsyncInterceptor()
125+
handler = AssemblyCallbackHandler(interceptor)
126+
127+
await handler.aon_tool_start(
128+
serialized={"name": "web_search"},
129+
input_str="query",
130+
run_id=uuid4(),
131+
decision=decision,
132+
)
133+
134+
assert interceptor.pending_wait_calls == 0

test/unit/adapters/langchain/test_callback_handler_sync.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,3 +122,66 @@ def test_on_llm_end_delegates_to_interceptor() -> None:
122122
handler.on_llm_end(response={"text": "done"}, run_id=uuid4())
123123

124124
assert interceptor.llm_end_calls == 1
125+
126+
127+
class _EnforcingInterceptor(SyncInterceptor):
128+
"""SyncInterceptor carrying the fail-closed enforce posture (AAASM-3106)."""
129+
130+
_enforce = True
131+
132+
133+
# --- AAASM-3107: unknown/None/malformed verdicts must fail closed under enforce ---
134+
135+
136+
@pytest.mark.parametrize(
137+
"decision",
138+
[
139+
None,
140+
"maybe",
141+
12345,
142+
{"status": "garbage"},
143+
{},
144+
],
145+
)
146+
def test_unknown_decision_denies_under_enforce(decision: object) -> None:
147+
handler = AssemblyCallbackHandler(_EnforcingInterceptor())
148+
149+
with pytest.raises(ToolExecutionBlockedError):
150+
handler.on_tool_start(
151+
serialized={"name": "web_search"},
152+
input_str="query",
153+
run_id=uuid4(),
154+
decision=decision,
155+
)
156+
157+
158+
@pytest.mark.parametrize(
159+
"decision",
160+
[
161+
None,
162+
"maybe",
163+
12345,
164+
{"status": "garbage"},
165+
{},
166+
],
167+
)
168+
def test_unknown_decision_allows_when_not_enforcing(decision: object) -> None:
169+
interceptor = SyncInterceptor()
170+
handler = AssemblyCallbackHandler(interceptor)
171+
172+
handler.on_tool_start(
173+
serialized={"name": "web_search"},
174+
input_str="query",
175+
run_id=uuid4(),
176+
decision=decision,
177+
)
178+
179+
assert interceptor.pending_wait_calls == 0
180+
181+
182+
def test_known_verdicts_unchanged_under_enforce() -> None:
183+
handler = AssemblyCallbackHandler(_EnforcingInterceptor())
184+
185+
assert handler._normalize_decision("allow") == ("allow", None)
186+
assert handler._normalize_decision({"status": "deny", "reason": "nope"}) == ("deny", "nope")
187+
assert handler._normalize_decision("pending") == ("pending", None)

0 commit comments

Comments
 (0)