hughescr/claude-code-config/tree/develop/my-plugins/git-guard
git-guard
一个进程内的 `tool.call` mod,会拒绝那些会静默丢弃未提交工作的 Bash git 命令。它是 `hooks/git-guard.sh` 的 mod 版本,切换完成后可取代该脚本,省去脚本在每次 Bash 调用中产生的 shell fork(这里约为 34 ms,而本机的基准低于一微秒)。
关于这个 mod
git-guard
一个进程内的 tool.call mod,会拒绝那些会静默丢弃未提交工作的 Bash git 命令。它是 hooks/git-guard.sh 的 mod 版本,切换完成后可取代该脚本,省去脚本在每次 Bash 调用中产生的 shell fork(这里约为 34 ms,而本机的基准低于一微秒)。
状态: 仅已暂存。在 Craig 作出其他决定前,settings.json 中运行 hooks/git-guard.sh 的 PreToolUse Bash 条目仍是正式的强制措施。此插件在安装前不会生效(先执行 marketplace update,再执行 install)。激活、验证与回滚见:my-plugins/MODS-ACTIVATION.md。
它会拒绝什么
通过 git 全局选项匹配(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):
| 命令 | 允许的例外 |
|---|---|
| git checkout ... -- <path>(checkout 后是单独的 -- token,之间没有 \|&;) | git checkout main、-b、--track |
| git restore | 纯 --staged 形式(任何位置都没有 --worktree 或 -W) |
| 命令中任意位置出现完整 token --hard 的 git reset | --soft、--mixed、--hard-not-really |
| 带强制标志(-f、-fd、-fdx、--force)的 git clean | 任何试运行(-n、--dry-run,即使同时有 --force) |
每个拒绝原因最后都会附上:「通过编辑来撤销你自己的修改,不要丢弃它们;如果确实需要这个破坏性命令,请向 Craig 请求例外。」空命令或非字符串命令会直接通过。tool.call 对子代理同样生效,因此没有子代理过滤器。
它如何匹配
hooks/guard.ts 是纯字符串逻辑(不导入 claude-code)。shell 脚本的 [[:space:]] 与 \b 遵循 BSD grep 和 libc 语义,而不是 Unicode 属性,因此字符类会原样复现:
[[:space:]]是去掉 U+FEFF 后的 JS\s(24 个码点)。- 关键字后的
\b使用hooks/wordchars.ts;这是本机 BSD grep 视为单词字符的生成表(706 个范围)。tests/gen-git-guard-wordchars.ts会重新生成它(从~/.claude执行bun my-plugins/git-guard/tests/gen-git-guard-wordchars.ts);只有在 macOS 升级后、且hooks/git-guard.sh仍存在作为参考时才重新生成。 git ... <subcommand>前缀按 token 逐个以线性时间扫描。shell 的正则表达式会发生二次方回溯(重复git -C5,700 次、约 40 KB 的输入耗时 5 s),这会跨过 hook 超时并导致放行。现在即使是 1 MB 的对抗性输入,也能在远低于 100 ms 内完成。
保留的漏洞(与脚本保持一致,稍后在单独标记的修改中修复)
- 命令中的任意位置出现
--staged都会允许git restore;不识别-S,所以git restore -S f会被拒绝。 - 任何位置出现类似试运行的 token(即使是
-name)都会允许git clean。 - 未知的全局选项,或
git -C reset ...(其值吞掉了reset),会漏过检查。--git-dir=使用空值时会破坏选项链。 git -c clean.requireForce=false clean -d会被允许。- 不防护 shell 混淆(变量、引号技巧、
eval、在脚本内部运行 git)。 - 仍有过度拒绝:
git reset HEAD~1; echo --hard和git clean -d; rm -f x会被拒绝。
有意的行为变化
- shell 的
set -o pipefail配合echo | grep -q,在约 64 KiB 以上的命令上可能因 SIGPIPE 偶尔漏掉匹配。mod 的行为是确定性的。 - 非字符串命令会直接通过(在 Bash schema 下无法到达)。
测试
claude plugin test my-plugins/git-guard 会运行 tests/guard.test.ts(通过 $.tool.call 运行 tests/corpus.ts 中的黄金语料、拒绝原因文本,以及 1 MB 性能案例)。通过让原始脚本和 mod 在同一份语料加随机 fuzz 上运行,已经证明两者保持一致。
安装
请先查看作者 README 确认 marketplace 和插件名称;命令可能随仓库结构改变。
claude plugin marketplace add hughescr/claude-code-config claude plugin install git-guard
原文 / 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.
