wrapper-guard: remove quotes instead of splitting on them; last name consumer
ci/woodpecker/pr/ci Pipeline was canceled

Round-five remediation of both blockers gate-ultron-01 raised on df83a9ee.
Measured against that head first; all eight returned rc=0 and each executes
the program the check exists to recognize:

    cu"rl" --config /tmp/w.cfg               -> allowed
    cu'rl' --config /tmp/w.cfg               -> allowed
    /usr/bin/cu\rl --config /tmp/w.cfg       -> allowed
    g"h" api -X POST repos/a/b/issues …      -> allowed
    /usr/bin/g\h api -X POST repos/a/b/… …   -> allowed
    curl --con"fig" /tmp/w.cfg               -> allowed
    /usr/bin/gh api -X POST repos/a/b/$EP …  -> allowed
    g"h" api -X POST repos/a/b/$EP …         -> allowed

BLOCKER 1. The previous commit said names are recognized "after quote
removal" and did not do that. It replaced quote characters with whitespace,
which is token SEPARATION: 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"` blocked under that version
only because the inserted space happened to land after a slash — a passing
case that established nothing about quote removal, and I read it as
confirmation. The characters are now DELETED, which is what quote removal
is. Backslashes go with them, because escaping is ordinary word formation
too. Deletion still handles substitution: `$(which curl)` becomes
`which curl`, where the name is a word on its own.

The flag is read from the same normalized copy for the same reason —
`--con"fig"` is one word spelling --config. No review raised that; the name
was simply the easier half to reach, and reading both halves the same way
is the entire point of having one normalization.

BLOCKER 2. A THIRD name consumer never went through the shared site: the
unreadable-endpoint arm kept a private bare-name copy of the scope gate's
regex against raw $CMD. 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, which is the defect one
layer downstream.

Fixing that surfaced the same mistake a third time inside this very edit:
my first version left the NAME in the refinement's tail regex, so the name
gate recognized `g"h" api` while the tail still demanded the undressed
spelling, and the two halves disagreed exactly as before. Caught by the
fixture, not by reading. Each half now asks one question: the name gate
asks WHO, from the normalized copy; the tail asks whether the ENDPOINT is
readable, from the raw text — deliberately raw, because the expansion
markers that make an endpoint unreadable are the characters the normalized
copy removes, and reading the tail from it would erase the evidence.

Controls: the 8 positive fixtures FAIL at df83a9ee and pass here; the
negatives — mycurl, curl-wrapper, mygh, mygh with an assembled endpoint, an
absolute-path read, and -K on a non-curl — pass at BOTH heads. Suite
166/166.

Unchanged and still stated in the comment rather than this message: a name
ABSENT from the text, assembled from variables or reached through a wrapper
script that execs the program, is invisible to any of this.
This commit is contained in:
Hermes Agent
2026-08-13 03:36:42 -05:00
parent df83a9eec2
commit 46f52eedfe
2 changed files with 76 additions and 18 deletions
@@ -314,6 +314,29 @@ FIXTURES="$TMP/fixtures.tsv"
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'
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'
# 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
@@ -48,25 +48,35 @@ CMD="$(printf '%s' "$INPUT" | jq -r '.tool_input.command // empty' 2>/dev/null |
# joined form, because that is the command.
CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
# A second reading of the SAME command, used only by the two checks below that
# have to recognize a program by name. Quote characters and the punctuation of
# command substitution become whitespace, so a name that the shell will resolve
# is a word here regardless of how it was dressed:
# A second reading of the SAME command, used by every check below that has to
# recognize a program by NAME. Quote characters, backslashes and the punctuation
# of command substitution are DELETED, so a name the shell will resolve is a word
# here regardless of how it was dressed:
#
# "/usr/bin/curl" --config /tmp/req quoted absolute path
# './curl' --config /tmp/req quoted relative path
# $(which curl) --config /tmp/req command substitution
# `which curl` --config /tmp/req the older spelling of the same thing
# /usr/bin/gh api -X POST repos/a/b/... absolute path to a provider CLI
# cu"rl" --config /tmp/req quotes INSIDE the word
# /usr/bin/cu\rl --config /tmp/req a backslash inside the word
# g"h" api -X POST repos/a/b/... the same, in the provider-CLI name
#
# All five executed the real program and all five returned ALLOW from the two
# name checks, at this file's previous head and the one before it. The first
# repair of this class matched curl as a basename and stopped there, which fixed
# the unquoted spelling only: the check still modelled ONE TEXTUAL PRESENTATION
# of a shell word rather than the word itself, so adding two quote characters
# restored the bypass, and the same defect sat untouched in the provider-CLI arm
# the whole time. Recognizing a name is either done after quote removal or it is
# caller-name parsing wearing a longer regex.
# Every one of them executed the real program and every one returned ALLOW, at
# each of this file's three previous heads. The repairs went bare word, then
# unquoted basename, then quotes-as-separators; each fixed a PRESENTATION and
# left the class, and the third is worth spelling out because it is the subtlest
# and it was mine: replacing quote characters with whitespace is token
# SEPARATION, 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"` blocked under that version only
# because the whitespace happened to land after a slash — a passing case that
# established nothing.
#
# So the characters are DELETED rather than replaced, which is what quote removal
# is, and backslashes go with them because escaping is ordinary word formation
# too. Deletion also handles substitution: `$(which curl)` becomes `which curl`,
# where the name is a word on its own.
#
# This does NOT try to be a shell. It cannot see a name that is absent from the
# text — assembled from variables, or reached through a wrapper script that
@@ -79,8 +89,7 @@ CMD="$(printf '%s' "$CMD" | sed -e ':a' -e 'N' -e '$!ba' -e 's/\\\n//g')"
# these commands inside quotes on a Bash line is refused too. Applying that same
# rule here keeps the file coherent — the alternative is a guard where the
# payload arm treats quotes as text and the name arms treat them as armour.
CMD_NAMES="$(printf '%s' "$CMD" | tr '"'"'"'\`()' ' ')"
CMD_NAMES="$(printf '%s' "$CMD_NAMES" | sed 's/\$/ /g')"
CMD_NAMES="$(printf '%s' "$CMD" | tr -d '"'"'"'\`()$\\')"
# The one place the shape of a program NAME is written down. Both callers below
# use it, so the next fix to this class lands in a single location instead of
@@ -340,8 +349,12 @@ fi
# command text at all. That is a limit of inspecting a command string rather
# than a defect this pattern can close, and it is stated rather than left for
# the next reader to find.
# The FLAG is read from $CMD_NAMES too. It is the same recognition problem as
# the name — `--con"fig"` is one word spelling --config — and no review has
# raised it yet only because the name was the easier half to reach. Reading both
# halves the same way is the point of having one normalization.
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}curl([[:space:]]|$)" \
&& printf '%s' "$CMD" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
&& printf '%s' "$CMD_NAMES" | grep -Eq -- '(^|[[:space:]])(-[A-Za-z]*K([[:space:]=]|$|[^[:space:]])|--config([[:space:]=]|$))'; then
cat <<EOF
BLOCKED: curl invocation whose request is supplied from a --config/-K file.
@@ -648,9 +661,31 @@ edit — that one is a real wrapper gap, and the override exists for it." ;;
&& printf '%s' "$CMD" | grep -Eq "$FORGE"; then unreadable=1; fi
printf '%s' "$CMD" | grep -Eq \
"$URLTOK($FORGE$URLTOK"'[$`]'"|"'[$`]'"$URLTOK$FORGE)" && unreadable=1
printf '%s' "$CMD" | grep -Eq \
'(^|[[:space:]|;&(])(gh|tea|glab|hub)[[:space:]]+api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]' \
&& unreadable=1
# THIRD name consumer, and the one that proves the point about a single
# site: while the scope gate above was repaired for path- and quote-dressed
# provider CLIs, this fail-closed refinement kept its own bare-name copy of
# the same regex against raw $CMD. A caller could therefore enter the scope
# gate through the fixed check and then fail to be recognized by the arm
# that refuses unreadable endpoints — name recognition differing between a
# gate and its own refinement, which is the defect one layer downstream.
# It reads $CMD_NAMES through $NAME_PREFIX like every other name check.
#
# The endpoint tail still asks $CMD, deliberately: this arm fires on an
# endpoint the guard CANNOT READ, and the expansion markers that make it
# unreadable are exactly the characters $CMD_NAMES removes. Reading the tail
# from the normalized copy would erase the evidence the check exists to find.
#
# The tail carries NO name of its own. Leaving one there was the same defect
# a third time in the same edit — the name gate would recognize `g"h" api`
# while the tail still demanded the undressed spelling, so the two halves
# disagreed about the same caller and the refinement failed open. Each half
# now asks exactly one question: the name gate asks WHO, from the normalized
# copy; the tail asks whether the ENDPOINT is readable, from the raw text.
if printf '%s' "$CMD_NAMES" | grep -Eq "${NAME_PREFIX}(gh|tea|glab|hub)[[:space:]]+api([[:space:]]|$)" \
&& printf '%s' "$CMD" | grep -Eq \
'(^|[[:space:]])api([[:space:]]+(--|--?[A-Za-z][A-Za-z-]*)([[:space:]]+[^-[:space:]][^[:space:]]*)?)*[[:space:]]+[^-[:space:]][^[:space:]]*[$`]'; then
unreadable=1
fi
if [ -z "$endpoint" ] && [ "$unreadable" -eq 1 ]; then
cat <<EOF