Raised by the maintainer while reading #213's measurement. It is the other
half of that issue, and the half that survives it.
Why this is specifically an auto problem
auto manufactures the condition. A planned node runs with
--setting-sources "" (coordinator.toolPolicyFor → isolatedSettingSources),
so the user's own pre-authorisations are not loaded and the plan's
allowed_tools is the entire grant. A hand-written run node inherits the
user's settings (internal/schedule builds ToolPolicy{AllowedTools: …} with
SettingSources nil), so it is far less likely to be denied anything.
The default permission_mode is dontAsk (internal/schedule/scheduler.go:47),
and a node has no TTY, so an unauthorised tool call is denied immediately —
it does not prompt and does not hang.
What actually happens on a denial
The tool call is refused, the model is told, and it keeps going. The node
exits 0. Whether the run notices depends entirely on whether the model chooses
to mention it and on what success_check asks.
We got lucky once already. In run 20260819-223604.575080000-1 the verify
node was denied Bash(gofmt …), said so honestly — "I did not route around the
denial … formatting is unverified" — and refused to claim PASS, so
result_matches failed it. A node that had started its reply with PASS and
put the caveat afterwards would have passed while never running the check.
handoff.LintToolGrants already names this exact failure in its warning text:
a tool your settings do not pre-authorise is denied silently, and the node's
own result_matches passes on the prose explaining it
The engine sees none of it — but the signal is structured
grep -rn "has been denied" over the product code returns nothing. There is
no detection at all.
The interesting part: a denial is not only prose. In the session transcript
it is a tool_result block carrying is_error: true and a tool_use_id
(verified on session 13f6c560-8204-4606-807e-183bb316a849). So unlike ADR
0009's session limit — which the CLI offers only as prose — this one has a
machine-readable shape.
It is simply out of band. The runner passes --output-format json and parses
six envelope fields (session_id, result, total_cost_usd, subtype,
is_error, errors — internal/runner/claude_protocol.go:84-89). Per-tool
results are not among them. Nothing in the tree uses stream-json.
Options, each with its real cost
- Read the session transcript (
~/.claude/projects/**.jsonl). The data is
there today. Cost: a new dependency on Claude's private on-disk format, and
no Codex equivalent — it would be a Claude-only signal, which ADR 0009 §Scope
already established is an acceptable but explicit narrowing.
- Consume
stream-json so tool results arrive in-band. Cost: a real change
to the runner's output handling, which is one of the four exec seams.
- Prose-match the final result. Cheapest, and the weakest: it depends on the
model choosing to mention the denial, which is exactly the behaviour that
cannot be relied on here.
- Do nothing to detect it; lean on
success_check.verify. The house answer
is already that a verify command is independent evidence. Arguably this issue
is "encourage verify harder" rather than "detect denials".
Why #213's prescription does not cover this
#213 proposed catching the mismatch statically, before the run. That was
measured and rejected — 110 noise in 114 hits (#217), and the failure is
structural. If the mismatch cannot be caught before the run, the remaining place
to catch it is during it, which is this issue.
Not proposing an option here. The measurement that would decide it: over the
planned corpus, how many nodes were denied at least one tool call, and how many
of those still reported PASS.
Raised by the maintainer while reading #213's measurement. It is the other
half of that issue, and the half that survives it.
Why this is specifically an
autoproblemautomanufactures the condition. A planned node runs with--setting-sources ""(coordinator.toolPolicyFor→isolatedSettingSources),so the user's own pre-authorisations are not loaded and the plan's
allowed_toolsis the entire grant. A hand-writtenrunnode inherits theuser's settings (
internal/schedulebuildsToolPolicy{AllowedTools: …}withSettingSourcesnil), so it is far less likely to be denied anything.The default
permission_modeisdontAsk(internal/schedule/scheduler.go:47),and a node has no TTY, so an unauthorised tool call is denied immediately —
it does not prompt and does not hang.
What actually happens on a denial
The tool call is refused, the model is told, and it keeps going. The node
exits 0. Whether the run notices depends entirely on whether the model chooses
to mention it and on what
success_checkasks.We got lucky once already. In run
20260819-223604.575080000-1theverifynode was denied
Bash(gofmt …), said so honestly — "I did not route around thedenial … formatting is unverified" — and refused to claim PASS, so
result_matchesfailed it. A node that had started its reply withPASSandput the caveat afterwards would have passed while never running the check.
handoff.LintToolGrantsalready names this exact failure in its warning text:The engine sees none of it — but the signal is structured
grep -rn "has been denied"over the product code returns nothing. There isno detection at all.
The interesting part: a denial is not only prose. In the session transcript
it is a
tool_resultblock carryingis_error: trueand atool_use_id(verified on session
13f6c560-8204-4606-807e-183bb316a849). So unlike ADR0009's session limit — which the CLI offers only as prose — this one has a
machine-readable shape.
It is simply out of band. The runner passes
--output-format jsonand parsessix envelope fields (
session_id,result,total_cost_usd,subtype,is_error,errors—internal/runner/claude_protocol.go:84-89). Per-toolresults are not among them. Nothing in the tree uses
stream-json.Options, each with its real cost
~/.claude/projects/**.jsonl). The data isthere today. Cost: a new dependency on Claude's private on-disk format, and
no Codex equivalent — it would be a Claude-only signal, which ADR 0009 §Scope
already established is an acceptable but explicit narrowing.
stream-jsonso tool results arrive in-band. Cost: a real changeto the runner's output handling, which is one of the four exec seams.
model choosing to mention the denial, which is exactly the behaviour that
cannot be relied on here.
success_check.verify. The house answeris already that a verify command is independent evidence. Arguably this issue
is "encourage verify harder" rather than "detect denials".
Why #213's prescription does not cover this
#213 proposed catching the mismatch statically, before the run. That was
measured and rejected — 110 noise in 114 hits (#217), and the failure is
structural. If the mismatch cannot be caught before the run, the remaining place
to catch it is during it, which is this issue.
Not proposing an option here. The measurement that would decide it: over the
planned corpus, how many nodes were denied at least one tool call, and how many
of those still reported PASS.