hughescr/claude-code-config/tree/develop/my-plugins/git-guard
git-guard
프로세스 내부에서 동작하는 `tool.call` mod로, 커밋하지 않은 작업을 조용히 버리는 Bash git 명령을 거부합니다. `hooks/git-guard.sh`를 mod 형태로 옮긴 것이며, 전환이 끝나면 스크립트를 대체해 Bash 호출마다 스크립트가 만들던 셸 fork를 줄입니다(여기서는 약 34 ms, 이 환경에서는 1마이크로초 미만)。
이 mod 소개
git-guard
프로세스 내부에서 동작하는 tool.call mod로, 커밋하지 않은 작업을 조용히 버리는 Bash git 명령을 거부합니다. hooks/git-guard.sh를 mod 형태로 옮긴 것이며, 전환이 끝나면 스크립트를 대체해 Bash 호출마다 스크립트가 만들던 셸 fork를 줄입니다(여기서는 약 34 ms, 이 환경에서는 1마이크로초 미만)。
상태: 스테이지만 완료되었습니다. 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 | 모든 dry run(-n, --dry-run, --force가 함께 있어도) |
모든 거부 사유는 다음 문장으로 끝납니다. 「버리는 대신 편집해서 자신의 수정 사항을 직접 되돌리세요. 이 파괴적인 명령이 정말 필요하다면 Craig에게 예외를 요청하세요。」빈 명령이나 문자열이 아닌 명령은 그대로 통과합니다. tool.call은 서브에이전트에도 적용되므로 서브에이전트 필터가 없습니다.
판정 방식
hooks/guard.ts는 순수 문자열 로직입니다(claude-code import 없음)。셸 스크립트의 [[:space:]]와 \b는 Unicode 속성이 아니라 BSD grep과 libc 의미론을 따르므로 문자 클래스를 그대로 재현합니다.
[[: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 단위로 선형 시간에 스캔합니다. 셸 정규식은 이차적으로 백트래킹했습니다(git -C를 5,700번 반복한 약 40 KB 입력에 5 s가 걸림)。이 때문에 hook 시간 제한을 넘겨 fail open 될 수 있었습니다. 이제 1 MB의 공격적인 입력도 100 ms보다 훨씬 짧은 시간에 끝납니다.
남아 있는 허점(스크립트와 동일하며, 나중에 별도의 표시된 변경으로 수정)
- 명령 어디에든
--staged가 있으면git restore를 허용합니다.-S는 인식하지 않으므로git restore -S f는 거부됩니다. - dry-run과 비슷한 token이 어디에든 있으면(
-name도 포함)git clean을 허용합니다. - 알 수 없는 전역 옵션이나
git -C reset ...(값이reset을 삼킴)은 빠져나갑니다. 빈 값을 가진--git-dir=는 옵션 연결을 깨뜨립니다. git -c clean.requireForce=false clean -d는 허용됩니다.- 셸 난독화(변수, 인용부호 트릭,
eval, 내부에서 git을 실행하는 스크립트)는 방어하지 않습니다. - 과도한 거부는 남아 있습니다.
git reset HEAD~1; echo --hard와git clean -d; rm -f x는 거부됩니다.
의도한 동작 변경
- 셸의
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의 golden corpus, 거부 사유 텍스트, 1 MB 성능 사례)。원본 스크립트와 mod를 같은 corpus 및 무작위 fuzz에 실행해 셸 스크립트와의 parity를 입증했습니다.
설치
먼저 작성자의 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.
