首页
/ gstack /freeze 技能深度解析:用 PreToolUse Hook 把 AI 编辑范围锁定在指定目录

gstack /freeze 技能深度解析:用 PreToolUse Hook 把 AI 编辑范围锁定在指定目录

2026-09-06 12:41:36作者:秋阔奎Evelyn

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-toolsBashReadAskUserQuestion——设置过程本身只需要读取和询问,不需要额外写文件权限。

Hook 注册声明

/freeze 的“拦截能力”来自 SKILL.md frontmatter 中的 hooks 字段。它以 PreToolUse 事件挂接在 EditWrite 两个工具上,工具调用前执行检查脚本:

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 后,脚本做三步处理:

  1. 相对路径转绝对路径(非 / 开头则拼上 $(pwd));
  2. 规范化:折叠重复斜杠、去掉尾斜杠;
  3. 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.tscheck-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.mdcareful/SKILL.mdfreeze/SKILL.mdguard/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 章节给出了三条重要限制,使用时必须清楚:

  1. 尾斜杠语义/src 不会匹配 /src-old——这是刻意设计;
  2. 只覆盖 Edit 与 Write 工具:Read、Bash、Glob、Grep 完全不受影响;
  3. 防误改,不是防绕过:像 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 会话划编辑围栏”提供了一个完整、可验证、失败方向正确的参考实现。

登录后查看全文
热门项目推荐
相关项目推荐