Skip to content

[AAASM-5665] 🐛 (adapters): Emit an audit record when a tool call is denied - #311

Merged
Chisanan232 merged 6 commits into
mainfrom
v0.0.1-rc.7/AAASM-5665/fix/deny_audit_record
Aug 7, 2026
Merged

[AAASM-5665] 🐛 (adapters): Emit an audit record when a tool call is denied#311
Chisanan232 merged 6 commits into
mainfrom
v0.0.1-rc.7/AAASM-5665/fix/deny_audit_record

Conversation

@Chisanan232

@Chisanan232 Chisanan232 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

run_governed_async_tool raised the policy violation straight past
_record_async_tool_result, so a denied call never even offered its outcome to the audit
hook — the only trace it happened was an in-process exception. This records the outcome before
raising.

Scope of the claim — read before approving. This does not make a denied call observable
in a released binary, and the PR does not say it does. See "The production sink is Unmeasured"
below. This takes AC1's second branch: "or the acceptance criteria that depend on audit
evidence are restated against the artifact that actually exists."

The production sink is Unmeasured

The audit hook is duck-typed. On the interceptor the SDK actually builds, neither hook
resolves
:

callback_handler type: RuntimeQueryInterceptor
  ABSENCE PROBE getattr('record_result') -> None
  ABSENCE PROBE getattr('on_tool_end')   -> None
  POSITIVE CONTROL 'check_tool_start' resolves: True
  POSITIVE CONTROL 'report_edge'       resolves: True

GatewayClient public surface: ['agent_id', 'allow_insecure', 'api_key', 'client', 'close',
 'control_plane_url', 'delegation_reason', 'depth', 'dispatch_tool', 'enforcement_mode',
 'gateway_url', 'parent_agent_id', 'report_edge', 'spawned_by_tool', 'team_id', 'timeout']

RuntimeQueryInterceptor defines only check_tool_start and delegates the rest to
GatewayClient, which has neither hook. So _record_async_tool_result finds nothing and emits
nothing — 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_tool has exactly
two consumers: google_adk/patch.py:192 and pydantic_ai/patch.py:324,:382. mcp,
microsoft_agent_framework and openai_agents each define their own local
_record_async_tool_result (mcp:194, microsoft_agent_framework:241, openai_agents:411) and
do 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-sdk has the identical gap (its only production GovernanceClient discards
the record), so this is a three-SDK issue.

Correcting the ticket

AAASM-5665 stated that AuditRecordingInterceptor.record_result "invents a hook the SDK does not
have"
. That is false. record_result is a real duck-typed hook, called at
_shared/tool_governance.py:190 with an on_tool_end fallback at :203, and duck-typed by seven
adapters — crewai:350, haystack:218, langchain/callback_handler.py:199, llamaindex:214,
mcp:204, microsoft_agent_framework:248, openai_agents:422 (seven listed, seven counted; an
earlier 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 _inner is also correct
— the real GatewayClient exposes only report_edge, so delegating would raise AttributeError.
The coordinator has corrected the ticket. The real defect was the raise at :225-228 jumping
past :241 — the same shape as go-sdk.

Also: unlike Go's RecordRequest, Python's hook does carry agent_id, so the record is
agent-attributable once a handler exists.

denied flag

The hook is duck-typed and every existing implementation was written against four keywords, so
passing a new one unconditionally would TypeError all of them. The flag is offered only to
handlers whose signature can receive it (explicit param or **kwargs); the rest still get the
record. 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.py and google_adk/patch.py re-export it by name in __all__, so the
rename broke two adapter surfaces and a test for a cosmetic gain. Out of scope.

Type of Change

  • 🔧 Bug fix

Breaking Changes

  • No — the new keyword is offered only to handlers that can accept it; existing four-keyword
    handlers are called exactly as before.

Related Issues

  • Related JIRA ticket: AAASM-5665
  • Sink dependency, same shape: AAASM-5681

Testing

  • Unit tests added/updated
Gate Exit
ruff check . 0
ruff format --check . 1pre-existing, see below
mypy agent_assembly 1pre-existing, see below
pytest test/ 01212 passed, 16 skipped (baseline 1208; +4, the four new controls)

Pre-existing failures, measured on untouched main in this same worktree before any edit, and
unchanged by this PR:

  • ruff format --checkscripts/check_contact_metadata.py (from commit 50b75c9, 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 × missing grpc
    stubs. None in files this PR touches. Note pre-commit's own mypy hook 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 PolicyViolationError with 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 second
answer.

Mutation Result
remove the contextlib.suppress guard FAILRuntimeError: audit handler exploded instead of the denial
none PASS

The control also asserts the hook was reached (attempts == 1), so it cannot pass for the wrong
reason on a path that never records.

Evidence the new control bites — same run each time, positive controls in the same class:

State New test Positive controls Result
Unfixed tool_governance.py FAIL PASS (2) 1 failed, 2 passed
Fixed PASS PASS 3 passed
Mutation: drop denied=True only FAIL at the denied assert PASS (2) 1 failed, 2 passed
Mutation: remove the whole deny-record call FAIL at the len(records) assert PASS (2) 1 failed, 2 passed

The 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

  • Code follows project style guidelines
  • Self-review completed
  • Comments added for complex logic
  • Documentation updated if needed
  • All tests passing

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

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

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
@sonarqubecloud

sonarqubecloud Bot commented Aug 7, 2026

Copy link
Copy Markdown

@Chisanan232
Chisanan232 merged commit fa3fe9f into main Aug 7, 2026
26 checks passed
@Chisanan232
Chisanan232 deleted the v0.0.1-rc.7/AAASM-5665/fix/deny_audit_record branch August 7, 2026 14:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant