Skip to content

Commit 4b90d89

Browse files
authored
Merge pull request #34 from hotdata-dev/fix/reland-subst-flag
feat(review): flag command substitution in the tool usage artifact
2 parents 47ae686 + eff75a4 commit 4b90d89

5 files changed

Lines changed: 291 additions & 7 deletions

File tree

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

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -277,15 +277,38 @@ jobs:
277277
# norm strips the wrappers the reviewer puts in front of a real command (timeout,
278278
# cd .. &&, env VAR=x) so they do not all collapse into "other". verb returns the
279279
# first matching label or "other" -- the output is always one of these literals.
280-
CMD_JQ='def norm: sub("^\\s+"; "") | sub("^timeout\\s+[0-9]+m?\\s+"; "") | sub("^cd\\s+[^&|;]+&&\\s*"; "") | sub("^env\\s+\\S+=\\S+\\s+"; ""); def verb: . as $c | ([[["^gh\\s+pr\\s+diff", "gh pr diff"], ["^gh\\s+pr\\s+view", "gh pr view"], ["^gh\\s+pr\\s+checks", "gh pr checks"], ["^gh\\s+pr\\s+review", "gh pr review"], ["^gh\\s+pr\\s+comment", "gh pr comment"], ["^gh\\s+api", "gh api"], ["^gh\\s", "gh other"], ["^git\\s+diff", "git diff"], ["^git\\s+log", "git log"], ["^git\\s+show", "git show"], ["^git\\s+blame", "git blame"], ["^git\\s", "git other"], ["^rg\\b", "rg"], ["^grep\\b", "grep"], ["^(fd|find)\\b", "find"], ["^(ls|tree)\\b", "ls"], ["^(sed|awk)\\b", "sed/awk"], ["^(cat|head|tail|wc)\\b", "cat/head/tail"], ["^(pytest|uv|python3?|cargo|npm|pnpm|yarn|bun|node|go|make|ruff|mypy|pyflakes)\\b", "run tests/build"]][] | select(.[0] as $re | $c | test($re))] | .[0] // ["", "other"]) | .[1]; def unquoted: gsub("\"[^\"]*\""; "") | gsub("\u0027[^\u0027]*\u0027"; ""); def classify: {cmd: (norm | verb), compound: (unquoted | test("\\||&&|;|>"))}; def toolname: if type == "string" and test("^[A-Za-z0-9_-]{1,64}$") then . else "unknown" end;'
280+
CMD_JQ='def norm: sub("^\\s+"; "") | sub("^timeout\\s+[0-9]+m?\\s+"; "") | sub("^cd\\s+[^&|;]+&&\\s*"; "") | sub("^env\\s+\\S+=\\S+\\s+"; ""); def verb: . as $c | ([[["^gh\\s+pr\\s+diff", "gh pr diff"], ["^gh\\s+pr\\s+view", "gh pr view"], ["^gh\\s+pr\\s+checks", "gh pr checks"], ["^gh\\s+pr\\s+review", "gh pr review"], ["^gh\\s+pr\\s+comment", "gh pr comment"], ["^gh\\s+api", "gh api"], ["^gh\\s", "gh other"], ["^git\\s+diff", "git diff"], ["^git\\s+log", "git log"], ["^git\\s+show", "git show"], ["^git\\s+blame", "git blame"], ["^git\\s", "git other"], ["^rg\\b", "rg"], ["^grep\\b", "grep"], ["^(fd|find)\\b", "find"], ["^(ls|tree)\\b", "ls"], ["^(sed|awk)\\b", "sed/awk"], ["^(cat|head|tail|wc)\\b", "cat/head/tail"], ["^(pytest|uv|python3?|cargo|npm|pnpm|yarn|bun|node|go|make|ruff|mypy|pyflakes)\\b", "run tests/build"]][] | select(.[0] as $re | $c | test($re))] | .[0] // ["", "other"]) | .[1]; def unquoted: gsub("\"[^\"]*\""; "") | gsub("\u0027[^\u0027]*\u0027"; ""); def classify: {cmd: (norm | verb), compound: (unquoted | test("\\||&&|;|>")), has_subst: test("`|\\$\\(")}; def toolname: if type == "string" and test("^[A-Za-z0-9_-]{1,64}$") then . else "unknown" end;'
281+
#
282+
# has_subst is the same kind of flag for the denials that outlived the frontloaded
283+
# context. Reads mostly stopped being refused once the context arrived in the prompt
284+
# -- denials fell from 5.2 a run to 0.5 -- and what is left is the *write* path:
285+
# `gh pr review` is allowlisted and still refused on 29% of its 241 attempts across
286+
# 170 settled-window runs and 8 repos, 1.6 times per affected run before the review
287+
# lands, costing those runs +$0.46 and +73s each (issue #33). compound reported 1 of
288+
# those 71, which is the point: it cannot see this. The standing hypothesis is the
289+
# review body rather than the command -- a body is markdown, and a backtick inside a
290+
# double-quoted argument is command substitution to anything parsing shell. So the
291+
# flag is tested against the *raw* command, not the unquoted form compound uses:
292+
# stripping quoted spans first would remove precisely the backticks in question. It
293+
# rides on `commands` as well as `denied_commands` because a denial rate needs its
294+
# base rate to mean anything. Boolean, like compound: a label, never a span of the
295+
# command.
296+
#
297+
# This shipped once before, in #26, and was reverted in 8b04393 -- not on its own
298+
# merits: #26 pushed the *context* step's run block to 22,016 characters, past the
299+
# 21,000-character expression limit, and the revert took this with it. #29 then moved
300+
# that step out to scripts/gather-review-context.sh, so the budget that forced the
301+
# revert is gone. The step below is 3.3k with the flag; the guard in
302+
# tests/workflow-lint-test.sh now fails before a run block can reach the limit again.
303+
#
281304
# commands and denied_commands answer two different questions: what the reviewer
282305
# spends its Bash budget on, and which of those the allowlist refuses. compound is
283306
# carried separately because an allowlisted command still gets denied when it is
284307
# piped or redirected, which no tool name or verb alone would show -- and it is
285308
# tested against the command with quoted spans removed, because `rg -n \"a|b\"` is
286309
# one allowlisted command and counting its alternation as a pipe would inflate
287310
# exactly the number the flag exists to produce.
288-
TOOL_USAGE_JQ='{tool_calls: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | .name | toolname] | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), commands: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | (.input.command // "") | classify] | group_by([.cmd, .compound]) | map({cmd: .[0].cmd, compound: .[0].compound, n: length}) | sort_by(-.n)), denials: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(.tool_name | toolname) | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), denied_commands: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(select(.tool_name == "Bash") | (.tool_input.command // "") | classify) | group_by([.cmd, .compound]) | map({cmd: .[0].cmd, compound: .[0].compound, n: length}) | sort_by(-.n)), result: (([.[]? | select(.type=="result")] | last // {}) | {subtype, is_error, num_turns, duration_ms, total_cost_usd})}'
311+
TOOL_USAGE_JQ='{tool_calls: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use") | .name | toolname] | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), commands: ([.[]? | select(.type=="assistant") | .message.content[]? | select(.type=="tool_use" and .name=="Bash") | (.input.command // "") | classify] | group_by([.cmd, .compound, .has_subst]) | map({cmd: .[0].cmd, compound: .[0].compound, has_subst: .[0].has_subst, n: length}) | sort_by(-.n)), denials: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(.tool_name | toolname) | group_by(.) | map({name: .[0], n: length}) | sort_by(-.n)), denied_commands: (([.[]? | select(.type=="result")] | last // {}) | (.permission_denials // []) | map(select(.tool_name == "Bash") | (.tool_input.command // "") | classify) | group_by([.cmd, .compound, .has_subst]) | map({cmd: .[0].cmd, compound: .[0].compound, has_subst: .[0].has_subst, n: length}) | sort_by(-.n)), result: (([.[]? | select(.type=="result")] | last // {}) | {subtype, is_error, num_turns, duration_ms, total_cost_usd})}'
289312
jq "$CMD_JQ $TOOL_USAGE_JQ" "$EXECUTION_FILE" > "${RUNNER_TEMP}/claude-tool-usage.json"
290313
env:
291314
# Via env, not a ${{ }} interpolation inside the script, so the path cannot be

README.md

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,21 @@ them would otherwise end the data block early and land the rest where it reads a
5151
### Tool usage artifact
5252

5353
Each run attaches a `claude-tool-usage-pr-<number>` artifact (14-day retention): tool call counts,
54-
Bash command labels with a compound flag, the denied subset of both, and the run's turn count and
55-
cost. It exists to diagnose permission denials against the workflow's `--allowedTools` list, since
56-
the job log records only the number of denials, never what was refused. Tool names alone proved
57-
insufficient — 520 of 567 denials in the first week were `Bash`, which is every command there is.
54+
Bash command labels with a compound flag and a command-substitution flag, the denied subset of both,
55+
and the run's turn count and cost. It exists to diagnose permission denials against the workflow's
56+
`--allowedTools` list, since the job log records only the number of denials, never what was refused.
57+
Tool names alone proved insufficient — 520 of 567 denials in the first week were `Bash`, which is
58+
every command there is.
59+
60+
The two flags are deliberately measured differently, and the difference is the point. `compound`
61+
strips quoted spans before looking for `| && ; >`, because `rg -n "a|b"` is one allowlisted command
62+
and counting its alternation as a pipe would inflate the number the flag exists to produce.
63+
`has_subst` tests the raw command for `` ` `` and `$(`, because the suspected trigger lives *inside*
64+
the quoted body: a review body is markdown, and a backtick in a double-quoted argument is command
65+
substitution to anything parsing shell. Frontloading the context fixed the read path — denials fell
66+
from 5.2 a run to 0.5 — but `gh pr review` is allowlisted and still refused on 29% of its 241
67+
attempts across 170 runs and 8 repos, at +$0.46 and +73s per affected run, with `compound` reporting
68+
1 of those 71. Measuring `has_subst` the same way as `compound` would have kept that invisible.
5869

5970
The artifact is a projection of the action's execution log, never the log itself — that file is the
6071
full conversation, and the runner holds a git credential the reviewer can read, which artifacts
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
[
2+
{
3+
"type": "assistant",
4+
"message": {
5+
"role": "assistant",
6+
"content": [
7+
{
8+
"type": "tool_use",
9+
"id": "t1",
10+
"name": "Bash",
11+
"input": {
12+
"command": "gh pr review 308 --request-changes --body \"**Blocking:** `components/GetUpdates.tsx:5` hardcodes the endpoint. Route it through `app/api/get-updates/route.ts` and apply `rateLimit(ip, 5, 60000 * 10)`.\""
13+
}
14+
}
15+
]
16+
}
17+
},
18+
{
19+
"type": "assistant",
20+
"message": {
21+
"role": "assistant",
22+
"content": [
23+
{
24+
"type": "tool_use",
25+
"id": "t2",
26+
"name": "Bash",
27+
"input": {
28+
"command": "gh pr review 308 --request-changes --body \"Blocking: components/GetUpdates.tsx line 5 hardcodes the endpoint. Route it through app/api/get-updates/route.ts and apply the rate limiter.\""
29+
}
30+
}
31+
]
32+
}
33+
},
34+
{
35+
"type": "result",
36+
"subtype": "success",
37+
"is_error": false,
38+
"num_turns": 11,
39+
"duration_ms": 151402,
40+
"total_cost_usd": 0.8021,
41+
"permission_denials": [
42+
{
43+
"tool_name": "Bash",
44+
"tool_input": {
45+
"command": "gh pr review 308 --request-changes --body \"**Blocking:** `components/GetUpdates.tsx:5` hardcodes the endpoint. Route it through `app/api/get-updates/route.ts` and apply `rateLimit(ip, 5, 60000 * 10)`.\""
46+
}
47+
}
48+
]
49+
}
50+
]

tests/tool-usage-test.sh

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,64 @@ expect_compound "rg -n 'a|b' src/ | head -20" true \
141141
expect_compound 'rg -n foo src/' false \
142142
"a plain search is not compound"
143143

144+
# has_subst answers the denial the frontloaded context did not remove: an allowlisted
145+
# `gh pr review` refused on the way to posting. Unlike compound it is tested against the raw
146+
# command, because the suspected trigger lives *inside* the quoted body -- a review body is
147+
# markdown, and backticks in a double-quoted argument are command substitution to anything
148+
# parsing shell. Running it through `unquoted` first would delete the evidence.
149+
#
150+
# Measured before this landed: `gh pr review` is refused on 29% of its 241 attempts across
151+
# 170 settled-window runs, on 8 repos, costing the affected runs +$0.46 and +73s each --
152+
# while `compound` reported 1 of 71. See issue #33.
153+
subst_of() {
154+
printf '%s' "$1" | jq -R -r "$CMD_JQ classify | .has_subst | tostring"
155+
}
156+
# expect_subst <command> <true|false> <description>
157+
expect_subst() {
158+
local actual
159+
actual=$(subst_of "$1")
160+
if [ "$actual" = "$2" ]; then
161+
echo "ok $3"
162+
else
163+
echo "FAIL $3: expected has_subst=$2, got $actual for: $1"
164+
failures=$((failures + 1))
165+
fi
166+
}
167+
168+
expect_subst 'gh pr review 21 --approve --body "nit: `foo` is wrong"' true \
169+
"a backtick inside the review body is flagged"
170+
expect_subst 'gh pr comment 21 --body "see $(basename x)"' true \
171+
"an explicit command substitution is flagged"
172+
expect_subst 'gh pr review 21 --approve --body "no markdown here"' false \
173+
"a plain body is not flagged"
174+
expect_subst 'rg -n foo src/' false \
175+
"a plain search is not flagged"
176+
# The distinction from compound, stated as an assertion: quoted spans are removed for one
177+
# flag and kept for the other, so a body whose only shell-ish characters are backticks is
178+
# has_subst without being compound. Getting these the same way round would make the two
179+
# columns redundant and lose the write-path denials again.
180+
expect_compound 'gh pr review 21 --approve --body "nit: `foo` is wrong"' false \
181+
"a backtick in a quoted body is not compound"
182+
183+
# End to end over the shape actually seen in production: the reviewer's first
184+
# `gh pr review --request-changes` was refused, and the retry that landed carried the same
185+
# feedback with the backticks removed. Both rows are `gh pr review`; has_subst is the only
186+
# thing that tells them apart, which is the whole reason it is grouped on.
187+
expect_jq execution-log-review-body.json \
188+
'[.commands[] | {cmd, has_subst, n}] | sort_by(.has_subst)' \
189+
'[{"cmd":"gh pr review","has_subst":false,"n":1},{"cmd":"gh pr review","has_subst":true,"n":1}]' \
190+
"the flagged and unflagged attempts are counted apart"
191+
expect_jq execution-log-review-body.json '.denied_commands' \
192+
'[{"cmd":"gh pr review","compound":false,"has_subst":true,"n":1}]' \
193+
"the denied review post is flagged and not compound"
194+
195+
# Same boundary as every other label: the flag is a boolean, so no part of the body it was
196+
# computed from may ride along with it.
197+
expect_absent execution-log-review-body.json "GetUpdates.tsx" \
198+
"the review body does not reach the artifact"
199+
expect_absent execution-log-review-body.json "rateLimit" \
200+
"code quoted in the review body does not reach the artifact"
201+
144202
# The containment assertion, and the one that has to keep holding: every label the
145203
# projection emits is a literal in CMD_JQ. Nothing derived from the transcript can satisfy
146204
# it, so the artifact cannot grow a credential path, a search pattern, or a file name
@@ -202,7 +260,7 @@ if printf '%s' "$leaked" | grep -qF "ghs_FAKETOKENFORTESTS" \
202260
|| printf '%s' "$leaked" | grep -qF "curl"; then
203261
echo "FAIL unrecognised command leaked into the projection: $leaked"
204262
failures=$((failures + 1))
205-
elif printf '%s' "$leaked" | jq -e '.commands == [{"cmd":"other","compound":false,"n":1}]' >/dev/null; then
263+
elif printf '%s' "$leaked" | jq -e '.commands == [{"cmd":"other","compound":false,"has_subst":false,"n":1}]' >/dev/null; then
206264
echo "ok unrecognised command reduces to \"other\""
207265
else
208266
echo "FAIL unrecognised command did not reduce to \"other\": $leaked"

0 commit comments

Comments
 (0)