Skip to content

fix(guards): match protect-paths per path token, fail closed, and stop double-registering guards with the plugin - #159

Merged
CodeWithJuber merged 10 commits into
masterfrom
fix/protect-paths-false-positives
Sep 24, 2026
Merged

CodeWithJuber merged 10 commits into
masterfrom
fix/protect-paths-false-positives

Conversation

@CodeWithJuber

Copy link
Copy Markdown
Owner

What & why

This PR fixes three problems that an evaluation run of forgekit against a real Next.js repo found (HostLelo, where the forgekit@forge plugin is enabled, so agents hit them today).

1. protect-paths blocked common read-only commands (finding, severity high).

  • The rule \.env(\.[A-Za-z0-9_-]+)?\b matched .env anywhere in a Bash command.
  • protectPathsDecision blocked grep -rn process.env src, rg 'process\.env\.WHMCS' src/lib, grep -rn import.meta.env src, git log -p --grep='.env handling', git show HEAD:src/lib/whmcs.ts | grep process.env, npx tsc --noEmit | grep -i process.env and cat src/i18n/messages.key.ts.
  • It also refused Reads of .env.example, .npmrc and src/app/docs/secrets/page.tsx.

2. The guards failed open and had coverage gaps (finding, severity high).

  • With no bash on PATH, run.mjs exited 1 (measured: no-bash exit=1). Claude Code treats exit 1 as a non-blocking error, so the guard was silently off. A signal-killed guard did the same.
  • Grep, Glob and NotebookRead calls never reached protect-paths.
  • These were allowed:
    • reads: sed -n p .env, awk 1 .env, tac .env, sort .env, … < .env, cat .e*v
    • destructive commands: rm -rf ./, rm -rf *, rm -rf .., git checkout -- ., git restore ., curl x | sudo sh

3. forge doctor ignored the enabled plugin (finding, severity medium).

  • It reported "forge hooks missing/stale (15/15 guard(s) absent)".
  • Its --fix would have merged the same 15 hooks into settings.json on top of the plugin's hooks/hooks.json, so every guard would run twice.

What changes

  • Path-token matching (global/guards/protect-paths.mjs):
    • A small shell-word splitter finds each simple command's FILE operands, and only those are tested against one secretKind() predicate. Tool paths use the same predicate.
    • Patterns, commit messages and --grep= values are never treated as paths.
    • Templates (.env.example and similar), *.key.ts and source files under secrets/ count as code, not secrets.
    • A project .npmrc is blocked only when it holds a literal _authToken, _auth or _password. ~/.npmrc is always protected.
    • Newly covered:
      • the missing readers
      • input redirection from a secret
      • globs that select a secret
      • $( … ) and backticks
      • ANSI-C quoting ($'\x2eenv')
      • a command on a second line
      • heredocs fed to a shell
    • The destructive-command cases listed in problem 2 are now blocked.
    • The Read matcher is now Read|Grep|Glob|NotebookRead in all three manifests.
  • Fail closed (global/guards/run.mjs):
    • The launcher runs protect-paths.mjs on its own node, so bash is no longer needed.
    • Any failure to reach a verdict now blocks with exit 2.
    • --fail-closed lets other guards opt in.
  • Plugin-aware doctor and init (src/doctor.js, src/init.js):
    • forgePluginEnabled() reads the user, project and local settings.
    • doctor reports "guards via the forgekit plugin", and its --fix merges permissions only.
    • Guards wired in both places are reported as a double registration.
    • forge init now checks the same scopes, at both of its call sites.

Review round

An independent review of the first four commits confirmed 1 blocker, 2 major and 7 minor issues. All are fixed in 771dc37 and af2870f:

  • Blocker: a flag between a plain reader or writer and the secret hid the file (cat -n .env, sort -n .env, uniq -c .env, cp -n .env x, diff -w .env x). All of these were blocked on master. Option values of plain readers and writers are now also checked as paths (fail closed).
  • Major:
    • A glued -eKEY dropped the real file (grep -eKEY .env, rg -eKEY .env, sed -ep .env). Short options are now read getopt style.
    • cd ~ && cat .npmrc read the user token. After any directory change, a relative .npmrc is now protected.
  • Minor:
    • git show :.env
    • rg KEY ./secrets/ and Grep on /run/secrets/
    • << inside arithmetic hid the next line
    • sudo bash <<EOF bodies were not scanned
    • bash -c "…; cat .env", eval and cat "$(echo .env)"
    • brace alternation (*.{env,pem}) and .env-local
    • forge init still wrote hooks when the plugin was enabled only in the project scope
    • docs versus permissions.deny

My own pass found three more gaps of the same kind, and they are closed too: for ((i=0; i<<2; …)), the glued git log -L1,5:.env, and git diff -- secrets/.

Measured: a probe of 56 reviewer cases gave 56 mismatches on eed1b7f and 0 now. In a matrix of about 90 commands, all 76 everyday commands are still allowed.

Deliberate choices:

  • For the blocker I used the reviewer's "fail closed" option, not per-command option tables.
  • For the deny-list issue I documented the behaviour instead of narrowing permissions.deny, because narrowing it would weaken the primary layer. On a settings.json install, Read(./.env.*) and Read(./**/.npmrc) still deny those Reads.
  • A Grep tool search of a directory named secrets is now blocked unless its glob or type keeps it to source files. A bare Grep of src/app/docs/secrets is refused; with glob: "*.tsx" or type: "ts" it is allowed.

Still out of scope, as documented in GUIDE: variable indirection (f=.env; cat $f), find -exec cat, xargs -a, command substitutions other than a literal $(echo …), and interpreters.

Tests added

  • test/protect_paths.test.js:
    • every false positive from the finding, plus every still-blocked and newly blocked case
    • review round: flags between a reader or writer and its file; glued -e/-f; git index paths and -L; recursive reads of a secrets/ directory; arithmetic <<; sh -c, eval and $(echo); braces and dash-separated env names; the cd cases for .npmrc against a real temp repo
    • unit tests for secretKind, globMatchesSecret, expandBraces (new public function), npmrcHasToken, shellSegments and secretShellAccess
    • end-to-end hook runs for Grep, Glob and NotebookRead
  • test/doctor_plugin.test.js: plugin scopes, a permissions-only merge, doctor --fix, double-registration detection, and forge init with the plugin enabled only in the project (checked to fail without the fix).
  • test/hook_launcher.test.js: fail-closed launcher cases.
  • test/settings_template.test.js: matcher test.
  • test/guards.test.js: updated .npmrc expectations.

Checks run (committed HEAD af2870f, Node v22.22.2)

  • npm test: exit 0. 1477 tests, 1474 pass, 0 fail, 3 skipped.
  • npm run check: exit 0. 14 warnings and 2 infos, all pre-existing (the baseline count).
  • npm run typecheck: exit 0.
  • node src/cli.js docs check: exit 0. Its ARCHITECTURE.md repo-map warning also appears on master.
  • bash -n global/guards/protect-paths.sh: OK. shellcheck was not installed locally.
  • End to end through node global/guards/run.mjs global/guards/protect-paths.sh, the bypasses above exit 2 and the false positives exit 0.

Checklist

  • npm test passes (Node 18/20/22). It passes on Node 22 locally; 18 and 20 were not run here.
  • npm run check passes (Biome lint + format)
  • New public functions have a test
  • Conventional commit message (feat:/fix:/docs: …)
  • CHANGELOG.md updated under ## [Unreleased]
  • No new runtime dependency (dev deps ok)
  • Substrate/docs updated if this changes forge substrate, forge impact, router/gate, or MCP substrate tools. Not applicable: none of those change. GUIDE, README, SECURITY and CHANGELOG were updated for the guard, doctor and init behaviour.

Risk & rollback

  • Risk level: medium. This changes what a security guard allows. A missed case would let a secret read through, although secret-redact still masks leaked values afterwards. An over-broad rule would block a legitimate command.
  • Known trade-offs:
    • After a cd, reading a relative .npmrc is blocked even when it is harmless.
    • A bare Grep of a directory named secrets needs a source-only glob or type.
    • With the plugin enabled only in one project's settings, forge init and doctor --fix leave the guard hooks out of the user-level settings.json. Other repos that do not enable the plugin then get no guards from that file.
  • The regex COMMAND_RULES still cost about 1.2s on a 10k-character pathological command. The new parsing code is linear, and the cost is the same on master.
  • Rollback plan: git revert the six commits (or only af2870f and 771dc37 to go back to the pre-review state). The manifests still point at run.mjs …/protect-paths.sh, so no settings.json install needs a re-merge in either direction.

Extra checks (tick if applicable)

  • npm run typecheck passes
  • Input validated at boundaries; errors handled (no swallowing). An unparsable payload, an internal error, nesting of sh -c/eval deeper than 8 levels, an over-cap brace expansion and an unreachable interpreter all block (exit 2).
  • Authorization/ownership checked (if it touches access). There is no user or ownership model here; the change is itself the file-access guard.
  • Logs contain no secrets/PII. Deny reasons name the path only. .npmrc content is read to look for a token and is never printed.
  • If AI-assisted: I understand it, verified the package APIs, and it has tests. Claude Code wrote this, and it has tests and uses Node built-ins only. A human reviewer should confirm before ticking.

Found by an evaluation run against the HostLelo site (CodeWithJuber/my-next-app).

🤖 Generated with Claude Code

https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW


Generated by Claude Code

The Bash rule matched `\.env(\.[\w-]+)?\b` anywhere in a command, so
read-only commands that merely mentioned an env accessor were blocked:
`grep -rn process.env src`, `rg 'import\.meta\.env'`,
`git log --grep='.env handling'`, `cat messages.key.ts`, plus Reads of
`.env.example`, a plain project `.npmrc` and a Next.js
`app/docs/secrets/page.tsx` route. Real reads slipped through instead:
`sed -n p .env`, `awk 1 .env`, `tac .env`, `... < .env`, `cat .e*v`.

The command is now split into shell words (quotes, `$'..'`, `$(..)`,
backticks, heredocs and comments honoured) and only each simple
command's FILE operands are tested against one secretKind() predicate
shared with tool paths. A grep/rg/sed/awk/jq pattern, a commit message
or a `--grep=` value is never a path.

- `.env.example`/`.sample`/`.template`/`.dist` are templates; `*.key.ts`
  is code; source files under a `secrets/` directory are code.
- A project `.npmrc` is protected only when it holds a literal
  `_authToken`/`_auth`/`_password` (an `${NPM_TOKEN}` reference is not a
  secret); the user-level `~/.npmrc` always is.
- New readers: sed awk tac sort uniq cut paste bat jq diff cmp fold rev
  hexdump `dd if=`; input redirection from a secret; globs that select
  a secret name.
- `rm -rf` of `.`, `..`, `./` or `*`; `git checkout .` and
  `git restore .` (not `--staged` only); `| sudo sh`, `| python3`.
- The protect-paths matcher covers Read|Grep|Glob|NotebookRead in all
  three hook manifests, and the Grep tool's `glob` filter is checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
With no bash on PATH the hook launcher exited 1, which Claude Code
treats as a NON-blocking hook error, so the PreToolUse secret guard was
silently off; a signal-killed guard also returned 1.

run.mjs now has a GUARD_POLICY: protect-paths.sh is only a thin
launcher over protect-paths.mjs, so the launcher runs that twin on its
own node (bash leaves the path, one process fewer per tool call), and
the guard is fail-closed: no interpreter, a spawn error, a signal or an
exit other than 0/2 becomes exit 2 with the reason on stderr.
`node run.mjs --fail-closed <guard>.sh` opts any other guard in.
Advisory guards keep the visible, non-blocking exit 1.

The hook manifests keep `run.mjs .../protect-paths.sh`, so guard
identity (guardKey) is unchanged and existing settings.json installs get
the fix with the package, no re-merge needed. doctor now requires
guards/protect-paths.mjs in an install and says protect-paths still
blocks when bash is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
…abled

doctor ignored `enabledPlugins`: with forgekit@forge enabled it still
reported "forge hooks missing/stale (15/15 guard(s) absent) - run forge
doctor --fix", and that fix merged the same hooks into settings.json on
top of the plugin's hooks/hooks.json, so every guard (the Stop gate
included) would run twice.

- init.js: forgePluginEnabled() reads `enabledPlugins` from the user,
  project and local settings (later scope wins). mergeSettings() takes
  `hooks: false`, and by default skips hook injection for a settings
  file that itself enables the plugin; the result says `hooksVia`.
- doctor: with the plugin enabled the settings row reports "guards via
  the forgekit plugin", its repair merges permissions only, and guards
  wired in BOTH places are reported as a double registration (no
  auto-fix that could add more hooks).
- `forge init` prints that the plugin supplies the hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
…n-aware doctor

CHANGELOG (Unreleased) entries for the three fixes; GUIDE gains a
"Protected paths" section (coverage, what is blocked and allowed, the
fail-closed launcher, `--fail-closed`, and the out-of-scope limits) and
the doctor row notes permissions-only repair under the plugin; README
and SECURITY state that protect-paths needs no bash and fails closed,
and that init/doctor do not duplicate the plugin's hooks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
The path-token rewrite let several secret reads through that master
blocked. Close each one and pin it with a regression test:

- One option table serves every plain reader and writer, so a flag it
  wrongly listed swallowed the file after it: `cat -n .env`,
  `sort -n .env`, `uniq -c .env`, `cp -n .env x`. Option values of plain
  readers/writers are now checked as paths too (fail closed; a count or
  delimiter is never a secret name), including `--opt=value`.
- Short options are read getopt style, so a glued pattern supplies the
  pattern (`grep -eKEY .env`, `grep -rnweKEY .env`, `sed -ep .env`) and
  a glued `-fFILE` is checked. `sed -i[SUFFIX]` stays a glued suffix.
- `git show :.env` / `:0:.env` and `git log -L1,5:.env` are checked.
- A relative `.npmrc` is protected once the command changes directory
  (`cd ~ && cat .npmrc`, `env -C`, `sudo -i`/`-D`).
- A recursive read of a `secrets/` directory is blocked (grep -r, rg,
  ag, git grep, git diff/log/show pathspecs, the Grep tool); the Grep
  tool may still search it when its glob/type keeps to source files.
- `<<` inside `$(( ))` / `(( ))` / `for (( ))` is a shift, not a
  heredoc that hid the next line; `sudo bash <<EOF` bodies are scanned.
- `bash -c '…'`, `eval …` and literal `$(echo …)` output are checked.
- Brace alternation (`.{env,x}`, `--include '*.{env,pem}'`, Grep glob
  `{.env,x}`) is expanded, and `.env-local` / `.env-prod` are env files.

Docs: GUIDE and CHANGELOG describe the new coverage, and say that a
settings.json install's permissions.deny still refuses Read of
`.env.example` and `.npmrc` (deny wins), so the relaxation reaches the
Read tool only on plugin installs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
mergeSettings looked for enabledPlugins only in the file it merged
into, so with the forgekit plugin enabled in the project or local
settings `forge init` still wrote every guard into
~/.claude/settings.json, and `forge doctor` then reported the double
registration init had just created. init() now asks
forgePluginEnabled() (user, project and local scopes, the same ones
doctor reads) and passes `hooks: false` at both call sites, including
`--settings-only`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUhB8JaPayd43w37dxiXrW
Signed-off-by: Claude <noreply@anthropic.com>
Comment thread global/guards/protect-paths.mjs Fixed
…match

Windows CI (Git Bash) failed 'must block Write of /home/u/.npmrc': MSYS
rewrites HOME/USERPROFILE (/home/u -> C:/Program Files/Git/home/u) before
node reads them, so the user-level npmrc was not recognised by HOME and,
with no file on disk, was allowed. Only the project's own npmrc is ordinary
config now; one outside the project (~/.npmrc, /etc/npmrc) is protected
without consulting HOME (fail closed).

CodeQL js/redos: /^\.env((?:[.-][\w-]+)*)~?$/ let a run of '--' split
exponentially many ways. Segments are \w+ now, so matching is linear.
Regression tests for both.

Signed-off-by: Claude <noreply@anthropic.com>
…alse-positives

Signed-off-by: Claude <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
@CodeWithJuber
CodeWithJuber marked this pull request as ready for review September 24, 2026 04:35
…alse-positives

Signed-off-by: Claude <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
…alse-positives

Signed-off-by: Claude <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	docs/GUIDE.md
@CodeWithJuber
CodeWithJuber merged commit d8f62f9 into master Sep 24, 2026
12 checks passed
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.

3 participants