wrapper-guard: read the command the shell will run, and stop losing the client behind option values
ci/woodpecker/pr/ci Pipeline failed

Round-four review, three more absence-driven allows.

1. The guard read the command as TYPED. A backslash before a newline is removed
   before anything else happens, so an endpoint token split across the join
   (`.../iss\` + newline + `ues/1/comments`) executed the comments endpoint while
   the literal token never appeared in the text. Continuations are now joined
   before every check, because the joined form IS the command. This is the same
   defect as the split-across-variables case, minus the excuse: there the token
   genuinely does not exist until the shell expands it, here it was sitting in
   the input the whole time and the guard chose the wrong reading of it.

2. Transparent prefixes take option VALUES. `sudo -u root curl` hid a live write
   because `root` was a word the prefix list did not know. Enumerating option
   grammars per prefix is the wrong game, so what is skipped is an option and at
   most one value for it, plus a bare duration for `timeout` — never an
   arbitrary word. `xargs echo curl ...` therefore stays ALLOWED, because there
   the command is echo and the client is its argument.

3. `find -exec` runs the client. It opens command position the same way an
   operator does, and now reads that way.

All seven reviewer repros are fixtures, each with its counter-case in the
allowed direction: a continuation inside a heredoc document stays a document,
`xargs echo curl` stays allowed, `sudo apt-get install curl` stays allowed, a
prefixed READ stays allowed. 48/48, and the 18-command ordinary sweep still
blocks none.

Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), prettier.
This commit is contained in:
Hermes Agent
2026-08-12 17:46:25 -05:00
parent e6a881a795
commit 2a2a87251a
2 changed files with 35 additions and 2 deletions
@@ -95,6 +95,22 @@ FIXTURES="$TMP/fixtures.tsv"
# the whole command into code because it contains an unrelated `sh -c` is how
# this blocked its author a second time.
printf '0\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 does not promote quoted prose to code\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 '0\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"}}\tjoining lines does not turn a document into a command\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'
# ...but only an OPTION and its value are skipped, never any word: in
# `xargs echo curl ...` the command is echo and the client is its argument.
printf '0\t{"tool_input":{"command":"xargs echo curl -d@b https://git.example.invalid/api/v1/repos/a/b/issues/1/comments"}}\ta client passed as an argument to echo is not a call\n'
printf '0\t{"tool_input":{"command":"sudo apt-get install curl"}}\tinstalling the client is not calling it\n'
# Execution through another command still opens command position.
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'
} > "$FIXTURES"
fail=0 n=0
@@ -35,6 +35,15 @@ INPUT="$(cat)"
CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null || true)"
[ -z "$CMD" ] && exit 0
# Read the command the SHELL will run, not the text as typed. A backslash before
# a newline is removed before anything else happens, so
# curl -d@b https://host/api/v1/repos/a/b/iss\
# ues/1/comments
# executes the comments endpoint while the literal token `issues` never appears
# in the text. Every check below — position, URL, body, endpoint — reads the
# joined form, because that is the command.
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
# Honour the override only when it is set in the command itself or the env.
case "$CMD" in *MOSAIC_WRAPPER_OVERRIDE=1*) exit 0 ;; esac
[ "${MOSAIC_WRAPPER_OVERRIDE:-0}" = "1" ] && exit 0
@@ -146,10 +155,18 @@ SKEL="$(printf '%s' "$CMD" | awk -v inv="$SHELL_EXECUTES_DATA" '
# `timeout 10 curl`, `/usr/bin/curl`. The `env` form matters most, because it is
# precisely what an agent reaches for to keep a credential out of the global
# environment — the careful spelling was the invisible one.
CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|[0-9]+[smhd]?|-[^[:space:]]+)[[:space:]]+'
CMD_PREFIX='([A-Za-z_][A-Za-z0-9_]*=[^[:space:]]*|env|command|builtin|exec|nohup|setsid|stdbuf|nice|ionice|sudo|doas|xargs|time|timeout|watch)[[:space:]]+'
# Their options take values: `sudo -u root curl` hid a live write because `root`
# was a word the list did not know. What is skipped is an OPTION and at most one
# value for it — not any word — so `xargs echo curl ...` stays allowed, because
# there `echo` is the command and curl is its argument. Plus a bare duration,
# which is `timeout`'s operand.
PREFIX_OPT='-[^[:space:]]+[[:space:]]+([^-][^[:space:]|;&(){}<>]*[[:space:]]+)?'
PREFIX_ARG='('"$PREFIX_OPT"'|[0-9]+[smhd]?[[:space:]]+)'
# A path in front of the client is still the client.
CLIENT='([^[:space:]]*/)?(curl|wget|httpie|http)'
CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\()[[:space:]]*('"$CMD_PREFIX"')*'"$CLIENT"'([[:space:]]|$)'
# `find -exec` opens command position the same way an operator does.
CLIENT_AT_CMD_POS='(^|[;&|(){}]|`|\$\(|-execdir|-exec)[[:space:]]*('"$CMD_PREFIX"'('"$CMD_PREFIX"'|'"$PREFIX_ARG"')*)?'"$CLIENT"'([[:space:]]|$)'
if printf '%s' "$SKEL" | grep -Eq "$CLIENT_AT_CMD_POS" \
&& printf '%s' "$CMD" | grep -Eq 'https?://'; then