Skip to content

harden(supply-chain): quote-aware tokeniser closes wrapped-install bypass (#140) - #146

Merged
thehoff merged 3 commits into
developfrom
feat/shell-quote-tokeniser-140
May 23, 2026
Merged

thehoff merged 3 commits into
developfrom
feat/shell-quote-tokeniser-140

Conversation

@thehoff

@thehoff thehoff commented May 23, 2026

Copy link
Copy Markdown
Owner

Closes #140. Rebased onto develop after #142/#143 merged — combined cleanly with no semantic conflicts (regex prefix anchor now (?:^|[\s;/\\'"]|&&|\|\|), verb has optional ['"]? after the head, then (?:\.(?i:cmd|exe|bat))* chained-suffix from #142/#143, then capture excluding \r\n from #142, plus develop's LINE_CONT_RE preprocess in detect_installs).

What this closes

Closes the HIGH bypass class both Codex and agy flagged when reviewing the original abs-path PR (#139): shell-quoted and command-substitution wrapped installs slip past the regex anchor.

  • `sh -c 'npm install pkg'` / `bash -c "pnpm install x"`
  • `$(npm install pkg)` / backticks
  • `'npm' install pkg` / `"pip" install x` (quoted head)
  • `npm install --description="install & test" lodash` (quoted operator in flag value truncated the package scan at the literal `&`)

How

Three layers added to src/hooks/supply_chain_gate.rs:

  1. Quote-aware tokeniser — `shell_tokens()` now honours single quotes, double quotes, and ANSI-C `$'…'` form. Operator characters inside quotes are no longer treated as command separators. Surfaces `sh -c `, `bash -c `, `$(…)`, and backtick payloads as first-class segments the gate can recurse into.
  2. Bounded recursion — `detect_installs` is now a wrapper that delegates to `detect_installs_into(cmd, depth, &mut out)` with `MAX_RECURSION_DEPTH = 6`. Each `sh -c` / `$(...)` / backtick body recurses at depth+1. Output is then `dedup_installs`'d (the outer regex + recursion both fire on `sh -c 'npm install foo'`; dedup absorbs the redundancy without information loss).
  3. Same-length quote masking — `mask_quoted_operators(cmd)` produces a same-length copy of the command where shell-operators inside quotes are replaced with ASCII spaces. The regex runs against the masked copy so a literal `&` inside `--description="install & test"` doesn't truncate the package scan; arg-text is re-sliced from the ORIGINAL cmd so package names keep their real characters.

Tests (14 new)

quoted_sh_c_install_detected, command_substitution_install_detected, backtick_substitution_install_detected, quoted_head_install_detected, quoted_operator_in_flag_value_does_not_truncate_scan, recursion-bound guard, dedup verifies, plus regression guards on the existing detection paths.

2,631 tests pass (+14 over develop's 2,617), clippy clean.

Per-the-mandate

Multi-model peer review (Codex + agy) will run on this rebased head before the merge question opens.

Out of scope (#141)

Plaintext-data false-positive guard. The quote-aware tokeniser lays the foundation for the principled fix (approach 2/3 in the issue body); #141 has shipped approach 1 (utility-prefix allowlist) as a pragmatic bandaid. Rebase + PR of #141 is the next move after this lands.

🤖 Generated with Claude Code

thehoff and others added 3 commits May 23, 2026 12:42
…pass (#140)

PR #139 review (Codex + Antigravity) flagged a HIGH bypass class: the naive
shell_tokens() did not honour shell quoting, so several install shapes
slipped past the gate.

  1. sh -c '<install>' / bash -c '<install>' wrapped installs
  2. $(<install>) / backtick <install> command-substitution installs
  3. 'npm' install pkg / "pip" install pkg quoted-head installs
  4. operator inside a quoted flag value (--description="install & test")
     truncated [^|;&<>]+ and dropped packages after the flag

Implementation:
- shell_tokens() walks bytes with quote-state for single, double, and
  ANSI-C $'…' quotes. Token text is the literal payload with quote
  chars stripped, so a quoted head matches npm/pip/etc.
- mask_quoted_operators() produces a same-length copy where operator
  bytes inside quoted regions become spaces. Package-list regexes run
  against this; the captured arg span is re-sliced from the original
  command to keep real characters in package names.
- extract_recursion_segments() pulls sh -c arg / bash -c arg / $(…) /
  backtick bodies out as flat strings. detect_installs_into() recurses
  on each (depth-capped at MAX_RECURSION_DEPTH = 6).
- Regex anchors now treat ' and " as command-start delimiters so
  'npm' install lodash is caught by the outer pass.
- Structural dedup collapses duplicates when the outer regex + recursion
  both fire on the same install.

Adversarial fixtures (TDD, all real shell forms):
- quoted_sh_c_install_detected
- quoted_bash_c_install_detected
- command_substitution_install_detected
- backtick_substitution_install_detected
- quoted_head_install_detected
- quoted_operator_in_flag_value_does_not_truncate_scan
- ansi_c_quoted_install_detected
- quoted_install_as_data_argument_still_flagged
  (echo "$(npm install foo)" — conservatively flagged: the install
  runs before echo prints, so the side-effect already happened)
- nested_sh_c_install_detected — defence in depth
- unmatched_quote_falls_back_safely — no panic on malformed input
- shell_tokens unit tests for quote stripping + operator-in-quote

Out of scope (documented in code comments): heredocs (<<EOF … EOF), eval
with variable expansion, process substitution <(…). These would need
shell-style word expansion, not just tokenisation.

Test count: 2582 -> 2596 (+14). All 2596 pass, 0 failures.
Clippy: 49 warnings before, 49 after — no new warnings on changed code.
Pre-existing type_complexity on parse_package_args left untouched per
task constraints.

Closes #140.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…c clusters, depth fail-open)

Codex + agy peer review on PR #146 surfaced 2 BLOCKERs + 1 HIGH + 1 LOW.
All four addressed here; +8 regression tests.

1. BLOCKER (both reviewers) — quoted SUBCOMMAND verb bypass.
   `npm 'install' lodash`, `pip "install" requests`, `'npm' 'install' x`.
   The package-bearing regexes did not allow quote chars around the verb
   subcommand, so the outer match missed. shell_tokens strips quotes so
   the bare-install path then saw a package after the verb and flipped
   `has_package=true` — silent skip. Fix: wrap each subcommand in
   optional `['"]?` in all 7 regexes:
     - NPM/PNPM: `\s+['"]?(?i:i|install|add)['"]?`
     - YARN: `\s+['"]?(?i:add)['"]?`
     - PIP/PIPX: `\s+['"]?(?i:install)['"]?`
     - UV: `(?:['"]?(?i:pip)['"]?\s+)?['"]?(?i:install|add)['"]?`
     - POETRY: `\s+['"]?(?i:add)['"]?`

2. HIGH (both reviewers) — `-c` combined-option bypass.
   `bash -lc '<install>'`, `bash -xc '<install>'`, `bash -x -c '<install>'`
   skipped recursion because `extract_recursion_segments` matched only
   the exact token `-c`. Fix: locate a shell head, then scan forward
   through short-option clusters for any cluster ending in `c`
   (handles -c, -lc, -xc, -ic, etc., plus `-x -c` order). Long options
   (`--`) and non-option tokens end the scan without recursion.

3. BLOCKER (both reviewers) — recursion depth-limit FAIL-OPEN.
   At `MAX_RECURSION_DEPTH` (6) the recursion silently dropped any
   installs at deeper nesting. A 7-deep nest payload
   (`sh -c "$(sh -c "$(sh -c '...')")"`) auto-allowed at the gate —
   the gate is supposed to be conservative. Fix: at the cap, if there
   are still unresolved recursion segments, push a synthetic
   Unvettable ParsedInstall so the caller fails CLOSED
   (Verdict::Ask/Unavailable) rather than Verdict::Skip.

4. LOW (agy) — order-sensitive dedup. `npm install a b && npm install b a`
   didn't collapse because the package-list comparison was order-strict.
   Fix: `installs_equivalent` now sorts cheap clones before comparison.
   Original ordering preserved in the input.

Tests (+8):
  - quoted_install_verb_detected
  - double_quoted_install_verb_detected
  - quoted_install_verb_combined_with_quoted_head
  - bash_lc_combined_option_install_detected
  - bash_xc_combined_option_install_detected
  - bash_separate_options_install_detected
  - recursion_depth_limit_surfaces_unvettable_not_skip (with a 7-deep
    nest payload — proves fail-closed)
  - dedup_collapses_reordered_package_lists

2,639 tests pass (+8 from prior #146 head), clippy clean.

Per the peer-review-codex-agy-mandatory rule: re-review on this head
is required before the merge question opens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…c-anywhere, -- marker)

agy round-2 peer review on PR #146 surfaced two new option-parsing
bypasses in the recursion extractor:

1. BLOCKER — `bash -c -- '<install>'`. POSIX end-of-options marker `--`
   was being treated as the recursion body, so the actual command after
   the marker silently bypassed the gate. Fix: after locating the `-c`
   cluster, skip any `--` token before reading the body.

2. HIGH — `bash -ce '<install>'`, `bash -cce '<install>'`. Short-option
   clusters where `c` is NOT the last char (e.g. `-ce` = `-c -e`,
   `-cce` = `-c -c -e`) still take the next token as the `-c` command
   argument in bash, but the original `ends_with('c')` check missed
   them entirely. Fix: `contains('c')` instead — every cluster carrying
   `c` triggers recursion.

+1 combined regression test (`bash_c_double_dash_install_detected`)
asserting both shapes detect.

2,640 tests pass (+1), clippy clean.

Mandate gate: Codex round-2 review still in flight on commit 2263116.
Will consolidate with that on return before opening the merge question.
This commit lands the agy round-2 findings unconditionally — both are
real bypasses regardless of what Codex finds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thehoff
thehoff merged commit c4f6e55 into develop May 23, 2026
4 checks passed
thehoff added a commit that referenced this pull request May 23, 2026
…llowlist (agy #148 BLOCKER)

agy round-1 peer review on PR #148 (re-implementation of #141) caught a
BLOCKER: `awk`, `gawk`, and `sed` were in DATA_CONSUMING_UTILITIES, but
those tools are Turing-complete with native command-execution primitives:

  - `awk 'BEGIN { system("npm install evil") }'`
  - `gawk` (and `mawk`) — same `system()` primitive
  - GNU `sed 's/.*/npm install bad/e'` — the `e` flag executes the
    replacement string as a shell command

Masking the segment to spaces removed the install from the regex pass
AND from the recursion sweep (recursion uses cmd_raw, but no shell-c
extractor walks awk/sed script bodies). Net: silent bypass.

Fix: remove `awk`/`gawk`/`sed` from the allowlist. The allowlist now
contains ONLY utilities that genuinely cannot spawn a shell. Added a
doc-comment block explicitly enumerating execution-capable exclusions
so a future contributor doesn't re-add them.

Also picked up agy's MEDIUM (allowlist coverage): added `jq`, `base64`,
`xxd`, `od`, `hexdump` — all genuinely data-consuming, no shell-out
flags, currently produce false-positive blocks when they read manifests
or payloads containing install-shaped text.

New allowlist (15 → 17 entries, awk/gawk/sed dropped, base64/xxd/od/
hexdump/jq added):
  echo, printf, cat, tac, tee,
  grep, egrep, fgrep, rg,
  head, tail, nl,
  base64, xxd, od, hexdump, jq

+8 regression tests:
  - awk/gawk/sed `system()`/`e`-flag installs MUST be detected (not masked).
  - jq/base64/xxd/od/hexdump with install-shaped substrings must NOT
    classify as installs.

2,661 tests pass (+8), clippy clean.

Out of scope, noted for tracking:
  - agy MEDIUM: multi-byte UTF-8 corruption in shell_tokens unquoted
    payloads (pre-existing, post-#146 contract). Worth a follow-up
    issue when context allows.

Codex peer review on #148 still in flight — this commit lands the
agy-found BLOCKER unconditionally given its severity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thehoff added a commit that referenced this pull request May 23, 2026
Manual version bump capping the #141 plaintext-FP guard cluster
(#139, #140/#146, #141/#148, #142, #143). Headline: zero-execution
data-utility allowlist (echo/printf/cat/grep/jq/base64/…) so that
install-shaped substrings printed-as-data no longer trigger the
supply-chain gate, with hard exclusions for rg / awk / gawk / sed
which can spawn subprocesses (Codex + agy round-3 GREEN/GREEN).

46 merged PRs since v0.1.9. See GH release v0.1.10 for the full
PR list + notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
noogalabs pushed a commit to noogalabs/contextcrawler that referenced this pull request Jun 4, 2026
thehoff#141)

The supply-chain gate's install-detection regexes match install-shaped
substrings anywhere in a command, including inside the ARGUMENTS of
ordinary data-consuming utilities. Live FP from peer review:

    echo "/usr/bin/npm install foo"

is data printed to stdout, but the gate treated the inner substring as
a real install verb and blocked.

Re-implementation of thehoff#141 on top of develop @ c4f6e55, composing
cleanly with the now-merged thehoff#142/thehoff#143/thehoff#146 work (line-continuation
preprocess, quote-aware tokeniser, recursion + depth-cap synthetic
Unvettable). The original thehoff#141 implementation (commit 7a6935b on
feat/plaintext-fp-guard-141) accumulated too many conflicts with the
follow-on merges to rebase cleanly — this is a fresh re-write of the
same byte-masking contract.

Design — head-verb allowlist, byte-mask preserving offsets:

- DATA_CONSUMING_UTILITIES — echo, printf, cat, tac, grep, egrep,
  fgrep, rg, awk, gawk, sed, head, tail, tee, nl. Same list as the
  original; deliberately narrow, only utilities whose canonical role
  is to EMIT or SEARCH their argv as data.
- command_head_is_data_utility(segment) — basename-normalises the
  first non-operator token (handles /bin/echo, /usr/bin/grep) and
  matches against the allowlist.
- mask_data_utility_segments(cmd) — walks the shell_tokens stream,
  identifies segments whose head is a data utility, overwrites their
  bytes with ASCII spaces in a same-length working copy. Chain
  operators (&&, ||, ;, |) and unmasked segments are preserved
  verbatim, so the regex prefix-anchors and the claimed-span dedup
  bookkeeping continue to work unchanged.

Composition with post-thehoff#146 develop — masking layer placement:

- Wired into detect_installs_into() (NOT detect_installs()), so it
  applies at every recursion depth. Wrappers like
  sh -c 'echo "npm install foo"' extract the inner echo payload
  via extract_recursion_segments and recurse; the inner head is
  also a data utility and must be masked at the inner depth.
- The recursion sweep below the masking now runs against cmd_raw
  (the unmasked input), NOT the masked copy. Reason: a substitution
  body nested inside a data-utility segment still executes at
  runtime — echo "$(npm install foo)" does install the package —
  so the gate must still vet that body. Masking suppresses the
  surface-level regex pass for that segment ONLY; the recursion
  sweep is left intact.
- Composes cleanly with mask_quoted_operators() (also same-length)
  and the LINE_CONT_RE collapse upstream.

Segment-end calculation differs from the original implementation —
post-thehoff#146 shell_tokens strips quote characters from token payloads
(so the literal token for "foo" is foo, payload len 3, source span
5 bytes including the quotes). The original mask used
seg_end = off + tok.len() which would under-mask quoted tokens.
This re-write tracks segment end via the next operator's source
offset (or cmd.len() at end-of-stream), which is robust to the
quote-stripping payload contract.

Tests: 13 new tests covering the FP cases (echo/grep/printf/cat/rg
with install-shaped argv), positive guards (vanilla + abs-path npm
install MUST still detect), tricky chain semantics (echo "x" &&
npm install foo, echo … ; npm install …, echo foo | npm install bar,
cd foo && npm install bar), abs-path data-utility heads
(/bin/echo …), plus a direct unit test on
command_head_is_data_utility. 2,653 tests pass (was 2,640), zero
regressions.

Out of scope (deferred to principled-fix follow-up): token-position
constraint and quote-context exclusion. They are the principled
fixes — this remains a head-verb allowlist bandaid and the
DATA_CONSUMING_UTILITIES list should shrink (or retire) once those
ship.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

harden(supply-chain): shell-quote-aware tokeniser for the install-detect gate

1 participant