Skip to content

harden(supply-chain): close abs/relative-path install bypass - #139

Merged
thehoff merged 1 commit into
developfrom
harden/abs-path-supply-chain-bypass
May 23, 2026
Merged

thehoff merged 1 commit into
developfrom
harden/abs-path-supply-chain-bypass

Conversation

@thehoff

@thehoff thehoff commented May 23, 2026

Copy link
Copy Markdown
Owner

Empirically observed bypass on 2026-05-23, while installing Pi.dev for the new harness integration. The Tirith supply-chain gate failed to fire on:

```
/Users/thehoff/.nvm/versions/node/v25.0.0/bin/npm install --ignore-scripts @earendil-works/pi-coding-agent
```

— a package well inside the gate's 3-day RecentRelease cooldown that should have blocked. Same shape works for pnpm, yarn, pip, uv, poetry, pipx.

Root cause

Two parallel assumptions in src/hooks/supply_chain_gate.rs:

  1. Regex prefix anchor. All 7 install-detect regexes (NPM_RE … PIPX_RE) anchored the verb on (?:^|\s|;|&&|\|\|). A path separator (/ POSIX, \ Windows) before the verb is not in that set, so /bin/npm install … and ./bin/pnpm i … never matched.
  2. Bare-lockfile token check. detect_bare_lockfile_installs matched the head token with a literal match tok { "npm" | "pnpm" | "yarn" => … }. An absolute or relative path token (e.g. "/Users/.../bin/npm") is not the literal string "npm", so the bare variant slipped past too.

Fix (surgical)

  • Prefix anchor in all 7 regexes → (?:^|[\s;/\\]|&&|\|\|). / and \ are now command-start delimiters alongside whitespace and shell operators. Behaviour unchanged for non-path invocations.
  • New installer_basename(tok) helper strips the last POSIX/Windows path component. The bare-install token check compares the basename, not the raw token.

Regression tests (10 new, were red, now green)

  • abs_path_{npm,pnpm,yarn,pip,uv,poetry,pipx}_*_detected — every ecosystem via absolute path.
  • abs_path_bare_npm_install_detected — bare lockfile install via abs path.
  • relative_path_npm_install_detected./node_modules/.bin/npm i ….
  • abs_path_does_not_double_match_mid_path_substring — negative guard: a path that merely contains the word install (no verb) must produce zero hits.

Out of scope (separate finding for follow-up)

The CONTEXTCRAWLER_SUPPLY_CHAIN=off env-var override did not propagate from a Claude Code Bash tool call to the spawned contextcrawler process. The gate code at line 993 reads the var correctly; the break is in env-passing from the PreToolUse hook contract. Worth its own investigation.

2,582 tests pass (+18 from develop), clippy clean on changed file.

🤖 Generated with Claude Code

Empirically observed on 2026-05-23 during Pi.dev install. The Tirith
supply-chain gate failed to fire on:

    /Users/thehoff/.nvm/versions/node/v25.0.0/bin/npm install \
        --ignore-scripts @earendil-works/pi-coding-agent

— a package well inside the gate's 3-day RecentRelease cooldown that
should have blocked. Root cause was a pair of identical assumptions in
the detector:

  1. The install-detect regexes (NPM_RE, PNPM_RE, YARN_RE, PIP_RE, UV_RE,
     POETRY_RE, PIPX_RE) anchored the verb on `(?:^|\s|;|&&|\|\|)`. A
     path separator (`/` POSIX or `\` Windows) before the verb is not in
     that set, so `/bin/npm install …` and `./bin/pnpm i …` never matched.

  2. `detect_bare_lockfile_installs` matched the head token with a
     literal `match tok { "npm" | "pnpm" | "yarn" => … }`. An absolute or
     relative path token (`"/Users/.../bin/npm"`) is not the literal
     string `"npm"`, so the bare-install variant also slipped past.

Fix (surgical, source-level only):

  - Prefix anchor in all 7 regexes → `(?:^|[\s;/\\]|&&|\|\|)`. Treats `/`
    and `\` as command-start delimiters alongside whitespace and shell
    operators. Same behaviour as before for non-path invocations.
  - New `installer_basename(tok)` helper strips the last path component
    (POSIX + Windows). `detect_bare_lockfile_installs` now compares the
    basename of the head token, not the raw token.

Regression tests (10 new, all previously red, now green):
  - abs_path_{npm,pnpm,yarn,pip,uv,poetry,pipx}_*_detected — every
    ecosystem with an abs-path invocation.
  - abs_path_bare_npm_install_detected — bare lockfile install via path.
  - relative_path_npm_install_detected — `./node_modules/.bin/npm i …`.
  - abs_path_does_not_double_match_mid_path_substring — negative guard:
    a path that merely mentions `install` (no verb) must produce zero
    hits.

Out of scope (separate finding, follow-up): the `CONTEXTCRAWLER_SUPPLY_CHAIN=off`
env-var override did not propagate from a Claude Code Bash tool call to
the contextcrawler-hook process. The gate code at line 993 reads the var
correctly; the breakage is in env-passing from the PreToolUse hook
contract. Worth investigating in its own pass.

2,582 tests pass (+18 from develop), clippy clean on changed file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@thehoff
thehoff merged commit 89eb9e5 into develop May 23, 2026
4 checks passed
@thehoff
thehoff deleted the harden/abs-path-supply-chain-bypass branch May 23, 2026 01:02
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
…oetry/uv bypasses

Three tight follow-ups from the two-model peer review of thehoff#139
(Codex + Antigravity / Gemini). The shell-quote tokeniser refactor and
the plaintext-data false-positive guard are tracked in thehoff#140 and thehoff#141 —
both bigger work, deliberately not stuffed here.

1. NEWLINE CHAIN (Antigravity BLOCKER). The arg-capture group
   `[^|;&<>]+` in all 7 install-detect regexes matched newlines, so a
   multi-line script like `npm install x\npip install y` chain-swallowed
   the second line and suppressed downstream detection. Adds `\r\n` to
   the negated class.

2. WINDOWS LAUNCHER (Codex MEDIUM). `npm.cmd`, `pip.exe`, `yarn.cmd`
   slipped past both the package-bearing regexes and the bare-install
   token match. Two-line fix: regex verb now accepts an optional
   `(?:\.(?:cmd|exe|bat))?` suffix; `installer_basename` strips those
   same suffixes after path-separator splitting.

3. POETRY / UV BARE LOCKFILE INSTALL (Antigravity HIGH).
   `detect_bare_lockfile_installs` only covered npm/pnpm/yarn, so
   `poetry install` and `uv sync` (resolving the full dep tree from
   pyproject.toml / poetry.lock / uv.lock with no package args) returned
   a silent Skip. Extended the match block to recognise both verbs and
   surface them as Pypi-ecosystem unvettable installs. Reshapes the
   `is_bare_install_verb: bool` thread to carry `Ecosystem` directly so
   the push records the right ecosystem (was hardcoded `Ecosystem::Npm`).
   The unvettable lockfile-source string also forks per ecosystem.

12 new regression tests:
  - newline_chain_both_installs_detected (`\n`)
  - newline_chain_carriage_return_also_caught (`\r\n`)
  - windows_launcher_{npm_cmd,pip_exe,abs_path_npm_cmd,bare_npm_cmd}_detected
  - poetry_install_bare_lockfile_detected
  - poetry_install_with_flags_still_bare_lockfile
  - poetry_add_still_handled_by_regex (sanity: package-bearing path)
  - uv_sync_bare_lockfile_detected
  - uv_pip_sync_bare_lockfile_detected
  - uv_abs_path_sync_detected

Out of scope, tracked:
  thehoff#140 — shell-quote-aware tokeniser (closes the HIGH bypass class:
         `sh -c '<install>'`, `$(<install>)`, backticks, quoted heads,
         quoted operators in flag values).
  thehoff#141 — plaintext-data false-positive guard (closes the MEDIUM
         regression risk: `echo "/usr/bin/npm install foo"` is data,
         not an install — hit live during this very peer review when
         agy received a prompt that quoted the verb).

2,594 tests pass (+12 from develop), clippy clean on changed file.

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
…pass (thehoff#140)

PR thehoff#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 thehoff#140.

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.

1 participant