fix: stop the auditor leaking reasoning and tool tokens into public comments - #3
Merged
Merged
Conversation
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.
…date check, avoid scratch-file races
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.
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.
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-toolsprefix-matching makesgit diff --output=<path>reachable — verified writing 19,551 bytes. That defeats the assumptionguardrail.shdocuments in a comment: it enumerates only tracked modifications, so any created file went uninspected.audit.ymlwroteANTHROPIC_AUTH_TOKENinto$GITHUB_ENV, a file the grantedReadtool 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-jsonemits typed content blocks, so extraction keeps only{type:"text"}—thinkingandtool_usecannot reach the summary by construction.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:
3400→3500port change and a stale README. The auditor emitted 4thinkingblocks and 7tool_useblocks; the pipeline returned onlySubject: docs: sync…plus one bullet. Zero reasoning, zero framing, README correctly fixed.No documentation updates needed —form the validator requires, and passed.Changes
Bashgrant removed; token injected in-process;persist-credentials: falseonaudit-mainclassify-run·extract-summary·validate-summary·run-auditor·decide-outcome·render-status93 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:
.typeon a scalar is an error, not null) — same pattern found and fixed pre-emptively in a second script.[[ ]]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 acontents: writetoken in.git/configwhile the model runs with unrestrictedRead;audit-mainalready setspersist-credentials: false.infra.Follow-on work
This was split out of a larger design.
docs/superpowers/specs/2026-08-08-pi-harness-and-model-benchmark-design.mdcovers 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:
model~deepseek/deepseek-v4-flash-latestz-ai/glm-5.2small-model~deepseek/deepseek-v4-flash-latestdeepseek/deepseek-v4-flash-0731efforthighxhighmodel-capabilitieseffort,thinking,…effort,xhigh_effort,thinking,…Two things worth noting. Both slugs are now pinned to concrete versions rather than
~…-latestfloating aliases, so a given workflow SHA always audits with the same models. Andxhigh_efforthad to be added tomodel-capabilities— Claude Code gates reasoning by pattern-matching the model ID, so without that capability declared it clampsxhighback tohighand the new effort default would have been a silent no-op.