[AAASM-5665] 🐛 (adapters): Emit an audit record when a tool call is denied - #311
Merged
Merged
Conversation
run_governed_async_tool raised the policy violation straight past _record_async_tool_result, so a denied call emitted nothing — the only trace it ever happened was an in-process exception that never reaches an auditor, and in a record stream a deny was indistinguishable from a call that was never attempted. Record the outcome before raising. The audit hook is duck-typed, so the new denied flag is offered only to handlers whose signature can receive it; handlers written against the existing four keywords still get the record. Refs AAASM-5665
Add a control over the persisted record for a denied call: the tool, the agent, the run it correlates with, the denied flag, and the deny reason. Teach the fixture to capture the flag. Also record in the fixture docstring that record_result is a hook the SDK genuinely calls — _record_async_tool_result duck-types it across seven adapters — since it had been read as a fixture invention. Refs AAASM-5665
The new comments implied a denied call now reaches an auditor. It does not on the shipped path: RuntimeQueryInterceptor defines only check_tool_start and delegates the rest to GatewayClient, which exposes neither record_result nor on_tool_end, so the audit hook resolves to None and nothing is emitted for allowed calls either. State that the flow offers the outcome to a duck-typed hook, and that tool outcomes stay Unmeasured until a sink is wired into the SDK's interceptor. Refs AAASM-5665
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
The conditional that decides whether to offer the denied flag had two uncovered branches: a handler that cannot accept the keyword, and a callable whose signature cannot be introspected. Both are the backward-compatibility guarantee the flag depends on — passing it unconditionally would raise TypeError from inside the governance flow and replace the policy denial with an unrelated error. Drive a real deny through run_governed_async_tool for each handler shape and assert the arguments it actually received. Refs AAASM-5665
Recording the deny put a caller-supplied, duck-typed hook on a path that previously never touched it, so a handler that raises substituted its own exception for the PolicyViolationError. The tool body still did not run — neither caller downgrades to allow — but a caller matching on PolicyViolationError stopped recognising the deny. Suppress failures around the call. A decided deny is final regardless of audit outcome; the repo settled this for the openai_agents path under AAASM-4782, so follow that rather than invent a second answer. Refs AAASM-5665
Assert the exception a governed call raises when the audit hook raises, and that the hook was actually reached — otherwise the assertion would pass for the wrong reason on a path that never records. Refs AAASM-5665
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



Description
run_governed_async_toolraised the policy violation straight past_record_async_tool_result, so a denied call never even offered its outcome to the audithook — the only trace it happened was an in-process exception. This records the outcome before
raising.
The production sink is
UnmeasuredThe audit hook is duck-typed. On the interceptor the SDK actually builds, neither hook
resolves:
RuntimeQueryInterceptordefines onlycheck_tool_startand delegates the rest toGatewayClient, which has neither hook. So_record_async_tool_resultfinds nothing and emitsnothing — for allowed calls as much as denied ones. Per ADR 0033 §6 the honest term for tool
outcomes on the shipped path is Unmeasured in audit evidence.
A caller supplying its own handler does get the record, and seven adapters duck-type the hook
for exactly that reason — so the flow-level fix is real groundwork.
Blast radius — the fix reaches two adapters, not seven.
run_governed_async_toolhas exactlytwo consumers:
google_adk/patch.py:192andpydantic_ai/patch.py:324,:382.mcp,microsoft_agent_frameworkandopenai_agentseach define their own local_record_async_tool_result(mcp:194,microsoft_agent_framework:241,openai_agents:411) anddo not route through the shared body, so the deny-record fix lands on google_adk and pydantic_ai
only. Whether the sibling adapters share the raise-past-record shape is not settled here; if they
do it wants a follow-up ticket, in the same spirit as AAASM-5683 filed off #355. Wiring a sink into the SDK's
own interceptor is a separate capability, the same blocking dependency as AAASM-5681
(
node-sdk).go-sdkhas the identical gap (its only productionGovernanceClientdiscardsthe record), so this is a three-SDK issue.
Correcting the ticket
AAASM-5665 stated that
AuditRecordingInterceptor.record_result"invents a hook the SDK does nothave". That is false.
record_resultis a real duck-typed hook, called at_shared/tool_governance.py:190with anon_tool_endfallback at:203, and duck-typed by sevenadapters —
crewai:350,haystack:218,langchain/callback_handler.py:199,llamaindex:214,mcp:204,microsoft_agent_framework:248,openai_agents:422(seven listed, seven counted; anearlier revision of this body stated seven and listed six, omitting langchain). All line numbers
in this body are relative to the PR head, not
main. Not delegating to_inneris also correct— the real
GatewayClientexposes onlyreport_edge, so delegating would raiseAttributeError.The coordinator has corrected the ticket. The real defect was the raise at
:225-228jumpingpast
:241— the same shape asgo-sdk.Also: unlike Go's
RecordRequest, Python's hook does carryagent_id, so the record isagent-attributable once a handler exists.
deniedflagThe hook is duck-typed and every existing implementation was written against four keywords, so
passing a new one unconditionally would
TypeErrorall of them. The flag is offered only tohandlers whose signature can receive it (explicit param or
**kwargs); the rest still get therecord. Without it a deny is indistinguishable from a tool that ran and returned the denial text.
Not done deliberately
An earlier revision renamed
_record_async_tool_result→_record_async_tool_outcome. Reverted:pydantic_ai/patch.pyandgoogle_adk/patch.pyre-export it by name in__all__, so therename broke two adapter surfaces and a test for a cosmetic gain. Out of scope.
Type of Change
Breaking Changes
handlers are called exactly as before.
Related Issues
Testing
ruff check .0ruff format --check .1— pre-existing, see belowmypy agent_assembly1— pre-existing, see belowpytest test/0— 1212 passed, 16 skipped (baseline 1208; +4, the four new controls)Pre-existing failures, measured on untouched
mainin this same worktree before any edit, andunchanged by this PR:
ruff format --check—scripts/check_contact_metadata.py(from commit50b75c9, unrelated).mypy— 4 errors: 3 ×Cannot find implementation or library stub for module named "agent_assembly._core"(native extension not built in a fresh worktree) and 1 × missinggrpcstubs. None in files this PR touches. Note
pre-commit's ownmypyhook passes on every commit.Round-2 addition: the deny is exception-isolated (PY-4)
Recording the deny put a caller-supplied, duck-typed hook on a path that previously never touched
it. A handler that raises therefore replaced the
PolicyViolationErrorwith its own exception —a new failure mode introduced by this PR. Bounded honestly: neither consumer wraps the call in
a governance-error handler that falls through to running the tool, so the tool body still does not
run and this is a correctness-of-signal bug, not an enforcement bypass. Fixed by following this
repo's own precedent (
openai_agents/patch.py:455-475, AAASM-4782) rather than inventing a secondanswer.
contextlib.suppressguardRuntimeError: audit handler explodedinstead of the denialThe control also asserts the hook was reached (
attempts == 1), so it cannot pass for the wrongreason on a path that never records.
Evidence the new control bites — same run each time, positive controls in the same class:
tool_governance.py1 failed, 2 passed3 passeddenied=Trueonlydeniedassert1 failed, 2 passedlen(records)assert1 failed, 2 passedThe two mutations fail at different assertions, so the control discriminates rather than
tripping on one coarse check.
What this does and does not deliver
Even fully fixed, this PR delivers a correctly wired call site, honestly labelled
Unmeasured— which is not the same as audited. AC1 is met on its second branch only ("or the acceptance
criteria that depend on audit evidence are restated against the artifact that actually exists").
Nothing here makes a denied call observable in a released binary; AAASM-5681 remains the blocking
dependency for that, across all three SDKs.
Checklist