gstack /freeze 技能深度解析:用 PreToolUse Hook 把 AI 编辑范围锁定在指定目录
gstack 的 /freeze 技能通过注册 Claude Code 的 PreToolUse Hook,在会话级别把 Edit/Write 工具的文件修改范围硬性锁定到一个指定目录,任何越界写入都会被直接拒绝(而非仅警告)。本篇基于 freeze/SKILL.md 及其底层 Hook 脚本 freeze/bin/check-freeze.sh 的完整实现,讲清楚 /freeze 的注册方式、边界状态文件的落盘机制、fail-closed 判定策略,以及符号链接、带空格路径等边界场景的处理原理,并附测试用例 test/hook-scripts.test.ts 的验证依据。读完你可以掌握:如何在自己的 Claude Code 会话中用 Hook 实现“编辑围栏”,以及如何设计一个不会静默失效的 deny 级 Hook。
一、/freeze 的定位与触发方式
从 freeze/SKILL.md 的 frontmatter 定义可以看出,/freeze 的设计目标是“Restrict file edits to a specific directory for the session”——在会话期间把文件编辑限制在指定目录。它的核心行为是:
Blocks Edit and Write outside the allowed path. 目标场景是调试时防止 Agent 顺手“修复”了无关代码,或者你希望把变更范围严格限定在某个模块内。
触发该技能的常见说法包括:“freeze”、“restrict edits”、“only edit this folder”、“lock down edits”。技能声明的 allowed-tools 为 Bash、Read、AskUserQuestion——设置过程本身只需要读取和询问,不需要额外写文件权限。
Hook 注册声明
/freeze 的“拦截能力”来自 SKILL.md frontmatter 中的 hooks 字段。它以 PreToolUse 事件挂接在 Edit 和 Write 两个工具上,工具调用前执行检查脚本:
hooks:
PreToolUse:
- matcher: "Edit"
hooks:
- type: command
command: "bash $HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
statusMessage: "Checking freeze boundary..."
- matcher: "Write"
hooks:
- type: command
command: "bash $HOME/.claude/skills/gstack/freeze/bin/check-freeze.sh"
statusMessage: "Checking freeze boundary..."
两个 matcher 指向同一个脚本,即无论是修改已有文件(Edit)还是创建/写入新文件(Write),都会先经过同一道边界检查。注意这里的路径锚定在 $HOME/.claude/skills/gstack/ 而非 ${CLAUDE_SKILL_DIR} 之类运行时变量——test/investigate-freeze-path.test.ts 专门回归验证了这一点:frontmatter hook 在任何运行时变量存在之前执行,用 ${CLAUDE_SKILL_DIR} 相对路径会导致命令永远解析失败、守卫静默失效(该测试对应 issue #2469 / #1871 的修复)。
技能被调用时还会向本地分析日志写入一条使用记录(~/.gstack/analytics/skill-usage.jsonl),用于技能使用统计,这是 gstack 各技能共用的埋点模式。
二、设置流程:询问目录并写入边界状态文件
按照 freeze/SKILL.md 的 Setup 章节,/freeze 的标准执行流程分三步:
1. 询问用户要限制的目录
通过 AskUserQuestion 发起文本输入型(而非多选)问题,提示词为:
“Which directory should I restrict edits to? Files outside this path will be blocked from editing.”
让用户直接键入路径,避免候选目录被预设。
2. 解析为绝对路径
FREEZE_DIR=$(cd "<user-provided-path>" 2>/dev/null && pwd)
echo "$FREEZE_DIR"
用 cd ... && pwd 把相对路径、~ 等统一解析成绝对路径,后续边界比较才有意义。
3. 加尾斜杠并写入状态文件
FREEZE_DIR="${FREEZE_DIR%/}/"
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
STATE_DIR="$GSTACK_STATE_ROOT"
mkdir -p "$STATE_DIR"
echo "$FREEZE_DIR" > "$STATE_DIR/freeze-dir.txt"
echo "Freeze boundary set: $FREEZE_DIR"
关键点:
- 尾斜杠是有意为之:
/src/不会前缀匹配到/src-old,避免/src冻结误伤同级的/src-old目录(这一点有专门测试覆盖,见下文); - 状态落在
freeze-dir.txt:Hook 脚本在每次 Edit/Write 调用时读取该文件,因此边界对整个会话持续生效,不依赖上下文记忆; - 状态根目录由
gstack-paths提供的$GSTACK_STATE_ROOT决定,Hook 侧对应读取${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}(见 check-freeze.sh 第 35–36 行),两者指向同一状态区。
设置完成后,Agent 应告知用户:编辑已被限制在 <path>/,越界 Edit/Write 会被阻断;修改边界重跑 /freeze,解除用 /unfreeze 或结束会话。
三、核心实现解析:check-freeze.sh 的判定逻辑
freeze/bin/check-freeze.sh 是 /freeze 的全部“执法力量”。它从 stdin 读取 Claude Code 传入的工具调用 JSON,输出决定放行或拒绝的 JSON 信封。整体流程与源码细节如下。
1. 决策信封必须嵌套在 hookSpecificOutput 内
脚本头部注释明确写道:
Returns a PreToolUse hookSpecificOutput with permissionDecision "deny" to block, or {} to allow. The decision MUST be nested under hookSpecificOutput — Claude Code ignores a top-level permissionDecision, which silently no-ops the block.
也就是说,拒绝必须输出形如:
{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"deny","permissionDecisionReason":"..."}}
放在顶层的 permissionDecision 会被 Claude Code 直接忽略,导致拦截“静默失效”。这是 PreToolUse Hook 开发中最容易踩的坑,gstack 在脚本、共享助手和技能文档中反复强调。
2. fail-closed:无法解析就拒绝
/freeze 属于 deny 级 Hook,其极性策略与 /careful(ask 级)相反:
| 输入情况 | /freeze(deny 级) |
|---|---|
状态文件 freeze-dir.txt 不存在 |
放行(尚未设置边界) |
payload 可正常解析,但无 file_path 字段(非文件类工具) |
放行 |
| payload 无法解析 JSON | 拒绝(fail closed) |
路径在边界内(含前导 ${FREEZE_DIR}/ 或完全相等) |
放行 |
| 路径在边界外 | 拒绝,并记录 hook_fire 分析事件 |
脚本对此的注释是:“A boundary that fails open is not a boundary”(一个失败放开的边界不是边界)。即使 gstack 自身安装损坏(共享助手文件缺失),Hook 也会输出 deny 并提示 “Reinstall gstack or run /unfreeze”,而不是放行。注意脚本用一个 if [ ! -f ... ] || ! . ... 守卫处理了这个场景——注释特别指出,bash 在非交互 shell 中对不存在文件的 .(source)视为致命错误,if 守卫抓不住,必须先做文件存在性检查。
3. 状态文件的读取:只修剪首尾空白
FREEZE_DIR=$(head -n 1 "$FREEZE_FILE" 2>/dev/null | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
这里修复过一类真实缺陷:旧实现用 tr -d '[:space:]' 会删掉路径内部的所有空格,导致 ~/My Project/src 这样的边界永远无法匹配任何文件——要么所有编辑被误拒,要么变形后的路径意外放行了错误的目录。新实现只修剪首尾空白,内部空格得以保留。源码还处理了状态文件中字面 ~ 前缀的展开(~ → $HOME),因为变量中的波浪号不会被 shell 展开,若原样保留就永远匹配不上绝对路径。
4. 路径规范化与符号链接的最终组件解析
拿到 file_path 后,脚本做三步处理:
- 相对路径转绝对路径(非
/开头则拼上$(pwd)); - 规范化:折叠重复斜杠、去掉尾斜杠;
- POSIX 可移植的符号链接解析
_resolve_path:
_resolve_path() {
local _p="$1" _dir _base _tgt _i=0
while [ -L "$_p" ] && [ "$_i" -lt 40 ]; do
_tgt=$(readlink "$_p" 2>/dev/null) || break
case "$_tgt" in
/*) _p="$_tgt" ;;
*) _p="$(dirname "$_p")/$_tgt" ;;
esac
_i=$((_i + 1))
done
_dir="$(dirname "$_p")"
_base="$(basename "$_p")"
_dir="$(cd "$_dir" 2>/dev/null && pwd -P || printf '%s' "$_dir")"
printf '%s/%s' "$_dir" "$_base"
}
这段实现针对的是一个经典的边界逃逸:旧版本只解析父目录,不跟随最后一个路径组件,于是“边界内的符号链接指向边界外目标”这种写入就能穿透检查——Hook 看到的 boundary/link.txt 在边界内,实际写入却落在边界外。现在的实现用 readlink 循环(上限 40 次,防环)把最终组件解析到真实目标后,再对目标路径做前缀检查;对于尚不存在的待创建文件(最终组件不是符号链接),父目录解析即正确行为。
5. 最终的边界判定与拒绝消息的 JSON 编码
case "$FILE_PATH" in
"${FREEZE_DIR}/"*|"${FREEZE_DIR}")
echo '{}' ;; # 边界内,放行
*)
gstack_hook_log_fire freeze boundary_deny
gstack_hook_decision deny "[freeze] Blocked: $FILE_PATH is outside the freeze boundary ($FREEZE_DIR). ..."
;;
esac
判定就是规范化后的前缀匹配,尾斜杠保证 /src/ 不会误匹配 /src-old。拒绝时先通过 gstack_hook_log_fire 记录一条 hook_fire 分析事件(只记 pattern 名,不记命令内容),再输出 deny。拒绝原因必须经过 gstack_hook_json_string 做 JSON 编码——源码注释指出:手工插值路径进 JSON,一旦路径含引号或换行就会产出畸形 JSON,Claude Code 会忽略整个决策,deny 恰好在对路径“最不可信”的时候静默失效。
6. 共享的 JSON 助手:careful/bin/hook-extract.sh
check-freeze.sh 并不自己解析 JSON,而是 source 一个与 /careful 共享的助手文件 careful/bin/hook-extract.sh。文件头注释解释了为什么必须是“一份拷贝”:两个 Hook 曾各自维护解析器副本,转义引号截断的 bug 在 careful 的副本里修掉了,而 freeze 的副本“silently kept the broken one”(静默保留了坏的那份)。合并后,任何解析修复天然覆盖两个 Hook。该助手提供四个函数:
gstack_hook_extract_field PAYLOAD FIELD:用 python3(首选,macOS 与多数 Linux 自带)或 node 兜底,从tool_input中取出字符串字段;解析失败返回 1,由调用方决定极性(careful 选 ask,freeze 选 deny);gstack_hook_json_string TEXT:把任意文本编码为合法 JSON 字符串字面量(引号、反斜杠、控制字符、换行);无任何解析器可用时,退化为仅保留安全字符集,保证信封 JSON 始终合法;gstack_hook_decision DECISION REASON:输出完整嵌套的hookSpecificOutput信封;gstack_hook_log_fire SKILL PATTERN:向${GSTACK_HOME:-$HOME/.gstack}/analytics/skill-usage.jsonl追加分析记录,尊重GSTACK_HOME环境变量以保证测试不污染真实数据文件,且失败绝不影响 Hook 决策。
四、测试用例对边界行为的验证
test/hook-scripts.test.ts 的 check-freeze.sh 测试块用真实进程执行 Hook 脚本(把 JSON payload 从 stdin 喂给 bash),覆盖的场景与上文实现一一对应:
| 场景 | 期望结果 |
|---|---|
| 编辑边界内文件 / 边界内子目录 | 放行(无 permissionDecision) |
编辑边界外文件、Write /etc/hosts |
permissionDecision: "deny",原因含 "freeze"、"outside" |
边界为 /src/ 时写 /src-old/index.ts |
deny(尾斜杠防前缀误匹配) |
| 状态文件不存在 | 全部放行 |
| 状态文件为空 | 放行 |
恶意 payload not json at all {{{{ |
deny(fail closed) |
路径含引号 /tmp/evil"quoted/x.ts |
输出可被 JSON.parse 解析的合法 deny JSON |
路径含换行 /tmp/evil\npath.ts |
同上,deny 且 JSON 合法 |
边界路径含空格 …/My Project/src |
边界内放行、边界外 deny(内部空格不丢失) |
| 共享助手文件缺失(模拟安装损坏) | deny,原因含 "fail closed" |
边界内符号链接指向边界外 secret.txt |
deny(最终组件被跟随解析) |
| 边界内真实文件 | 放行(符号链接逻辑不误伤普通文件) |
另外,test/hook-scripts.test.ts 还有一组 frontmatter 回归测试,断言 investigate/SKILL.md、careful/SKILL.md、freeze/SKILL.md、guard/SKILL.md 中所有 command: 行都锚定 $HOME、绝不出现 CLAUDE_SKILL_DIR。
五、/unfreeze 解除与组合使用
解除边界由配对的 /unfreeze 技能完成,见 unfreeze/SKILL.md。其实现只有一段 shell:读取并删除状态文件,向用户报告之前被冻结的目录:
eval "$(~/.claude/skills/gstack/bin/gstack-paths)"
STATE_DIR="$GSTACK_STATE_ROOT"
if [ -f "$STATE_DIR/freeze-dir.txt" then
PREV=$(cat "$STATE_DIR/freeze-dir.txt")
rm -f "$STATE_DIR/freeze-dir.txt"
echo "Freeze boundary cleared (was: $PREV). Edits are now allowed everywhere."
else
echo "No freeze boundary was set."
fi
文档特别提示:/unfreeze 只是删掉状态文件,/freeze 注册的 Hook 在整个会话内仍然挂着——只是每次触发时读不到状态文件,于是全部放行;需要重新冻结时再跑一次 /freeze 即可。
在 gstack 的技能矩阵里,/freeze 不是孤立的。从 guard/SKILL.md 可见,/guard 会同时注册 careful 的 Bash 检查 Hook 和 freeze 的 Edit/Write 检查 Hook,把“危险命令告警”与“编辑边界锁定”叠加成双重防护;/careful(见 careful/SKILL.md)则是 ask 级的破坏性命令守卫,与 freeze 共享同一份 JSON 解析助手,二者极性相反:careful 对无法解析的 payload 选择询问,freeze 选择拒绝。
六、能力边界:它是编辑围栏,不是安全边界
freeze/SKILL.md 的 Notes 章节给出了三条重要限制,使用时必须清楚:
- 尾斜杠语义:
/src不会匹配/src-old——这是刻意设计; - 只覆盖 Edit 与 Write 工具:Read、Bash、Glob、Grep 完全不受影响;
- 防误改,不是防绕过:像
sed -i这样的 Bash 命令仍然可以修改边界外的文件。文档原话是 “This prevents accidental edits, not a security boundary”。
因此 /freeze 的正确定位是:在多模块代码库中做定向调试或重构时,防止 AI Agent 顺手改动无关模块的工程护栏;如果目标是权限隔离,仍应依赖操作系统、CI 权限等真正的安全机制。
七、小结:一个可复用的 deny 级 Hook 设计样本
/freeze 虽然只有百来行 shell,却浓缩了 Claude Code PreToolUse Hook 工程化设计的几条硬经验,值得在自己的项目中直接借鉴:
- 决策必须嵌套在
hookSpecificOutput内,顶层permissionDecision会被静默忽略; - deny 级 Hook 必须 fail-closed:解析失败、助手缺失、payload 畸形,一律拒绝;
- 拒绝原因必须走 JSON 编码,绝不手工插值路径,否则恶意路径会让决策整体失效;
- 前缀匹配要带尾斜杠,防
/src误伤/src-old; - 符号链接要解析到最终组件,否则边界内链接指向边界外即可逃逸;
- 状态文件 + 尾斜杠 + 只修剪首尾空白,让带空格的路径也能正确匹配;
- 共享解析助手只保留一份,消除“修了一处、另一处静默带病”的漂移。
结合 unfreeze/SKILL.md 的解除流程和 test/hook-scripts.test.ts 的进程级回归测试,/freeze 为“用 Hook 给 AI 会话划编辑围栏”提供了一个完整、可验证、失败方向正确的参考实现。
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 StartedRust0623
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