Skip to content
dsh.fish
Bundle

git-for-dsh

Allowlisted git execution for DeepSeek Harness: a model-facing git tool that runs outside the file sandbox, with a settings page where the user ticks which git subcommands are permitted and, per path, which reads and writes are allowed, prompted or refused

Source
theRMM714
License
MIT
Updated
Updated 19 hours ago

Readme

# git-for-dsh

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

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

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

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

## 为什么需要它

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

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

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

dsh 自带的交互式 bash 工具在 Windows 上基本起不来,报错通常是 `PTY shell exited during startup` 或 `terminal inspection is unsupported on platform win32`。原因有三层,都在 dsh 代码里可以查到:

- **进程检查器只实现到 Linux/macOS** —— 报错原文就在 `dsh-subprocess-local` 里;
- **交互式终端依赖 PTY** —— 报错原文在 `dsh-terminal-bash` 里;
- **Windows 的 ACL 沙箱以受限令牌隔离进程** —— `dsh-sandbox-windows-acl` 通过 FFI 直接调用 `createRestrictedToken`(带 restricting SID),而 MSYS 运行时需要创建共享内存映射,这类操作会被受限令牌拒绝。

**本插件的 `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`):装包即挂载,**不需要手改任何配置文件**。

```sh
# 从 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
```

**更新**同为一条命令:

```sh
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`:

```yaml
- 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 的 `settings.yaml`。

Host 半注册 `git-tool` 设置命名空间;Client 半通过 `ctx.get('settingsScope')` 绑定同一命名空间写入。**清单本身在构建时从 `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 2.55 验证,这些全局选项在子命令**之后**不再被 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` 被钉成 `<ssh 程序> -o BatchMode=yes -o StrictHostKeyChecking=accept-new`,环境通道同样压过一切配置文件,所以仓库无法指定程序来当 ssh,而 SSH 传输仍然可用。
- **diff 类子命令强制带 `--no-ext-diff --no-textconv`**:`diff.<driver>.command` 是通配键,无法逐个钉死,因此在命令上关闭外部 diff 与 textconv。调用方传 `--ext-diff` / `--textconv` 会被拒绝,无法把它打开。

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

### 4. 仓库配置审计

前三项针对的是「键能被钉死」的情况。git 原生通配(`filter.<driver>.clean`、`url.<base>.insteadOf`、`alias.<name>`、`merge.<driver>.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** 有两档「拒绝」,差别在**判定宽度** —— 这是一个取舍,由用户选择:

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

**目标范围**(三档,默认「仅工作区」):`git_exec` 的目标目录由调用参数给出,在加这个闸门之前它**不受任何限制** —— 模型可以把 git 指向机器上的任何目录。三档分别是 **仅工作区**(目标必须落在本次会话的工作区内)、**指定路径**(必须落在你列出的某个根目录内;一个都没配时直接拒绝)、**无限制**(任何目录,即旧行为,需显式选择)。

三条比较规则各防一类失效:按**路径段**比较(`/work/app2` 不算在 `/work/app` 之内)、**先解析软链接**(否则工作区里一个指向 `/` 的链接就能走出去)、**Windows 上忽略大小写**。判定的是目标的**生效值** —— 省略 `workdir` 时它会默认成会话工作区。

**写入脚本时检查内容**(三档,默认「严格」):`bash deploy.sh` 会把 git 调用藏在一个文件名后面,而判别器只看得到命令行。开启后,脚本在**写入时**就被判一次内容:内容已经在调用参数里,因此这个判定**不做任何文件系统访问**,命中时**拒绝这次写入**(脚本根本不会落地),而不是等它被执行时才拒。判多宽由**这个设置自己**的三档决定:**严格**(默认)在内容里**提到** git 时即命中,抓得住"只提了一句"的脚本,也难免误伤;**限制**只在**命令位置**出现时命中,基本不误伤;**关闭**则不判内容(脚本运行时的命令行判别仍然生效)。

这条保护的边界是:**以其他方式到来的脚本不被内容检查** —— 已存在的文件、由别的命令生成的文件、嵌套脚本、here-document、运行时才确定的解释器、或换一种语言调用 git,都看不到。它是一层启发式,不是保证。之所以不在执行 `bash script.sh` 时去读文件内容,是因为守卫跑在 dsh 进程里、对每一次工具调用同步执行,在那里做同步文件读取会拖住整个应用。

**凭据 / 身份文件**三档(**禁止**(默认)/ **询问** / **允许**):判定 `read`/`write`/`edit`/`glob`/`grep` 的路径参数(解析为绝对路径、跟随软链接,**等于**或**包含**受保护文件),以及 bash 命令文本提到它。

默认保护 `~/.git-credentials` 与 `~/.gitconfig`,可在设置页修改。

**为什么拦 git**:`bash` 里的 git 绕过本插件的允许清单、参数闸门、配置审计与审批 —— 它读全局配置、跑未加固的环境。所以默认拒绝,并明确提示改用 `git_exec`。

**为什么拦凭据**:本机凭据文件对同 uid 可读(dsh 的沙箱限制写入、不限制读取),「AI 顺手读一下」是现实存在的路径。守卫关掉这条路,而插件内部的 git(沙箱外、不是工具调用)不受影响,推送照常。

### 如实说明它的强度

**这是一道策略闸门,不是安全边界。** 设置页上也是这么写的。

- 拦得住:`git status`、`/usr/bin/git log`、`cat ~/.git-credentials`、`read ~/.gitconfig`、`grep -r . ~` 这类**直接**写法;
- 拦不住:运行时拼出来的路径(`$(printf …)`、base64、变量拼接)、把命令写进脚本再执行、或任何**不经过工具**的通道。

真正的硬保证只有一条 —— **在沙箱里遮蔽这些文件**,那样沙箱内的一切(包括 `git credential fill`)都读不到。但**那是 dsh 沙箱的职责,本插件不去改 dsh 源码**,因此只如实标注强度。

守卫自身的设计约束:任何内部错误都 `next()`(放行),因为一个抛错的守卫会掐断会话里的每一次工具调用;代价是「坏掉的守卫看起来和平庸的守卫一样」,所以单元测试**直接驱动这个监听器**。

## 远程与凭据

### 代理:插件只负责把**你的**代理拉起来

远程操作需要出网,而本插件不碰凭据。它做的是「替你启动代理」这一件事,省掉在启动 dsh 前 `export HTTPS_PROXY=…`。

设置页两项:

| 设置 | 含义 |
| --- | --- |
| **端口** | `127.0.0.1` 上的端口;`0` 关闭本功能(那时 git 继承 dsh 进程已有的代理变量) |
| **启动命令** | 首次远程操作时执行一次,用来拉起代理 |

首次需要远程操作(`clone`/`fetch`/`pull`/`push`/`ls-remote`)时:

1. **端口已有代理在监听** → **直接用它**:给 git 注入 `HTTPS_PROXY`/`HTTP_PROXY`(并设 `NO_PROXY=127.0.0.1,localhost,::1`,免得代理请求被自己代理)。不启动、也不会停止它 —— 那是用户的进程;
2. **端口空闲 + 有启动命令** → 执行它,最多等 8 秒轮询端口,起来后同样注入;
3. **端口空闲 + 没有启动命令** → 拒绝并指出该填哪一项;
4. 启动后 8 秒仍未监听 → 杀掉进程、报出它的输出。

只有**由插件启动**的那个进程会在插件退出时被收掉(注册在本插件的 fiber 上)。

填完端口可以点旁边的「端口测试」:它由 Host 侧执行探测(不是浏览器去连),不仅回答「通不通」,还会**扫一遍常见代理端口并报告哪个在监听**。端口填错是这里最容易犯的错。

**本功能解决的是「出网」,不是「认证」。** 部分网络环境下直连 `github.com` 会 TCP 握手成功但 HTTPS 卡死(同一环境下 `api.github.com` 正常),经代理则可正常返回 `HTTP 200`。

**它与凭据的关系**:插件**不注入任何凭据**,令牌留在用户启动的那个代理里。这与「凭据永不进入 AI 上下文」是两件事:代理里有什么、放哪儿,仍由用户决定(见下文的边界说明)。

「启动命令」是一段会被执行的**受信配置** —— 只有用户能写它(模型的写权限被限制在会话工作区内,动不了 `settings.yaml`)。

### 本机凭据:默认隔离,可显式放开

设置页的「允许 git 使用本机凭据」开关(默认**关闭**)决定这个工具有没有认证能力:

| | 关闭(默认) | 开启 |
| --- | --- | --- |
| `~/.gitconfig` 与系统配置 | 隐藏 | **生效** |
| `credential.helper` | 清空(钉死为空) | 交给 git 自己读 |
| 钉死「指定程序」的键 | 全部生效 | **仍然全部生效** |
| 远程写操作(push) | 一律失败(`cannot read Username`) | 可以认证 |

开启之后的代价写在设置页上(**不必开启即可读到**):

- `url.<base>.insteadOf` 可以把某个主机**重定向到别处** —— 凭据可能被送到非预期的服务器;
- `credential.helper` 与 `alias.*` 是 git 会**执行的程序**。

也就是说:默认隐藏全局配置时被压住的这些行为会一起回来。**只应在信任这台机器的全局配置时开启。**

开启这一项时,令牌不经过参数、审批提示与会话记录,因为 git 自己读取它。

**这一项不解决「令牌会不会被 AI 读到」。** 同一 uid 下 `git credential fill` 能直接取出令牌,同 uid 进程的环境变量也可读,而且模型还能用 `bash` 绕过本插件直接驱动 git。所以本工具能承诺的仍然只有一条:**它自己不读取、不传递、不存储凭据,也不把凭据写进参数、审批提示或会话记录**(URL 里内嵌凭据的形式已被拒绝)。真正的隔离只能来自沙箱之外的边界。

### 本插件的承诺,以及它的边界

本插件只承诺一件事:**它自己不读取、不传递、不存储凭据,也不成为泄漏路径**。

- `credential.helper` 被清空,`~/.gitconfig` 与系统配置默认隐藏;
- 子进程环境由 harness 擦除名字含 `KEY|PASSWORD|SECRET|TOKEN` 的变量;
- `git config -f <任意文件>` 被拒绝,因此不能用它去读仓库外的文件;
- 调用方无法用 `-c` / `--config-env` 把配置塞进来。

**它不做、也做不到的事**:dsh 的文件策略限制的是**写入**,不限制**读取**。在 `workspace-write` 下,工作区外的文件(含 `~/.git-credentials`)对模型仍然可读。因此「凭据永不进入 AI 上下文」**不可能由插件保证** —— 那取决于沙箱边界、以及由哪个 uid 持有密钥。本插件不管理 `~/.git-credentials`,也不建议把密钥托付给它保管。

远程认证建议走**外部代理**:在 DSH 进程环境里设置代理(例如 `HTTPS_PROXY=http://127.0.0.1:PORT`),git 会继承它并经由代理访问远端 —— `scrubbedParentEnv()` 明确保留代理变量,这条路不需要任何插件配置。

### 与 git 自带提示的差异

有些失败下 git 会建议去改配置,例如分叉历史时提示 `git config pull.rebase false`(是否出现取决于 git 版本)。**配置写入在本工具里被拒绝**,照提示做会再撞一次墙。等效的旗标都在允许清单里:

```sh
git pull --rebase origin main
git pull --no-rebase origin main
git pull --ff-only origin main
```

遇到「git 让你改配置」的提示时,先找该操作的对应旗标。

## 设置项一览

设置页「Git 工具」分为五个页签:**策略与代理**、**只读**、**写操作**、**远程**、**日志**。第一个页签放各项开关,后三个页签是按档位分组的操作清单。

| 分组 | 设置项 | 默认 | 作用 |
| --- | --- | --- | --- |
| 允许清单 | 只读 / 写操作 / 远程 三档勾选 | 只读档全开 | 勾选后 AI 才能执行对应子命令;每档还有全选开关,并显示 `已选/总数` |
| 插件 | 启用本插件 | 开 | 关闭后 `git_exec` 拒绝调用、工具守卫不再拦截,立即生效无需重启 |
| 审批 | `写操作前询问我`(设置页文案) | 开 | 写档与远程档的每次调用都弹审批,而不只靠勾选放行 |
| 闸门 | 原生 git | 禁止 | 禁止 / 限制 / 询问 / 允许 |
| 闸门 | 凭据与身份文件 | 禁止 | 禁止 / 询问 / 允许 |
| 闸门 | 受保护的路径 | `~/.git-credentials, ~/.gitconfig` | 逗号分隔 |
| 闸门 | 路径权限 | 内置五行,三项全不勾 | 每行一条路径,三个复选框:**读**/**写**=静默放行,**询问**=每次访问先问你(批准即放行该次),**三项都不勾=静默禁止(不打扰你)**。内置行(凭据与身份文件)默认全不勾、**不可删除**、可随时修改;自己加的路径可删除。没有"整表开关",停用某行=清空它的勾选 |
| 闸门 | bash 里的路径判定 | 启发式 | 两选:**启发式**(明显读按读、明显写按写、其余按写=保守兜底)或**一律按写**。守卫只能看到命令文本,所以这一层**本质是启发式**,不是文件访问事实 |
| 闸门 | 目标范围 | 仅工作区 | git 可以在哪里执行:仅工作区(默认)/指定路径(只在列出的根目录内)/无限制(机器上任何目录) |
| 闸门 | 写入脚本时检查内容 | 严格 | 三档:严格(提到即拒)/限制(只在命令位置判,基本不误伤)/关闭。写入时按参数内容判,不产生文件系统访问 |
| 闸门 | SSH 程序 | `/usr/bin/ssh` | 由 `GIT_SSH_COMMAND` 钉死;旁边有「探测」按钮 |
| 凭据 | 允许 git 使用本机凭据 | 关 | 开启后远程写操作可以认证,代价见上文 |
| 代理 | 端口 | `0`(关闭) | `127.0.0.1` 上的端口;旁边有「端口测试」按钮 |
| 代理 | 启动命令 | 空 | 首次远程操作时执行一次 |
| 仓库配置审计 | 出现危险配置键时 | 一票拒绝 | 一票拒绝 / 只拒受影响 / 尽量中和 / 关闭审计 |
| 诊断日志 | 写诊断日志 | 开 | 每次闸门判定记一行(含耗时) |
| 诊断日志 | 心跳行 | 关 | 每 5 秒一行,报出卡在半途的调用及其时长 |
| 诊断日志 | 日志路径 | 空 | 留空使用 `$DSH_HOME/git-for-dsh.log` |

### SSH:程序路径可以探测,不用自己填

设置页「闸门」分组里的 **SSH 程序** 旁边有个 **「探测」** 按钮:它会扫描 `$PATH` 加上常见位置(含 WSL 下的 Windows OpenSSH:`/mnt/c/Windows/System32/OpenSSH/ssh.exe`、Git for Windows 自带的那个),逐个验证**是否可执行**并取版本号,然后把**所有可用的候选列出来**,点一个就填进去。

- 探测**只在点击时运行** —— 它要访问文件系统,而「点击时访问」没问题、「每次调用都访问」会拖慢每一次工具调用;
- 一个都没找到时会明说:这台机器上 SSH 远端用不了,需要先装 OpenSSH(HTTPS 远端不受影响);
- 需要这个选项的原因:程序路径由 `GIT_SSH_COMMAND` 钉死(配置改不了它),所以路径填错就等于 SSH 不可用 —— 而不同发行版/Windows 互操作下它的位置并不统一。

## 诊断与排障

### 诊断日志

默认开启,写在 `$DSH_HOME/git-for-dsh.log`(设置页可改路径或关掉)。在终端里盯着它:

```sh
tail -f ~/.dsh/git-for-dsh.log
```

记什么:

| 行 | 含义 |
| --- | --- |
| `activate` | 激活时的策略快照(开关、档位、代理、ssh 程序…) |
| `guard.enter` | 某次工具调用进入守卫,bash 调用附带命令前 60 个字符(脱敏后) |
| `guard.exit` | 守卫的判定与**耗时(毫秒)** |
| `guard.error` | 守卫自身出错(仍会放行,绝不断调用) |
| `tool.done` | 一次工具调用真正结束(失败时带 `failed=true`) |
| `git_exec.refused` | 因插件关闭而拒绝 |
| `heartbeat` | 心跳行(仅在开启该项时写入) |
| `log.cleared` | 日志被设置页清空 |

插件开关关闭时,守卫不再拦截,也不再写调用日志。

**两处可选项**:

- **心跳行**(默认**关闭**,与「写诊断日志」**互不影响**):开启后每 5 秒写一行 `heartbeat n=… open=… openMs=…`,报出「当前有哪个调用卡在半途、卡了多久」,用于**把卡死定位到具体阶段**:没有它,「闲置时开始的卡死」与「调用中开始的卡死」在日志里长得一样。两个开关独立:只开心跳 = 存活探针(日志里只有心跳行);只开调用日志 = 审计轨迹。
- **命令前缀**(始终记录):`guard.enter` 会带上该次 bash 命令的**前 60 个字符**(脱敏后)。只写 `tool=bash` 看不出在跑什么,这一条是定位「哪条命令引发卡死」的唯一线索。

日志用**同步追加**写:这样「缺了一行」只可能是真的没写,而不是烂在缓冲里。它也是本插件里**唯一**碰文件系统的地方;日志路径默认放在 harness 状态旁边,而不是 Windows 盘上。超过 2MB 自动轮转为 `.1`(每 200 行检查一次大小)。

三条设计约束(都写进了 `src/log.js`):**同步落盘**(一行写完才返回)、**出错只关日志**(绝不影响它正在记录的调用,但失败会报给宿主终端)、**只记长度与判定不记内容**(值长度上限 200 字符,并对凭据 URL 与令牌形状的字符串脱敏)。

### 诊断卡死:最后一行就是线索

`guard.enter` 与 `guard.exit` 是**两行**,所以

- 有 `enter` 没有 `exit` → 卡在**守卫内部**;
- 有 `exit` 之后没有下文 → 卡在**下游**(dsh 的工具派发、文件系统、Windows 侧)。

### 进程卡住时:只能从进程外动手

进程卡住时它救不了自己:设置页正是由被卡住的进程提供的,页面上的「重启」按钮会跟着一起死,进程内定时器同样无救。**只有另一个进程能动手**,所以仓库里带了 `tools/watchdog.sh`:

```sh
# 在 dsh 之外另开一个终端(必须,沙箱/进程外才能探到真实服务)
tools/watchdog.sh                      # 只探测并报告
DSH_RESTART_CMD='dsh web' tools/watchdog.sh --restart   # 卡住时 kill -9 并重启
```

它的探针是**对 dsh 自己的 web 服务发 HTTP 请求**:回答来自事件循环,所以请求超时说明循环没在跑。它**不依赖本插件**,因此在「插件就是元凶」的情况下照样有效。重启用的是 `kill -9` —— 事件循环被卡死时,JavaScript 写的信号处理器没有机会运行,**只有内核处理的信号能穿透**。

**守卫内部的扫描都有步数预算**:预算耗尽时守卫按**最严策略**拒绝并说明原因,而不是让宿主进程停住。定时器无法中断同步循环,所以只有循环自己会去查的计数器有效。预算设在**远高于任何真实命令**的量级,只有「这段代码没预料过的输入」才会触发。

**最后一道逃生口**:无论如何都要能不用插件启动 dsh ——

```sh
DSH_GIT_TOOL_DISABLED=1 dsh web    # 这一行启动时,本插件的组件行不会加载
```

(注意:设置页里的「启用本插件」开关是**软**开关 —— 它会让 `git_exec` 拒绝调用并停止守卫拦截,但组件行仍在管线里;上面这个环境变量才是**完全不加载**。)

### 运行开关:不用重启就能关掉插件

设置页最上面的「启用本插件」是一个**运行时开关**:

- **关闭**:`git_exec` 拒绝调用(并说明这是开关而不是允许清单问题),**工具守卫完全不再拦截**;
- **立即生效**,无需重启 —— 这是它的用途:做 A/B 对比。怀疑某次卡顿是插件造成的,就关掉它再试同样的操作;关掉还卡,就与插件无关。

(dsh 自带的 Cordis 面板只能管理**动态**插件,管不到 profile 加载的插件行,所以这个开关由本插件自己提供。)

## 审核与回退

Host 半默认对 `write` 与 `remote` 档位的每一次调用弹出审批,而不只依赖勾选(`approveMutating`)。设置页可关闭。

### 插件出问题时怎么恢复

插件崩了不应该让用户去删文件、也不应该让 dsh 起不来。三档手段,从轻到重:

1. **加一行环境变量**(前提:该行写了上面的 `disabled: !!js …`)。`DSH_GIT_TOOL_DISABLED=1` 启动即整行不加载,不用改任何文件,去掉变量就恢复。注意它只管**这一行**:Host 半自己已有兜底,Client 半也已能在读不到服务时降级成受限页面。

   ```sh
   DSH_GIT_TOOL_DISABLED=1 dsh --profile web
   ```

2. **删掉 patch 里的那三行**。`cordis.patch.yml` 的 `patchReload: live` 会在启动时重新读它;这一步不需要卸载依赖,`package.json` 与 `node_modules` 里的软链留着不影响。

3. **彻底卸载**。`dsh plugin --profile <profile> remove git-for-dsh` —— 它同时会把这个依赖从 `dsh.profile.bundles` 里摘掉(reconciler 按已安装状态对齐)。若曾在 profile 自己的 patch 里重述过该行,再删掉那一行。

**两层兜底**(都在代码里,不依赖用户记得用上面的开关):

- Host 半的 `apply()` 把整段初始化包在 try/catch 里。初始化失败只在日志留下一行带原因的 `git-tool: activation failed …`,`git_exec` 不注册,会话照常可用。
- Client 半导出 `inject: ['slots', 'settingsScope']`(这是让激活等待服务就绪的机制),服务确实缺失时用 `ctx.get()` 可选读取并降级成不可写页面;**整个工厂体包在 try/catch 里**,求值失败就交出一个空操作的插件,而不是让这次插件加载失败。页面本身还套了 React error boundary。

## 开发

```sh
npm test       # 66 个断言:目录、闸门、环境、拼接、Host 集成、Client bundle 加载/降级/渲染
npm run build  # 把 src/ 拷贝到 lib/,package.json 指向 lib/
node scripts/verify-served-bundle.mjs <bundle>   # 用真实加载语义验证一个已构建/已服务的 bundle
node scripts/verify-driver-hardening.mjs         # 用真实 git + 恶意仓库验证两条代码执行路径已关闭(含控制组)
node scripts/verify-config-audit.mjs             # 用真实 git 验证审计命令能跑、能看到 include 进来的键、三种裁定正确

测试用夹具仓库位于 `.git-test-fixture/`(已加入 `.gitignore`):3 个提交、1 个分支、1 个标签,用来在真实仓库上驱动工具而不污染工程本身。
```

`src/` 就是运行时产物:Host 半是普通 ESM,Client 半是手写的 client module bundle(`window.__ModuleLoader__.load({ id, factory })`,id 为包名)。因此本包**不需要任何打包工具**——Profile 里不必装构建链,Client 产物也可直接阅读。

`node_modules/` 里的 `@deepseek-ai/*` 是指向本机 DSH 安装的软链,仅供本地测试解析 peer 依赖;`lib/` 由 build 生成。两者都不入库。

### 布局预览

设置页的排版问题(挤在一起、两行并成一行)没法靠断言发现,得看。所以有一个离线预览:

```sh
node scripts/render-preview.mjs                 # 生成 .preview.html
DSH_SHELL_CSS=<shell 的 index-*.css> node scripts/render-preview.mjs   # 带上真实主题色
```

它用**构建产物里那个页面组件**渲染出真实 DOM,再套上 shell 的样式表,因此看到的就是页面长什么样,不需要登录、不需要浏览器会话。仓库里的 `git-tool-settings-preview.html` 是它的输出。

## 延伸阅读

- [PITFALLS.md](PITFALLS.md) —— 开发过程中踩过的坑,每条都写了「现象 / 根因 / 怎么判 / 怎么避 / 谁守着」。靠前的条目涉及让 dsh 起不来、让闸门空转、让设置页变成只读的问题,改动这个项目之前先读那篇。
- [DESIGN.md](DESIGN.md) —— 设计决策与取舍:为什么某个机制是这样做的、它换来什么、代价与边界在哪里。维护者改动前需要知道的背景都在这里。

## 文件布局

```
PITFALLS.md          踩过的坑:现象 / 根因 / 怎么判 / 怎么避 / 谁守着
DESIGN.md            设计决策与取舍、边界与代价
cordis.patch.yml     自带的补丁层:把本包作为一行插件挂进 profile
tools/watchdog.sh    进程外的看门狗:探测 dsh 的 web 服务,卡死时 kill -9 并重启
src/git-catalog.js   操作目录、参数闸门、环境构造、命令拼接
src/index.js         Host 半:git_exec 工具、设置命名空间、系统提示段、工具守卫、端口测试路由
src/client.js        Client 半:设置页 UI(勾选、策略、代理、日志)
src/log.js           诊断日志:同步追加、脱敏、2MB 轮转
src/proxy.js         代理端口探测与等待
scripts/build.mjs    把 src/ 拷贝到 lib/
scripts/render-preview.mjs        渲染离线布局预览
scripts/verify-served-bundle.mjs  用真实加载语义验证构建/服务的 bundle
scripts/verify-driver-hardening.mjs  真实 git + 恶意仓库验证代码执行路径已关闭(含控制组)
scripts/verify-config-audit.mjs      真实 git 验证审计命令与三种裁定(含对照)
test/git-catalog.test.mjs  目录、闸门、环境、拼接
test/host.test.mjs         Host 半集成(允许清单闸门、审批闸门、结果形状、兜底)
test/client.test.mjs       Client bundle 真实加载、服务声明完备性、错误兜底
test/log.test.mjs          诊断日志:两个开关、脱敏、轮转
test/proxy.test.mjs        代理端口探测、等待、环境注入
.git-test-fixture/         测试用的夹具仓库(已加入 .gitignore)
```

Install

dsh plugin --profile web add github:theRMM714/git-for-dsh#dd12b21309e691bdc7d07e13ad7081a8d0c276e5

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
Source