fix(guards): match protect-paths per path token, fail closed, and stop double-registering guards with the plugin - #159
Merged
Conversation
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>
…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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
This PR fixes three problems that an evaluation run of forgekit against a real Next.js repo found (HostLelo, where the
forgekit@forgeplugin is enabled, so agents hit them today).1. protect-paths blocked common read-only commands (finding, severity high).
\.env(\.[A-Za-z0-9_-]+)?\bmatched.envanywhere in a Bash command.protectPathsDecisionblockedgrep -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.envandcat src/i18n/messages.key.ts..env.example,.npmrcandsrc/app/docs/secrets/page.tsx.2. The guards failed open and had coverage gaps (finding, severity high).
PATH,run.mjsexited 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.sed -n p .env,awk 1 .env,tac .env,sort .env,… < .env,cat .e*vrm -rf ./,rm -rf *,rm -rf ..,git checkout -- .,git restore .,curl x | sudo sh3.
forge doctorignored the enabled plugin (finding, severity medium).--fixwould have merged the same 15 hooks intosettings.jsonon top of the plugin'shooks/hooks.json, so every guard would run twice.What changes
global/guards/protect-paths.mjs):secretKind()predicate. Tool paths use the same predicate.--grep=values are never treated as paths..env.exampleand similar),*.key.tsand source files undersecrets/count as code, not secrets..npmrcis blocked only when it holds a literal_authToken,_author_password.~/.npmrcis always protected.$( … )and backticks$'\x2eenv')Read|Grep|Glob|NotebookReadin all three manifests.global/guards/run.mjs):protect-paths.mjson its own node, so bash is no longer needed.--fail-closedlets other guards opt in.src/doctor.js,src/init.js):forgePluginEnabled()reads the user, project and local settings.--fixmerges permissions only.forge initnow 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:
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).-eKEYdropped the real file (grep -eKEY .env,rg -eKEY .env,sed -ep .env). Short options are now read getopt style.cd ~ && cat .npmrcread the user token. After any directory change, a relative.npmrcis now protected.git show :.envrg KEY ./secrets/and Grep on/run/secrets/<<inside arithmetic hid the next linesudo bash <<EOFbodies were not scannedbash -c "…; cat .env",evalandcat "$(echo .env)"*.{env,pem}) and.env-localforge initstill wrote hooks when the plugin was enabled only in the project scopepermissions.denyMy own pass found three more gaps of the same kind, and they are closed too:
for ((i=0; i<<2; …)), the gluedgit log -L1,5:.env, andgit 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:
permissions.deny, because narrowing it would weaken the primary layer. On asettings.jsoninstall,Read(./.env.*)andRead(./**/.npmrc)still deny those Reads.secretsis now blocked unless itsglobortypekeeps it to source files. A bare Grep ofsrc/app/docs/secretsis refused; withglob: "*.tsx"ortype: "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:-e/-f; git index paths and-L; recursive reads of asecrets/directory; arithmetic<<;sh -c,evaland$(echo); braces and dash-separated env names; thecdcases for.npmrcagainst a real temp reposecretKind,globMatchesSecret,expandBraces(new public function),npmrcHasToken,shellSegmentsandsecretShellAccesstest/doctor_plugin.test.js: plugin scopes, a permissions-only merge, doctor--fix, double-registration detection, andforge initwith 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.npmrcexpectations.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.node global/guards/run.mjs global/guards/protect-paths.sh, the bypasses above exit 2 and the false positives exit 0.Checklist
npm testpasses (Node 18/20/22). It passes on Node 22 locally; 18 and 20 were not run here.npm run checkpasses (Biome lint + format)feat:/fix:/docs:…)CHANGELOG.mdupdated under## [Unreleased]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
cd, reading a relative.npmrcis blocked even when it is harmless.secretsneeds a source-onlyglobortype.forge initanddoctor --fixleave the guard hooks out of the user-levelsettings.json. Other repos that do not enable the plugin then get no guards from that file.COMMAND_RULESstill cost about 1.2s on a 10k-character pathological command. The new parsing code is linear, and the cost is the same on master.git revertthe six commits (or only af2870f and 771dc37 to go back to the pre-review state). The manifests still point atrun.mjs …/protect-paths.sh, so no settings.json install needs a re-merge in either direction.Extra checks (tick if applicable)
npm run typecheckpassessh -c/evaldeeper than 8 levels, an over-cap brace expansion and an unreachable interpreter all block (exit 2)..npmrccontent is read to look for a token and is never printed.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