Five additional review findings on top of the round-1 tokenizer fix.
Combined patch surface is small (one push branch, new switch branch,
exploded subshell handling); all six review issues are now closed.
P1 — --force --force-if-includes still destructive (Greptile, line 217):
Previous logic treated --force-if-includes as a safety guarantee
alongside --force-with-lease. Per git-scm.com/docs/git-push,
--force-if-includes is a no-op WITHOUT --force-with-lease, so a
combination of --force --force-if-includes is just --force. Push
branch now treats only --force-with-lease as a lease, and reports
force when --force / -f is present.
P2 — git switch destructive forms not detected (Greptile, line 234):
Added a switch branch to isDestructiveGit covering:
--discard-changes (explicit discard)
--force / -f (ignore conflicts, overwrite)
-C <branch> (force-create, overwrites existing branch)
P0 — backtick + $(...) subshell bypass (CodeRabbit, line 64):
Added explodeSubshells() that promotes `...` and $(...) contents
to top-level segment separators. Run on both the SQL/dd regex
input and the per-segment shell tokenizer input. Loops up to 4
passes to catch a layer of nesting. Without this,
`echo y | $(rm -rf /tmp)` slipped past the segment splitter
because the destructive command lived inside a sub-expression.
P0 — +refspec force push (CodeRabbit, line 217):
`git push origin +main`, `+refs/heads/main:refs/heads/main`, etc.
force a non-fast-forward update of that specific ref. Push branch
now also flags any positional arg starting with `+` that matches
a refspec shape. Excludes bare `+` and numeric-only tokens.
P2 — missing --force --force-if-includes regression test
(Greptile, line 1202): added.
Tests (+10 on top of the round-1 +10):
Bypass-now-blocked:
- git push --force --force-if-includes (force-if-includes is no-op
without lease — bare force is still in effect)
- git push origin +main (+refspec bare branch)
- git push origin +refs/heads/main:refs/heads/main (+refspec full)
- git switch --discard-changes
- git switch --force
- git switch -f (short form)
- git switch -C (force-create)
- echo y | `rm -rf /tmp` (backtick subshell)
- echo y | $(rm -rf /tmp) (dollar-paren subshell)
Still-allowed:
- git switch feature (plain)
67/67 in gateguard-fact-force.test.js. 2380/2380 across the full
suite. yarn lint clean. All seven CI validators pass.
Refs #1843.
Six classes of bypass in scripts/hooks/gateguard-fact-force.js
DESTRUCTIVE_BASH regex, plus a separate false-positive class.
Same shape of issue as the block-no-verify holes addressed in
#1843: a single-regex shell parser can never cover the flag-order
variations git and rm allow.
Real bypasses observed locally (all ALLOW today, should BLOCK):
git push -f origin main (short form of --force)
git -c core.foo=bar reset --hard (intervening -c global)
rm -fr /tmp/junk (reverse flag order)
rm -r -f /tmp/junk (split flag form)
git reset HEAD --hard (intervening ref token)
git clean -fd (combined -f + -d flag)
False positive observed locally (BLOCK today, should ALLOW):
git commit -m "fix: rm -rf race in worker" (destructive phrase
inside quoted message)
Behavior fix that comes along: --force-if-includes is now exempted
alongside --force-with-lease. Both are safety-checked variants;
the previous regex used a negative lookahead that only spelled out
--with-lease, so --force-if-includes blocked under the old code
even though it is the safer-not-harder choice.
Fix shape (mirrors block-no-verify #1843):
- DESTRUCTIVE_SQL_DD regex kept for `drop table`, `delete from`,
`truncate`, `dd if=` — these are stable keyword phrases. Quoted
strings are stripped before the regex runs so the phrase is not
matched inside a commit message body.
- isDestructiveBash() tokenizes the command into segments at
unquoted ; | & boundaries, then per segment:
* isDestructiveRm — detects `rm` with both r and f set
across combined or split flag tokens.
* isDestructiveGit — finds the git subcommand after skipping
global options (-c key=val, -C path, --git-dir=, etc.),
then handles reset, checkout --, clean -f*, push --force
(with --force-with-lease / --force-if-includes exemption),
commit --amend, and rm -r* preservation.
- Command tokens go through commandBasename() so /usr/bin/rm,
rm.exe, and RM all normalize to "rm".
Tests (+10 in tests/hooks/gateguard-fact-force.test.js):
Bypass-now-blocked (7):
- denies short-form git push -f
- denies git reset --hard with intervening -c global option
- denies rm -fr (reverse flag order)
- denies rm -r -f (split flag form)
- denies git reset HEAD --hard
- denies git clean -fd
- denies destructive command in second chained segment
False-positive-now-allowed (3):
- allows destructive phrase inside `-m` commit message (rm -rf)
- allows SQL phrase inside `-m` commit message (drop table)
- allows --force-if-includes as a safety-checked variant
Local verification:
yarn lint clean
scripts/ci/validate-* (agents/commands/rules/skills/hooks/
install-manifests/no-personal-paths) pass
node tests/run-all.js 2380/2380 pass
Caveat (unrelated): yarn test still fails at check-unicode-safety
on skills/windows-desktop-e2e/SKILL.md (U+2605) per #1843's
caveat — independent of this change.
Provenance: discovered during a security pass on ECC after PR
#1843 (block-no-verify shell-words rewrite) landed. Same class of
regex-based shell parser issue, same shape of fix.
Refs #1843.
- isChecked() no longer calls saveState() — read-only operation
should not write to disk (was causing 3x writes per tool call)
- Test cleanup uses fs.rmSync(recursive) instead of fs.rmdirSync
which failed with ENOTEMPTY when .tmp files remained
9/9 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
P1 (cubic-dev-ai): Test process PID differs from spawned hook PID,
so test was seeding/clearing wrong state file. Fix: pass fixed
CLAUDE_SESSION_ID='gateguard-test-session' to spawned hooks.
P2 (cubic-dev-ai): Pruning checked array could evict __bash_session__
and other session keys, causing gates to re-fire mid-session. Fix:
preserve __prefixed keys during pruning, only evict file-path entries.
9/9 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Addresses reviewer feedback from @affaan-m:
1. State keyed by CLAUDE_SESSION_ID / ECC_SESSION_ID
- Falls back to pid-based isolation when env vars absent
- State file: state-{sessionId}.json (was .session_state.json)
2. Atomic write+rename semantics
- Write to temp file, then fs.renameSync to final path
- Prevents partial reads from concurrent hooks
3. Bounded checked list (MAX_CHECKED_ENTRIES = 500)
- Prunes to last 500 entries when cap exceeded
- Stale session files auto-deleted after 1 hour
9/9 tests pass.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Destructive bash gate previously denied every invocation with no
isChecked call, creating an infinite deny loop. Now gates per-command
on first attempt and allows retry after the model presents the required
facts (targets, rollback plan, user instruction).
Addresses greptile P1: "Destructive bash gate permanently blocks"
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- GATEGUARD_STATE_DIR env var for test isolation (hook + tests)
- Exit code assertions on all 9 tests (no vacuous passes)
- Non-vacuous allow-path assertions (verify pass-through preserves input)
- Robust newline-injection assertion
- clearState() now reports errors instead of swallowing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1. Use run-with-flags.js wrapper (supports ECC_HOOK_PROFILE, ECC_DISABLED_HOOKS)
2. Add session timeout (30min inactivity = state reset, fixes "once ever" bug)
3. Add 9 integration tests (deny/allow/timeout/sanitize/disable)
Refactored hook to module.exports.run() pattern for direct require() by
run-with-flags.js (~50-100ms faster per invocation).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>