首页
/ gstack /unfreeze 技能详解:如何清除 /freeze 目录级编辑边界而不结束会话

gstack /unfreeze 技能详解:如何清除 /freeze 目录级编辑边界而不结束会话

2026-09-06 18:44:41作者:舒璇辛Bertina

/unfreeze 是 gstack 安全技能家族(/careful/freeze/guard)中的"解锁"技能,用于清除 /freeze 设置的会话级编辑边界,让 Agent 恢复对全部目录的编辑权限。阅读本文后,你将掌握 /unfreeze 的触发时机、核心命令的每一行含义、底层 hook 的 fail-closed 判定逻辑、状态文件 freeze-dir.txt 的路径解析机制,以及与 /guard、/investigate 等技能的协作关系。

一、/unfreeze 在 gstack 中的定位

gstack 是一个以"CEO、设计师、工程经理、QA"等 23 个有主见工具为形态的 Claude Code 工作流集合。为了在调试时防止 Agent 顺手"修复"无关代码,/freeze 会把所有 Edit / Write 操作**限制(blocked,而非仅警告)**在某个指定目录内;/unfreeze 则是与之对称的"解除操作"。

两者的技能元信息(frontmatter)揭示了各自定位:

维度 /freeze /unfreeze
description Restrict file edits to a specific directory for the session Clear the freeze boundary set by /freeze, allowing edits to all directories again
核心触发词 freeze edits to directory、lock editing scope、restrict file changes unfreeze edits、unlock all directories、remove edit restrictions
allowed-tools Bash、Read、AskUserQuestion Bash、Read
hooks PreToolUse 匹配 Edit / Write

需要特别注意的是:解除边界不需要结束会话。这是 /unfreeze 与"直接关闭对话重来"两种做法的本质区别——它的语义是"扩宽当前会话的编辑范围,同时保留会话内的其它状态"。在 SKILL.md 的路由规则中,User asks to restrict edits to a directory → invoke /freeze or /unfreeze,二者是一对由主技能入口统一调度的互逆命令。

二、技能触发条件与使用时机

根据 unfreeze/SKILL.md 的"When to invoke this skill",满足以下任一情况就应调用 /unfreeze:

  • 你想在不结束当前会话的前提下,把编辑范围从"冻结目录"重新扩大为"全部目录";
  • 用户提出了 "unfreeze"、"unlock edits"、"remove freeze"、"allow all edits" 等明确请求。

三、核心命令逐行拆解

/unfreeze 的实际执行体很精简,但每一段都有明确职责。它由两个 bash 块组成。

3.1 技能使用埋点

mkdir -p ~/.gstack/analytics
echo '{"skill":"unfreeze","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")'"}'  >> ~/.gstack/analytics/skill-usage.jsonl 2>/dev/null || true

该块向 ~/.gstack/analytics/skill-usage.jsonl 追加一条 JSONL 记录,字段含 skill(固定为 unfreeze)、ts(UTC 时间戳)与 repo(通过 git rev-parse --show-toplevel 探测的仓库名,探测失败时回退为 unknown)。末尾的 2>/dev/null || true 保证这是一次"尽力而为"(best-effort)的埋点——即使写日志失败,也绝不会影响后续真正的解锁动作。埋点模式在整个技能家族中是统一的,/freeze/guard 的 SKILL 文档中均以相同的 JSONL 结构开头。

3.2 清除边界(Clear the boundary)

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

逐行语义如下:

  1. eval "$(...gstack-paths)":导入 gstack 的运行时路径变量。bin/gstack-paths 会输出 GSTACK_STATE_ROOTPLAN_ROOTTMP_ROOT 三个变量。其中状态根目录决定 freeze 状态文件写在哪里。
  2. STATE_DIR="$GSTACK_STATE_ROOT":取状态根目录,默认解析为 $HOME/.gstack(详见下文第五节)。
  3. 状态检查与删除:若 freeze-dir.txt 存在,先把原值读入 PREV 用于向用户汇报,随后删除该文件,并输出 Freeze boundary cleared (was: <原目录>);若文件本就不存在,则输出 No freeze boundary was set.,表示当前本就处于"无冻结"状态。

注意:这里的删除动作只能说明"gstack 不再认为存在边界"。真正决定是否放行 Edit/Write 的是注册在会话里的 PreToolUse hook(详见第四节)。

四、清除边界之后:hook 依然注册,只是"放行一切"

/unfreeze 文档末尾明确指出一个容易被忽略的语义:

/freeze hooks are still registered for the session — they will just allow everything since no state file exists. To re-freeze, run /freeze again.

也就是说,/unfreeze 不会反注册 hook/freeze 的 frontmatter 中注册了两条 PreToolUse 规则,分别匹配 EditWrite 工具,调用 freeze/bin/check-freeze.sh

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"

当 hook 在每次 Edit / Write 调用时被触发,它首先检查状态文件是否存在(freeze/bin/check-freeze.sh):

# If no freeze file exists, allow everything (not yet configured)
if [ ! -f "$FREEZE_FILE" ]; then
  echo '{}'
  exit 0
fi

这正是 /unfreeze"删除即解锁"原理的落地处:状态文件是边界的唯一事实来源(source of truth),文件不存在即边界不存在。因此 /unfreeze 删除文件后,hook 仍然每调用必触发,但每次都命中 {} 放行分支。若需要重新设立边界,只需再次运行 /freeze 写入新的 freeze-dir.txt

五、状态文件与路径解析机制

/unfreeze 能正确工作,前提是它读写状态文件的位置与 /freeze 的 hook 读取位置完全一致。两者最终都收敛到 freeze-dir.txt,但各自路径解析略有分工:

  • /freeze 写、/unfreeze 删:通过 eval "$(gstack-paths)"GSTACK_STATE_ROOT
  • check-freeze.sh 读freeze/bin/check-freeze.sh 使用 STATE_DIR="${CLAUDE_PLUGIN_DATA:-$HOME/.gstack}"

bin/gstack-pathsGSTACK_STATE_ROOT 的解析回退链是:

GSTACK_HOME -> CLAUDE_PLUGIN_DATA(仅当 CLAUDE_PLUGIN_ROOT 含 gstack)-> $HOME/.gstack -> .gstack

其中对 CLAUDE_PLUGIN_DATA 的信任加了守卫:只有 CLAUDE_PLUGIN_ROOT 确认当前进程是 gstack 插件时才采用,避免其它插件(如 codex)泄漏的 CLAUDE_PLUGIN_DATA 污染 gstack 状态目录(见 bin/gstack-paths)。在标准安装下(插件数据目录或 $HOME/.gstack),两者解析到同一目录,读写天然一致。由于输出值经过 printf %q 转义,eval 可以字节级还原路径(含空格、反斜杠等场景),调用方仍需对 "$GSTACK_STATE_ROOT" 做引号展开。

状态文件 freeze-dir.txt 本身只有一行:冻结目录的绝对路径。为保证判定正确,写入方会强制以 / 结尾(freeze/SKILL.md 的 Setup 步骤执行 FREEZE_DIR="${FREEZE_DIR%/}/"),这样前缀匹配时 /src 不会误匹配 /src-old。读取方则做进一步加固(freeze/bin/check-freeze.sh):

  • 只修剪首尾空白:历史实现用 tr -d '[:space:]' 会连内部空格一起删掉,导致 ~/My Project/src 这类带空格边界永远无法匹配;现在改用 sed 只清首尾;
  • 展开字面 ~:状态文件里的 ~/ 前缀不会在变量中自动展开,脚本将其显式映射为 $HOME

六、底层原理:fail-closed 的 hook 判定与共享 JSON 提取器

为什么"删掉状态文件"就足以解锁?因为 /freeze 的设计极性是默认关闭(fail-closed),这在 freeze/SKILL.md 的 "How it works" 与 freeze/bin/check-freeze.sh 头注释中都被反复强调:a boundary that fails open is not a boundary

具体判定流程(freeze/bin/check-freeze.sh):

  1. 从 stdin 读取 Edit/Write 工具的输入 JSON;
  2. 用共享提取器取出 tool_input.file_path
  3. 无法解析的载荷一律 deny(fail-closed)——hook 看不懂的东西绝不能放行;
  4. 能解析但无 file_path 的载荷(非文件类工具)放行;
  5. 解析失败但有文件路径时,规范化路径(去双斜杠与尾部斜杠)、解析 .. 与符号链接(含末级组件,防止"边界内软链指向边界外"绕过),再做前缀匹配;
  6. 命中边界前缀输出 {} 放行;否则输出 deny 决策并记录一次 hook_fire 分析事件。

一个关键的协议细节:拒绝决策必须嵌套在 hookSpecificOutput 字段下返回(careful/bin/hook-extract.sh):

gstack_hook_decision() {
  ...
  printf '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"%s","permissionDecisionReason":%s}}\n' "$_ghd_decision" "$_ghd_encoded"
}

Claude Code 会忽略顶层 permissionDecision,只有嵌套在 hookSpecificOutput 下的 permissionDecision: "deny" 才能真正阻断操作——文档注释中明确记录了"顶层字段会静默 no-op"这一坑。

另外值得注意的工程实践是:/freeze/careful 共用了同一份 JSON 工具脚本 careful/bin/hook-extract.sh。其头注释说明:历史上两个 hook 各带一份解析器副本,修复了 careful 副本的"转义引号截断" bug 后 freeze 仍悄悄用着坏副本;合并为一份后,任何解析修复天然同时覆盖两个 hook。若该共享文件缺失(安装损坏或升级中断),freeze hook 会内联构造一条 deny 决策,继续 fail-closed 阻断freeze/bin/check-freeze.sh),而不是静默放行。

七、与 /guard、/investigate 的协作生态

  • /guard = /careful + /freeze/guard 是"全安全模式",同时注册"危险命令警告"(check-careful.sh,作用于 Bash)与"目录编辑边界"(check-freeze.sh,作用于 Edit/Write)。它的 Setup 同样会写 freeze-dir.txt,因此文档明确说明解除编辑边界的方式正是 /unfreeze"To remove the edit boundary, run /unfreeze. To deactivate everything, end the session."
  • /investigate 自动冻结investigate/SKILL.md 在调试时检测受影响文件所在的最窄目录,自动把它写入冻结状态文件,并在结束时提示用户 "Run /unfreeze to remove the restriction." 换句话说,/unfreeze 是调试闭环(freeze → 修复 → unfreeze)的收尾动作。
  • 会话级生命周期:边界只存在于当前会话(session-scoped)。hook 每次调用读取状态文件、每调用一次判定一次;结束会话同样等效于清除边界。

八、安装前提与运行环境

运行 /unfreeze 需要以 gstack 的标准安装为前提(本仓库是只读的,仅用于查看):

  • 技能按 ~/.claude/skills/gstack/ 前缀安装,unfreeze/SKILL.md/freeze/careful/guard 一起由 gstack 的 setup 脚本安装(/guard 的依赖说明特别强调三者需同时安装,因为 guard 直接引用 freeze/careful 的 hook 脚本);
  • gstack-paths 存在于同一安装目录的 bin/ 下,运行时通过 eval 引入;
  • hook 脚本的 frontmatter 路径一律以 $HOME/.claude/skills/gstack/ 锚定。相关测试 test/hook-scripts.test.ts 断言所有 command: 行必须使用 $HOME 锚定、不得使用 CLAUDE_SKILL_DIRtest/investigate-freeze-path.test.ts 也对 investigate 中引用 check-freeze.shHOME锚定做了同样的防回归约束——因为frontmatterhook在运行时变量存在之前就会执行,HOME 锚定做了同样的防回归约束——因为 frontmatter hook 在运行时变量存在之前就会执行,`{CLAUDE_SKILL_DIR}` 这类相对引用会静默无法解析,导致守卫失效。

九、使用要点与常见问题

  1. /unfreeze 不等于"反安装 /freeze":hook 依然注册、每调用仍执行,只是因状态文件不存在而放行一切。要重新冻结请再次运行 /freeze
  2. /freeze 不是安全沙箱/freeze 的 Notes 明确指出它只拦截 Edit/Write 工具,Bash 里的 sed 等命令仍可修改边界外文件——它是"防误改",不是访问控制。/unfreeze 解锁的同样是这层"防误改",而非系统级权限。
  3. 判别边界是否存在的权威方法:查看状态目录下是否存在 freeze-dir.txt(默认 $HOME/.gstack/freeze-dir.txt)。/unfreeze 输出 No freeze boundary was set. 即表示该文件不存在。
  4. 带空格的目录边界:supported。写入侧保留内部空格、读取侧只清首尾空白、hook 采用真正的 JSON 解析而非 grep 截断,三重保障确保路径匹配可靠。

若想快速上手体验完整链路,可以按 /freeze 的 Setup 流程冻结一个目录、触发一次越界编辑观察 hook 拦截,再运行 /unfreeze 观察边界清除提示——整个过程无需结束会话。

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