Skip to content

auto: a node denied its tools reports PASS if it says so — the engine never sees the denial #218

Description

@jitokim

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.toolPolicyForisolatedSettingSources),
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, errorsinternal/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

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions