From 2a2a87251a6d9cba6fe6a4563d4ea69b52ebb35d Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Wed, 12 Aug 2026 17:46:25 -0500 Subject: [PATCH] wrapper-guard: read the command the shell will run, and stop losing the client behind option values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../framework/tools/git/test-wrapper-guard.sh | 16 ++++++++++++++ .../framework/tools/git/wrapper-guard.sh | 21 +++++++++++++++++-- 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh index aaaf6587..52dd6d21 100755 --- a/packages/mosaic/framework/tools/git/test-wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/test-wrapper-guard.sh @@ -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 < "$FIXTURES" fail=0 n=0 diff --git a/packages/mosaic/framework/tools/git/wrapper-guard.sh b/packages/mosaic/framework/tools/git/wrapper-guard.sh index 0250a109..cd539736 100755 --- a/packages/mosaic/framework/tools/git/wrapper-guard.sh +++ b/packages/mosaic/framework/tools/git/wrapper-guard.sh @@ -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