theRMM714/git-for-dsh

git-for-dsh

deepseek harness的git工具

미분류deepseek-harness

git-for-dsh

免责声明:这是个人测试用的项目,使用这个项目出现任何问题,作者不负任何责任,如果同意再进行使用。

一个 DeepSeek Harness 插件:让 AI 能在文件沙箱之外执行 git 命令,同时由用户自己勾选「允许哪些 git 子命令」。

插件由两半组成,装一次即可:

  • Host 半(src/index.js)注册模型可见的工具 git_exec,并实现五道闸门:用户的允许清单、参数闸门、配置写入闸门、仓库配置审计,以及执行环境硬化。
  • Client 半(src/client.js)在「设置 → Git 工具」里渲染勾选页,用户在这里决定放行哪些子命令。

为什么需要它

为了更精准、安全地控制 AI 使用 git 指令的边界。

具体做法是显式地不设沙箱,改用允许清单作为安全边界:只有用户勾选过的子命令能启动进程,且参数必须通过参数闸门。

之所以必须离开沙箱,是因为沙箱里的 git 能做本地操作、做不了远端操作 —— 被挡住的不是 git 的读写,而是它必须捕获输出的帮助进程,以及 TLS 的凭证获取:

沙箱内的调用 · 实测结果 git init / git add / git commit(工作区内) · 成功:写 .git、写对象文件都允许 git / curl 走 schannel TLS · 0.2 秒内失败:schannel: AcquireCredentialsHandle failed: SEC_E_NO_CREDENTIALS,直连与走代理一样 git push 拉起凭据助手(sh.exe) · fatal error - couldn't create signal pipe, Win32 error 5,随后 fatal: could not read Username git 调自带的 MSYS2 ssh.exe · 同一个 signal pipe 错误 对照:ssh 换成 Windows 原生 OpenSSH · 可用 对照:换 http.sslBackend=openssl · TLS 可用,push 仍卡在凭据助手

这些都不是文件权限问题 —— 同一个工作区里写 .git 是允许的。它们来自进程级限制:git 要捕获帮助进程的输出,而被捕获的子进程需要一类沙箱不发放的管道;TLS 则取不到凭证。

本插件的每次调用都以 sandboxPolicy: { mode: 'danger-full-access' } 运行 git:当前 dsh 的沙箱执行器在该模式下直接透传本地执行、不调用 `confine()`,因此上述限制一个都不存在。约束改由闸门承担。

在 Windows 上还有一条更硬的理由

dsh 的交互式 bash 工具依赖两条在 Windows 上并不存在的路径,报错原文就在 dsh 自己代码里:

  • 进程检查器只实现到 Linux/macOS —— 报错原文就在 dsh-subprocess-local 里;
  • 交互式终端依赖 PTY —— 报错原文在 dsh-terminal-bash 里;
  • Windows 的 ACL 沙箱以受限令牌隔离进程 —— dsh-sandbox-windows-acl 通过 FFI 直接调用 createRestrictedToken(带 restricting SID),而 MSYS 运行时在启动时依赖一类受限令牌下拿不到的运行时通信 —— 实测表现为 couldn't create signal pipe, Win32 error 5。

即使不经过交互式 bash,沙箱内的 git 也做不了远端操作 —— 实测是三条路各自被挡(见上文那张表:TLS 取不到凭证、凭据助手与 MSYS2 ssh 的 signal pipe 被拒),而本地操作不受影响,连 git commit 都可以。

本插件的 `git_exec` 两条都避开了,因此不受这些限制:

  • 它把命令交给 dsh 的 ctx.shell 执行一次性命令(ctx.shell 每个 host 只有一个实现;在 Windows 上由 win32 层换成 pwsh 那套,在 Linux/macOS 上是 bash 那套),不使用交互式 PTY,也不依赖终端检查器;
  • 它以 danger-full-access 在沙箱之外运行 git,因此既不进入受限令牌沙箱,也不受沙箱的命名管道限制。

换句话说,在 Windows 上它并不"绕道 Git Bash" —— 它走的是一条不撞上这些坑的路径,这一点可以通过本工具在 Windows 上正常推送/读取仓库直接验证。

一条已知的细节:命令要经过 host 的 shell 解析(Linux/macOS 上是 bash 那套,Windows 上是 pwsh 那套),因此参数一律用单引号包裹 —— 这是两种 shell 的共同安全区(单引号内 $、反引号、反斜杠都不展开)。仅"参数里本身含单引号"时转义写法不同,插件按平台选择;含逗号的参数也会加引号,因为 PowerShell 会把裸逗号当成数组分隔符。

安装

本包装有自带的补丁层(dsh.bundle.patch):装包即挂载,不需要手改任何配置文件。

# 从 npm(发布后)
dsh plugin --profile <profile> add git-for-dsh

# 或直接从 GitHub(构建产物 lib/ 已入库,因此不需要构建脚本,也就不需要 allowBuilds 放行)
dsh plugin --profile <profile> add github:theRMM714/git-for-dsh

# 或本地路径(开发时最省事:link 是符号链接,改完即生效)
dsh plugin --profile <profile> add /path/to/git-for-dsh

更新同为一条命令:

dsh plugin --profile <profile> update git-for-dsh
从 GitHub 安装/更新时,shell 里要能访问 github.com。若网络需要代理,先 export HTTPS_PROXY=http://127.0.0.1:。

安装做的事(dsh plugin 的职责):pnpm 装包 → 把包加进 profile 的依赖 → 把声明了 `dsh.bundle` 的依赖并入 `dsh.profile.bundles` 层栈。装载的行来自本包自带的 cordis.patch.yml:

- insert:
    # The development brake: with DSH_GIT_TOOL_DISABLED=1 the row does not load …
    - id: tool-git
      name: git-for-dsh
      disabled: !!js process.env.DSH_GIT_TOOL_DISABLED === '1'

disabled 那一行的作用:设了 DSH_GIT_TOOL_DISABLED=1 时整行不加载,因此一个坏掉的插件只花掉一个环境变量,而不需要改文件(它只管这一行,见「恢复与回退」)。

若在 profile 自己的 cordis.patch.yml 里重述了同一 id,用户层最后应用、按行覆盖(不是冲突),所以想改这一行就在那里改。

重启 Profile 后:

  • 模型获得 git_exec 工具;
  • 设置面板出现「Git 工具」页,勾选即刻生效并持久化到 Profile 的 cordis.patch.yml(用户补丁层里的 tool-git 条目)。

Host 半注册 tool-git 设置条目;Client 半通过 ctx.configForms.get('tool-git') 取同一个表单读写。清单本身在构建时从 `src/git-catalog.js` 直接嵌进浏览器产物(scripts/build.mjs 替换 __GIT_TOOL_CATALOG__),所以勾选页和 Host 的闸门读的是同一份清单,而浏览器不需要任何通往 Host 的运行时通道 —— 改完目录要重新 npm run build 并刷新页面。

设置页底部有一行「页面版本 `」(由 scripts/build.mjs 写入)。如果它与仓库里 lib/client.js` 的指纹不一致,说明浏览器还在用旧的 bundle,强制刷新即可。

使用 git_exec

参数 · 必填 · 含义 argv · 是 · 程序名之后的 git 参数,第一个元素是子命令,例如 ["log", "--oneline", "-n", "20"] paths · 否 · 本次命令涉及的文件或目录,插件拼成 git … -- description · 是 · 一句话说明这条命令做什么,显示在界面上 workdir · 否 · 工作目录;默认会话工作区,相对路径按会话工作区解析 timeoutMs · 否 · 超时毫秒数;本地操作默认 120000,触及远端的操作默认 300000 justification · 否 · 需要审批时提供的一句话理由

返回 exitCode、signal、timedOut、operation、command、stdout、stderr。

文件路径放在 paths 里,不要塞进 argv:插件会拼成 git … -- ,因此空格、通配符、以 - 开头的文件名都不需要转义。

工具描述与系统提示段都从当前允许清单实时生成,模型任何时刻都能看到自己能用哪些操作。

五道闸门与执行环境

1. 允许清单

src/git-catalog.js 是唯一事实来源,47 个操作按三个风险档位分组:

档位 · 含义 · 操作数 · 默认 read · 只读仓库、索引、引用与配置 · 22 · 开启 write · 改动工作区、暂存区或本地分支 · 16 · 关闭 remote · 克隆、推送,或永久丢弃历史与未跟踪文件 · 9 · 关闭

未勾选的子命令在进程启动前就被拒绝,模型看到的是「该操作未启用」而不是「沙箱拒绝」,因此不会试图用别的方式绕过。

只读档的「形式风险」

一个操作可以列出来是只读、写进去是改状态:branch、tag、remote 归在只读档,但它们的变更形式会创建引用、写 .git/config。因此判定按形式进行,分两类处理:

操作 · 只读形式 · 变更形式 · 处理 branch · branch、branch -a -v、branch --list · branch 、-d、-m · 变更形式按写档,走审批 tag · tag、tag -l · tag 、-a、-d · 同上 remote · remote、-v、show、get-url · prune、update · 同上 remote · — · add、remove、rename、set-url、set-head、set-branches · 直接拒绝(写 .git/config) config · config 、--get、--list · config 、--unset、-f · 直接拒绝

remote set-url 与 config 同样是「拒绝」而不是「审批」,原因是它静默改变后续 push 的去向,而 push 的审批提示里只有命令,没有 URL。

2. 参数闸门

无论勾选了什么都不放行以下参数:

  • -c / --config-env / -C / --git-dir / --work-tree / --exec-path / --bare 等 —— 配置注入或切换仓库,但只在「全局位置」(第一个参数)拒绝。经真实 git 验证,这些全局选项在子命令之后不再被 git 采纳(git status -C 报 unknown switch),而那里它们往往是子命令自己的旗标:switch -c、commit -C、add -u、init --bare。一律拒绝会误伤日常操作。
  • --upload-pack / --receive-pack / --exec —— 指定 git 要执行的程序,任何位置都拒绝。
  • -u 按子命令判定:对 fetch / pull / ls-remote 它是 --upload-pack 的短形式(拒绝),对 add / commit / push 是普通旗标(放行)。

另有一条操作数规则:每个操作在目录里声明自己是否接受尾部操作数(positional)、是否接受文件路径(filePaths)。两者都没声明的(count-objects、ls-files、ls-tree、for-each-ref、name-rev)只允许带选项的形式。

3. 配置不能变成程序

只读操作会读取仓库配置,而配置里可以指定要执行的程序,这足以绕过整个允许清单:

~~~text git config --local core.fsmonitor "sh -c '…'" → git status 执行了它 git config --local diff.evil.command "sh -c '…'" + .gitattributes → git diff 执行了它 ~~~

status 和 diff 都是默认放行的只读操作,因此「只放行看状态」实际上等于给了代码执行能力。三处封堵:

  • `config` 只保留读取形式:最多一个操作数,且不接受 --unset / --add / --replace-all / --edit / -f 等写入或选文件旗标。git config user.name X 会被拒绝。
  • 指定程序的配置键被钉死:core.fsmonitor、core.gitProxy 连同 core.pager、core.hooksPath、credential.helper 一起,通过环境变量注入(优先级高于任何配置文件)。ssh 也在其中,但方式不同:GIT_SSH_COMMAND 被钉成 -o BatchMode=yes -o StrictHostKeyChecking=accept-new,环境通道同样压过一切配置文件,所以仓库无法指定程序来当 ssh,而 SSH 传输仍然可用。
  • diff 类子命令强制带 `--no-ext-diff --no-textconv`:diff..command 是通配键,无法逐个钉死,因此在命令上关闭外部 diff 与 textconv。调用方传 --ext-diff / --textconv 会被拒绝,无法把它打开。

scripts/verify-driver-hardening.mjs 用真实 git 与一个恶意仓库验证这两条路径已关闭,并且带控制组(未加固时两个标记都必须出现),否则「没看到标记」可能只是没武装。

4. 仓库配置审计

前三项针对的是「键能被钉死」的情况。git 原生通配(filter..clean、url..insteadOf、alias.、merge..driver)没有对应的单个环境键可以覆盖,钉不死就只能拒绝。

所以每次调用前先跑一次 git config --local --includes --list --name-only -z,命中危险键就按配置的裁定处理:

裁定(设置页文案) · 行为 refuse-repo(一票拒绝,默认) · 该仓库里出现任何危险键,本工具在该仓库拒绝执行任何命令 refuse-affected(只拒受影响) · 只拒「会被该键影响」的子命令(例如 alias.* 劫持不了 git 内建命令,不影响 status) neutralize(尽量中和) · 已被钉死的键照常工作,只拒无法钉死的通配键 off(关闭审计) · 关闭审计:不检查仓库配置,因此也不为此多起一个 git 进程。危险键可以借此执行程序(core.fsmonitor、diff.*.command),只应在完全信任仓库时使用

这个命令有两条硬性要求:

  • 必须带 `--includes`。git config --local --list 看不到由 [include] 引入的键,而它们照样生效:一个被 include 进来的 core.fsmonitor 能执行却对 --local --list 不可见。不带 --includes 的审计会恰好漏掉「会执行的那些」。
  • `--name-only` 必须与 `--list` 同用。只写 --name-only 时 git 以 exit 129(no action specified)失败;而「读不到配置」按设计不算拒绝,于是闸门会完全空转。该命令在代码里只有一处定义,并由 scripts/verify-config-audit.mjs 用真实 git 复查它确实能跑、能看到 include 进来的键、三种裁定都正确(off 档不检查配置,因此不在此脚本范围内)。

审计也未必是「对抗 AI 的边界」—— 仓库配置就在工作区内,模型可以直接写它。它的价值在于不管配置怎么来的,运行前都会被检查:敌对仓库(clone 来、不是自己写的)尤其如此。

5. 执行环境

每次调用都固定为非交互,由 buildEnv() 通过环境变量注入(因此不需要放行 -c):

  • GIT_TERMINAL_PROMPT=0、GIT_ASKPASS='' —— 不等待终端输入。
  • core.pager=cat、GIT_EDITOR=true、GIT_SEQUENCE_EDITOR=true —— 不分页、不打开编辑器。
  • core.hooksPath 指向平台空设备(Windows 上为 NUL,其余为 /dev/null)—— 不运行仓库钩子。
  • credential.helper= 清空,并且默认隐藏 ~/.gitconfig 与系统配置(GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM 指向平台空设备,Windows 上为 NUL)—— 用户级配置里的 alias、url.insteadOf、credential.helper 都不会生效。
  • 子进程环境由 harness 擦除名字含 KEY|PASSWORD|SECRET|TOKEN 的变量。
  • 默认取会话工作区作为工作目录,而不是 shell 自身的默认目录。

这两件事都随「允许 git 使用本机凭据」开关变化(见下文「本机凭据」):默认关闭时如上;开启后本机与系统配置会恢复生效,credential.helper 也不再被清空。开关关闭是默认值,也是推荐值。

性能:审计结果按仓库配置状态缓存

一次 git_exec 会起两个 git 进程:子命令本身,以及执行前的仓库配置审计。在 WSL 上进程启动是毫秒级开销,所以审计结果会缓存 —— 但缓存键覆盖了判定所依赖的每一个输入:

  • 运行目录;
  • 危险键策略;
  • 仓库自身配置的状态戳(mtime + size,含 config.worktree)。

配置一改,戳就变,缓存自动失效;缓存里存的是解析出的键名,而判定每次都按当前子命令重新计算,所以不存在「缓存住的结论」。

两个边界是写明的:

  1. `[include]` 引入的文件:它们的路径不额外问 git 就看不到(而问 git 正是缓存要省掉的调用),所以对被 include 的文件的修改由 30 秒 TTL 兜住,而不是立刻失效;
  2. worktree(.git 是文件而非目录):没有可监视的配置,于是完全不缓存(宁可每次都跑,也不缓存一个无法验证的东西)。

工具守卫:拦住「顺手用 bash 跑 git」和「顺手读凭据」

除了 git 的允许清单,插件还装了一个工具守卫(tools/pre-execute waterfall),对每一次工具调用做判定。原生 git 与凭据路径是两项独立设置,档位不同:原生 git 有四档,凭据路径有三档。

覆盖范围:需要命令文本的那一层(下面这张表里的原生 git 拦截、命令文本里的路径/读写成因启发式,以及 guard.enter 的命令前缀记录)同时挂在 bash 与 pwsh 上 —— Windows 跑的是后者,只认一个就等于在某个平台上整体失效。另一层按路径参数判定,与工具名无关:read/write/edit/glob/grep 的路径参数、写入脚本的内容检查、以及 git_exec 自身参数的判定。

原生 git 有两档「拒绝」,差别在判定宽度 —— 这是一个取舍,由用户选择:

档位 · 判定 · 代价 禁止(默认) · 命令里出现 git 调用就拒 · 安全,但只是「提到」 git 也会被拒(例如 echo "(关于 git)"、含该词的脚本) 限制 · 只在命令位置判定:命令开头、; && \ · \ · \ · $( ( 之后,或 sudo/env/xargs/do 等前缀之后;sh -c "git …" 会递归检查引号内 · 不误伤,但可能漏掉生僻写法(A=1 git status、改名的二进制) 询问 · 判定方式同「限制」,命中时弹一次审批 · — 允许 · 不拦截 · —