wrapper-guard: judge the payload, not the caller — delete the code/data parser
ci/woodpecker/pr/ci Pipeline was canceled

Round-five review found command substitution executing inside the very quoted
spans the skeleton was discarding as prose:

  echo "$(curl -d@b .../issues/1/comments)"
  msg="$(curl -d@b .../issues/1/comments)"

The unquoted and process-substitution forms already blocked, so the same call
was refused or allowed depending on a quote character. That makes it a
classification defect rather than another spelling, and it is the nineteenth
write to reach execution through this file by the same route: the client was
ABSENT from the skeleton, so the guard allowed.

The reviewer's judgement, which I asked for and accept: this is fitting to the
test set. Answering "code or data" from shell text with sed and awk is not a
hard problem, it is the wrong problem.

It was also not portable. CI has been red at `sanitization` since round four,
and the log says why: under the image's busybox awk the octal escape in the
quote-stripping regex does not bite, every quoted span survives into the
skeleton, and the guard began refusing ordinary prose. Five allow-direction
fixtures failed in CI that pass under GNU awk. A control that reverses its
verdict with the awk on the host is not a control.

So the client detection is gone — the skeleton, the invoker list, the prefix
list, the option-value skipping, all of it. What remains asks two questions of
the text: is this a write, and does it name an endpoint a wrapper owns. It
cannot fail open by hiding the caller because it never looks for one, and it
now catches clients it was never taught: `python -c ... requests.post(...)` and
`wget --post-data` are both fixtures.

The cost is stated in the file and pinned in both directions: QUOTING one of
these calls on a Bash command line is refused as well. Ten fixtures that used
to assert "discussing a call is not making one" now assert the opposite, and
the boundary that stops this becoming block-everything is asserted just as
hard — a quoted READ, an endpoint named without a body flag, a quoted write to
an UNWRAPPED endpoint, and the wrapper's own body flag all still pass. The
18-command ordinary-work sweep blocks none.

The rule an agent can hold without a parser: do not put a raw write to a
wrapped forge endpoint on a Bash command line, not even inside quotes. Write
the example with a file-writing tool.

60/60 fixtures, verified inside the CI image (busybox) as well as locally.
Gates: sanitization, resident budget, test enumeration, tools-index (self-test
4/4, git suite 100%), issue-close, prettier.
This commit is contained in:
Hermes Agent
2026-08-12 17:59:14 -05:00
parent 2a2a87251a
commit b1254f52f3
2 changed files with 115 additions and 115 deletions
@@ -59,58 +59,84 @@ FIXTURES="$TMP/fixtures.tsv"
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/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 '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' 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'
# And the other direction, which is the failure mode that gets a hook deleted: # --- round six changed the contract in this direction, and these fixtures are
# discussing a call is not making one. In each of these the client sits behind # where it shows. They used to assert that discussing a call is not making one.
# a quote, never at command position. # Five rounds proved there is no textual way to tell a quoted example from a
printf '0\t{"tool_input":{"command":"grep -R \\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\" docs/"}}\tgrepping for an example is not calling it\n' # quoted command, so the guard stopped trying: it judges the payload, and a
printf '0\t{"tool_input":{"command":"echo \\"curl -d https://git.example.invalid/api/v1/repos/a/b/pulls\\" > note.txt"}}\twriting an example into a file is not calling it\n' # payload inside quotes is still a payload. Quoting one of these on a Bash
printf '0\t{"tool_input":{"command":"python3 -c '"'"'print(\\"curl -d https://git.example.invalid/api/v1/repos/a/b/issues\\")'"'"'"}}\tprinting an example is not calling it\n' # 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. # 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' 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'
# --- and the case the AUTHOR hit, one level in from the reported one: an # --- the case the AUTHOR hit twice while chasing the above: sending a message
# operator INSIDE a quoted string is not an operator. This blocked a message # that QUOTED one of these fixtures. Under the old contract that was a defect
# that merely quoted the fixture above. Position is judged on the skeleton. # to be parsed away; under this one it is the documented cost, and the message
printf '0\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\\""}}\tan operator inside a quoted string is not an operator\n' # gets composed with a file-writing tool instead.
printf '0\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 is data, not code\n' 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. # ...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' 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. The skeleton was right about position and wrong about which # --- round three. Each of these four is a real write that a bare-name match
# words hold it. A word in front of a command does not displace the command: # for the client could not see, because an ordinary word sat in front of it.
# each of these four is a real write that the bare-name match could not see. # 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":"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":"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":"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":"/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'
# ...and the prefix list must stay a list of prefixes. `echo` is not one. 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'
printf '0\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 a command after echo is not running it\n'
# A shell standing between quoted data and execution makes that data code, # 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 # and the pipe is the form agents actually use. Filing it as data allowed the
# call to vanish from the skeleton while still running. # 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":"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":"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' 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'
# ...but a pipe to anything that is not a shell leaves the data as data. # ...and the questions that used to follow — is the pipe target a shell, does a
printf '0\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 an example to wc is not executing it\n' # shell on one line execute a string on another — no longer have to be answered
# ...and a shell on ONE line does not execute a string on another. Switching # at all. Both of these carry the payload, both are refused, and neither
# the whole command into code because it contains an unrelated `sh -c` is how # outcome depends on parsing what the pipe or the other line does.
# this blocked its author a second time. 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 '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' 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 # --- 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 # 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. # 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/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":"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' 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 # 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`. # 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":"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":"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 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'
# `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' 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. # 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' 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'
} > "$FIXTURES" } > "$FIXTURES"
fail=0 n=0 fail=0 n=0
@@ -22,6 +22,10 @@
# curl for reads, for registry/manifest calls, and for endpoints with no # curl for reads, for registry/manifest calls, and for endpoints with no
# wrapper (there are many) all pass untouched. # wrapper (there are many) all pass untouched.
# #
# One consequence is worth knowing before it surprises you: it judges the
# payload, not the caller, so a command that merely QUOTES such a write is
# refused as well. See the long note at section 2 for why that trade was made.
#
# Break-glass, for a genuine gap where no wrapper can express the call: # Break-glass, for a genuine gap where no wrapper can express the call:
# MOSAIC_WRAPPER_OVERRIDE=1 <command> # MOSAIC_WRAPPER_OVERRIDE=1 <command>
# Using it means "no wrapper covers this" — if that is wrong, the fix is to # Using it means "no wrapper covers this" — if that is wrong, the fix is to
@@ -83,103 +87,73 @@ EOF
fi fi
# ---- 2/3. provider API writes --------------------------------------------- # ---- 2/3. provider API writes ---------------------------------------------
# A raw provider write is four things at once: an HTTP client, a URL, a mutating # A raw provider write this guard cares about is two things: a WRITE, and a URL
# verb or a request body, and a path fragment naming an endpoint a wrapper # naming an endpoint a Mosaic wrapper already owns. Reads are untouched — they
# already owns. All four are required, which is what keeps reads and unwrapped # are how you gather evidence — and the many endpoints with no wrapper flow
# endpoints flowing. # through.
# #
# Deliberately NOT gated on the literal "/api/v1/repos/". An independent reviewer # It deliberately does NOT ask which program makes the call, or whether that
# broke that version in one line: build the path in shell variables # program sits at shell command position. It used to, and that is the whole
# p=/api/v1/repo; q=s/a/b/pulls/1/reviews; curl -d@body "https://host${p}${q}" # history of this file. Answering "is this code or is this data" from the text
# and the host-anchored literal never appears, so the check read clean while the # of a shell command required a skeleton with quoted spans and heredoc bodies
# write went through. The endpoint fragments below survive it, because the # removed, an invoker list for the forms where a shell executes quoted text, a
# fragment has to appear somewhere for the URL to be constructible at all. # prefix list for `env`/`sudo`/`timeout`, option-value skipping, and
# The client must be at COMMAND POSITION, and that has to be judged against the # backslash-newline joining. Five rounds of adversarial review put nineteen
# CODE in the command, not against its text. Review caught the text version # writes straight through it, and every one had the same shape: the client was
# blocking ordinary work: # ABSENT from the skeleton, so the guard allowed. Variables, line continuations,
# grep -R "curl -d https://host/api/v1/repos/a/b/issues" docs/ # command prefixes, option values, pipes into a shell, and finally command
# echo "curl -d https://host/api/v1/repos/a/b/pulls" > note.txt # substitution inside the very quotes the skeleton was discarding:
# Talking about a call is not making one, and over-blocking is not the safe # echo "$(curl -d@b .../issues/1/comments)"
# direction: a guard that blocks ordinary work gets switched off, and a guard # msg="$(curl -d@b .../issues/1/comments)"
# that is off permits everything. # Classifying code against data in shell text with sed and awk is not a hard
# problem, it is the wrong problem. It was not even portable: under CI's busybox
# awk the quote-stripping silently failed, the skeleton kept every quoted span,
# and the guard started refusing ordinary prose instead — which is the other way
# a control like this dies.
# #
# A first fix required the client to follow a shell operator. That lasted until # So the client detection is gone, and with it that entire failure class: what
# the author sent a message quoting one of these fixtures — the quoted text # is left cannot fail open by hiding the caller, because it never looks for one.
# contained `... && GITEA_TOKEN=$T curl -d@b .../merge`, so an operator appeared # It looks for the payload. Something that names a wrapped endpoint and carries
# INSIDE the quotes and the guard blocked the message. Same defect, one level # a body is refused however it is spelled — curl, wget, `python -c`, or a form
# in: an operator inside a string is not an operator. # nobody has thought of yet.
# #
# So the position test runs against a SKELETON — the command with its data spans # The cost is real and belongs in the open, because over-blocking is how a hook
# (quoted strings, heredoc bodies) removed. Endpoint, URL and body detection all # gets switched off: QUOTING one of these calls on a Bash command line now
# still run against the FULL text, because real calls quote their URLs and a # blocks too. `grep -R "curl -d .../issues" docs/` is refused, and so is echoing
# skeleton would be blind to them. # an example into a file. There is no textual way to tell a quoted example from
# a quoted command — that is exactly the finding above — so the rule is the one
# an agent can hold in mind without a parser:
# #
# The exception is the reason quotes are data at all: if something is about to # do not put a raw write to a wrapped forge endpoint on a Bash command line,
# EXECUTE the quoted text, then the quotes hold code and the skeleton is the # not even inside quotes.
# full text again. The first version of this list named only `bash -c`, `sh <<` #
# and `eval`, and review immediately produced the spellings it did not know: # Write the example with a file-writing tool, or leave the body flag out of it.
# printf '%s\n' 'curl -d@b .../comments' | sh # That is a deliberate narrowing of scope, not an oversight. This hook stops
# cat <<EOF | sh # mistakes; it is not a sandbox, and pretending otherwise is how you get a
# sh -s <<EOF # control nobody can trust the boundaries of.
# Each one executes; each one had its client filed away as data. So the test is #
# not "which invocation form did I think of" but "does a shell stand between # Scoped to commands carrying a URL, so nothing without one is even considered.
# this data and execution" — including the pipe, which is the common form. if printf '%s' "$CMD" | grep -Eq 'https?://'; then
SHELL_EXECUTES_DATA='(^|[[:space:]|;&(])((ba|z)?sh|dash)([[:space:]]+-[a-z]*[cs]([[:space:]]|$)|[[:space:]]*<<)'
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|(^|[[:space:]|;&(])eval([[:space:]]|$)'
SHELL_EXECUTES_DATA="$SHELL_EXECUTES_DATA"'|\|[[:space:]]*((ba|z)?sh|dash)([[:space:]]|$)'
# That decision is made PER LINE, not for the whole command. The first version # Write detection, now client-agnostic. Every spelling curl accepts, because
# switched globally, and it blocked its author again within the hour: a script # the guard is defeated by the one spelling it does not know: `-d@body` (no
# whose only shell invocation was an unrelated `docker run ... sh -c 'echo hi'` # space) and `--request=POST` (equals form) both slipped past the first
# had every OTHER quoted span on every other line promoted to code with it. # version. Plus wget's forms and a library call, which a client-shaped test
# A shell on one line does not execute a string on another, and a guard that # could not have seen at all.
# says otherwise is back to blocking ordinary work. #
SKEL="$(printf '%s' "$CMD" | awk -v inv="$SHELL_EXECUTES_DATA" ' # A body flag is read as a body flag wherever it appears. `ls -d */ && curl -s
function ascode(s) { gsub(/["\047]/, ";", s); return s } # quotes separate # .../issues/1/comments` is therefore refused, which is a read wearing a
function asdata(s) { gsub(/\047[^\047]*\047/, "", s); gsub(/"[^"]*"/, "", s); return s } # write's flag. That direction is the acceptable one: it costs an override on
{ # a rare command, where the reverse costs a silent raw write.
# Inside a heredoc: the body is code only if the line that OPENED it fed a
# shell (`cat <<EOF | sh`, `sh -s <<EOF`). Otherwise it is a document.
if (hd != "") { if ($0 == hd) { hd=""; next }
if (hdcode) print ascode($0); next }
if (match($0, /<<-?[[:space:]]*\047?"?[A-Za-z_][A-Za-z0-9_]*/)) {
t = substr($0, RSTART, RLENGTH); sub(/^<<-?[[:space:]]*[\047"]?/, "", t)
hd = t; hdcode = ($0 ~ inv)
}
print ($0 ~ inv) ? ascode($0) : asdata($0)
}')"
# Command position is not "the first word is literally `curl`". A word can sit
# in front of a command without displacing it, and review found four ordinary
# ones hiding a real write: `env GITEA_TOKEN=$T curl`, `command curl`,
# `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|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)'
# `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
# Write detection. Every spelling curl accepts, because the guard is defeated
# by the one spelling it does not know: `-d@body` (no space) and
# `--request=POST` (equals form) both slipped past the first version.
is_write=0 is_write=0
printf '%s' "$CMD" | grep -Eq -- \ printf '%s' "$CMD" | grep -Eq -- \
'-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--request[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1 '-X[[:space:]]*(POST|PATCH|PUT|DELETE)|--(request|method)[[:space:]=]*(POST|PATCH|PUT|DELETE)' && is_write=1
# curl sends POST implicitly when handed a body, in any of these forms. # curl sends POST implicitly when handed a body, in any of these forms.
printf '%s' "$CMD" | grep -Eq -- \ printf '%s' "$CMD" | grep -Eq -- \
'(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file' && is_write=1 '(^|[[:space:]])(-d|-F|-T)|--data([-a-z]*)?[[:space:]=]|--json[[:space:]=]|--form|--upload-file|--post-(data|file)[[:space:]=]' && is_write=1
# ...and a library call is a write without any flag at all.
printf '%s' "$CMD" | grep -Eq -- \
'\.(post|put|patch|delete)\(' && is_write=1
if [ "$is_write" -eq 1 ]; then if [ "$is_write" -eq 1 ]; then
endpoint=""; wrapper="" endpoint=""; wrapper=""