77# Two jobs. First, the projection has to actually answer the question it exists for: the
88# action prints only permission_denials.length to the job log, so the denied tool *names*
99# live nowhere else and a change that drops them would be invisible until someone went
10- # looking for data that was never collected.
10+ # looking for data that was never collected. Names turned out not to be enough -- 520 of
11+ # 567 denials in the first week were "Bash" -- so the projection labels commands too, and
12+ # the labels have to survive the same way.
1113#
1214# Second, and the reason this file is worth more than its assertions: the input is the full
1315# conversation -- every tool input and every tool result. The runner has a readable git
1416# credential (checkout persists one via includeIf into $RUNNER_TEMP/git-credentials-*.config)
1517# and Read is unrestricted, so a transcript can contain a live token. ::add-mask:: scrubs the
1618# job log but not artifacts. The leak assertions below hold the line that this artifact
17- # carries names and counts only; widening the projection to "just the tool inputs too" is
18- # exactly the change that would quietly publish a token.
19+ # carries names, counts, and labels drawn from a closed vocabulary -- never text from the
20+ # transcript. "Just the first token of the command" or "just the command prefix" is exactly
21+ # the change that would quietly publish a credential path, so the vocabulary assertion
22+ # below asserts the containment directly: every label in the artifact appears in CMD_JQ.
1923
2024set -euo pipefail
2125
2226cd " $( dirname " $0 " ) /.."
2327
24- WORKFLOW=.github/workflows/claude-pr-review.yml
28+ # shellcheck source=tests/lib.sh
29+ . tests/lib.sh
2530
26- TOOL_USAGE_JQ=$( sed -n " s/^ *TOOL_USAGE_JQ='\(.*\)'\$ /\1/p" " $WORKFLOW " )
27- if [ -z " $TOOL_USAGE_JQ " ]; then
28- echo " FAIL: no TOOL_USAGE_JQ='...' assignment found in $WORKFLOW " >&2
29- exit 1
30- fi
31- if [ " $( printf ' %s\n' " $TOOL_USAGE_JQ " | wc -l) " -ne 1 ]; then
32- echo " FAIL: more than one TOOL_USAGE_JQ assignment in $WORKFLOW " >&2
33- exit 1
34- fi
31+ CMD_JQ=$( extract_jq CMD_JQ)
32+ TOOL_USAGE_JQ=$( extract_jq TOOL_USAGE_JQ)
3533
3634failures=0
3735
38- # project <fixture> -- run the shipped filter over a fixture
36+ # project <fixture> -- run the shipped filter over a fixture, composed as the workflow does
3937project () {
40- jq " $TOOL_USAGE_JQ " " tests/fixtures/$1 "
38+ jq " $CMD_JQ $ TOOL_USAGE_JQ" " tests/fixtures/$1 "
4139}
4240
4341# expect_jq <fixture> <jq expression> <expected> <description>
@@ -69,14 +67,14 @@ expect_absent() {
6967 fi
7068}
7169
72- # The denial names are the whole point: two Grep denials and one Glob, ranked.
70+ # The denial names: three Bash, two Grep, one Glob, ranked.
7371expect_jq execution-log-denials.json ' .denials' \
74- ' [{"name":"Grep","n":2},{"name":"Glob","n":1}]' \
72+ ' [{"name":"Bash","n":3},{"name":" Grep","n":2},{"name":"Glob","n":1}]' \
7573 " denied tool names survive with counts"
7674
7775# Calls are counted per tool, most-used first, independent of whether they were denied.
7876expect_jq execution-log-denials.json ' .tool_calls[0]' \
79- ' {"name":"Grep ","n":2 }' \
77+ ' {"name":"Bash ","n":5 }' \
8078 " tool calls counted and ranked"
8179expect_jq execution-log-denials.json ' [.tool_calls[].name] | sort' \
8280 ' ["Bash","Grep","Read"]' \
@@ -86,6 +84,64 @@ expect_jq execution-log-denials.json '[.tool_calls[].name] | sort' \
8684expect_jq execution-log-denials.json ' .result.num_turns' ' 19' " turn count carried through"
8785expect_jq execution-log-denials.json ' .result.subtype' ' "success"' " result subtype carried through"
8886
87+ # Bash calls are labelled, and compound forms are counted apart from bare ones. `gh pr diff`
88+ # appears twice for that reason: allowlisted on its own, refused the moment it is redirected
89+ # into a file and chained -- which is why the artifact has to distinguish them.
90+ expect_jq execution-log-denials.json \
91+ ' [.commands[] | .cmd + (if .compound then " (compound)" else "" end)] | sort' \
92+ ' ["cat/head/tail","gh pr diff","gh pr diff (compound)","rg","run tests/build"]' \
93+ " Bash commands labelled, compound forms kept separate"
94+
95+ # `timeout 900 uv run pytest` has to reach "run tests/build" rather than "other": the
96+ # wrappers the reviewer puts in front of a command are what norm exists to strip.
97+ expect_jq execution-log-denials.json \
98+ ' [.commands[] | select(.cmd == "run tests/build")] | length' ' 1' \
99+ " timeout wrapper stripped before labelling"
100+
101+ # The denied subset is the actionable half: what the allowlist is actually costing.
102+ expect_jq execution-log-denials.json \
103+ ' [.denied_commands[] | .cmd + (if .compound then " (compound)" else "" end)] | sort' \
104+ ' ["cat/head/tail","gh pr diff (compound)","run tests/build"]' \
105+ " denied Bash commands labelled"
106+
107+ # Non-Bash denials carry no command label -- there is no command to label.
108+ expect_jq execution-log-denials.json ' [.denied_commands[].n] | add' ' 3' \
109+ " only Bash denials appear in denied_commands"
110+
111+ # The containment assertion, and the one that has to keep holding: every label the
112+ # projection emits is a literal in CMD_JQ. Nothing derived from the transcript can satisfy
113+ # it, so the artifact cannot grow a credential path, a search pattern, or a file name
114+ # without this failing first.
115+ vocabulary=$( printf ' %s' " $CMD_JQ " | grep -o ' ", "[a-z /]*"\]' | sed ' s/^", "//; s/"\]$//' | sort -u)
116+ if [ -z " $vocabulary " ]; then
117+ echo " FAIL vocabulary: no labels found in CMD_JQ, so the containment test proves nothing"
118+ failures=$(( failures + 1 ))
119+ else
120+ emitted=$( project execution-log-denials.json | jq -r ' [.commands[], .denied_commands[]] | .[].cmd' | sort -u)
121+ unknown=$( comm -23 <( printf ' %s\n' " $emitted " ) <( printf ' %s\n' " $vocabulary " | grep -v ' ^other$' ; echo other) )
122+ if [ -z " $unknown " ]; then
123+ echo " ok every emitted label comes from the CMD_JQ vocabulary"
124+ else
125+ echo " FAIL emitted labels outside the CMD_JQ vocabulary: $unknown "
126+ failures=$(( failures + 1 ))
127+ fi
128+ fi
129+
130+ # An unrecognised command must fall back to "other" and carry none of itself across. A bare
131+ # curl with a bearer token is the worst case: the whole command is the secret.
132+ leaky=' [{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","id":"t","name":"Bash","input":{"command":"curl -H \"Authorization: Bearer ghs_FAKETOKENFORTESTS\" https://api.github.com"}}]}}]'
133+ leaked=$( printf ' %s' " $leaky " | jq -c " $CMD_JQ $TOOL_USAGE_JQ " )
134+ if printf ' %s' " $leaked " | grep -qF " ghs_FAKETOKENFORTESTS" \
135+ || printf ' %s' " $leaked " | grep -qF " curl" ; then
136+ echo " FAIL unrecognised command leaked into the projection: $leaked "
137+ failures=$(( failures + 1 ))
138+ elif printf ' %s' " $leaked " | jq -e ' .commands == [{"cmd":"other","compound":false,"n":1}]' > /dev/null; then
139+ echo " ok unrecognised command reduces to \" other\" "
140+ else
141+ echo " FAIL unrecognised command did not reduce to \" other\" : $leaked "
142+ failures=$(( failures + 1 ))
143+ fi
144+
89145# The leak assertions. The fixture has the reviewer reading the runner's git credentials
90146# file, which is the concrete path by which a token reaches the transcript.
91147expect_absent execution-log-denials.json \
@@ -100,6 +156,12 @@ expect_absent execution-log-denials.json \
100156expect_absent execution-log-denials.json \
101157 " retention-days" \
102158 " search patterns from a denied call do not reach the artifact"
159+ expect_absent execution-log-denials.json \
160+ " ANTHROPIC_API_KEY" \
161+ " search patterns inside a Bash command do not reach the artifact"
162+ expect_absent execution-log-denials.json \
163+ " pr21.diff" \
164+ " file names inside a Bash command do not reach the artifact"
103165
104166# A clean run: no denials, and the MCP inline-comment tool counted like any other.
105167expect_jq execution-log-clean.json ' .denials' ' []' " clean run reports no denials"
@@ -111,14 +173,16 @@ expect_jq execution-log-clean.json '[.tool_calls[].name] | sort' \
111173# continue-on-error, but the filter should still produce a usable artifact rather than
112174# abort, so the tool calls made before the run died are not lost.
113175expect_jq execution-log-truncated.json ' .denials' ' []' " log with no result message yields no denials"
176+ expect_jq execution-log-truncated.json ' .denied_commands' ' []' \
177+ " log with no result message yields no denied commands"
114178expect_jq execution-log-truncated.json ' .tool_calls' ' [{"name":"Read","n":1}]' \
115179 " tool calls survive a log with no result message"
116180expect_jq execution-log-truncated.json ' .result.num_turns' ' null' \
117181 " missing result message yields null fields, not an error"
118182
119183# Degenerate input must not crash the filter.
120- empty=$( printf ' []' | jq -c " $TOOL_USAGE_JQ " )
121- if [ " $empty " = ' {"tool_calls":[],"denials":[],"result":{"subtype":null,"is_error":null,"num_turns":null,"duration_ms":null,"total_cost_usd":null}}' ]; then
184+ empty=$( printf ' []' | jq -c " $CMD_JQ $ TOOL_USAGE_JQ" )
185+ if [ " $empty " = ' {"tool_calls":[],"commands":[]," denials":[],"denied_commands ":[],"result":{"subtype":null,"is_error":null,"num_turns":null,"duration_ms":null,"total_cost_usd":null}}' ]; then
122186 echo " ok empty log projects to empty counts"
123187else
124188 echo " FAIL empty log: got $empty "
0 commit comments