Skip to content

fix: stop the auditor leaking reasoning and tool tokens into public comments - #3

Merged
heysanil merged 27 commits into
mainfrom
fix/auditor-output-hygiene
Aug 9, 2026
Merged

heysanil merged 27 commits into
mainfrom
fix/auditor-output-hygiene

Conversation

@heysanil

@heysanil heysanil commented Aug 9, 2026 •

Copy link
Copy Markdown
Member

Since defaulting both model tiers to DeepSeek V4 Flash via OpenRouter, docs-sentinel has been posting corrupted PR comments. Two distinct bugs, both observed on heysanil/invisible-string:

Tool-call framing leak (#8, #11) — the entire comment body was </|DSML|parameter> fragments (| = U+FF5C, DeepSeek's native tool-call tokens escaping OpenRouter's Anthropic-compat shim). The auditor's real verdict was destroyed, yet the comment still rendered under "no documentation drift detected." A destroyed audit looked identical to a clean pass.

Reasoning leak (#9, #10, #12) — full chain-of-thought published, conclusion buried at the end.

Both entered at one line: jq -r '.result' treated free-form LLM prose as a trusted structured field and piped it into a public comment and a git commit body.

Two live security defects found during review

Neither was known when this work started; both are exploitable on the current default config.

  • Bash(git diff:*) was a file-write primitive. --allowed-tools prefix-matching makes git diff --output=<path> reachable — verified writing 19,551 bytes. That defeats the assumption guardrail.sh documents in a comment: it enumerates only tracked modifications, so any created file went uninspected.
  • The model token was written to disk where the auditor could read it. audit.yml wrote ANTHROPIC_AUTH_TOKEN into $GITHUB_ENV, a file the granted Read tool can open.

Separately bad; together a read primitive plus a write primitive in a job holding contents: write.

Approach

Reasoning and tool calls are now excluded structurally, not by pattern-matching. claude -p --output-format stream-json emits typed content blocks, so extraction keeps only {type:"text"} — thinking and tool_use cannot reach the summary by construction.

gate → build-context ─┐
                      ▼
   ┌──────────── run-auditor.sh ─────────────────┐
   │  claude --output-format stream-json         │
   │    → classify-run    (did it complete?)     │
   │    → extract-summary (typed text blocks)    │
   │    → validate-summary (shape + tree agree)  │
   │  ↑── reset tree, regenerate context, retry ─┘
   └──────────────────┬──────────────────────────┘
                      ▼
      guardrail → compose → commit
                      ▼
        decide-outcome → render-status  (always runs)

Execution vs hygiene failures are kept apart. A run that completed but produced unusable prose keeps its doc edits. A run that didn't complete has all edits discarded — the guardrail proves paths and churn are legal, not that a half-finished edit set is coherent.

One renderer owns every outcome. The "no documentation drift detected" heading is now reachable only from audit succeeded ∧ not degraded ∧ guardrail passed ∧ nothing changed. Every other terminal state renders as inconclusive with a specific reason, or as infrastructure failure.

Verified end to end against the live model

Not just unit tests — the real prompt through the real CLI:

  • Drift case: repo with a 3400→3500 port change and a stale README. The auditor emitted 4 thinking blocks and 7 tool_use blocks; the pipeline returned only Subject: docs: sync… plus one bullet. Zero reasoning, zero framing, README correctly fixed.
  • No-drift case: the model produced exactly the No documentation updates needed — form the validator requires, and passed.
  • PR #8 payload replayed through the full pipeline: renders "Docs audit — inconclusive."

Changes

Area Change
Security Bash grant removed; token injected in-process; persist-credentials: false on audit-main
Engine classify-run · extract-summary · validate-summary · run-auditor · decide-outcome · render-status
Guardrail Rejects untracked files (the backstop the tool allowlist alone was providing)
Prompt Fenced summary contract; no shell access
Docs README degradation modes; corrected a stale "draft PR" claim

93 bats tests (was 39), shellcheck and actionlint clean.

Review

Ten tasks, each implemented by a fresh subagent and independently reviewed, plus a whole-branch review. Reviewers mutation-tested rather than read diffs, which caught two defects in the plan itself:

  • A jq selector crashed on non-object JSON (.type on a scalar is an error, not null) — same pattern found and fixed pre-emptively in a second script.
  • Bare [[ ]] assertions don't fail bats tests on bash 3.2 — only the last statement decides. Deleting core logic passed all 8 tests. All 35 assertions guarded; the suite is now load-bearing.

Known follow-ups (not blocking)

  • audit-pr's checkout keeps a contents: write token in .git/config while the model runs with unrestricted Read; audit-main already sets persist-credentials: false.
  • No per-attempt timeout: a hung first attempt exhausts the step budget and loses the retry, mislabelled as infra.
  • A hygiene-degraded run on the default branch opens a docs-sync PR whose body reads confident; the inconclusive signal only reaches the step summary.

Follow-on work

This was split out of a larger design. docs/superpowers/specs/2026-08-08-pi-harness-and-model-benchmark-design.md covers migrating to the Pi harness and building a model benchmark — justified by benchmark fairness (today every non-Anthropic model is measured through a shim Anthropic models bypass) rather than by this incident.

Model defaults changed in this PR

Also swaps the default model routing, which is the other half of the response to the DeepSeek incident:

Input Was Now
model ~deepseek/deepseek-v4-flash-latest z-ai/glm-5.2
small-model ~deepseek/deepseek-v4-flash-latest deepseek/deepseek-v4-flash-0731
effort high xhigh
model-capabilities effort,thinking,… effort,xhigh_effort,thinking,…

Two things worth noting. Both slugs are now pinned to concrete versions rather than ~…-latest floating aliases, so a given workflow SHA always audits with the same models. And xhigh_effort had to be added to model-capabilities — Claude Code gates reasoning by pattern-matching the model ID, so without that capability declared it clamps xhigh back to high and the new effort default would have been a silent no-op.

heysanil added 27 commits August 8, 2026 18:40
Design for two coupled problems observed since defaulting to DeepSeek V4
Flash via OpenRouter:

- DeepSeek DSML tool-call tokens leaking into PR comments, destroying the
  auditor's verdict while still rendering as "no drift detected"
- Chain-of-thought flooding the commit body and PR comment

Root cause is architectural: audit.yml treats `jq -r '.result'` as a
trusted structured field. Migrating the harness to Pi makes reasoning and
tool calls structurally separate content-block types rather than a
formatting convention, and adds a mechanical validator with retry-then-
degrade so an unusable summary can never masquerade as a clean pass.

Also specifies a benchmark that ranks candidate models by replaying real
merged PRs, using doc-diff holdout for automatic ground truth.
Three independent reviews (Codex, a Fable subagent, and empirical
verification against the installed toolchain) established that the
DeepSeek leak fix does not require a harness migration: Claude Code's
`--output-format stream-json` already emits typed content blocks, so
reasoning and tool framing can be excluded structurally today.

Split into a v1.1 hotfix on the current harness and a v2 Pi migration
justified by benchmark fairness and config simplification instead.

The v1.1 spec also covers two live security defects found during review:

- `Bash(git diff:*)` permits arbitrary file creation via `--output=`,
  defeating the guardrail's documented no-untracked-files assumption
- the model API token is written to $GITHUB_ENV, a file the auditor's
  own Read tool can open

And corrects a fatal flaw in the original design: routing degraded
output through compose-message.sh could never fix the false
"no documentation drift detected" heading, because that step only runs
when the guardrail reports edits — while the failure being fixed is the
no-edit case.
- comment-pr-skip cannot run engine/render-status.sh: actions/checkout in a
  reusable workflow fetches the caller's repo, and that job has no
  contents: read. Keep its inline wording.
- the render ladder fell through to OUTCOME=clean on a guardrail violation,
  because guardrail.sh exits 1 without writing changed=. Add rungs for
  steps.guard.outcome and steps.commit.outcome.
- ANTHROPIC_API_KEY used a GitHub ternary whose middle operand was '',
  which is falsy, so the bearer path handed the key to both variables.
- add a per-step timeout so a hung attempt fails into the degraded path
  instead of a job cancellation that skips the rendering steps.
- use !cancelled() so a superseded run cannot clobber a newer comment.
- fall back to a hardcoded infra body when the renderer itself is missing.
Bash(git diff:*) is prefix-matched, so git diff --output=<path> reaches a
file-write primitive. That defeats the assumption guardrail.sh documents --
it enumerates only tracked modifications, so any created file goes
uninspected. The unified diff is already in .docs-sentinel-context.md, so
the grant was near-redundant.

The model API token was also written to $GITHUB_ENV, a file on disk the
auditor's own Read tool can open. Inject it in-process on the auditor step
instead, and drop persisted push credentials from the default-branch
checkout.
.type applied to a bare JSON scalar is a jq error, not a null, so an
unguarded selector aborts the script under set -e with jq's exit code
instead of the documented one-line reason. Found by review in
classify-run.sh; the same pattern was present in extract-summary.sh.
A failing `[[ ]]` in the middle of a bats test body does not abort the
test on bash 3.2 — only the last statement's truth value decides pass/fail,
so earlier assertions are silently ignored. Proven by deleting the
prefer-fenced-message loop from extract-summary.sh: all 8 tests still
passed. `[ ]` is unaffected.

Guards all 35 bare [[ ]] assertions in the plan with || return 1 and adds
a Global Constraint so later tasks cannot reintroduce the pattern.
The sticky comment substituted 'No documentation updates needed' under the
'no documentation drift detected' heading whenever the summary file was
empty and the guardrail reported no edits -- which is exactly the degraded
state that PRs #8 and #11 hit. A single always-run renderer now owns every
terminal state, and the no-drift heading is reachable only from a completed
run with a validated summary.
The if/elif chain choosing OUTCOME/KIND/EDITS_LANDED/REASON lived only in an
inline workflow run: block, so tests/status-comment.bats -- which exercises
render-status.sh after the outcome was already chosen -- could not catch a
regression in the ladder itself. Deleting the GUARD_RESULT rung reproduced
the original production bug exactly (guardrail rejection falls through to
clean) with CI staying green.

Extract the ladder into engine/decide-outcome.sh, a pure function of its
env inputs, with a bats suite covering all nine reachable states plus a
named regression test for the GUARD_RESULT rung. Also reword the push-rung
REASON, which previously said 'could not be pushed' even when the compose
step failed and nothing was ever committed.
classify-run.sh counted every line (including blank ones) toward the
'total' side of the parsed/total comparison, but jq's fromjson? drops
blank lines when parsing. A single trailing newline in the event
stream therefore always mismatched and was classified as a truncated
stream, discarding a successful run's edits and degrading every PR to
an inconclusive comment.

Count non-blank lines instead. A genuinely truncated tail still
mismatches and is correctly rejected.
Both audit-pr and audit-main capped the JOB at 25 minutes while the
auditor STEP inside them is capped at 20, leaving only 5 minutes for
checkout, engine fetch, npm install, and every guard/compose/commit/
render step that follows. If the job itself timed out, the run is
cancelled, if: ${{ !cancelled() }} goes false, and no status comment
is posted at all — the silent-failure mode the workflow otherwise
guards against everywhere else.

Raise both job timeouts to 35 minutes so a hung attempt fails the
step (leaving the rendering steps to run) instead of cancelling the
job.
render-status.sh's push-kind copy and decide-outcome.sh's REASON for
the same rung render in the same comment but disagreed on wording.
Standardize on 'committed', since this rung also fires when compose
failed and nothing was ever pushed.
The existing 'tree and context file are reset between attempts' test
only asserted attempt-1 pollution was gone, never that
build-context.sh actually reran for attempt 2 — deleting that call
passed the suite untouched, leaving attempt 2 to run blind with no
context file.
The table documented hygiene and execution but not guardrail or push,
both of which decide-outcome.sh emits and render-status.sh has
distinct copy for. Also correct the guardrail bullet: a violation
doesn't just fail the job loudly, it also posts a sticky inconclusive
comment.
Routes the main auditor tier to z-ai/glm-5.2 and the Haiku/background tier
to deepseek/deepseek-v4-flash-0731, both pinned to concrete versions
instead of OpenRouter's ~...-latest floating aliases, so a given workflow
SHA always audits with the same models.

Raises the default effort to xhigh on every tier. That also requires
adding xhigh_effort to model-capabilities: Claude Code gates reasoning by
pattern-matching the model ID, and without the capability declared it
clamps xhigh back down to high — which would have made the new effort
default a silent no-op. Both models advertise reasoning_effort upstream.
max_effort stays out; neither advertises it.
@heysanil
heysanil merged commit baf68ac into main Aug 9, 2026
3 checks passed
@heysanil
heysanil deleted the fix/auditor-output-hygiene branch August 9, 2026 05:21
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