gstack /careful:基于 Claude Code PreToolUse Hook 的破坏性命令护栏机制
careful/SKILL.md 定义了 gstack 中的 /careful 技能——一套挂载在 Claude Code PreToolUse 钩子上的破坏性命令护栏。本文基于该文档及其配套实现 check-careful.sh 完整讲解它拦截哪些命令、两级(HIGH/MEDIUM)决策如何生效、如何 fail-closed 防御绕过,以及如何通过 careful-patterns.txt 做只增不减的项目级扩展,读完即可理解一个安全钩子从技能注册到逐字符校验的完整链路。
一、技能定位:在 Bash 执行前加一道人工确认
/careful 是 gstack 23 个技能中的安全防护技能,文档中的 "When to invoke this skill" 给出了触发场景:
- 操作生产环境、调试线上系统、在共享环境中工作时;
- 用户明确说出 "be careful"、"safety mode"、"prod mode"、"careful mode" 时。
它的行为契约是:激活后每一条 bash 命令在运行前都会被检查破坏性模式;一旦命中,Agent 会被警告(MEDIUM)或直接拒绝(HIGH),用户可以逐条覆盖 MEDIUM 警告后继续执行。
技能的完整注册信息见 careful/SKILL.md 的 frontmatter,各字段含义如下:
| 字段 | 取值 | 作用 |
|---|---|---|
name |
careful |
技能名,用户以 /careful 调用 |
version |
0.1.0 |
技能版本 |
description |
Safety guardrails for destructive commands | 供技能路由/发现使用 |
triggers |
be careful / warn before destructive / safety mode |
自然语言触发词 |
allowed-tools |
Bash、Read |
技能内允许的工具白名单 |
hooks.PreToolUse |
matcher: Bash → bash $HOME/.claude/skills/gstack/careful/bin/check-careful.sh |
每次 Bash 工具调用前执行钩子脚本 |
两个工程细节值得注意:
- 钩子命令锚定
$HOME。test/hook-scripts.test.ts 中的 "frontmatter hook command paths" 用例专门断言careful/SKILL.md、freeze/SKILL.md、guard/SKILL.md等文件的command:行必须包含$HOME/.claude/skills/gstack/,且绝不能引用CLAUDE_SKILL_DIR——因为 frontmatter 钩子在运行时变量就绪之前就会执行,相对变量路径会静默解析失败,护栏从此"永远不触发"。 - 钩子是会话作用域(session-scoped)的。文档明确写道:"To deactivate, end the conversation or start a new one." 激活与退出都不需要卸载任何东西,结束会话即解除全部防护。
激活时文档还要求执行一段埋点脚本,把 {"skill":"careful","ts":...,"repo":...} 追加到 ~/.gstack/analytics/skill-usage.jsonl,用于本地使用统计(失败静默,不影响护栏本身)。
二、保护清单:MEDIUM 级破坏命令家族
文档的 "What's protected" 表格是护栏的完整基线,实现与之一一对应:
| 模式 | 示例 | 风险 | 源码内 pattern 名 |
|---|---|---|---|
rm -rf / rm -r / rm --recursive |
rm -rf /var/data |
递归删除 | rm_recursive |
DROP TABLE / DROP DATABASE |
DROP TABLE users; |
数据丢失 | drop_table |
TRUNCATE |
TRUNCATE orders; |
数据丢失 | truncate |
git push --force / -f |
git push -f origin main |
历史改写 | git_force_push |
git reset --hard |
git reset --hard HEAD~3 |
未提交工作丢失 | git_reset_hard |
git checkout . / git restore . |
git checkout . |
未提交工作丢失 | git_discard |
kubectl delete |
kubectl delete pod |
生产环境影响 | kubectl_delete |
docker rm -f / docker system prune |
docker system prune -a |
容器/镜像丢失 | docker_destructive |
在 check-careful.sh 中,这八个家族按顺序做 grep -qE 匹配,命中即停(后面的家族检查都带 [ -z "$WARN" ] 前置条件),每条都有固定的警告文案,例如:
rm -r家族(注意正则rm\s+(-[a-zA-Z]*[rR]|--recursive)同时接受 BSD/macOS 的大写-R与 GNU 的--recursive)→ "Destructive: recursive delete (rm -r). This permanently removes files."- SQL 家族在匹配前会把命令整体转小写(
CMD_LOWER),因此mysql -e drop database mydb这类小写输入同样命中; - force-push 家族除了
-f/--force,还匹配 git 的 plus-refspec 语法(git push origin +main)——这种写法无需任何 flag 即可强制推送,正则(^|[[:space:]])\+[^[:space:]]专门覆盖它。
三、安全例外:白名单 rm 的锚定式匹配
文档 "Safe exceptions" 一节列出不告警的构建产物清理:
rm -rf node_modules .next dist __pycache__ .cache build .turbo coverage
但"允许"的实现远比一句白名单苛刻。check-careful.sh 用一条锚定完整命令的正则来放行:
'^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*[rR][a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#(`]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$'
源码注释解释了这条正则背后的三道加固(对应 issue #2039 的防御波次):
- 只匹配整条命令,而非"最后那个 rm"。若只解析末段的 rm,
rm -rf / # rm -rf node_modules这种"危险命令 + 注释伪装"就会被末段的白名单后缀骗过;锚定^...$使任何前缀、后缀、注释都落不进白名单。 - 目标 token 排除
(和反引号。rm -rf $(./wipe-all)/node_modules或反引号变体以白名单后缀结尾,但括号内可以执行任意命令,因此命令替换一律不能"搭白名单的便车"(普通$VAR展开无括号,仍放行)。 - 多行命令绝不进入白名单。
case "$CMD" in *$'\n'*)直接让含换行的命令落入破坏性检查——因为 JSON 解析后 payload 里的\n是真实换行符,rm -rf /\nrm -rf node_modules这种"换行分隔、末行无害"的攻击形态无法命中锚定白名单。
结果就是源码注释所说的"未知形态 fail closed,落到破坏性检查":测试 把 rm -rf /; rm -rf node_modules、rm -rf / && rm -rf node_modules、rm -rf / # rm -rf node_modules、rm -rf node_modules || rm -rf / 等一系列组合全部钉死为 ask;甚至 cd app && rm -rf node_modules 也会触发询问——注释里明确这是设计好的 fail-closed 假阳性:没有真正的 shell 解析器就无法区分"安全前缀 + 安全 rm"与"危险在前"的利用形态,所以宁可多问。
四、工作原理:PreToolUse 钩子的完整调用链
文档 "How it works" 一段概括了机制,check-careful.sh 与共享助手 hook-extract.sh 给出了全部细节。
4.1 输入与命令提取
Claude Code 在每次 Bash 工具调用前,把包含 tool_input 的 JSON 通过 stdin 传给钩子脚本。钩子需要从中取出 command 字段,这里历史上出过严重 bug:旧版提取器是 grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"',[^"]* 在第一个转义引号处截断,导致带引号参数的命令被截掉后半段——源码注释里列了三个真实失守样例:
git commit -m "wip" && rm -rf / -> 提取到 'git commit -m ' -> 放行
bash -c "rm -rf /" -> 提取到 'bash -c ' -> 放行
echo "x"; rm -rf ~ -> 提取到 'echo ' -> 放行
现在的提取逻辑在 hook-extract.sh 的 gstack_hook_extract_field 中:优先用 python3 -c 'json.loads(...)' 做真正的 JSON 解析,失败则回退到 node -e;解析失败(返回码 1)时调用方决定策略——careful 的策略是 fail closed:payload 非空但解析不了时,返回 ask 并提示"Could not parse the tool payload to safety-check this command. Approve only if you know what it does." 注释的定性很直接:"一个把守破坏性命令的钩子,不能因为读不懂输入就默认放行。"
而解析成功但没有 command 字段(非 Bash 工具的 payload)或 command 非字符串时,则输出空 {} 放行,保证钩子不误伤其他工具。test/hook-scripts.test.ts 的 "command extraction" 组用例分别钉住了这三种极性。
hook-extract.sh 是 careful 与 freeze 两个钩子共享的单一副本——注释记录了它存在的原因:两个钩子曾各带一份提取器,转义引号截断 bug 修在 careful 那份里时 freeze 的坏副本一直静默留着;共享之后"任何解析修复一次落地,构造上同时到达两个钩子"。
4.2 输出:hookSpecificOutput 信封
文档强调了一条 Claude Code 的隐性契约:决策必须嵌套在 hookSpecificOutput 之下,顶层的 permissionDecision 会被 Claude Code 直接忽略,警告等于没写。gstack_hook_decision(hook-extract.sh)统一生成三种结果之一:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"[careful] ..."}}
permissionDecision 取值为 ask(警告,用户可覆盖)或 deny(拒绝,见下节 HIGH 级)。无命中时输出空对象 {}。理由文本由 gstack_hook_json_string 做 JSON 编码——注释特意警告"绝不能用 printf/sed 拼接钩子 JSON:路径里的引号或换行会造出畸形 JSON,而 Claude Code 对畸形决策的整个处理方式就是静默忽略——恰好在关键时刻 no-op 的 deny"。
4.3 Shell 混淆绊线
所有模式检查都是把命令当字符串匹配,但 bash 执行的是字符串展开之后的语义。check-careful.sh 因此设置了一条前置"绊线":命令中出现 ${IFS}/$IFS 分隔(rm${IFS}-rf${IFS}/ 能匹配 rm\s+ 之外的任何正则却执行完整的递归删除)、$(echo ... base64 ...) 展开、或 base64 -d | sh 管道到 shell 时,直接返回 ask 并提示 "Shell obfuscation detected (IFS word-splitting or base64-to-shell). Read the command carefully before approving."。源码的立场是"不与 bash 拼解析能力"——这些分裂/解码原语在人类真正想无人值守执行的命令中极其罕见,一律要求人工过目。测试 同时验证了 rm${IFS}-rf${IFS}/、echo cm0gLXJmIC8= | base64 -d | sh 会触发询问,而 cat file.b64 | base64 -d > out.bin 这类普通 base64 解码不受影响。
4.4 遥测
每次命中(无论 ask/deny)都会调用 gstack_hook_log_fire 追加一条 {"event":"hook_fire","skill":"careful","pattern":...,"ts":...,"repo":...} 记录——只记 pattern 名,从不记录命令内容;且目录取自 GSTACK_HOME 环境变量,让测试不会污染操作者真实的 analytics 文件;写日志失败也是 best-effort,绝不影响钩子决策本身。
五、HIGH 级:两种"灾难形态"直接拒绝
文档 "HIGH tier (hard deny)" 一节是最值得精读的部分:在可覆盖的 MEDIUM 警告之上,还有两种形态是 deny 而不是 ask,且实现(check-careful.sh)对边界做了大量推敲。
5.1 仅 SIMPLE 命令有资格进入 HIGH
字符串匹配无法回答复合命令"到底做什么"(cd X && git push --force——哪个 cwd?哪个仓库?),所以钩子先用 case 检查命令是否含 ;、&&、||、| 或换行;含任一则 _IS_SIMPLE=0,整体落入 MEDIUM ask 家族——"保守失败 = 询问,绝不猜测"。
5.2 递归删除 /、~、$HOME
触发前提:命令以(可选 sudo +)rm 开头,且存在递归 flag(长/短任意位置,--no-preserve-root 跟在目标后面也算)。然后逐 token 判定:
- 跳过装饰 token:选项、
--、重定向(2>/dev/null是 Agent 生成命令最常见的后缀)、后台符; - 剥一层引号——
rm -rf "/"与rm -rf /等价,不能靠引号躲过拒绝; - 每一个非选项 token 都必须是根类目标(
/、~、$HOME、/*等),任何一个普通目标 token 出现即不算 HIGH; - 全程
set -f(noglob),防止字面量/*在 word-splitting 时被 shell 展开。
命中则输出:[careful][HIGH] Recursive delete of / or the home directory is blocked while /careful is active. If you truly mean it, end the /careful session first. 测试钉住了 rm -R / → deny(大写 -R 是 BSD/macOS 的递归标志,早期正则只认小写 r,rm -R / 曾静默放行),以及 rm -fR /home/user 这种非根目标仍是 MEDIUM ask(test/hook-scripts.test.ts)。
5.3 force-push 到默认分支
判定链条分四步:
- 命令形如
git push; - 存在 force 语义:
-f/--force,或 plus-refspec(+main、+HEAD:main);--force-with-lease被刻意排除——它是安全变体,注释写明 "never HIGH"; - 解析默认分支:优先
git symbolic-ref refs/remotes/origin/HEAD;由于 Conductor worktree 常常没有这个符号引用(而 worktree 恰是 gstack 的主部署环境),失败时回退探测refs/remotes/origin/main/refs/remotes/origin/master; - 目标比对用定长字符串 token 比较而非把分支名插进正则(注释:正则元字符会过度/不足匹配),带斜杠的默认分支如
release/2.0保持完整;+main、HEAD:main等 refspec 会先剥+和前缀再比较。另有特例:裸git push --force(只有 force flag、无远端/ref)指向当前分支的 upstream,仅当当前分支就在默认分支上时才算 HIGH。
测试用临时 git 仓库把默认分支钉成 trunk(见 withGitRepo),确保 git push --force origin main 在非默认目标下仍是 MEDIUM ask 而不是误伤。
5.4 定位:建议性的硬停,不是策略边界
文档对 HIGH 级的自我定性必须原样继承:"A best-effort advisory hard-stop, not a policy boundary: the escape hatch is ending the opt-in, session-scoped /careful session." 即:它尽力拦住两种灾难,但逃生门是结束这个用户主动开启的会话——真正的强制策略边界需要企业级管控,/careful 明确不扮演那个角色。
六、项目级自定义模式:只能加,不能减
文档 "Project patterns (additive only)" 允许在两个位置追加告警规则,每行一条 POSIX ERE,支持 # 注释:
- 全局:
~/.gstack/careful-patterns.txt - 按项目:
~/.gstack/projects/<slug>/careful-patterns.txt
实现(check-careful.sh)体现了"只增不减"的两层保障:
- 加载时机:这些文件只在八个内置家族全部未命中后才被读取,所以无论文件内容是什么,都无法抑制或弱化基线警告;
- 容错:空行与
#注释跳过;无效 ERE(grep返回码 2)跳过该行为止——"钩子绝不能因为配置里的一个拼写错误而崩掉"。
性能上有一个值得注意的短路:解析项目 slug 需要一次子进程 + git 调用,而钩子对每条 Bash 命令都要跑,所以先 find ... -name careful-patterns.txt -print -quit 探测是否存在任何按项目的模式文件,存在才去执行 bin/gstack-slug 解析 slug,把常态开销降到零。
GSTACK_HOME 环境变量可整体重定向状态目录(默认 ~/.gstack),测试正是靠它把模式文件与 analytics 都关进沙箱。
七、测试如何锁住这套护栏
test/hook-scripts.test.ts 以 spawnSync('bash', [check-careful.sh]) + JSON stdin 的方式对钩子做端到端断言,覆盖矩阵与文档逐条对应,且包含大量"攻击形态"回归用例:
| 用例 | 期望 |
|---|---|
rm -rf /var/data |
ask,reason 含 "recursive delete" |
rm -rf node_modules / rm -rf .next dist / rm -Rf node_modules |
无决策(放行) |
rm -rf /; rm -rf node_modules 等 7 种"安全伪装"组合 |
ask |
rm -rf $(./wipe-all)/node_modules(命令替换搭白名单) |
ask |
rm -R / |
deny,reason 含 "HIGH" |
git commit -m "wip" && rm -rf / 等 4 种引号截断形态 |
ask(#2426 回归) |
| 非 JSON stdin / 非 JSON 载荷 | ask(fail closed) |
rm${IFS}-rf${IFS}/、echo ... | base64 -d | sh |
ask(obfuscation) |
psql -c "DROP TABLE users" 等带引号 SQL |
ask |
无 command 字段的 payload、command: 42 |
放行(非 Bash 载荷) |
ls -la、git status、npm install 等 |
放行 |
这套测试的意义在于:护栏的每一条"保守失败"决策都是被用例显式钉住的设计意图(例如 "A future per-segment parser must consciously change this test"),防止后续优化在"消除假阳性"的名义下悄悄打开 fail-closed 的缺口。
八、启用、组合与退出
- 启用:安装 gstack 后(setup 脚本会把
careful/bin等全部运行资产装到技能目录,见 setup 中关于 "installs every runtime asset a skill ships" 的排除式清单),对 Claude Code 说 "be careful" / "safety mode" / "prod mode",或由路由匹配/careful。frontmatter 中的hooks随即注册 PreToolUse 检查,状态消息为 "Checking for destructive commands..."。 - 组合:姊妹技能 /guard("full safety mode")直接复用
careful/bin/check-careful.sh的 Bash 钩子,再叠加/freeze的 Edit/Write 目录边界钩子,实现"破坏命令告警 + 编辑范围锁定"的最大安全组合;二者由同一安装流程一起部署。 - 退出:结束当前会话或开启新会话即可——钩子是会话作用域的,不存在需要清理的持久状态。
小结
/careful 的价值不在模式表本身,而在于它展示了一个 AI Agent 安全钩子应有的工程姿态:真实的 JSON 解析而非 grep 取字段、锚定式白名单、对混淆原语的绊线、fail-closed 的输入极性、只增不减的用户扩展、以及对"这是建议性硬停而非策略边界"的诚实定位。这些取舍大多能从 careful/SKILL.md、careful/bin/check-careful.sh、careful/bin/hook-extract.sh 与 test/hook-scripts.test.ts 的注释和用例中逐条找到依据,适合作为 Agent 工具链安全设计的参考样本。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00