dsh-git-push-gate
v1.1.1
Published
Tool-layer gate: refuses agent-initiated git push at tools/pre-execute, before any shell runs. Local commits stay untouched. Matching core lives in the cross-agent shared gate repo, located through GIT_PUSH_GATE_HOME (portable default <homedir>/shared/hoo
Maintainers
Readme
dsh-git-push-gate(DSH 侧适配器)
工具层闸门:agent 发起的 git push 在 shell 执行之前被拦下。本地 git add / git commit 完全不受影响。
本文件只是 DSH 侧适配器的说明书。闸门是跨 agent 共用资产,正本仓库在
共用闸门仓库(默认 <homedir>/shared/hooks/git-push-gate,可用环境变量 GIT_PUSH_GATE_HOME 覆盖),
总览见那里的 README.md。判定核心、回归矩阵、压缩提交脚本、审计日志都在那个仓库里,
不在 DSH 检出内 —— 换 agent、换 DSH 版本都不会动它。
为什么必须在这一层
命令层拦截放在 shell 之后都拦不住:
| 方案 | 实测结果 |
|---|---|
| pre-push 钩子改写 | 只在远端为空/可快进时有效。本地历史与上游压缩提交必然分叉,git push 是 non-fast-forward,git 在调用 pre-push 之前就拒绝,钩子拿不到机会(本地路径远端与 file:// 远端同样如此)。 |
| git config alias.push | 覆盖不了内置命令,实测仍走内置 push。 |
| 工具层 tools/pre-execute | 命中即返回 {kind:'deny'},原命令根本不执行。本插件。 |
挂载
走官方 bundle 机制,vendor tgz 实体安装(0 junction)——这不是偏好:link: 依赖会在
pnpm install 重建 node_modules 时被清掉,重启后报 cannot resolve profile bundle(本机
2026-08-15 实撞,见 ops/PATCHES.md 中危 5)。装法一步到位:
pwsh -File <shared-gate-home>\adapters\dsh\install.ps1 -Profile web -AlsoProfile headless它做的事:
npm pack源码目录 →$DSH_HOME/profiles/<p>/vendor/dsh-git-push-gate-<ver>.tgz- profile
package.json:dependencies["dsh-git-push-gate"] = "file:vendor/dsh-git-push-gate-<ver>.tgz",dsh.profile.bundles加"dsh-git-push-gate" - 解包 tgz 到
profiles/<p>/node_modules/dsh-git-push-gate/(实体目录,非 junction) - 重启 app 生效(宿主插件无 HMR:base 的
hmr行disabled: true)
改源码后必须重装:node_modules 里是 tgz 解出来的拷贝,不是软链。改完
lib/index.js 重跑一次 adapters\dsh\install.ps1 即可。
判定逻辑不在这个包里(在 <shared-gate-home>/scripts/git-push-gate-core.mjs),所以只改判据时无需重装。
验证挂载:dsh --profile <p> --dump-config EXIT=0 且出现 # == dsh-git-push-gate 行;
profiles/<p>/node_modules 的 junction 计数应保持 0。
判定逻辑
判定核心只有一份:<shared-gate-home>/scripts/git-push-gate-core.mjs。DSH 插件与 Claude Code 的
PreToolUse 钩子都 import 它,两侧不会漂移。
改核心必须重启宿主:插件在加载阶段 import 核心并常驻内存,只改文件不重启,会话里仍按旧
核心判定(实测分裂:同一条命令,新起的 node 进程判 HIT,宿主内判 miss)。Claude Code 侧每次
钩子调用都新起进程,改完即生效。
判定是静态、引号感知的命令分析(不调 git、不联网):
- 顶层按
;|&&&||换行切段 - 每段剥掉 env 赋值、
&调用符、透明包装器后取命令字。包装器分三类:通用 (sudoenvnicetimeoutxargsstdbuf…)、本机 CLI 代理(rtk,它把rtk git push原样转给 git)、shell 内建(commandbuiltinexec,它们绕过函数与 别名,后面的词才是真跑的);再往下一层是包/脚本启动器(npxnpmpnpmyarnbunxbunuvuvxpipx),它们紧跟的dlx/exec/x/run只起引导作用 - 命令字是
git(含git.exe、绝对路径)→ 跳-c-C--git-dir等全局选项, 子命令是push即命中 - 命令字是 shell(
shbashzshpwshpowershellcmdwsl…)→ 取出它要执行的 载荷(-c/-Command/-lc//c)后递归分析,深度上限 6
反斜杠在 POSIX shell 与 PowerShell 里含义不同,所以分析跑两遍,严的一遍胜出:
- 第一遍:反斜杠后面是普通字母时保留字面,Windows 路径分隔符不被吃掉
(
D:\Tools\Git\cmd\git.exe push认得出git) - 第二遍:把所有
\x折成x,即 POSIX shell 的解释 (\g\i\t push origin master一并认出)
覆盖:git push、git -c <any> push、git -C <dir> push、--force / --force-with-lease、
cd x && git push、a; git push、pwsh -Command "git push"、bash -lc 'git push'、
cmd /c "git push"、嵌套 shell、"C:\Program Files\Git\cmd\git.exe" push、
& "C:\...\git.exe" push、D:\Tools\Git\cmd\git.exe push、\g\i\t push、
rtk git push、rtk.exe git push、"rtk" git push、rtk git -C <dir> push、
command git push、builtin git push、exec git push、npx git push、
npm exec git push、pnpm dlx git push、yarn dlx git push、bun x git push、
uvx git push、uv run git push、pipx run git push、
timeout 5 git push、timeout -k 5 10 git push、timeout -s KILL 5 git push、
timeout --foreground 5 git push、timeout 0.5s git push、command -p git push、
builtin -p git push、exec -a <name> git push、xargs -I {} git push、
sudo -u <user> git push、env -u <var> git push、stdbuf -o L git push。
包装器的接值选项按包装器分表:同一面 -p 在 sudo 那里接值(--prompt),在 command
那里是纯开关;exec -a <name> 接值。timeout 除选项外还吃 1 个裸位置参数(duration),
所以 timeout 5 git push 剥完词头是 git 而不是 5。未单列的包装器回落共享表。
不误伤:git fetch / log / status / commit / add / diff / ls-remote /
config / commit-tree 一律放行;git commit -m "explain git push" 这类引号里的字面量
不命中(因为引号内容只在命令字是 shell 时才递归分析)。包装器剥完后命令字不是 git 的同样
放行:rtk status、rtk log、rtk git status、npx create-react-app、pnpm install、
uv run pytest。同一批包装器带非 push 子命令的也放行:timeout 5 ls、
timeout 5 git status、timeout 5 git log --oneline -5、timeout 5(根本没有命令)、
command -p git status、command -p ls、command -v git、command -V git、exec ls、
exec -c ls、exec -a foo ls、nice -n 10 git status、sudo -u root git status、
env -u FOO git status、stdbuf -o L git status。
git push --dry-run / -n:放行,并在审计日志写 allow-dry-run。
git push --help:放行,写 allow-help。
回归矩阵:node <shared-gate-home>/scripts/git-push-gate-selftest.mjs(117 例,含反例)。
配置
cordis 行配置(cordis.patch.yml 里那行加 config:):
mode: deny(默认)—— 拒绝 + 指路,原命令不执行mode: squash—— 拒绝,但代跑publish.ps1生成压缩提交,回报生成的提交 sha 与推送命令mode: off—— 放行,但仍写审计(bypass-mode-off)allowDryRun: true(默认)onAnalyzeError: deny(默认)—— 判定器抛异常时保守拦下(见「失败模式」); 设allow才放行,属显式选择onCoreUnavailable: regex(默认)—— 判定核心整个文件加载不到时的兜底(见「失败模式」);deny-all一律拒,allow不挂钩子(显式选择,等于关掉闸门)
审计
每次命中写一行 JSONL 到 <shared-gate-home>/logs/gate-audit.log(可用 GIT_PUSH_GATE_AUDIT_LOG 覆盖):
{"ts":"…","source":"dsh","tool":"pwsh","decision":"deny","rule":"git-push","cwd":"…","cmd":"git push origin master"}source 区分 dsh / claude。decision ∈ deny / allow-dry-run / allow-help /
bypass-mode-off / deny+squash / deny+squash-noop / deny+squash-failed /
deny+analyze-error / core-missing。写日志失败不影响判定。
失败模式(刻意选边)
- 判定核心
git-push-gate-core.mjs丢失或 import 失败 → 插件仍然挂 pre-execute,改用 保守正则兜底(FALLBACK_PUSH_RE,与 Claude 侧钩子同一份口径;这条例正则有三个字面量, 见根 README「一处要改三处的地方」): 明显像 push 就拒,其余放行。启动时打印[git-push-gate] DEGRADED (regex) …,审计逐条写decision=core-missing/rule=fallback/outcome=deny|allow。 这条是粗网:只判"像不像 push",所以降级态下git push --dry-run也会被拒(正常态放行)。 匹配前会先去引号(replace(/["']/g, ''))再套正则:shell 本来就去引号,所以git "push" origin master/git 'push' origin master/git pu"sh"都是真推送, 不去引号的网会整条漏掉。代价只有一个方向 —— 这张网只会更严,不会更松:- 误拒(正常态放行、降级态拒):
git push --dry-run、git push --help、echo git push origin master、git commit -m "docs: explain push"—— 正则只认"像 git … push 的样子",不问它是不是真在推。 - 已无「漏判」:去引号后,带引号与不带引号的子命令走同一条判定。
换句话说,降级态不会放过正常态会拦的东西;但它仍然是判「像不像」,不是判「是不是」。
配置
onCoreUnavailable: regex(默认)/deny-all(连echo都拒)/allow(不挂钩子)。 默认刻意不做 deny-all:核心被误删时全拦会让 agent 连echo都跑不了。
- 误拒(正常态放行、降级态拒):
- 判定抛异常(核心在,但分析器炸了)→ 失败关闭:返回
deny(文案已拦下:git push 闸门判定器异常,保守拒绝本次命令), 审计写deny+analyze-error。判定器有解析 bug 时不静默漏过;确需放行得显式配onAnalyzeError: allow并重启宿主。两侧口径一致:核心出问题时本侧不比 Claude 侧松。 - 本闸门管的是"配了它的 agent"。用户手敲终端不受管;挡的是 agent 的顺手与故意,不是"任何人"。
- 硬边界仍是凭证:能推上游的凭证不在 agent 手里。闸门 + 凭证两层,缺一不可。
- 静态匹配覆盖不到的路径(已知,未拦):
git p这类展开成 push 的别名;git push字符串 经其它语言运行时二次解释(如python -c "subprocess.run(['git','push'])");从别的机器/别的 工具推送。这些属于"凭证边界"该管的部分。
相关
<shared-gate-home>/scripts/git-push-gate-core.mjs—— 判定核心(单一正本)<shared-gate-home>/scripts/git-push-gate-selftest.mjs—— 117 例回归矩阵<shared-gate-home>/scripts/publish.ps1—— 生成唯一一条压缩提交(纯 plumbing,不推送)~/.claude/hooks/git-push-gate.mjs—— Claude Code 侧 PreToolUse 钩子<shared-gate-home>/adapters/dsh/test/pre-execute.test.mjs—— 插件契约测试(假 ctx 驱动真事件)
