hughescr/claude-code-config/tree/develop/my-plugins/git-guard
git-guard
An in-process `tool.call` mod that denies Bash git commands which silently discard uncommitted work. It is the mod form of `hooks/git-guard.sh` and replaces it once cut over, saving the shell forks that script cost on every Bash call (about 34 ms per call, against under a microsecond here).
About this mod
git-guard
An in-process tool.call mod that denies Bash git commands which silently discard uncommitted work. It is
the mod form of hooks/git-guard.sh and replaces it once cut over, saving the shell forks that script cost on
every Bash call (about 34 ms per call, against under a microsecond here).
Status: staged only. Until Craig decides otherwise, the PreToolUse Bash entry in settings.json that runs
hooks/git-guard.sh stays the enforcement of record. This plugin is inert until it is installed
(marketplace update, then install). Activation, verification and rollback: my-plugins/MODS-ACTIVATION.md.
What it denies
Matched through git global options (git -C dir ..., git -c k=v ..., --git-dir, --work-tree, --namespace,
--exec-path, --super-prefix=, --config-env=, -p, --paginate, -P, --no-pager, --no-optional-locks,
--no-replace-objects, --literal-pathspecs, --glob-pathspecs, --noglob-pathspecs, --icase-pathspecs,
--bare):
| Command | Allowed exceptions |
|---|---|
| git checkout ... -- <path> (a bare -- token after checkout, no \|&; between) | git checkout main, -b, --track |
| git restore | the pure --staged form (no --worktree or -W anywhere) |
| git reset with --hard as a whole token anywhere in the command | --soft, --mixed, --hard-not-really |
| git clean with a force flag (-f, -fd, -fdx, --force) | any dry run (-n, --dry-run, even with --force) |
Every deny reason ends with: "Undo your own edits by editing instead of discarding them; ask Craig for an
exception if this destructive command is genuinely needed." A command that is empty or not a string passes
through. tool.call fires for subagents as well, so there is no subagent filter.
How it matches
hooks/guard.ts is pure string logic (no claude-code imports). The shell script's [[:space:]] and \b
are BSD grep and libc semantics, not Unicode properties, so the character classes are reproduced exactly:
[[:space:]]is JS\sminus U+FEFF (24 code points).\bafter a keyword useshooks/wordchars.ts, a generated table of the characters BSD grep treats as word characters on this machine (706 ranges).tests/gen-git-guard-wordchars.tsregenerates it (bun my-plugins/git-guard/tests/gen-git-guard-wordchars.tsfrom~/.claude); rerun it only after a macOS upgrade, whilehooks/git-guard.shstill exists as the reference.- The
git ... <subcommand>prefix is scanned token by token in linear time. The shell's regexes backtracked quadratically (git -Crepeated 5,700 times, about 40 KB, took 5 s), which would have crossed the hook timeout and failed open. Every 1 MB adversarial input now finishes in well under 100 ms.
Preserved holes (parity with the script, to fix later in a separate, flagged change)
--stagedanywhere in the command allowsgit restore;-Sis not recognised, sogit restore -S fis denied.- A dry-run-like token anywhere (even
-name) allowsgit clean. - An unknown global option, or
git -C reset ...(the value swallowsreset), slips through.--git-dir=with an empty value breaks the option chain. git -c clean.requireForce=false clean -dis allowed.- No defence against shell obfuscation (variables, quoting tricks,
eval, scripts that run git inside). - Over-denies remain:
git reset HEAD~1; echo --hardandgit clean -d; rm -f xare denied.
Deliberate behaviour changes
- The shell's
set -o pipefailwithecho | grep -qcould flakily miss a match on commands over about 64 KiB (SIGPIPE). The mod is deterministic. - A non-string command passes through (unreachable under the Bash schema).
Tests
claude plugin test my-plugins/git-guard runs tests/guard.test.ts (the golden corpus in tests/corpus.ts through
$.tool.call, deny-reason text, and 1 MB performance cases). Parity with the shell script was proven by running
the original script and the mod over the same corpus plus random fuzz.
Installation
Check the author's README for the marketplace and plugin name first. Commands may change as the repository evolves.
claude plugin marketplace add hughescr/claude-code-config claude plugin install git-guard
Original text / README
git-guard
An in-process tool.call mod that denies Bash git commands which silently discard uncommitted work. It is
the mod form of hooks/git-guard.sh and replaces it once cut over, saving the shell forks that script cost on
every Bash call (about 34 ms per call, against under a microsecond here).
Status: staged only. Until Craig decides otherwise, the PreToolUse Bash entry in settings.json that runs
hooks/git-guard.sh stays the enforcement of record. This plugin is inert until it is installed
(marketplace update, then install). Activation, verification and rollback: my-plugins/MODS-ACTIVATION.md.
What it denies
Matched through git global options (git -C dir ..., git -c k=v ..., --git-dir, --work-tree, --namespace,
--exec-path, --super-prefix=, --config-env=, -p, --paginate, -P, --no-pager, --no-optional-locks,
--no-replace-objects, --literal-pathspecs, --glob-pathspecs, --noglob-pathspecs, --icase-pathspecs,
--bare):
| Command | Allowed exceptions |
|---|---|
| git checkout ... -- <path> (a bare -- token after checkout, no \|&; between) | git checkout main, -b, --track |
| git restore | the pure --staged form (no --worktree or -W anywhere) |
| git reset with --hard as a whole token anywhere in the command | --soft, --mixed, --hard-not-really |
| git clean with a force flag (-f, -fd, -fdx, --force) | any dry run (-n, --dry-run, even with --force) |
Every deny reason ends with: "Undo your own edits by editing instead of discarding them; ask Craig for an
exception if this destructive command is genuinely needed." A command that is empty or not a string passes
through. tool.call fires for subagents as well, so there is no subagent filter.
How it matches
hooks/guard.ts is pure string logic (no claude-code imports). The shell script's [[:space:]] and \b
are BSD grep and libc semantics, not Unicode properties, so the character classes are reproduced exactly:
[[:space:]]is JS\sminus U+FEFF (24 code points).\bafter a keyword useshooks/wordchars.ts, a generated table of the characters BSD grep treats as word characters on this machine (706 ranges).tests/gen-git-guard-wordchars.tsregenerates it (bun my-plugins/git-guard/tests/gen-git-guard-wordchars.tsfrom~/.claude); rerun it only after a macOS upgrade, whilehooks/git-guard.shstill exists as the reference.- The
git ... <subcommand>prefix is scanned token by token in linear time. The shell's regexes backtracked quadratically (git -Crepeated 5,700 times, about 40 KB, took 5 s), which would have crossed the hook timeout and failed open. Every 1 MB adversarial input now finishes in well under 100 ms.
Preserved holes (parity with the script, to fix later in a separate, flagged change)
--stagedanywhere in the command allowsgit restore;-Sis not recognised, sogit restore -S fis denied.- A dry-run-like token anywhere (even
-name) allowsgit clean. - An unknown global option, or
git -C reset ...(the value swallowsreset), slips through.--git-dir=with an empty value breaks the option chain. git -c clean.requireForce=false clean -dis allowed.- No defence against shell obfuscation (variables, quoting tricks,
eval, scripts that run git inside). - Over-denies remain:
git reset HEAD~1; echo --hardandgit clean -d; rm -f xare denied.
Deliberate behaviour changes
- The shell's
set -o pipefailwithecho | grep -qcould flakily miss a match on commands over about 64 KiB (SIGPIPE). The mod is deterministic. - A non-string command passes through (unreachable under the Bash schema).
Tests
claude plugin test my-plugins/git-guard runs tests/guard.test.ts (the golden corpus in tests/corpus.ts through
$.tool.call, deny-reason text, and 1 MB performance cases). Parity with the shell script was proven by running
the original script and the mod over the same corpus plus random fuzz.
