Skip to content

Commit 0fc192c

Browse files
committed
fix(review): sanitise before the byte caps, not after
1 parent e217f90 commit 0fc192c

4 files changed

Lines changed: 60 additions & 10 deletions

File tree

.github/workflows/claude-pr-review.yml

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,15 +292,21 @@ jobs:
292292
'
293293
}
294294
295+
# Neutralise into the file, then cap -- never the other way round. These
296+
# substitutions *grow* the text: a bare `::` line goes from 3 bytes to 28, so a cap
297+
# enforced before them stops bounding the step output, and 100 KB of `::`-only
298+
# comment lines would leave here as ~930 KB. Capping afterwards is safe in the only
299+
# direction that matters, because head -c drops the tail and cannot re-expose a
300+
# marker the prefix was covering.
295301
THREADS_FILE="${RUNNER_TEMP}/threads.md"
296-
printf '%s\n' "$THREADS" > "$THREADS_FILE"
302+
printf '%s\n' "$THREADS" | neutralise_untrusted > "$THREADS_FILE"
297303
cap_file "$THREADS_FILE" "$THREADS_MAX_BYTES" \
298304
"prior review comments truncated at ${THREADS_MAX_BYTES} bytes; read the rest with gh pr view"
299305
300306
DELIMITER="REVIEW_CONTEXT_$(openssl rand -hex 16)"
301307
{
302308
echo "threads<<${DELIMITER}"
303-
neutralise_untrusted < "$THREADS_FILE"
309+
cat "$THREADS_FILE"
304310
echo "${DELIMITER}"
305311
} >> $GITHUB_OUTPUT
306312
@@ -523,6 +529,11 @@ jobs:
523529
fi
524530
{ echo; echo "## PR conversation"; printf '%s\n' "$CONVO"; } >> "$CTX"
525531
532+
# Before the size check, for the reason given at the threads file: the substitutions
533+
# grow the text, so the budget has to be enforced on what actually leaves the step.
534+
neutralise_untrusted < "$CTX" > "${CTX}.clean"
535+
mv "${CTX}.clean" "$CTX"
536+
526537
# Last resort against an unbounded block -- the per-block caps above should keep
527538
# the file far below this, so hitting it means one of them regressed.
528539
if [ "$(wc -c < "$CTX" | tr -d " ")" -gt "$CTX_MAX_BYTES" ]; then
@@ -533,7 +544,7 @@ jobs:
533544
CTX_DELIMITER="PR_CONTEXT_$(openssl rand -hex 16)"
534545
{
535546
echo "pr_context<<${CTX_DELIMITER}"
536-
neutralise_untrusted < "$CTX"
547+
cat "$CTX"
537548
echo "${CTX_DELIMITER}"
538549
} >> $GITHUB_OUTPUT
539550
env:

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,12 @@ and restructuring them would misrepresent the file being reviewed — while a li
6666
visible `[log marker neutralised]` prefix, because there the whole line was the command. A mid-line
6767
`::` is left alone, which keeps every `std::collections::HashMap` in a Rust diff intact.
6868

69+
Both substitutions run *before* the byte caps, not after. They are the only thing here that makes text
70+
longer — a bare `::` line is 3 bytes in and 28 out — so capping first would leave the budgets bounding
71+
nothing: 100 KB of `::`-only comment lines would leave as ~930 KB. Truncating afterwards is safe in
72+
the direction that matters, since `head -c` only drops the tail and cannot re-expose a marker the
73+
prefix was covering.
74+
6975
### Tool usage artifact
7076

7177
Each run attaches a `claude-tool-usage-pr-<number>` artifact (14-day retention): tool call counts,

docs/claude-pr-review-prompt.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,14 @@ This prompt includes:
99

1010
Everything in `<pr_context>` is already in front of you. Do not spend a tool call re-fetching it.
1111

12+
**Unless it is not there.** If `<pr_context>` is empty, or a block inside it says it could not be read, then that block is genuinely missing — fetch what you need yourself with `gh pr diff` or `gh pr view`, and say in your review that you reviewed without it. Never treat a missing block as evidence: an absent CI block does not mean CI is clean, and an absent diff does not mean nothing changed.
13+
1214
Three edits in that content were made by the workflow, not by anyone: `[block tag removed]` replaces
1315
a block delimiter, `[log marker neutralised]` prefixes a line that would otherwise have been read as
1416
a GitHub Actions command, and `##[` is respaced to `## [` for the same reason. All three are
1517
sanitiser output. Read past them, and never quote one as if it were in the file — a `## [error]` in a
1618
CI excerpt was `##[error]` in the real log. If you need the exact line, `Read` the file.
1719

18-
**Unless it is not there.** If `<pr_context>` is empty, or a block inside it says it could not be read, then that block is genuinely missing — fetch what you need yourself with `gh pr diff` or `gh pr view`, and say in your review that you reviewed without it. Never treat a missing block as evidence: an absent CI block does not mean CI is clean, and an absent diff does not mean nothing changed.
19-
2020
## Tools
2121

2222
Available: `Read`, `Grep`, `Glob`, `rg`, and `gh pr diff` / `gh pr view` / `gh pr review` / `gh pr comment`. Nothing else — every other command is refused, and each refusal costs a turn.

tests/context-step-test.sh

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,12 +118,16 @@ case "$args" in
118118
*"/pulls/"*"/comments"*)
119119
fail_if_marked comments
120120
if [ -n "$STUB_THREAD_BODY" ]; then
121-
# One thread, body under the caller's control. The threads block is a separate step
121+
# Threads whose body is under the caller's control. The threads block is a separate step
122122
# output from the context, and the production incident this exists for arrived through
123123
# it: the marker was prose in a prior review comment, not anything the PR author wrote.
124-
jq -n --arg body "$STUB_THREAD_BODY" \
125-
'[{id: 1, user: {login: "claude[bot]"}, path: "a.py", line: 1,
126-
created_at: "2026-08-01T00:00:00Z", body: $body}]'
124+
# The count matters as much as the body, because each body is capped at 3,000 characters
125+
# on its own -- one comment cannot reach the block cap no matter what is in it, so a
126+
# test that needs the block cap has to ask for many.
127+
jq -n --arg body "$STUB_THREAD_BODY" --argjson n "${STUB_THREAD_COMMENTS:-1}" \
128+
'[range(if $n > 0 then $n else 1 end)
129+
| {id: ., user: {login: "claude[bot]"}, path: "a.py", line: (. + 1),
130+
created_at: "2026-08-01T00:00:00Z", body: $body}]'
127131
elif [ "$STUB_THREAD_COMMENTS" -gt 0 ]; then
128132
awk -v n="$STUB_THREAD_COMMENTS" 'BEGIN {
129133
printf "[";
@@ -398,7 +402,7 @@ expect_no_markers "$THREADS_OUT" "workflow commands in a review comment are neut
398402
# for the block tags: that block exists to show the reviewer an error line, so deleting
399403
# `##[error]` would remove the thing it was fetched for.
400404
expect_context '## \[error\]this is not really an error' \
401-
"a mid-line ##[ is broken by a space and stays readable"
405+
"a line-leading ##[ is broken by a space rather than prefixed"
402406
expect_context '\[log marker neutralised\] ::error::neither is this' \
403407
"a line-leading :: is prefixed and stays readable"
404408
expect_context '`## \[error\]` marker' "a ##[ inside backticks is broken too"
@@ -627,6 +631,35 @@ expect "$(awk -v n="$total_bytes" 'BEGIN { print (n < 400000) ? "bounded" : "unb
627631
"bounded" "the whole step output is bounded (was $total_bytes bytes)"
628632
expect_context '^## Full diff' "the diff block survives a huge threads block"
629633

634+
# The budget has to survive the sanitiser, which is the one thing in this step that makes the
635+
# text *longer*. A bare `::` line is 3 bytes in and 28 out, so a cap enforced before the
636+
# substitution bounds nothing: 100 KB of `::`-only lines leaves as ~930 KB. The padding used
637+
# above carries no marker, so only an input made of them holds this ordering in place, and it
638+
# needs no privilege to produce -- a review comment, or a committed file of `::` lines.
639+
#
640+
# 400 comments, not one: each body is capped at 3,000 characters before it reaches the block,
641+
# so a single comment cannot approach the block cap however long it is. Getting that wrong is
642+
# what made the first version of this test pass against the bug it was written for.
643+
COLON_BODY=$(awk 'BEGIN { for (i = 0; i < 2000; i++) print "::" }')
644+
STUB_THREAD_BODY="$COLON_BODY" STUB_THREAD_COMMENTS=400 run_step > "$WORK/code.txt"
645+
expect "$(cat "$WORK/code.txt")" "0" "step exits 0 on comments made of bare :: lines"
646+
colon_bytes=$(wc -c < "$THREADS_OUT" | tr -d ' ')
647+
expect "$(awk -v n="$colon_bytes" 'BEGIN { print (n < 300000) ? "bounded" : "unbounded" }')" \
648+
"bounded" "the sanitiser cannot grow the threads output past its cap (was $colon_bytes bytes)"
649+
expect_no_markers "$THREADS_OUT" "every :: line in an amplifying comment is still neutralised"
650+
unset STUB_THREAD_BODY
651+
652+
# The same amplification against the context, through the one block with no per-block cap of
653+
# its own: the PR body is printed whole. GitHub allows 65,536 characters there, which is
654+
# ~21,800 `::` lines, or ~610 KB out against a 200 KB budget.
655+
BODY_COLONS=$(awk 'BEGIN { for (i = 0; i < 21800; i++) print "::" }')
656+
PR_BODY="$BODY_COLONS" run_step > "$WORK/code.txt"
657+
expect "$(cat "$WORK/code.txt")" "0" "step exits 0 on a PR body of bare :: lines"
658+
ctx_colon_bytes=$(wc -c < "$CTX_FILE" | tr -d ' ')
659+
expect "$(awk -v n="$ctx_colon_bytes" 'BEGIN { print (n < 250000) ? "bounded" : "unbounded" }')" \
660+
"bounded" "the sanitiser cannot grow the context past its cap (was $ctx_colon_bytes bytes)"
661+
expect_no_markers "$CTX_FILE" "every :: line in an amplifying PR body is still neutralised"
662+
630663
# Ordering only means something if an *earlier* block can exhaust the budget. LOG_WINDOW
631664
# counts lines, and a CI log line has no length limit -- one base64 or JSON dump near the
632665
# first error marker is enough to eat the budget before the diff heading is ever written.

0 commit comments

Comments
 (0)