Skip to content

chore: 补上 Bash 侧的 worktree 强制,堵住 sed -i / tee / 重定向绕过主 checkout 守卫 - #3452

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3435-bash-write-guard
Aug 6, 2026
Merged

chore: 补上 Bash 侧的 worktree 强制,堵住 sed -i / tee / 重定向绕过主 checkout 守卫#3452
yinlianghui merged 1 commit into
mainfrom
claude/issue-3435-bash-write-guard

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#3435

问题

.claude/settings.json 里 guard-main-checkout.sh 只注册在 Edit|Write|NotebookEdit matcher 上,所以它只看得见工具化的文件写入。同一个写操作换成 Bash 就完全不经过钩子:

sed -i 's/foo/bar/' packages/fields/src/x.tsx
cat > packages/fields/src/x.tsx << EOF
printf 'x' > packages/x.ts
pnpm build | tee build.log

这些命令在共享主 checkout 里一声不响地改文件,而 CLAUDE.md / AGENTS.md 给每个 agent 的心智模型是「PreToolUse 钩子强制此规则」。声明的强制范围大于实际的强制范围 —— 与 #3430(stash 共享栈)同族。

做法

新增 .claude/hooks/guard-main-checkout-bash.sh,挂到 #3433 已经建好的 Bash matcher 上(与 guard-shared-stash.sh 并列,任一 exit 2 即拦截)。

与 guard-shared-stash.sh 同形状:

仓库判定直接沿用 guard-main-checkout.sh 自己的逻辑,两个钩子对「共享 checkout」的定义因此不可能分歧:取目标路径最近的存在祖先目录 → git rev-parse --git-dir → 非 git 仓库(/tmp、scratchpad、$HOME dotfiles)放行 → git-dir 命中 */worktrees/*(linked worktree)放行 → 其余(共享主 checkout、任何 sibling 仓库)拦截。

逃生舱沿用同一个 OS_ALLOW_MAIN_EDITS=1,不另起变量:同一条规则,同一个开关。

精确优先于召回

在任意 shell 命令里认出写目标本质上是启发式,误拦风险远高于 Edit 的 file_path;而一个会拦住自己看不懂的活的钩子迟早被关掉,关掉之后它什么也守不住。所以只认能高置信度读出目标路径的写法:

> / >> 重定向(含 2> 这类带 fd 前缀的)、sed -iperl -iteecpmvrmtouch

读操作永远不拦:grep / cat FILE / ls / git -C main grep 产生不了写目标。worktree 的建立/拆除命令(git worktree add|removepnpm install)同理,已在验证里逐条确认。

有意留的口子(脚本注释里逐条写明)

  • 包装调用:bash -c '...'xargsssh host '...'、make、以及会写文件的脚本 —— 与 guard-shared-stash.sh 给自己写明的边界是同一条
  • 写操作藏在自己源码文本里的程序:node -epython3 -c、awk —— 目标不是 shell token,没有东西可读
  • 目标里含展开或通配符($VAR、反引号、命令替换、*?~)—— 不执行就解析不出来
  • payload 没带 cwd 时的相对路径(绝对路径照样判)
  • 子 shell 里的 cd 被当成粘性的 —— 分段不报告自己是按哪个分隔符切的

这些是有意为之而不是疏忽:真心要写的人本来就有 OS_ALLOW_MAIN_EDITS=1,而这个钩子的目标是 agent 干活干到一半反射性敲出来的 sed -i,不是存心绕过的人。

写「关于这条禁令」的东西不能触发禁令

两层:

  1. 引号感知的分段 + 词法切分 —— '...' / "..." 里的 >sed -i 是字面文本。grep -n "sed -i" .claude/ 放行。
  2. heredoc 正文剥离 —— cat > /tmp/notes.md << EOF 的正文是文档不是命令。分段是按换行切的,光靠分段这一层会把正文里举例的 sed -i ... 当成真命令拦下来。这正是 objectstack#4890 的教训:写规则的那个 PR 不能被自己写的规则拦住。

引入 heredoc 的那一行自己的重定向照样算数 —— 自测矩阵里有成对的 allow / block 两例钉住这个区别。

自测

.claude/hooks/guard-main-checkout-bash.selftest.sh,与 #3433 的自测同形状,但自建 fixture:临时 git init 的主仓库 + 它的 linked worktree + 一个非仓库目录,所以矩阵不依赖跑在哪台机器、哪个 checkout 上。不联网、不构建。

70 passed, 0 failed

覆盖:共享主 checkout 的各种写法拦截(16 例)、经分隔符/环境变量前缀/绝对 argv0/cd 到达(6 例)、同样的写法落在 linked worktree 放行(8 例)、仓库外(/tmp、非仓库目录、/dev/null)放行(5 例)、读操作放行(10 例)、写「关于禁令」的东西(7 例)、有意留的口子(6 例)、逃生舱(2 例)、payload 解析不了 fail open(5 例)、无 jq 兜底(5 例)。

guard-shared-stash 的自测同步复跑,32 例仍全绿。

反向验证

先定方向再跑:能产出拦截的只有 target_is_shared_checkout 这一个判定,把它改成恒不命中,只可能减少拦截 —— 这里没有任何下游按数量判定的 gate,所以不存在 PR #5046 那种「删掉分支反而多出诊断」的方向。预测:27 条 block 断言全部翻成 allow,43 条 allow 断言一条不动。

在 scratch 副本上把该函数改成恒 return 1 后实跑:

43 passed, 27 failed

27 条失败全部是 block 翻 allow(26 条走 expect 打印 want=block got=allow,第 27 条是手写的绝对路径断言,自带文案),allow 断言零位移。方向与数量都与预测一致 —— 说明这些 block 用例是真的由该判定产出的,不是因为「什么都没产出」而碰巧通过。

其它

  • bash -n 两个脚本均通过;jq . 校验 settings.json 合法。
  • 改动文件做了控制字符自查:grep -naP 扫 C0 控制字符区间(NUL 到 US,除去制表/换行/回车),干净。写这条时本身踩了一次 PR #5140 的坑 —— 描述控制字符的那行被工具实体化成了真的 NUL 和 0x1F 字节,已按「只描述转义、不粘贴字节」改掉并复扫。
  • 无 changeset:纯流程/工具改动,不面向用户 —— 沿用 chore(hooks): 拦截共享 refs/stash 的 git stash,worktree 隔离盖不住它 (#3430) #3433 的先例。
  • 改动只落在 .claude/hooks/.claude/settings.json

Generated by Claude Code

guard-main-checkout.sh 只注册在 "Edit|Write|NotebookEdit" matcher 上,同一个写操作
换成 Bash 就完全不经过钩子:sed -i、cat > path <<EOF、tee、printf ... > path 都能
静默改共享主 checkout,而 CLAUDE.md / AGENTS.md 宣称「PreToolUse 钩子强制此规则」。
声明的强制范围 > 实际的强制范围(objectui#3435,与 #3430 同族)。
新增 guard-main-checkout-bash.sh,挂到已有的 Bash matcher 上,与 guard-shared-stash.sh
同形状:jq 为主 + sed 兜底解析 payload、引号感知分段、exit 0 放行 / exit 2 拦截并把理由
写到 stderr、解析不了一律 fail open。仓库判定直接沿用 guard-main-checkout.sh 的逻辑
(最近存在祖先目录 -> git rev-parse --git-dir -> */worktrees/* 放行、非仓库放行),
两个钩子对「共享 checkout」的定义因此不可能分歧;逃生舱沿用同一个 OS_ALLOW_MAIN_EDITS=1,
不另起变量。
精确优先于召回:只认 > / >> 重定向、sed -i、perl -i、tee、cp、mv、rm、touch 这些能
高置信度读出目标路径的写法;bash -c、xargs、node -e / python -c、含 $VAR 或通配符的
目标、payload 缺 cwd 时的相对路径,一律放行并在注释里写明是有意留的口子。读操作
(grep / cat FILE / ls / git grep)永远不拦。
除分段外多加一层 heredoc 剥离:cat > /tmp/notes.md <<EOF 的正文是文档不是命令,
只靠按换行分段会把正文里举例的 sed -i 当成真命令拦下来(objectstack#4890 的教训)。
自测矩阵 .claude/hooks/guard-main-checkout-bash.selftest.sh:自建临时 git 仓库 +
linked worktree + 非仓库目录,70 例全绿,不联网不构建。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GTRjn8xBqp75dk7kFupVRt
@vercel

vercelBot commented Aug 6, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 6, 2026 5:30am

Request Review

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] guard-main-checkout.sh 只挂 Edit|Write|NotebookEdit,经 Bash 写文件(sed -i / tee / heredoc)可绕过 worktree 强制

2 participants

@yinlianghui@claude