ci/woodpecker/pr/ci Pipeline was successful
Classify git clone and worktree add operands so HOME-valued environment assignments, sources, references, templates, and metadata do not impersonate checkout destinations. Preserve both forms of clone --separate-git-dir as real placement targets and distinguish shell words, command boundaries, and redirections in the existing quote-aware normalized stream. Deliberate fail-closed residual: unknown future Git options with a separate following word are not adjudicated as source-only. Their value remains a possible placement, so a HOME-shaped value blocks rather than silently creating a bypass. Relative destinations whose effective path depends on cwd remain out of scope in #1197.
621 lines
58 KiB
Bash
Executable File
621 lines
58 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# test-wrapper-guard.sh — hermetic behavioural regression for wrapper-guard.sh.
|
|
#
|
|
# Resolves no credentials, touches no network, and creates no repository: the
|
|
# guard reads a hook payload on stdin and answers with an exit code, so the whole
|
|
# contract is testable from fixtures.
|
|
#
|
|
# The fixtures are written to a temp file rather than passed inline, and this is
|
|
# not stylistic. The guard inspects the literal text of the Bash command it is
|
|
# handed. A test that embeds `git clone ... $HOME` inside its own command line
|
|
# trips the guard on the harness instead of on the fixture — which is exactly
|
|
# what happened the first time this was checked by hand. Substring matching over
|
|
# whole command text is the guard's deliberate fail-closed posture; a test that
|
|
# does not account for it silently measures the wrong thing.
|
|
#
|
|
# Exit: 0 = every fixture behaved as specified · 1 = at least one did not
|
|
|
|
set -uo pipefail
|
|
|
|
HERE="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
|
GUARD="${1:-$HERE/wrapper-guard.sh}"
|
|
[ -x "$GUARD" ] || { printf 'test-wrapper-guard: not executable: %s\n' "$GUARD" >&2; exit 2; }
|
|
|
|
TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
|
|
FIXTURES="$TMP/fixtures.tsv"
|
|
|
|
# Each line: <expected-exit> TAB <hook payload> TAB <what it proves>
|
|
# [ TAB <substring the block message must contain> ]
|
|
# 0 = allowed, 2 = blocked. The optional fourth field is how the remediation
|
|
# itself gets checked; without it a block is only asserted to have happened,
|
|
# not to have been useful.
|
|
{
|
|
printf '2\t{"tool_input":{"command":"git clone https://example.invalid/x ~/wt"}}\tcheckout into $HOME is refused\n'
|
|
printf '2\t{"tool_input":{"command":"git worktree add ~/wt topic"}}\tworktree into $HOME is refused\n'
|
|
printf '2\t{"tool_input":{"command":"g\\"it\\" clone https://example.invalid/x $HOME/wt"}}\ta double quote inside git does not hide a checkout\n'
|
|
printf '2\t{"tool_input":{"command":"g'"'"'it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta single quote inside git does not hide a checkout\n'
|
|
printf '2\t{"tool_input":{"command":"g\\\\it clone https://example.invalid/x $HOME/wt"}}\tan unquoted escape inside git does not hide a checkout\n'
|
|
# Path words use the same quote/escape state machine as names, but preserve
|
|
# substitutions so HOME remains visible. Quotes do not split the path word.
|
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"/wt"}}\ta closing quote between HOME and slash does not hide the path\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}/wt"}}\tthe braced HOME spelling is the same home path\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x \\"${HOME}\\"/wt"}}\tbraced HOME may also end a quoted span before the slash\n'
|
|
# The target may be HOME itself. End-of-command and whitespace terminate the
|
|
# token just as a slash does; punctuation that can extend a path does not.
|
|
printf '2\t{"tool_input":{"command":"git clone x $HOME"}}\tthe unbraced variable may name HOME exactly\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\""}}\tquotes do not change the exact HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}"}}\tthe braced variable may name HOME exactly\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x ~"}}\ttilde may name HOME exactly\n'
|
|
printf '2\t{"tool_input":{"command":"git worktree add $HOME topic"}}\twhitespace terminates an exact HOME target before another argument\n'
|
|
# Unquoted POSIX metacharacters terminate the target word even without spaces.
|
|
printf '2\t{"tool_input":{"command":"git clone x $HOME;echo x"}}\tsemicolon terminates an exact HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x \\"$HOME\\"&& echo x"}}\tand-if terminates a quoted exact HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x ${HOME}| cat"}}\ta pipe terminates a braced exact HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x ~&"}}\tbackground operator terminates a tilde HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x $HOME</dev/null"}}\tinput redirection terminates the target word\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x $HOME>out"}}\toutput redirection terminates the target word\n'
|
|
printf '2\t{"tool_input":{"command":"( git clone x $HOME)"}}\ta subshell close terminates the exact HOME target\n'
|
|
printf '2\t{"tool_input":{"command":"git clone x $HOME\\necho x"}}\ta literal newline terminates the target word\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME_BACKUP/wt"}}\ta longer HOME-prefixed variable is a different path\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOMEBREW/wt"}}\tHOMEBREW is not HOME either\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME.bak/wt"}}\ta dot continues the path token into a sibling name\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt"}}\tplus is ordinary sibling filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt"}}\tat-sign is ordinary sibling filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME,bak/wt"}}\tcomma is ordinary sibling filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME:bak/wt"}}\tcolon is ordinary sibling filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME=bak/wt"}}\tequals is ordinary sibling filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x ${HOME}+bak/wt"}}\tbraced HOME plus suffix is still a sibling\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester+bak/wt"}}\ta literal plus-suffixed home path is a sibling\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester@bak/wt"}}\ta literal at-suffixed home path is a sibling\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME+bak/wt;echo x"}}\ta later terminator does not turn a sibling into HOME\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME@bak/wt&& echo x"}}\tand-if after a sibling preserves the allow\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x \\"$HOME;bak/wt\\""}}\ta quoted semicolon is filename content, not a boundary\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME\\\\;bak/wt"}}\tan escaped semicolon is filename content, not a boundary\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME\\u001b/wt"}}\ta raw internal-marker byte is encoded as filename content\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x /home/tester.bak/wt"}}\ta literal sibling path is not beneath HOME\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x /home/testerx/wt"}}\ta longer literal basename is not HOME\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x ~root/wt"}}\tanother account tilde is not this account HOME\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x $HOME}/wt"}}\ta closing brace without an opening brace is a literal suffix\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x ${HOME/wt"}}\tan opening brace without a close is not a HOME expansion\n'
|
|
# Quote removal must not create an expansion the shell never performs.
|
|
printf '0\t{"tool_input":{"command":"git clone x '"'"'$HOME'"'"'/wt"}}\tsingle-quoted HOME is a literal directory name\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x \\\\$HOME/wt"}}\tan escaped dollar makes HOME literal outside quotes\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x \\"\\\\$HOME\\"/wt"}}\tan escaped dollar makes HOME literal inside double quotes\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x \\"~/wt\\""}}\ttilde does not expand inside double quotes\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x '"'"'~/wt'"'"'"}}\ttilde does not expand inside single quotes\n'
|
|
printf '0\t{"tool_input":{"command":"git clone x \\\\~/wt"}}\tan escaped tilde is literal too\n'
|
|
printf '0\t{"tool_input":{"command":"git clone https://example.invalid/x /src/wt"}}\tcheckout onto a work filesystem is fine\n'
|
|
# Round ten: placement is decided by the destination and the one clone option
|
|
# that creates repository state elsewhere, not by every HOME-valued word in
|
|
# the command. Sources, templates, references, and environment are not targets.
|
|
printf '0\t{"tool_input":{"command":"NOTE=$HOME git clone https://example.invalid/x /src/wt"}}\tan unrelated assignment carrying HOME is not checkout placement\n'
|
|
printf '0\t{"tool_input":{"command":"git clone --reference=$HOME https://example.invalid/x /src/wt"}}\ta HOME reference is an object source, not checkout placement\n'
|
|
printf '0\t{"tool_input":{"command":"GIT_DIR=$HOME/x git clone https://example.invalid/x /src/wt"}}\tclone does not place its destination from ambient GIT_DIR\n'
|
|
printf '0\t{"tool_input":{"command":"git clone --template=$HOME/t https://example.invalid/x /src/wt"}}\ta HOME template source is not checkout placement\n'
|
|
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir=$HOME/gd https://example.invalid/x /src/wt"}}\tseparate-git-dir explicitly places repository state under HOME\n'
|
|
printf '2\t{"tool_input":{"command":"git clone --separate-git-dir $HOME/gd https://example.invalid/x /src/wt"}}\tthe space-separated placement option is equivalent\n'
|
|
printf '0\t{"tool_input":{"command":"git worktree add --reason=$HOME/note /src/wt"}}\ta worktree reason is metadata, not its path\n'
|
|
printf '0\t{"tool_input":{"command":"git clone $HOME/source /src/wt"}}\ta HOME source with an explicit safe destination is not placement\n'
|
|
printf '0\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x /src/wt"}}\ta space-separated HOME reference remains a source\n'
|
|
printf '0\t{"tool_input":{"command":"git clone --template $HOME/t https://example.invalid/x /src/wt"}}\ta space-separated HOME template remains a source\n'
|
|
printf '2\t{"tool_input":{"command":"git clone 2>/dev/null https://example.invalid/x $HOME/wt"}}\ta redirection before clone arguments does not become the destination\n'
|
|
printf '2\t{"tool_input":{"command":"git clone --reference $HOME https://example.invalid/x $HOME/wt"}}\ta source option does not hide a later HOME destination\n'
|
|
# Routing this arm through the shared name site also repaired an over-block it
|
|
# had carried from the start: the old whole-command regex found `git` INSIDE a
|
|
# longer word, so these two were refused at every head before this commit.
|
|
# Same class as mycurl and curl-wrapper, and refusing them is how a guard gets
|
|
# routed around instead of repaired.
|
|
printf '0\t{"tool_input":{"command":"mygit clone https://example.invalid/x $HOME/wt"}}\tmygit is a different program and its checkout is not ours\n'
|
|
printf '0\t{"tool_input":{"command":"gitfoo clone https://example.invalid/x $HOME/wt"}}\tthe name has to end where git ends\n'
|
|
printf '0\t{"tool_input":{"command":"curl -s -X GET https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\treads are never blocked\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\treview write has a wrapper\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tmerge write has a wrapper\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://api.github.com/repos/a/b/issues"}}\tGitHub host is covered too\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/releases"}}\tan endpoint with no wrapper passes\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVE\\"} https://example.invalid/x"}}\tthe APPROVE token is caught anywhere\n'
|
|
printf '0\t{"tool_input":{"command":"ls -la /src"}}\tordinary commands are untouched\n'
|
|
printf '0\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=1 curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tbreak-glass works\n'
|
|
printf '0\t{"tool_input":{}}\tan empty payload does not block the session\n'
|
|
# --- bypasses an independent reviewer demonstrated against the first version.
|
|
# Each of these returned 0 (allowed) and each is a real write. They are pinned
|
|
# as fixtures rather than fixed-and-forgotten because the class is recurring:
|
|
# the guard reads text, so every spelling it does not know is a hole.
|
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t-d@body with no space is still a body\n'
|
|
printf '2\t{"tool_input":{"command":"curl --request=POST -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t--request=POST equals-form is still a method\n'
|
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@b https://git.example.invalid${p}${q}"}}\ta path split across variables is still that path\n'
|
|
printf '2\t{"tool_input":{"command":"curl --data-binary @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\t--data-binary is a body\n'
|
|
printf '2\t{"tool_input":{"command":"curl -F f=@b https://git.example.invalid/api/v1/repos/a/b/issues"}}\t-F multipart is a body\n'
|
|
# Reads must survive every one of those broadenings, or the guard gets disabled.
|
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tno body and no verb is a read\n'
|
|
printf '0\t{"tool_input":{"command":"grep -rn /pulls/ src/ | head -20"}}\ta path fragment in a grep is not an API call\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://registry.example.invalid/v2/x/manifests/latest"}}\tan unwrapped API is not this guard'"'"'s business\n'
|
|
# --- round two of the same review. Splitting the ENDPOINT TOKEN defeats any
|
|
# amount of fragment matching, because the endpoint does not exist until the
|
|
# shell expands it. The guard now refuses to clear a write whose URL it cannot
|
|
# read, rather than pretending it read one.
|
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/iss; b=ues/1/comments; curl -d@body https://git.example.invalid${a}${b}"}}\tan endpoint token split across variables is unreadable, not absent\n'
|
|
printf '2\t{"tool_input":{"command":"a=/api/v1/repos/a/b/pu; b=lls/1/reviews; curl -d@body https://git.example.invalid${a}${b}"}}\tsame split, review endpoint\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @payload https://hooks.example.invalid/services/${WEBHOOK_ID}"}}\tan opaque URL that is not forge-shaped stays allowed\n'
|
|
# --- round six changed the contract in this direction, and these fixtures are
|
|
# where it shows. They used to assert that discussing a call is not making one.
|
|
# Five rounds proved there is no textual way to tell a quoted example from a
|
|
# quoted command, so the guard stopped trying: it judges the payload, and a
|
|
# payload inside quotes is still a payload. Quoting one of these on a Bash
|
|
# command line is now refused, and the way to write the example is a
|
|
# file-writing tool. This is the deliberate cost of the mechanism change.
|
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tquoting a wrapped write is refused even in a grep\n'
|
|
printf '2\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\t...and when written into a file\n'
|
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\t...and when printed from another language\n'
|
|
# The boundary that keeps this from being "block everything": what is refused
|
|
# is a WRITE to a WRAPPED endpoint. Mentioning either alone still passes, and
|
|
# these are asserted as hard as the blocks above.
|
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -s https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/"}}\tquoting a READ example is untouched\n'
|
|
printf '0\t{"tool_input":{"command":"echo \\"the wrapped endpoint is https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tnaming the endpoint without a body flag is untouched\n'
|
|
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/releases\\" docs/"}}\tquoting a write to an UNWRAPPED endpoint is untouched\n'
|
|
printf '0\t{"tool_input":{"command":"issue-comment.sh --repo a/b --issue 1 --body @msg.md"}}\tthe wrapper itself carries a body flag and must never trip its own guard\n'
|
|
# Command position must still catch the real thing behind operators and env.
|
|
printf '2\t{"tool_input":{"command":"cd /tmp && GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\ta real call behind && and an assignment is still a call\n'
|
|
# --- the case the AUTHOR hit twice while chasing the above: sending a message
|
|
# that QUOTED one of these fixtures. Under the old contract that was a defect
|
|
# to be parsed away; under this one it is the documented cost, and the message
|
|
# gets composed with a file-writing tool instead.
|
|
printf '2\t{"tool_input":{"command":"send.sh -m \\"repro was: cd /tmp && curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tquoting the repro in a message is refused too\n'
|
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues\\nEOF"}}\ta heredoc body carrying the payload is refused with it\n'
|
|
# ...but quotes stop being data the moment something executes them.
|
|
printf '2\t{"tool_input":{"command":"bash -c \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge\\""}}\tbash -c makes the quoted text code again\n'
|
|
# --- round three. Each of these four is a real write that a bare-name match
|
|
# for the client could not see, because an ordinary word sat in front of it.
|
|
# They are kept as fixtures after the mechanism change even though the guard no
|
|
# longer looks for a client at all: they are the evidence for WHY it stopped,
|
|
# and a future re-narrowing that reintroduced position would fail here first.
|
|
printf '2\t{"tool_input":{"command":"env GITEA_TOKEN=$T curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tenv VAR=... in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"command curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tcommand in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ttimeout N in front of the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan absolute path to the client is still the client\n'
|
|
printf '2\t{"tool_input":{"command":"echo timeout 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments >> notes.md"}}\tnaming the call after echo carries the payload, so it is refused\n'
|
|
# A shell standing between quoted data and execution makes that data code,
|
|
# and the pipe is the form agents actually use. Filing it as data allowed the
|
|
# call to vanish from the skeleton while still running.
|
|
printf '2\t{"tool_input":{"command":"printf '"'"'%%s\\\\n'"'"' '"'"'curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments'"'"' | sh"}}\tquoted code piped to a shell is code\n'
|
|
printf '2\t{"tool_input":{"command":"cat <<EOF | sh\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\ta heredoc piped to a shell is code\n'
|
|
printf '2\t{"tool_input":{"command":"sh -s <<EOF\\ncurl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\nEOF"}}\tsh -s reads its script from the heredoc\n'
|
|
# ...and the questions that used to follow — is the pipe target a shell, does a
|
|
# shell on one line execute a string on another — no longer have to be answered
|
|
# at all. Both of these carry the payload, both are refused, and neither
|
|
# outcome depends on parsing what the pipe or the other line does.
|
|
printf '2\t{"tool_input":{"command":"grep -R \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" docs/ | wc -l"}}\tpiping the payload to wc is refused without asking what wc is\n'
|
|
printf '2\t{"tool_input":{"command":"docker run --rm alpine sh -c '"'"'echo hi'"'"'\\necho \\"example: curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\" >> notes.md"}}\tan unrelated shell on another line no longer changes the answer either way\n'
|
|
# --- round four. The guard was still reading the command as typed rather than
|
|
# as the shell will run it: a backslash before a newline is removed before
|
|
# anything else happens, so the endpoint token can be split across the join.
|
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments"}}\ta line continuation inside the endpoint token is still that endpoint\n'
|
|
printf '2\t{"tool_input":{"command":"curl -d@b https://git.example.invalid/api/v1/repos/a/b/pu\\\\\\nlls/1/reviews"}}\tsame join, review endpoint\n'
|
|
printf '2\t{"tool_input":{"command":"cat >> notes.md <<EOF\\nwe ran: curl -d@b https://git.example.invalid/api/v1/repos/a/b/iss\\\\\\nues/1/comments\\nEOF"}}\tthe join still runs first, and the joined payload is refused in a document too\n'
|
|
# Transparent prefixes take option VALUES, and the value was a word the list
|
|
# did not know — so the client went missing again behind an ordinary `sudo -u`.
|
|
printf '2\t{"tool_input":{"command":"sudo -u root curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option value after a prefix does not hide the client\n'
|
|
printf '2\t{"tool_input":{"command":"timeout --signal TERM 10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tan option pair plus a duration does not hide the client\n'
|
|
printf '2\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe payload behind xargs echo is refused rather than adjudicated\n'
|
|
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
|
|
# Execution through another command needed its own case under the old design.
|
|
printf '2\t{"tool_input":{"command":"find . -maxdepth 0 -exec curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments ;"}}\tfind -exec runs the client\n'
|
|
# --- round five, and the finding that ended the parser. Command substitution
|
|
# inside a double-quoted span EXECUTES, while the skeleton was discarding that
|
|
# span as inert prose. The unquoted and process-substitution forms already
|
|
# blocked, which is what made it a classification defect rather than a spelling
|
|
# one: the same call was refused or allowed depending on a quote character.
|
|
printf '2\t{"tool_input":{"command":"echo \\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tcommand substitution inside double quotes executes\n'
|
|
printf '2\t{"tool_input":{"command":"echo \\"`curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments`\\""}}\tso does the backtick form\n'
|
|
printf '2\t{"tool_input":{"command":"msg=\\"$(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)\\""}}\tand an assignment RHS is not data either\n'
|
|
printf '2\t{"tool_input":{"command":"echo $(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tthe unquoted form, which blocked before and must keep blocking\n'
|
|
printf '2\t{"tool_input":{"command":"cat <(curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments)"}}\tprocess substitution, same\n'
|
|
printf '2\t{"tool_input":{"command":"bash --command \\"curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\""}}\tthe long-option spelling of bash -c needs no entry in any list now\n'
|
|
# A client the guard was never taught is the point of dropping client
|
|
# detection: neither of these names curl at all.
|
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'import requests; requests.post(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments\\", json={})'"'"'"}}\ta library call is a write with no flag and no curl\n'
|
|
printf '2\t{"tool_input":{"command":"wget --post-data=x https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\twget spells its body differently and is still a write\n'
|
|
|
|
# Round six scoped the guard on `https?://`, and review found the absence shape
|
|
# had simply moved to that new boundary: a raw provider CLI carries no scheme,
|
|
# so the guard never reached the write question. These are the reported repros.
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues -f title=x -f body=y"}}\tgh api is a raw write with no URL scheme at all\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE"}}\tand it reaches the endpoint the review wrapper owns\n'
|
|
printf '2\t{"tool_input":{"command":"tea api -X POST repos/a/b/issues/1/comments -f body=x"}}\ttea api, same shape, different CLI\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x git.example.invalid/api/v1/repos/a/b/issues"}}\ta scheme-less host path is still an API write\n'
|
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues -f title=x"}}\tgh POSTs implicitly when handed a field, exactly as curl does with -d\n'
|
|
# ...and the boundary that stops a broader scope gate becoming block-everything.
|
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/pulls/1"}}\treading through a provider CLI stays untouched\n'
|
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/releases -f tag_name=v1"}}\tno wrapper owns releases, whoever calls it\n'
|
|
printf '0\t{"tool_input":{"command":"tea pulls create --title x --repo a/b"}}\tprovider PORCELAIN is out of scope by decision, not by accident\n'
|
|
printf '0\t{"tool_input":{"command":"rm -f /var/tmp/api/v1-issues-notes.txt"}}\t-f is only a body when it carries key=value\n'
|
|
printf '0\t{"tool_input":{"command":"grep -f patterns.txt /src/api/v1/repos/a/b/issues.log"}}\tsame, on the flag agents actually collide with\n'
|
|
|
|
# Wrong remediation is its own defect: /issues/1/labels used to block with
|
|
# "use issue-create.sh", which is not the wrapper for that call. Round seven
|
|
# answered that by letting EVERY path under a numbered issue or PR through,
|
|
# and review showed the reasoning ("no wrapper owns these") was false in this
|
|
# tree. These are the reported repros, all rc 0 before round eight, and each
|
|
# asserts the wrapper the advice must name — not merely that a block happened.
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f title=x"}}\tan issue edit is issue-edit.sh, not a wrapper gap\tissue-edit.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1"}}\tsame call through curl, same wrapper\tissue-edit.sh\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1/labels -f labels[]=bug"}}\tlabels are wrapped, and the advice says by which\tissue-edit.sh\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/assignees -f assignees[]=u"}}\tassignees are issue-assign.sh\tissue-assign.sh\n'
|
|
printf '2\t{"tool_input":{"command":"gh api repos/a/b/issues/1/assignees -f assignees[]=u"}}\tthe array field spelling is a body with no -X at all\tissue-assign.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/labels"}}\ta PR is an issue where labels live, so the issue wrapper owns them\tissue-edit.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1"}}\tPR state is pr-close.sh, and the gap in that arm is stated\tpr-close.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/4"}}\ta milestone state change is milestone-close.sh, not the create wrapper\tmilestone-close.sh\n'
|
|
# SPAN. A wrapper that owns a slice of an endpoint must not be advertised as
|
|
# owning the endpoint. milestone-close.sh takes only -t <title> and sends
|
|
# state=closed, so a title/description/due-date edit is a gap and the message
|
|
# has to say so — round eight named the wrapper and stopped there.
|
|
printf '2\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/milestones/1"}}\ta milestone edit blocks, but the advice states the close-only span\towns the CLOSE only\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f assignee=u"}}\tissue-edit.sh cannot set an assignee, so the message names the one that can\tissue-assign.sh owns the assignee\n'
|
|
# The residue: still genuinely owned by nothing, and still flowing through.
|
|
# Requesting a reviewer is not submitting one; pr-review.sh files verdicts and
|
|
# nothing in the tree adds a requested reviewer.
|
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/pulls/1/requested_reviewers -f reviewers[]=u"}}\tno wrapper requests a reviewer, so it is not refused with pr-review.sh\n'
|
|
# SPAN, applied to the guard's OWN fail-closed rule rather than to a wrapper.
|
|
# The scope gate admits three shapes; the unreadable-endpoint rule asked only
|
|
# for `https?://`, so a split endpoint in the other two was in scope to block,
|
|
# produced no readable endpoint, and fell through to allow. Same defect class
|
|
# as the milestone arm, one layer up. Each shape gets its own fixture, because
|
|
# a single one would have passed on the arm that already worked.
|
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST ${p}${q} -f title=x"}}\ta split endpoint in a provider-CLI api call is unreadable, not absent\n'
|
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/issues/1/comm; q=ents; gh api -X POST ${p}${q} -f body=x"}}\tsame, comments\n'
|
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/pulls/1/rev; q=iews; gh api -X POST ${p}${q} -f event=APPROVED"}}\tsame, and a verdict is the costliest one to lose\n'
|
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; tea api -X POST ${p}${q} -f title=x"}}\tevery CLI the scope gate admits, not just gh\n'
|
|
printf '2\t{"tool_input":{"command":"p=/api/v1/repos/a/b/iss; q=ues; curl -X POST -d x git.example.invalid${p}${q}"}}\ta schemeless forge host with a split path is unreadable too\n'
|
|
printf '2\t{"tool_input":{"command":"h=git.example.invalid; q=ues; curl -X POST -d x ${h}/api/v1/repos/a/b/iss${q}"}}\tthe expansion may come first; the token is what matters\n'
|
|
# And the reason this is not "any variable blocks a write": a payload in a
|
|
# variable is the SAFE way to pass one and leaves the endpoint fully legible.
|
|
printf '0\t{"tool_input":{"command":"gh api repos/a/b/git/refs -f sha=$SHA"}}\tan expansion in a body value leaves the endpoint readable\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d \\"$BODY\\" https://git.example.invalid/api/v1/repos/a/b/git/refs"}}\tsame for a quoted body on an unwrapped endpoint\n'
|
|
printf '0\t{"tool_input":{"command":"gh api repos/${OWNER}/${REPO}/git/refs"}}\ta read with a split endpoint is still a read\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X PATCH -d @b https://git.example.invalid/api/v1/repos/a/b/issues/comments/5"}}\tediting a comment has no wrapper; only creating one does\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/stopwatch/start"}}\tno wrapper owns a stopwatch, and none is invented for it\n'
|
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/times -f time=60"}}\tnor time tracking\n'
|
|
printf '0\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=+1"}}\tnor reactions\n'
|
|
# ...and the residue must be decided by the SEGMENT, never by a stray slash.
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=see-/docs/x"}}\ta slash inside the body is not a subresource\tissue-edit.sh\n'
|
|
# The arms above the numbered ones must keep blocking, with their own wrappers.
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\tthe wrapped subresource must not fall through\tissue-comment.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/issues"}}\tnor may issue creation\tissue-create.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls"}}\tPR creation is wrapped and must not fall through with them\tpr-create.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tand a review still names the review wrapper\tpr-review.sh\n'
|
|
|
|
# SPAN a third time, now in the scope gate itself: it asked for `/api/v[0-9]`,
|
|
# which is Gitea's spelling. GitHub's API carries no version segment at all
|
|
# (`api.github.com/repos/...`), so the schemeless Gitea write was in scope and
|
|
# the schemeless GitHub one was not — a gate calibrated to one dialect rather
|
|
# than to what identifies a provider API. `/repos/` is the marker both share.
|
|
# These endpoints are READABLE, so each asserts the wrapper it must name; a
|
|
# rc-only fixture here would pass on the unreadable arm and prove nothing.
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues"}}\ta schemeless GitHub host is a provider API even with no version segment\tissue-create.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/issues/1/comments"}}\tsame, and the subresource still names its own wrapper\tissue-comment.sh\n'
|
|
printf '2\t{"tool_input":{"command":"host=api.github.com; curl -X POST -d x ${host}/repos/a/b/issues"}}\tthe host may be a variable; the path is what the guard reads\tissue-create.sh\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/pulls/1/reviews"}}\ta verdict is the costliest call to lose to a spelling\tpr-review.sh\n'
|
|
printf '2\t{"tool_input":{"command":"p=/repos/a/b/iss; q=ues; curl -X POST -d x api.github.com${p}${q}"}}\tand the split form of it is unreadable, not absent\n'
|
|
# The end-of-options marker, which is the one option not spelled like one.
|
|
printf '2\t{"tool_input":{"command":"p=repos/a/b/iss; q=ues; gh api -X POST -- ${p}${q} -f title=x"}}\ta bare -- must not walk the endpoint past the scanner\n'
|
|
# Widening a scope gate may not create a block. Reads and unwrapped endpoints
|
|
# in the newly admitted shape have to stay allowed, or this is a regression
|
|
# wearing a fix'"'"'s clothes.
|
|
printf '0\t{"tool_input":{"command":"curl api.github.com/repos/a/b/issues"}}\tadmitting a shape to the gate does not make a read a write\n'
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d x api.github.com/repos/a/b/git/refs"}}\tno wrapper owns git refs, on GitHub'"'"'s spelling either\n'
|
|
printf '0\t{"tool_input":{"command":"gh api -- repos/a/b/issues"}}\tthe marker in a read is still a read\n'
|
|
|
|
# The APPROVE trap, in the spelling a provider CLI uses, and the value that
|
|
# must never trip it.
|
|
printf '0\t{"tool_input":{"command":"curl -X POST -d {\\"event\\":\\"APPROVED\\"} https://git.example.invalid/api/v1/repos/a/b/releases"}}\tAPPROVED is the correct value and is never the trap\n'
|
|
# Documented over-block, pinned so it is a known boundary and not a surprise.
|
|
printf '2\t{"tool_input":{"command":"python3 -c '"'"'print(\\"https://git.example.invalid/api/v1/repos/a/b/issues/1/comments .post(\\")'"'"'"}}\tprose carrying .post( near a wrapped URL is refused, by the same payload rule\n'
|
|
|
|
# --- round nine, all four from one adversarial pass, and three of them are
|
|
# the same shape: a test written over the WHOLE command text deciding an
|
|
# ALLOW. That is the fail-open form this file keeps rediscovering, and it had
|
|
# reached the break-glass itself.
|
|
#
|
|
# BREAK-GLASS. `case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*)` cleared the entire
|
|
# command if that string appeared anywhere in it — so quoting the override in a
|
|
# note, or naming a variable after it, disabled the guard for the call sitting
|
|
# beside it. The override is now read POSITIONALLY: leading `NAME=value`
|
|
# assignments only, exactly where the shell would honour one.
|
|
printf '2\t{"tool_input":{"command":"echo \\"MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews\\" >> notes.md"}}\tquoting the override in a document does not arm it\n'
|
|
printf '2\t{"tool_input":{"command":"NOTES=MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tan assignment whose VALUE is the override is not the override\n'
|
|
printf '2\t{"tool_input":{"command":"MOSAIC_WRAPPER_OVERRIDE=10 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\t=10 matched the old substring test and is not the value 1\n'
|
|
printf '0\t{"tool_input":{"command":"GITEA_TOKEN=$T MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe override still works behind other assignments, as the shell reads it\n'
|
|
# The cost, pinned rather than discovered later: positional means positional.
|
|
printf '2\t{"tool_input":{"command":"cd /tmp && MOSAIC_WRAPPER_OVERRIDE=1 curl -d@b https://git.example.invalid/api/v1/repos/a/b/pulls/1/merge"}}\tan override after && is not in command position and does not arm\n'
|
|
|
|
# SUBRESOURCE REFINEMENT, same defect one arm lower. It asked whether a
|
|
# subresource appears ANYWHERE in the command, so a numbered-object write was
|
|
# cleared on the strength of text in its own BODY. Inverted: clear only when
|
|
# EVERY numbered-object occurrence carries a subresource.
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/pulls/2/files"}}\ta subresource in the body does not clear a write to the numbered issue\tissue-edit.sh\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X PATCH repos/a/b/issues/1 -f body=cf-/issues/3/reactions"}}\tsame, quoting a subresource of the same object type\tissue-edit.sh\n'
|
|
# ...and its documented cost, in the safe direction.
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/issues/1/reactions -f content=cf-/issues/2"}}\tan unwrapped subresource write that quotes a bare issue is refused\n'
|
|
|
|
# -K/--config. curl reads the method, the body, the headers AND the URL from
|
|
# that file, so none of them are in the command: every write test above read 0
|
|
# and the call went through. An unreadable request is not a cleared one.
|
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews"}}\tthe request in a config file is unreadable, so it is refused\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"curl -K /tmp/req https://git.example.invalid/api/v1/repos/a/b/issues"}}\tthe short spelling, same answer\t--config/-K\n'
|
|
# Cost, stated: this refuses a --config read against a host that has nothing
|
|
# to do with a forge. The alternative is to require a forge marker in a
|
|
# command whose URL may itself be in the file, which is the hole again.
|
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/req https://example.invalid/anything"}}\tan unrelated https URL with --config is refused too, by decision\t--config/-K\n'
|
|
printf '0\t{"tool_input":{"command":"eslint --config .eslintrc.json src/"}}\t--config on a command that is not curl is nobody'"'"'s business\n'
|
|
|
|
# ROUND TEN. The --config check above was first written INSIDE the API-shape
|
|
# gate, so it was guarded by a condition that the capability it guards against
|
|
# removes. A config file can carry the URL; delete the URL from the command and
|
|
# nothing is API-shaped, the branch is never entered, and the guard reports
|
|
# clean on precisely the call it exists to refuse. It is now asked of any curl.
|
|
printf '2\t{"tool_input":{"command":"curl --config /tmp/provider-write.cfg"}}\ta config file can own the URL, so there is nothing API-shaped left to gate on\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"curl -K/tmp/provider-write.cfg"}}\tcurl accepts the value attached to the short flag\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"curl -sK /tmp/provider-write.cfg"}}\tand inside a bundle, which a space-separated test does not see\t--config/-K\n'
|
|
printf '0\t{"tool_input":{"command":"tar -K /tmp/archive.tar"}}\t-K on a command that is not curl is not this hook'"'"'s business\n'
|
|
# curl by any ordinary path spelling. The first version of the config check
|
|
# matched the bare word only, so these three executed the same wrapped write
|
|
# while the guard reported clean. Recognizing only the unqualified name is
|
|
# caller-name parsing, and that is the class this file exists to refuse.
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/curl --config /tmp/provider-write.cfg"}}\tan absolute path is the same invocation, not a different one\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"env /usr/bin/curl -K/tmp/provider-write.cfg"}}\tand it is still curl behind env, with the value attached\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"./curl --config /tmp/provider-write.cfg"}}\ta relative path costs two characters and used to be enough\t--config/-K\n'
|
|
# The prefix must end at a slash: a basename that merely ENDS in curl is a
|
|
# different program, and blocking it would be the over-block that gets a guard
|
|
# routed around rather than fixed.
|
|
printf '0\t{"tool_input":{"command":"mycurl --config /tmp/provider-write.cfg"}}\tmycurl is not curl, and over-blocking is its own failure\n'
|
|
printf '0\t{"tool_input":{"command":"/opt/x/curl-wrapper --config /tmp/provider-write.cfg"}}\tnor is curl-wrapper, whose name only starts the same way\n'
|
|
|
|
# And the same name once it is punctuated. The basename repair above fixed the
|
|
# UNQUOTED path spelling and nothing else, so two quote characters restored the
|
|
# bypass it had just closed: the check was still modelling one presentation of
|
|
# a shell word instead of the word. Every one of these executes the real curl.
|
|
printf '2\t{"tool_input":{"command":"\\"/usr/bin/curl\\" --config /tmp/provider-write.cfg"}}\tquoting a path does not make it a different program\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"'"'"'./curl'"'"' --config /tmp/provider-write.cfg"}}\tnor does quoting a relative one\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"$(which curl) --config /tmp/provider-write.cfg"}}\tthe name is in the text even when a substitution supplies the path\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"`which curl` --config /tmp/provider-write.cfg"}}\tand in the older spelling of the same substitution\t--config/-K\n'
|
|
|
|
# The provider-CLI SCOPE gate had the identical defect, untouched while the
|
|
# curl arm was repaired twice. It decides whether write detection runs at all,
|
|
# so failing to admit these is indistinguishable from allowing them — and no
|
|
# URL marker rescues them, because provider CLI paths carry no leading slash.
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/issues -f title=x"}}\tan absolute path to a provider CLI is still a provider CLI\n'
|
|
printf '2\t{"tool_input":{"command":"./gh api -X POST repos/a/b/issues -f title=x"}}\tand a relative one still is too\n'
|
|
printf '2\t{"tool_input":{"command":"/usr/local/bin/tea api -X POST repos/a/b/issues -f title=x"}}\tthe same is true of every CLI the gate names, not just the first\n'
|
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/issues -f title=x"}}\tmygh is not gh, and the scope gate must not over-admit either\n'
|
|
printf '0\t{"tool_input":{"command":"/usr/bin/gh api repos/a/b/issues"}}\ta read through an absolute path is still a read\n'
|
|
|
|
# Quotes and backslashes INSIDE the word. The previous repair replaced quote
|
|
# characters with whitespace, which is token separation and not quote removal:
|
|
# a shell removes a quote without splitting the word around it, so `cu"rl"` is
|
|
# one word naming curl while whitespace made it two words naming neither.
|
|
# `"/usr/bin/curl"` passed under that version only because the inserted space
|
|
# happened to land after a slash, which established nothing.
|
|
printf '2\t{"tool_input":{"command":"cu\\"rl\\" --config /tmp/provider-write.cfg"}}\ta quote inside the word does not make it another program\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"cu'"'"'rl'"'"' --config /tmp/provider-write.cfg"}}\tand a single quote inside it is the same word again\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/cu\\\\rl --config /tmp/provider-write.cfg"}}\tescaping is ordinary word formation, not a disguise\t--config/-K\n'
|
|
# A backslash is NOT uniformly removed. It is literal inside single quotes,
|
|
# and inside double quotes when it precedes anything other than $, `, ",
|
|
# backslash, or newline. These spell a different program and must stay allowed.
|
|
printf '0\t{"tool_input":{"command":"'"'"'cu\\\\rl'"'"' --config /tmp/provider-write.cfg"}}\ta backslash inside single quotes remains literal\n'
|
|
printf '0\t{"tool_input":{"command":"\\"cu\\\\rl\\" --config /tmp/provider-write.cfg"}}\ta backslash before r inside double quotes remains literal\n'
|
|
printf '0\t{"tool_input":{"command":"'"'"'g\\\\it'"'"' clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a single-quoted non-git name is not a checkout\n'
|
|
printf '0\t{"tool_input":{"command":"\\"g\\\\it\\" clone https://example.invalid/x $HOME/wt"}}\ta literal backslash in a double-quoted non-git name is not a checkout\n'
|
|
# The other branch of the same rule: OUTSIDE quotes a backslash escapes the
|
|
# next character, so an escaped quote is a literal quote IN the name and the
|
|
# program is not curl. Held separately from the cases above because it is a
|
|
# different arm of the state machine, and an arm without a fixture is a rule
|
|
# that is not held.
|
|
printf '0\t{"tool_input":{"command":"cu\\\\\\"rl\\\\\\" --config /tmp/provider-write.cfg"}}\tan escaped quote is a literal quote in the name\n'
|
|
# Three quoted segments concatenate into ONE word. This is the shape that
|
|
# distinguishes quote removal from token separation, so it is worth its own line.
|
|
printf '2\t{"tool_input":{"command":"\\"cu\\"'"'"'r'"'"'\\"l\\" --config /tmp/provider-write.cfg"}}\tadjacent quoted segments are one word, and that word is curl\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/issues -f title=x"}}\tthe CLI name is a word on the same terms\n'
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/g\\\\h api -X POST repos/a/b/issues -f title=x"}}\tincluding when it is escaped behind a path\n'
|
|
# The FLAG is the same recognition problem as the name, and is read the same
|
|
# way. No review raised this one; the name was simply the easier half to reach.
|
|
printf '2\t{"tool_input":{"command":"curl --con\\"fig\\" /tmp/provider-write.cfg"}}\tone word spelling --config is still --config\t--config/-K\n'
|
|
|
|
# The unreadable-endpoint arm is the THIRD name consumer. It kept a private
|
|
# bare-name copy of the scope gate's regex, so a caller could be admitted by
|
|
# the repaired gate and then go unrecognized by the fail-closed refinement —
|
|
# a gate and its own refinement disagreeing about who the caller is.
|
|
printf '2\t{"tool_input":{"command":"/usr/bin/gh api -X POST repos/a/b/$EP -f title=x"}}\ta path-qualified CLI with an assembled endpoint is still unreadable\n'
|
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST repos/a/b/$EP -f title=x"}}\tand so is a quoted one, which is where the two halves disagreed\n'
|
|
printf '0\t{"tool_input":{"command":"mygh api -X POST repos/a/b/$EP -f title=x"}}\tmygh is still not gh, in the refinement as well as the gate\n'
|
|
|
|
# Shapes nobody raised. Written down because reasoning that they were already
|
|
# covered is precisely what produced two of the rounds above; each one below
|
|
# was measured, and the three that fail at df83a9ee are here on that evidence.
|
|
# `\curl` is the ordinary way to bypass a shell alias and is a thing people
|
|
# actually type, which makes it the least hypothetical entry in the file.
|
|
printf '2\t{"tool_input":{"command":"\\\\curl --config /tmp/provider-write.cfg"}}\tescaping the leading character to dodge an alias still names curl\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"cur\\"l\\" --config /tmp/provider-write.cfg"}}\tthe quote may sit at any offset in the word\t--config/-K\n'
|
|
printf '2\t{"tool_input":{"command":"g\\"h\\" api -X POST \\"repos/a/b/$EP\\" -f title=x"}}\tboth halves dressed at once, which is where they last disagreed\n'
|
|
|
|
# Over-blocking is a real failure and not a safe direction: a guard that
|
|
# refuses legitimate work gets routed around instead of repaired. These four
|
|
# pass at both heads, which is what a regression guard is for.
|
|
printf '0\t{"tool_input":{"command":"gh api \\"repos/a/b/issues\\""}}\ta quoted read is still a read\n'
|
|
printf '0\t{"tool_input":{"command":"curl https://example.com/file.txt -o /tmp/f"}}\tan ordinary download is not a provider write\n'
|
|
printf '0\t{"tool_input":{"command":"echo \\"$EP\\" && gh --version"}}\tno api subcommand, so nothing to refuse\n'
|
|
printf '0\t{"tool_input":{"command":"echo \\"not a curl call\\""}}\tthe word inside a string, with no flag, is prose\n'
|
|
|
|
# Percent-encoded endpoints. Not hypothetical: /issues/1174 and /iss%%75es/1174
|
|
# both returned HTTP 200 with the same object from the live forge, so the
|
|
# encoded spelling IS the wrapped endpoint and the literal comparison below it
|
|
# sees a segment matching nothing. Refused rather than decoded — a decoder has
|
|
# to be exactly right about depth and normalization, which is the parser
|
|
# mistake this file declines everywhere else.
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%75es/1/comments -f body=x"}}\tan encoded path segment reaches the wrapped endpoint\tpercent-escape\n'
|
|
printf '2\t{"tool_input":{"command":"curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/revi%%65ws"}}\tsame for the review endpoint, which is the one that matters most\tpercent-escape\n'
|
|
printf '2\t{"tool_input":{"command":"gh api -X POST repos/a/b/iss%%2575es/1/comments -f body=x"}}\tdouble-encoded too, which is why this refuses instead of decoding\tpercent-escape\n'
|
|
# Scoped to writes, deliberately. Reads are never blocked by this guard and a
|
|
# query string carrying %%20 is an ordinary URL, not a hazard.
|
|
printf '0\t{"tool_input":{"command":"curl -s https://git.example.invalid/api/v1/repos/a/b/issues?q=a%%20b"}}\ta percent-escape in a READ is not this hook'"'"'s business\n'
|
|
} > "$FIXTURES"
|
|
|
|
fail=0 n=0
|
|
while IFS=$'\t' read -r want payload why remedy; do
|
|
[ -n "${want:-}" ] || continue
|
|
n=$((n + 1))
|
|
out="$(printf '%s' "$payload" | "$GUARD" 2>&1)"
|
|
got=$?
|
|
if [ "$got" != "$want" ]; then
|
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
|
fail=1
|
|
continue
|
|
fi
|
|
# A block that names the wrong wrapper is a defect in its own right, and until
|
|
# now it was invisible here: the harness read the exit code and nothing else,
|
|
# so /issues/1/labels blocking with "use issue-create.sh" passed every run for
|
|
# six rounds. Where a fixture states the remediation it expects, assert it.
|
|
if [ -n "${remedy:-}" ] && ! printf '%s' "$out" | grep -Fq -- "$remedy"; then
|
|
printf 'FAIL %s (blocked, but the advice does not name %s)\n' "$why" "$remedy"
|
|
fail=1
|
|
continue
|
|
fi
|
|
printf 'ok %s\n' "$why"
|
|
done < "$FIXTURES"
|
|
|
|
# ---- $HOME resolution ------------------------------------------------------
|
|
# These cannot be fixtures. Every case above varies the COMMAND; this defect
|
|
# varies the ENVIRONMENT, and the loop has no way to express that.
|
|
#
|
|
# The checkout arm built its pattern from "$HOME" without asking whether $HOME
|
|
# was a usable value. Three values it is not: unset (which is a crash under
|
|
# `set -u`, not a decision), empty (the pattern collapses to `/`, so `~|\$HOME|`
|
|
# matches whatever the empty alternative touches), and "/" (every absolute path
|
|
# is under it, so the comparison stops discriminating). An agent seat running
|
|
# with no HOME — a systemd unit without one, a container, `env -i` — got the
|
|
# checkout question answered by accident rather than on the merits.
|
|
#
|
|
# The fix resolves $HOME once, rejects all three, and BLOCKS the checkout it
|
|
# cannot adjudicate. A guard may not clear a question it was unable to ask. The
|
|
# blast radius of that fail-closed arm is asserted below to be one command shape
|
|
# and not the session: with no HOME at all, ordinary commands still pass and the
|
|
# API arms still block.
|
|
home_case() {
|
|
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
|
local out got
|
|
n=$((n + 1))
|
|
local payload
|
|
payload="$(jq -nc --arg command "$cmd" '{tool_input:{command:$command}}')"
|
|
if [ "$homeval" = "@unset" ]; then
|
|
out="$(printf '%s' "$payload" | env -u HOME "$GUARD" 2>&1)"
|
|
else
|
|
out="$(printf '%s' "$payload" | env HOME="$homeval" "$GUARD" 2>&1)"
|
|
fi
|
|
got=$?
|
|
if [ "$got" != "$want" ]; then
|
|
printf 'FAIL %s (want exit %s, got %s)\n' "$why" "$want" "$got"
|
|
fail=1
|
|
return
|
|
fi
|
|
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
|
printf 'FAIL %s (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
|
fail=1
|
|
return
|
|
fi
|
|
printf 'ok %s\n' "$why"
|
|
}
|
|
|
|
home_case 'HOME unset: a checkout is refused, not adjudicated' \
|
|
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
|
home_case 'HOME empty: same, and it is not the same thing as unset' \
|
|
2 '' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
|
home_case 'HOME=/ : every path is under it, so it discriminates nothing' \
|
|
2 '/' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
|
home_case 'a usable HOME still allows a checkout onto a work filesystem' \
|
|
0 '/home/tester' 'git clone https://example.invalid/x /src/wt'
|
|
home_case 'a usable HOME still catches the literal path' \
|
|
2 '/home/tester' 'git clone https://example.invalid/x /home/tester/wt' 'checks a repository out under'
|
|
home_case 'a usable HOME catches the exact literal path without a trailing slash' \
|
|
2 '/home/tester' 'git clone https://example.invalid/x /home/tester' 'checks a repository out under'
|
|
home_case 'quotes around the exact literal HOME path do not change the target' \
|
|
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"' 'checks a repository out under'
|
|
home_case 'a quoted literal HOME segment remains contiguous with the suffix' \
|
|
2 '/home/tester' 'git clone https://example.invalid/x "/home/tester"/wt' 'checks a repository out under'
|
|
home_case 'and the unexpanded $HOME spelling, which needs no resolution at all' \
|
|
2 '/home/tester' 'git worktree add $HOME/wt topic' 'checks a repository out under'
|
|
# The fail-closed arm is scoped to checkouts. If it were not, a seat with no
|
|
# HOME would have every command it runs refused, which is how a guard gets
|
|
# disabled rather than fixed.
|
|
home_case 'HOME unset does not block an ordinary command' \
|
|
0 '@unset' 'ls -la /src'
|
|
home_case 'HOME unset does not stop the API arms doing their job' \
|
|
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
|
|
|
# ---- the guard standing on its own -----------------------------------------
|
|
# Every case above runs the guard from the directory holding its siblings, so
|
|
# `[ -x "$W/pr-review.sh" ]` succeeds and the $HOME fallback beside it never
|
|
# evaluates. That is a property of the HARNESS, not of the guard, and it hid a
|
|
# live fail-open: with the guard copied somewhere alone AND no HOME, the
|
|
# fallback expanded an unset variable under `set -u` and the script died at
|
|
# rc=1 — on EVERY arm, before any adjudication. A PreToolUse hook exiting
|
|
# nonzero-but-not-2 is a non-blocking error, so that seat ran with no guard and
|
|
# nothing reported it.
|
|
#
|
|
# The first remediation moved that expansion four lines earlier and called it
|
|
# closed. It was not closed, because the test could not reach it. So the guard
|
|
# is copied ALONE here — no siblings, no installed mosaic home — which is the
|
|
# deployment this file already claims to support ("still works from a repo
|
|
# checkout with no installed mosaic home").
|
|
LONE="$TMP/lone"; mkdir -p "$LONE"
|
|
cp "$GUARD" "$LONE/wrapper-guard.sh"; chmod +x "$LONE/wrapper-guard.sh"
|
|
|
|
lone_case() {
|
|
local why="$1" want="$2" homeval="$3" cmd="$4" needle="${5:-}"
|
|
local out got
|
|
n=$((n + 1))
|
|
if [ "$homeval" = "@unset" ]; then
|
|
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env -u HOME "$LONE/wrapper-guard.sh" 2>&1)"
|
|
else
|
|
out="$(printf '%s' "{\"tool_input\":{\"command\":\"$cmd\"}}" | env HOME="$homeval" "$LONE/wrapper-guard.sh" 2>&1)"
|
|
fi
|
|
got=$?
|
|
if [ "$got" != "$want" ]; then
|
|
printf 'FAIL %s [standalone] (want exit %s, got %s)\n' "$why" "$want" "$got"
|
|
fail=1
|
|
return
|
|
fi
|
|
if [ -n "$needle" ] && ! printf '%s' "$out" | grep -Fq -- "$needle"; then
|
|
printf 'FAIL %s [standalone] (exit %s, but the message does not say %s)\n' "$why" "$got" "$needle"
|
|
fail=1
|
|
return
|
|
fi
|
|
printf 'ok %s [standalone]\n' "$why"
|
|
}
|
|
|
|
lone_case 'no siblings and no HOME: an ordinary command still passes, not rc=1' \
|
|
0 '@unset' 'ls -la /src'
|
|
lone_case 'no siblings and no HOME: a wrapped write is still refused' \
|
|
2 '@unset' 'curl -X POST -d @b https://git.example.invalid/api/v1/repos/a/b/pulls/1/reviews' 'pr-review.sh'
|
|
lone_case 'no siblings and no HOME: a checkout is refused, not adjudicated' \
|
|
2 '@unset' 'git clone https://example.invalid/x /src/wt' 'unset or unusable'
|
|
# Spelled without quotes on purpose. The payload is interpolated into a JSON
|
|
# string by the helper, so a fixture carrying bare double quotes produces
|
|
# malformed JSON, jq returns empty, and the guard exits 0 on an empty command —
|
|
# a PASS that measures nothing. That is what the first version of this case did.
|
|
lone_case 'no siblings and no HOME: the APPROVE trap still fires' \
|
|
2 '@unset' 'gh api -X POST repos/a/b/pulls/1/reviews -f event=APPROVE'
|
|
lone_case 'no siblings, usable HOME: ordinary commands unaffected' \
|
|
0 '/home/tester' 'ls -la /src'
|
|
|
|
printf '\n'
|
|
if [ "$fail" -eq 0 ]; then
|
|
printf 'wrapper-guard: %d/%d fixtures behaved as specified.\n' "$n" "$n"
|
|
else
|
|
cat <<'EOF'
|
|
wrapper-guard drifted from its contract.
|
|
|
|
A guard that blocks too much gets routed around, and a guard that blocks too
|
|
little is decoration. Both directions are failures here, which is why the
|
|
allowed cases are asserted as hard as the blocked ones.
|
|
EOF
|
|
fi
|
|
exit "$fail"
|