Claude Code 编辑后自动排版:从 PostToolUse 到可检查的 JSON hook

先让一份小配置稳定排版,再核对触发范围、写回边界和失败留下的文件。

你能带走什么
  • 按工具事件与项目路径双重限定处理范围
  • 把 JSON 输入当结构化数据读取,文件名不拼进 shell
  • PostToolUse 失败会反馈,但不会撤销工具已经完成的编辑

1. 让一个小配置文件保持可读

一份菜单配置刚被压成了一行。数据没有变,代码审查却很难看清下一次提交到底改了哪一项。把排版接在文件编辑之后,可以让这个小问题不用每次都写进提示词。本文用菜单 JSON 演示:保留字段顺序,以两个空格缩进,文件末尾补一个换行。中文直接保留,条目和数量仍然能由人快速核对。

这里交付的是可独立运行的本地格式器、模拟事件和完整测试。已经运行的是 Python 对真实样本文件的读写;没有启动 Claude Code,没有调用模型,也没有改动读者的项目设置。后面的接入步骤需要你在自己的测试项目里另外验证。样例面向支持 O_NOFOLLOW 与 dir_fd 的 POSIX 环境,本文在 Linux、Python 3.12.14 下验算;Windows 原生环境不在覆盖范围。

理解三个先后步骤,才能判断失败时磁盘上留下了什么
图 1 失败反馈不构成文件事务,也不恢复工具执行前的版本

2. 先确认触发点能覆盖你的编辑方式

官方的 自动格式化示例使用 PostToolUse 与 Edit|Write。它在对应工具成功后触发;Bash 或外部编辑器改文件不会命中这个工具名筛选。脚本从标准输入读取 JSON,取 tool_input.file_path,而不是从对话文字猜路径。文件工具提供绝对路径。见 PostToolUse 参考。

因此,这个工作流适合你已经知道将由 Write 或 Edit 更新的小型数据文件。如果任务惯常通过 shell 生成目录,本例不会自动替你扫描工作区。不要因为目录里还有未格式化文件就把 matcher 改成所有工具;先确定新的触发方式,再写清它能修改哪些文件。

3. 在空白目录里搭一个可丢弃的实验

建一个新的 hook-formatting-demo 目录,把下面各节标明名称的文件放进去。按标出的路径逐段保存完整文件后,在 hook-formatting-demo 目录运行后文命令。settings.example.json 只是待审阅的配置样本,保留这个文件名不会启用 hook。开始前确认 python3 已存在,不需要额外安装格式器。

hook-formatting-demo/
├── .claude/hooks/format_json.py
├── data/menu.json
├── demo.py
├── test_hook.py
└── settings.example.json

data/menu.json

{"title":"周末菜单","servings":2,"items":["米饭","番茄"],"enabled":true}

这个数据集刻意很小。我们的格式器只接受整数形式的 JSON 数值,拒绝小数、指数形式数值与 NaN,也拒绝重复对象键。这些是本例的选择,用来缩小数值处理范围,并避免吞掉重复字段。它保留受支持数据的数值语义,不保留原始数字写法:例如 -0 会规范化为 0;超长整数还受 Python 解释器的十进制位数上限约束,超限会拒绝处理并保留当前文件。如果你的文件需要金额小数、精确科学计数或 JSONC 注释,请先换成项目已经审阅过的格式器,不要直接删除这些检查。

4. 先把格式化范围限制在 data 目录

脚本所在位置决定项目根:.claude/hooks/format_json.py 向上三层。传入 cwd 或环境变量不能让它临时扩大写入范围。只有项目内 data/ 子树且后缀精确为 .json 的文件会被考虑;项目外路径、相对路径与包含 .. 的路径直接报错,项目内其他目录或扩展名则安静跳过。

这也意味着格式器脚本不要软链接到公共工具目录,否则由真实脚本位置算出的根会改变。若会话切换到另一份 worktree,这个固定根设计不会自动跟随。应在对应副本重新布置并审阅脚本,不能把陌生 cwd 当作新授权根。项目根及它的祖先目录在本例中被视为可信、稳定的基础。

本例只服务一个已审阅、没有并发写入的项目副本
图 2 这些检查缩小误写范围;不构成对抗恶意进程的沙箱

逐层使用目录文件描述符打开子目录,配合 O_NOFOLLOW 拒绝符号链接;最终文件还要是只有一个硬链接的普通文件。文件名不会被拼接进 shell,因此空格、分号和美元符号都只是文件名的一部分。这里不用路径字符串的 startswith 来判断目录归属,因为 data-backup 并不等于 data 的子目录。

5. 完整格式器:解析完成后才替换

读取上限为 1 MiB。先在内存中完成解析和输出;含末尾换行的 UTF-8 输出也不得超过 1 MiB,超限时保留原文件。只有这项检查通过,才创建同目录临时文件,复核目标文件的设备号、inode、大小和修改状态,最后执行替换。内容已经符合要求时不写回,因此第二次运行可以保留原 inode。正常的内容解析失败发生在临时文件创建前,不会把一半输出留到目标文件中。

.claude/hooks/format_json.py

"""POSIX teaching hook: only data/*.json; no external formatter or network."""
import json
import os
from pathlib import Path
import secrets
import stat
import sys

LIMIT = 1024 * 1024
ROOT = Path(__file__).resolve().parents[2]


def reject_number(value):
    raise ValueError("only integer JSON numbers are supported")


def unique_object(pairs):
    result = {}
    for key, value in pairs:
        if key in result:
            raise ValueError("duplicate JSON key")
        result[key] = value
    return result


def parse(text):
    return json.loads(text, object_pairs_hook=unique_object,
                      parse_float=reject_number, parse_constant=reject_number)


def format_file(path):
    # Reject lexical traversal rather than normalizing it away.
    if not path.is_absolute() or ".." in path.parts:
        raise ValueError("expected absolute path without parent traversal")
    parts = path.relative_to(ROOT).parts
    if len(parts) < 2 or parts[0] != "data" or path.suffix != ".json":
        return "skipped"
    if not hasattr(os, "O_NOFOLLOW"):
        raise ValueError("requires POSIX O_NOFOLLOW")
    directory = os.open(ROOT, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
    temporary = None
    try:
        # Open every descendant directory without following symbolic links.
        for part in parts[:-1]:
            child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
                            dir_fd=directory)
            os.close(directory)
            directory = child
        name = parts[-1]
        fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
                     dir_fd=directory)
        with os.fdopen(fd, "rb") as source:
            before = os.fstat(source.fileno())
            if not stat.S_ISREG(before.st_mode) or before.st_nlink != 1:
                raise ValueError("expected regular file with one hard link")
            if before.st_size > LIMIT:
                raise ValueError("file too large")
            original = source.read(LIMIT + 1)
            if len(original) > LIMIT:
                raise ValueError("file too large")
        value = parse(original.decode("utf-8"))
        output = (json.dumps(value, ensure_ascii=False, indent=2,
                             allow_nan=False) + "\n").encode("utf-8")
        # Count the final UTF-8 bytes, including the trailing newline.
        if len(output) > LIMIT:
            raise ValueError("formatted output too large")
        if output == original:
            return "unchanged"
        temporary = ".format-" + secrets.token_hex(12)
        fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL |
                     os.O_NOFOLLOW, 0o600, dir_fd=directory)
        with os.fdopen(fd, "wb") as target:
            target.write(output)
            target.flush()
            os.fchmod(target.fileno(), stat.S_IMODE(before.st_mode) & 0o777)
        current = os.stat(name, dir_fd=directory, follow_symlinks=False)
        signature = lambda s: (s.st_dev, s.st_ino, s.st_size, s.st_mtime_ns,
                               s.st_ctime_ns, s.st_nlink)
        if signature(current) != signature(before):
            raise ValueError("file changed during formatting")
        os.replace(temporary, name, src_dir_fd=directory, dst_dir_fd=directory)
        temporary = None
        return "formatted"
    finally:
        if temporary is not None:
            os.unlink(temporary, dir_fd=directory)
        os.close(directory)


def main():
    try:
        raw = sys.stdin.buffer.read(LIMIT + 1)
        if len(raw) > LIMIT:
            raise ValueError("hook input too large")
        event = json.loads(raw.decode("utf-8"), object_pairs_hook=unique_object)
        if not isinstance(event, dict):
            raise ValueError("expected JSON object")
        if event.get("hook_event_name") != "PostToolUse":
            return 0
        if event.get("tool_name") not in {"Write", "Edit"}:
            return 0
        payload = event.get("tool_input")
        if not isinstance(payload, dict):
            raise ValueError("missing tool_input object")
        raw_path = payload.get("file_path")
        if not isinstance(raw_path, str) or not raw_path or "\x00" in raw_path:
            raise ValueError("missing file_path string")
        format_file(Path(raw_path))
        return 0
    except (ValueError, OSError, UnicodeError, RecursionError) as error:
        # Do not copy file contents or arbitrary input into model-visible logs.
        print("JSON formatting did not finish (" + type(error).__name__ +
              "). Inspect the file and rerun the local formatter; "
              "the preceding tool edit has NOT been rolled back.", file=sys.stderr)
        return 2


if __name__ == "__main__":
    sys.exit(main())

这是普通开发目录里的保守处理,不是事务系统。最后一次状态检查与替换之间仍有竞态,其他进程可以在这个间隙改文件;目录也可能被移动。请在单一写入者的测试副本使用,停止编辑器自动保存或其他格式器后再验证。它没有跨进程锁、崩溃持久性承诺,也不防御有权修改项目脚本的攻击者。

替换会创建新 inode。代码保留普通 Unix 权限位,但不承诺保留 ACL、扩展属性、所有者和创建时间;具有这些要求的文件不适合直接使用本例。临时文件清理同样可能被操作系统错误打断,异常退出后应检查 data 目录是否遗留 .format- 前缀文件。

6. 把模拟事件送进去,观察磁盘结果

下面的 demo.py 从当前样本读取内容,构造明确标为 offline 的事件,再通过标准输入启动格式器。绝对路径由脚本计算,所以把目录放到带空格的位置也可以运行。tool_response 只是构造一个接近协议形状的样本;格式器不依赖其中字段,也不会把它作为文件修改成功的证据。

demo.py

"""Feed a simulated PostToolUse event to the local script."""
import json
from pathlib import Path
import subprocess
import sys

root = Path(__file__).resolve().parent
file = root / "data/menu.json"
event = {
    "session_id": "offline-demo",
    "cwd": str(root),
    "hook_event_name": "PostToolUse",
    "tool_name": "Write",
    "tool_input": {"file_path": str(file), "content": file.read_text()},
    "tool_response": {"filePath": str(file), "type": "create"},
    "tool_use_id": "offline-tool-1"
}
result = subprocess.run([sys.executable, str(root / ".claude/hooks/format_json.py")],
                        input=json.dumps(event), text=True, capture_output=True)
print("Local hook exit:", result.returncode)
print(result.stderr, end="")
print(file.read_text(), end="")
sys.exit(result.returncode)
# 在前文已进入的 hook-formatting-demo 根目录执行
python3 demo.py

这条命令会真实改写 data/menu.json,适合在刚才的可丢弃目录运行。本文运行同一演示得到以下内容。第二次运行仍然返回 0,但不会再次替换已经排好的文件。

Local hook exit: 0
{
  "title": "周末菜单",
  "servings": 2,
  "items": [
    "米饭",
    "番茄"
  ],
  "enabled": true
}

注意,退出 0 同时可能表示已排版、原本整齐或明确跳过的文件。要判断某个文件是否真的处理过,请像测试那样比较字节或 inode,不能只看返回码。脚本成功时不往 stdout 写状态行,避免把诊断文字混入 hook 输出协议。

7. 审阅后再接入项目设置

把下面配置合并进测试项目的 .claude/settings.json,已有 settings 不要整份覆盖。command 通过 python3 解释执行脚本,所以不需要给 Python 文件额外设置执行位。路径加引号以支持项目名中的空格;10 秒是本例自行选择的超时值。

settings.example.json

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/format_json.py\"",
            "timeout": 10
          }
        ]
      }
    ]
  }
}

在 Claude Code 内打开 /hooks 核对条目,然后请它使用 Write 或 Edit 修改测试菜单,再查看实际文件和差异。若条目没出现,先检查 JSON 和文件位置;官方说明配置通常自动重载,必要时重启会话。组织托管策略也可能禁止项目 hooks。排查入口见 官方配置排障。这些接入动作尚未在本文环境运行。

命令中的 python3 取自运行环境 PATH。在正式项目里,请先确认它对应你审阅和测试过的解释器。hook 是会自动执行的项目代码,拿到陌生仓库时应先读脚本与设置,再决定是否启用。不要为了排版绕过组织策略或扩大文件系统权限。

8. 失败以后,前一次编辑仍然存在

本例错误输出使用 stderr 并返回 2。对 PostToolUse,官方说明该反馈会送给 Claude,但工具已经执行;它不能阻止或撤销前面的写入。其他返回码也不应被理解成恢复文件的指令。参见 逐事件退出码说明。

把脚本排版失败与前一次工具编辑分开处理
图 3 需要恢复时先看差异和已有备份,不自动丢弃工作区改动

试想工具把菜单改成缺少右括号的 JSON。格式器会拒绝处理,磁盘保留的是这个缺右括号的新版本。我们没有写回旧内容,因为格式器并不知道这次编辑之前的完整文件;盲目恢复还可能抹掉人的并行修改。测试中的 invalid_json_not_rolled_back 用字节比较确认了这一点。

下一步应先看当前差异,确定是编辑本身不完整,还是格式器不支持文件中的数据类型。前者可以由你授权修正;后者需要选择合适的格式器。若确实需要恢复,先检查已有版本控制或备份,明确恢复哪个文件和哪个版本。不要在错误处理里自动执行丢弃工作区改动的命令。

解释器不存在、脚本路径拼错或者进程被超时终止时,脚本自己的错误分支可能根本没有机会执行。所以本地单元测试、Claude 中条目出现、实际写入触发、最后磁盘文件正确,是四个需要分别观察的结果。

9. 完整回归测试:在临时目录里练习失败

以下测试每次创建独立项目,结束后由 TemporaryDirectory 清理。它会复制格式器,再测试真正的文件读写;不会使用你的真实 Claude 配置。文件名中的 shell 字符、链接、FIFO、范围外路径、超长内容和重复键都有针对性检查。

test_hook.py

import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

SOURCE = Path(__file__).parent / ".claude/hooks/format_json.py"


class HookTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory()
        self.addCleanup(self.temp.cleanup)
        self.base = Path(self.temp.name)
        self.root = self.base / "project with spaces"
        self.hook = self.root / ".claude/hooks/format_json.py"
        self.hook.parent.mkdir(parents=True)
        shutil.copyfile(SOURCE, self.hook)
        (self.root / "data").mkdir()
        self.file = self.root / "data/menu.json"
        self.file.write_text('{"菜名":"番茄","份数":2}', encoding="utf-8")

    def run_hook(self, path=None, **overrides):
        event = {"hook_event_name": "PostToolUse", "tool_name": "Write",
                 "tool_input": {"file_path": str(path or self.file)}}
        event.update(overrides)
        return self.raw(json.dumps(event))

    def raw(self, text):
        return subprocess.run([sys.executable, str(self.hook)], input=text,
                              text=True, capture_output=True)

    def fails_unchanged(self, text):
        self.file.write_text(text)
        result = self.run_hook()
        self.assertEqual(result.returncode, 2)
        self.assertEqual(result.stdout, "")
        self.assertIn("NOT been rolled back", result.stderr)
        self.assertEqual(self.file.read_text(), text)
        self.assertEqual(list(self.file.parent.glob(".format-*")), [])

    def test_write_formats_real_bytes(self):
        old = json.loads(self.file.read_text())
        result = self.run_hook()
        self.assertEqual(result.returncode, 0)
        self.assertEqual(result.stdout, "")
        self.assertEqual(json.loads(self.file.read_text()), old)
        self.assertIn('\n  "菜名": "番茄"', self.file.read_text())

    def test_edit(self):
        self.assertEqual(self.run_hook(tool_name="Edit").returncode, 0)

    def test_idempotent(self):
        self.run_hook()
        inode = self.file.stat().st_ino
        data = self.file.read_bytes()
        self.run_hook()
        self.assertEqual(self.file.read_bytes(), data)
        self.assertEqual(self.file.stat().st_ino, inode)

    def test_spaces_and_shell_symbols(self):
        target = self.root / "data/a ; $(no-command).json"
        self.file.rename(target)
        self.assertEqual(self.run_hook(target).returncode, 0)
        self.assertIn('\n', target.read_text())

    def test_nested_file(self):
        folder = self.root / "data/nested"
        folder.mkdir()
        target = folder / "menu.json"
        self.file.rename(target)
        self.assertEqual(self.run_hook(target).returncode, 0)

    def test_outside_root(self):
        outside = self.base / "outside.json"
        outside.write_text('{"outside":true}')
        self.assertEqual(self.run_hook(outside).returncode, 2)
        self.assertEqual(outside.read_text(), '{"outside":true}')

    def test_parent_traversal(self):
        self.assertEqual(self.run_hook(self.root / "data/../data/menu.json").returncode, 2)

    def test_relative_path(self):
        self.assertEqual(self.run_hook(Path("data/menu.json")).returncode, 2)

    def test_other_directory_skipped(self):
        target = self.root / "settings.json"
        target.write_text('{"keep":true}')
        self.assertEqual(self.run_hook(target).returncode, 0)
        self.assertEqual(target.read_text(), '{"keep":true}')

    def test_other_suffix_skipped(self):
        target = self.root / "data/notes.txt"
        target.write_text('untouched')
        self.assertEqual(self.run_hook(target).returncode, 0)
        self.assertEqual(target.read_text(), 'untouched')

    def test_file_symlink(self):
        link = self.root / "data/link.json"
        link.symlink_to(self.file)
        before = self.file.read_bytes()
        self.assertEqual(self.run_hook(link).returncode, 2)
        self.assertEqual(self.file.read_bytes(), before)

    def test_directory_symlink(self):
        outside = self.base / "external"
        outside.mkdir()
        (outside / "menu.json").write_text('{}')
        (self.root / "data/link").symlink_to(outside, target_is_directory=True)
        self.assertEqual(self.run_hook(self.root / "data/link/menu.json").returncode, 2)
        self.assertEqual((outside / "menu.json").read_text(), '{}')

    def test_hard_link(self):
        os.link(self.file, self.base / "alias.json")
        self.assertEqual(self.run_hook().returncode, 2)

    def test_missing_file(self):
        self.file.unlink()
        self.assertEqual(self.run_hook().returncode, 2)

    def test_fifo_rejected_without_hang(self):
        self.file.unlink()
        os.mkfifo(self.file)
        self.assertEqual(self.run_hook().returncode, 2)

    def test_invalid_json_not_rolled_back(self):
        self.fails_unchanged('{"new_edit":')

    def test_duplicate_key(self):
        self.fails_unchanged('{"a":1,"a":2}')

    def test_float(self):
        self.fails_unchanged('{"a":0.1}')

    def test_nonfinite(self):
        self.fails_unchanged('{"a":NaN}')

    def test_large_file(self):
        self.fails_unchanged(' ' * (1024 * 1024 + 1))

    def test_bad_event(self):
        for raw in ['{', '[]', '{"tool_name":"Write","tool_name":"Edit"}']:
            self.assertEqual(self.raw(raw).returncode, 2)

    def test_missing_input(self):
        self.assertEqual(self.run_hook(tool_input={}).returncode, 2)

    def test_wrong_event_and_tool_skip(self):
        before = self.file.read_bytes()
        for values in [{"hook_event_name": "PreToolUse"}, {"tool_name": "Bash"}]:
            self.assertEqual(self.run_hook(**values).returncode, 0)
        self.assertEqual(self.file.read_bytes(), before)

    def test_permission_bits(self):
        self.file.chmod(0o640)
        self.run_hook()
        self.assertEqual(self.file.stat().st_mode & 0o777, 0o640)


if __name__ == "__main__":
    unittest.main(verbosity=2)
# 在前文已进入的 hook-formatting-demo 根目录执行
python3 -B -m unittest -v test_hook

本次在 Linux、Python 3.12.14 下运行 24 个 unittest 方法,全部通过;其中部分方法包含多组输入。测试数量表示本文的本地覆盖,不能据此推断 Claude 的加载、模型行为或所有操作系统已经验证。这里没有模拟真实并发写入、断电、磁盘满和 ACL,也未证明不存在所有竞态。

10. 把它改成团队工作流之前

先挑一份低风险配置,在项目副本中确认前后语义与 diff,再逐渐扩大允许名单。新增语言时,把解析和格式化交给已在项目中固定版本的工具,避免每次 hook 临时下载依赖;传文件名应使用参数数组,并保留路径检查。不要让格式器顺便做提交、推送或删除,因为这些操作有各自的审阅与授权边界。

如果团队经常多人同时编辑、依赖文件元数据,或想覆盖 Bash 生成文件,需要重新设计调用和写回方式。当前脚本刻意只服务 data/ 下的小型整数字段 JSON。保留这个限制,比把所有文件一口气交给自动后处理更容易排查。

项目中的 skill 可以描述何时检查,子代理可以读差异并报告问题;本篇 hook 承担的是确定性文件后处理。把它们分开,你才能知道一份文件为什么变了、哪一步失败,以及下一步是否还需要人决定。

11. 补充:hook 已配置,为什么脚本仍没启动?

如果 demo.py 和原来的 24 项回归测试都通过,hook 却仍提示找不到脚本,先检查进程有没有真正启动。前面的 Python 调用直接传入参数列表,绕过了 settings 中 command 的 shell 解析;它能证明格式器可用,却不能单独证明配置里的引号、环境变量和启动目录正确。

这类问题有具体报告:#6336(2026-09-30)描述 learn 1.0.0 在 Claude Code 2.1.286、macOS 上因单引号保留变量字面量而启动失败;#4973(2026-08-06)也报告了类似现象。它们是当事人的环境记录,不是本文已复现的插件故障。另一个含空格路径的 #1992(2026-05-23)已在 5 月 29 日由维护者确认修复并关闭,只能作为历史案例。

下面新增的是本地启动实验:从 JSON 文件读出命令,不经外层 shell 拼接,再让 /bin/sh -c 收到解码后未改写的字符串。每一组先运行只读探针,再用同样的路径、引号和 cwd 条件独立运行原格式器的临时副本。另有 8 组直接取出原文 settings.example.json 里的 command,保持它的 python3 和 $CLAUDE_PROJECT_DIR 写法不变,覆盖四种路径和两个启动目录。

先用只读探针观察 argv、stdin 和 cwd,再以原格式器的固定文件输出验证结果;不代表 Claude 内部实现
图 4 原创启动层实验图。探针与格式器分开执行,不给原格式器添加诊断代码。

按 2026-10-13 核对的 官方 hooks 参考,存在 args 字段时使用 exec form:command 只写可执行文件,参数逐项放进 args,不再经过 shell。不要把整个“python3 脚本路径”写进 command,也不要把 command 改成数组。插件路径说明同样优先推荐 exec form;使用 shell form 时给路径变量加双引号。

以下是供审阅的 settings.exec.example.json,不是自动启用的配置。现有项目只合并已确认的条目。当前文档不证明旧版 CLI 支持同一写法;接入前仍需核对实际安装版本。

{
  "hooks": {
    "PostToolUse": [
      {
        "matcher": "Edit|Write",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/format_json.py"],
            "timeout": 10
          }
        ]
      }
    ]
  }
}

12. 启动层实验:从 JSON 原文到只读探针

把本节三个完整文件放到原文实验目录的根目录,与 test_hook.py 并列。启动实验不向格式器插入探针。本页格式器已包含 2026-10-20 的输出大小修正,实验的固定哈希同步更新;原 24 项测试、原菜单和原 settings 示例仍不改。也可以使用后文完整源码 ZIP,解压后进入其中的 hook-launch-demo 文件夹。需要已有 Python 3、/bin/sh、可写的 /tmp,以及原格式器要求的 POSIX 文件接口;不需要安装依赖。

hook-launch-demo/
├── .claude/hooks/format_json.py   # 本页当前格式器,含输出大小修正
├── data/menu.json
├── demo.py
├── test_hook.py                  # 原 24 项测试,字节不变
├── settings.example.json
├── settings.exec.example.json   # 供审阅,不启用 hook
├── launch_probe.py              # 新增:只读观察
├── launch_lab.py                # 新增:88 组本地用例
└── test_launch.py                # 新增:9 项验证

四个固定目录名为 plain-project、project with spaces、project's-apostrophe、project-$DOLLAR。每组开始前,临时菜单都重置成前文相同的紧凑 JSON;标准输入采用同一事件结构,含中文、转义换行和末尾换行,只把绝对项目路径替换为本组目录。项目根与项目外 cwd 是实际的进程启动目录;事件里的 cwd 则固定指向项目根,不把两者混为一谈。

python3 -B -m unittest -v test_hook test_launch
python3 -B launch_lab.py > launch-report.local.json

第一条命令运行原 24 项测试和新增 9 项测试;第二条生成 88 组记录并创建或覆盖当前目录的 launch-report.local.json。实际文件操作都发生在自动清理的临时项目中,不改动随包提供的菜单。临时目录的随机部分和本机解释器路径每次可能不同;用例数量、结果分类与菜单的固定字节应相同。

这是专门执行作者固定样本的实验,不是 Claude 配置加载器。代码只从自己的单处理器 JSON 样本中读取 command;不实现 matcher、托管策略或真实事件分发。直接 argv 分支里的 replace 是实验明确写出的单次路径替换,不是 Python 自动展开,更不是对 Claude 占位符实现的验证。不要把陌生 settings 交给它执行。

完整文件:launch_probe.py
"""Read-only launch witness: echo synthetic argv, cwd and stdin; write no files."""
import base64
import hashlib
import json
import os
import sys

raw = sys.stdin.buffer.read()
print(json.dumps({
    "probe": "hook-launch-v1",
    "argv": sys.argv,
    "cwd": os.getcwd(),
    "stdin_b64": base64.b64encode(raw).decode("ascii"),
    "stdin_sha256": hashlib.sha256(raw).hexdigest(),
}, ensure_ascii=False))
完整文件:launch_lab.py
"""Controlled local launch experiment, NOT a Claude configuration emulator.

Only authored cases below are executed. Never feed this runner unknown settings.
Shell commands are read from JSON verbatim. Direct argv gets ONE explicit local
placeholder replacement; this does not test Claude's placeholder implementation.
All child writes target disposable /tmp copies. stdout is the report destination.
"""
import base64
import hashlib
import json
import os
from pathlib import Path
import platform
import shlex
import shutil
import subprocess
import sys
import tempfile

HERE = Path(__file__).resolve().parent
PYTHON = str(Path(sys.executable).resolve())
ROOT_NAMES = ("plain-project", "project with spaces", "project's-apostrophe",
              "project-$DOLLAR")
MODES = ("single", "unquoted", "double", "argv")
ORIGINAL = '{"title":"周末菜单","servings":2,"items":["米饭","番茄"],"enabled":true}\n'.encode()
EXPECTED = ('{\n  "title": "周末菜单",\n  "servings": 2,\n  "items": [\n'
            '    "米饭",\n    "番茄"\n  ],\n  "enabled": true\n}\n').encode()
FORMATTER_SHA256 = "527d93acf4f69e436af941c70cdea0d769dc011060653a673221c307bd21126c"


def digest(data):
    return hashlib.sha256(data).hexdigest()


def b64(data):
    return base64.b64encode(data).decode("ascii")


def require(ok, message):
    # Unlike assert, these checks stay enabled under python -O.
    if not ok:
        raise AssertionError(message)


def config(mode, target):
    name = "launch_probe.py" if target == "probe" else "format_json.py"
    parts = ["${CLAUDE_PROJECT_DIR}/.claude/hooks/" + name]
    if target == "probe":
        parts.append("${CLAUDE_PROJECT_DIR}/data/menu.json")
    if mode == "argv":
        return {"type": "command", "command": PYTHON, "args": parts}
    quote = {"single": "'", "unquoted": "", "double": '"'}[mode]
    return {"type": "command", "command": shlex.quote(PYTHON) + " " +
            " ".join(quote + part + quote for part in parts)}


def dispatch(config_path, raw, cwd, env):
    """Load our one-handler fixture; do not evaluate JSON through an outer shell."""
    result = {"config_text": config_path.read_text(encoding="utf-8"),
              "command_from_json": None, "spawn_argv": None,
              "returncode": None, "error": None, "errno": None,
              "stdout": "", "stderr": ""}
    try:
        handler = json.loads(result["config_text"])
        command = handler["command"]
        result["command_from_json"] = command
        if "args" in handler:
            # Authored local fixture resolver, NOT Claude's own expansion.
            def resolve(value):
                if "${CLAUDE_PROJECT_DIR}" not in value:
                    return value
                return value.replace("${CLAUDE_PROJECT_DIR}", env["CLAUDE_PROJECT_DIR"])
            argv = [resolve(command)] + [resolve(arg) for arg in handler["args"]]
        else:
            argv = ["/bin/sh", "-c", command]  # command unchanged after JSON decode
        result["spawn_argv"] = argv
        proc = subprocess.run(argv, input=raw, cwd=cwd, env=env,
                              capture_output=True, timeout=10)
        result.update(returncode=proc.returncode,
                      stdout=proc.stdout.decode("utf-8"),
                      stderr=proc.stderr.decode("utf-8"))
    except (json.JSONDecodeError, KeyError, OSError) as error:
        result.update(error=type(error).__name__, errno=getattr(error, "errno", None))
    return result


def run_case(base, root, target, label, mode, cwd_label, expected_code,
             expected_error=None, override=None, missing_env=False):
    file = root / "data/menu.json"
    file.write_bytes(ORIGINAL)  # reset the temporary fixture before EVERY launch
    cwd = root if cwd_label == "root" else base / "outside"
    event = {"session_id": "offline-launch-v1", "hook_event_name": "PostToolUse",
             "tool_name": "Write", "cwd": str(root),
             "tool_input": {"file_path": str(file)}, "note": "原始事件\n第二行"}
    raw = (json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n").encode()
    env = {"PATH": str(Path(PYTHON).parent), "LC_ALL": "C", "LANG": "C",
           "PYTHONIOENCODING": "utf-8", "PYTHONDONTWRITEBYTECODE": "1"}
    if not missing_env:
        env["CLAUDE_PROJECT_DIR"] = str(root)
    handler = config(mode, target) if override is None else override
    text = handler if isinstance(handler, str) else json.dumps(handler, ensure_ascii=False)
    fixture = base / "case.json"
    fixture.write_text(text, encoding="utf-8")
    before = file.read_bytes()
    observed = dispatch(fixture, raw, cwd, env)
    after = file.read_bytes()
    success = expected_code == 0 and expected_error is None
    require(observed["returncode"] == expected_code, label + ": exit " + str(observed))
    require(observed["error"] == expected_error, label + ": error " + str(observed))
    reached, proof, echo, argv_exact, stdin_exact = False, "no target output", None, None, None
    if target == "probe" and success:
        echo = json.loads(observed["stdout"])
        expected_argv = [str(root / ".claude/hooks/launch_probe.py"), str(file)]
        # For relative control, argv[0] is intentionally relative.
        if label == "relative-script":
            expected_argv[0] = ".claude/hooks/launch_probe.py"
        argv_exact = echo["argv"] == expected_argv
        stdin_exact = echo["stdin_b64"] == b64(raw) and echo["stdin_sha256"] == digest(raw)
        require(echo["probe"] == "hook-launch-v1" and argv_exact and stdin_exact,
                label + ": probe payload mismatch")
        require(echo["cwd"] == str(cwd), label + ": cwd mismatch")
        reached, proof = True, "probe JSON echo"
    elif target == "formatter" and success:
        require(observed["stdout"] == "", label + ": formatter unexpectedly printed stdout")
        require(after == EXPECTED, label + ": wrong formatted bytes")
        reached, proof = True, "exact expected bytes replaced the compact fixture"
    else:
        require(observed["stdout"] == "", label + ": unexpected target output")
    require(after == (EXPECTED if target == "formatter" and success else ORIGINAL),
            label + ": unexpected file mutation")
    if success:
        require(observed["stderr"] == "", label + ": unexpected stderr")
    observed.update(label=label, mode=mode, root_name=root.name, cwd_label=cwd_label,
                    process_cwd=str(cwd), event_cwd=str(root), target=target,
                    reached=reached, arrival_evidence=proof, probe=echo,
                    observed_argv_exact=argv_exact, observed_stdin_exact=stdin_exact,
                    supplied_stdin_b64=b64(raw), supplied_stdin_sha256=digest(raw),
                    before_sha256=digest(before), after_sha256=digest(after),
                    file_result="formatted" if after == EXPECTED else "unchanged")
    return observed


def run_lab():
    require(os.name == "posix" and Path("/bin/sh").is_file(), "requires POSIX /bin/sh")
    formatter = HERE / ".claude/hooks/format_json.py"
    require(digest(formatter.read_bytes()) == FORMATTER_SHA256, "original formatter changed")
    records = []
    # A predictable, space-free parent isolates the four authored path names.
    with tempfile.TemporaryDirectory(prefix="hook-launch-", dir="/tmp") as temporary:
        base = Path(temporary)
        (base / "outside").mkdir()
        for name in ROOT_NAMES:
            root = base / name
            hooks = root / ".claude/hooks"
            hooks.mkdir(parents=True)
            (root / "data").mkdir()
            shutil.copyfile(formatter, hooks / "format_json.py")
            shutil.copyfile(HERE / "launch_probe.py", hooks / "launch_probe.py")
            for cwd_label in ("root", "outside"):
                for mode in MODES:
                    expected = 2 if mode == "single" or (mode == "unquoted" and " " in name) else 0
                    for target in ("probe", "formatter"):
                        records.append(run_case(base, root, target, "matrix", mode,
                                                cwd_label, expected))
        # Exercise the original article's JSON command, including its bare $VAR
        # spelling and python3 PATH lookup, without rewriting that command.
        settings = json.loads((HERE / "settings.example.json").read_text())
        original_handler = settings["hooks"]["PostToolUse"][0]["hooks"][0]
        for name in ROOT_NAMES:
            for cwd_label in ("root", "outside"):
                records.append(run_case(base, base / name, "formatter",
                                        "original-settings-command", "double",
                                        cwd_label, 0, override=original_handler))
        root = base / ROOT_NAMES[0]
        for target in ("probe", "formatter"):
            good = config("double", target)
            guard = dict(good, command=good["command"].replace(
                "${CLAUDE_PROJECT_DIR}", "${CLAUDE_PROJECT_DIR:?missing project root}"))
            missing_script = dict(good, command=good["command"].replace(
                "/.claude/hooks/", "/.claude/absent/"))
            missing_interpreter = dict(good, command=good["command"].replace(
                shlex.quote(PYTHON), shlex.quote(str(base / "absent-python")), 1))
            combined = {"command": PYTHON + " " + str(root / ".claude/hooks/absent.py"), "args": []}
            negatives = [
                ("missing-env-guard", "double", guard, True, 2, None),
                ("missing-env-local-resolver", "argv", config("argv", target), True, None, "KeyError"),
                ("missing-script", "double", missing_script, False, 2, None),
                ("missing-interpreter", "double", missing_interpreter, False, 127, None),
                ("malformed-json", "double", '{"command":', False, None, "JSONDecodeError"),
                ("combined-exec-command", "argv", combined, False, None, "FileNotFoundError"),
            ]
            for label, mode, handler, unset, code, error in negatives:
                records.append(run_case(base, root, target, label, mode, "outside", code,
                                        error, handler, unset))
            relative = dict(good, command=good["command"].replace(
                '"${CLAUDE_PROJECT_DIR}/.claude/hooks/', '".claude/hooks/'))
            for cwd_label in ("root", "outside"):
                records.append(run_case(base, root, target, "relative-script", "double",
                                        cwd_label, 0 if cwd_label == "root" else 2,
                                        override=relative))
    require(len(records) == 88, "unexpected case count")
    return {"scope": "local POSIX shell and direct argv; no Claude runtime",
            "python": platform.python_version(), "system": platform.system(),
            "shell_path": "/bin/sh", "shell_resolved": str(Path("/bin/sh").resolve()),
            "formatter_sha256": FORMATTER_SHA256,
            "original_sha256": digest(ORIGINAL), "expected_sha256": digest(EXPECTED),
            "original_utf8": ORIGINAL.decode(), "expected_utf8": EXPECTED.decode(),
            "case_count": len(records), "records": records}


if __name__ == "__main__":
    print(json.dumps(run_lab(), ensure_ascii=False, indent=2))
完整文件:test_launch.py
"""Fixed expected outcomes for our authored POSIX fixture matrix."""
import base64
import json
from pathlib import Path
import unittest
import launch_lab as lab


class LaunchTests(unittest.TestCase):
    @classmethod
    def setUpClass(cls):
        cls.report = lab.run_lab()
        cls.rows = cls.report["records"]

    def test_fixed_input_output(self):
        self.assertEqual(lab.digest(lab.ORIGINAL),
                         "b18343520a32a08164bdef71d62b9292b05b279f91c32c313e3a878cde25f674")
        self.assertNotEqual(lab.ORIGINAL, lab.EXPECTED)
        self.assertEqual(json.loads(lab.ORIGINAL), json.loads(lab.EXPECTED))
        self.assertEqual(lab.EXPECTED, (json.dumps(json.loads(lab.ORIGINAL),
                                                 ensure_ascii=False, indent=2) + "\n").encode())

    def test_matrix_covers_roots_modes_cwds_and_targets(self):
        rows = [r for r in self.rows if r["label"] == "matrix"]
        self.assertEqual(len(rows), 64)
        self.assertEqual(len({(r["root_name"], r["mode"], r["cwd_label"], r["target"])
                              for r in rows}), 64)
        for row in rows:
            expected = 2 if row["mode"] == "single" or (
                row["mode"] == "unquoted" and " " in row["root_name"]) else 0
            self.assertEqual(row["returncode"], expected)
            self.assertEqual(row["reached"], expected == 0)

    def test_shell_gets_json_command_unchanged(self):
        for row in self.rows:
            if row["spawn_argv"] and row["spawn_argv"][:2] == ["/bin/sh", "-c"]:
                self.assertEqual(row["spawn_argv"][2], json.loads(row["config_text"])["command"])
                self.assertEqual(row["spawn_argv"][2], row["command_from_json"])

    def test_successful_probes_echo_exact_stdin_argv_and_cwd(self):
        rows = [r for r in self.rows if r["target"] == "probe" and r["reached"]]
        self.assertEqual(len(rows), 23)
        for row in rows:
            self.assertTrue(row["observed_argv_exact"])
            self.assertTrue(row["observed_stdin_exact"])
            raw = base64.b64decode(row["probe"]["stdin_b64"], validate=True)
            self.assertTrue(raw.endswith(b"\n"))
            self.assertIn("原始事件".encode(), raw)
            self.assertEqual(row["probe"]["cwd"], row["process_cwd"])
            self.assertEqual(row["before_sha256"], row["after_sha256"])

    def test_formatter_success_is_exact_output_not_zero_alone(self):
        rows = [r for r in self.rows if r["target"] == "formatter" and r["reached"]]
        self.assertEqual(len(rows), 31)
        for row in rows:
            self.assertEqual(row["file_result"], "formatted")
            self.assertEqual(row["after_sha256"], lab.digest(lab.EXPECTED))
            self.assertNotEqual(row["before_sha256"], row["after_sha256"])
            self.assertIsNone(row["observed_stdin_exact"])  # deliberately not instrumented
            self.assertIsNone(row["observed_argv_exact"])

    def test_failures_preserve_bytes_and_have_no_arrival(self):
        rows = [r for r in self.rows if not r["reached"]]
        self.assertEqual(len(rows), 34)
        for row in rows:
            self.assertEqual(row["before_sha256"], row["after_sha256"])
            self.assertEqual(row["file_result"], "unchanged")
            self.assertEqual(row["stdout"], "")
            self.assertIsNone(row["probe"])
        expected = {"missing-env-guard": (2, None),
                    "missing-env-local-resolver": (None, "KeyError"),
                    "missing-script": (2, None), "missing-interpreter": (127, None),
                    "malformed-json": (None, "JSONDecodeError"),
                    "combined-exec-command": (None, "FileNotFoundError")}
        for label, outcome in expected.items():
            pair = [r for r in rows if r["label"] == label]
            self.assertEqual(len(pair), 2)
            self.assertTrue(all((r["returncode"], r["error"]) == outcome for r in pair))

    def test_relative_script_depends_on_process_cwd(self):
        for row in [r for r in self.rows if r["label"] == "relative-script"]:
            self.assertEqual(row["reached"], row["cwd_label"] == "root")
            if row["cwd_label"] == "outside":
                self.assertNotEqual(row["event_cwd"], row["process_cwd"])

    def test_original_settings_command_is_not_rewritten(self):
        original = json.loads((Path(__file__).parent / "settings.example.json").read_text())
        command = original["hooks"]["PostToolUse"][0]["hooks"][0]["command"]
        rows = [r for r in self.rows if r["label"] == "original-settings-command"]
        self.assertEqual(len(rows), 8)
        for row in rows:
            self.assertEqual(row["command_from_json"], command)
            self.assertEqual(row["spawn_argv"], ["/bin/sh", "-c", command])
            self.assertTrue(row["reached"])
            self.assertEqual(row["after_sha256"], lab.digest(lab.EXPECTED))

    def test_original_formatter_is_byte_identical(self):
        data = (Path(__file__).parent / ".claude/hooks/format_json.py").read_bytes()
        self.assertEqual(lab.digest(data), lab.FORMATTER_SHA256)


if __name__ == "__main__":
    unittest.main()

13. 88 组固定结果:返回码、到达证据与文件字节

本节保留 2026-10-13 的原始结果和当时的格式器哈希。2026-10-20 已对修正后的格式器重跑同一 88 组样本,结果分类与菜单字节不变;新增输出限额测试见文末。两轮源码哈希不同,不把旧记录当作新代码的运行证据。

本轮在 Linux、Python 3.12.14、/bin/sh → /usr/bin/dash 下运行:33 个 unittest 方法通过,88 组用例符合固定期望。其中 64 组覆盖 4 种目录 × 2 种 cwd × 4 种写法 × 2 个目标;8 组保留原 settings command;16 组检查缺失依赖、无效配置和相对路径。用例包括启动前就失败的情况,因此“88 组”不等于 88 个目标进程都启动过。

目录名单引号变量不加引号双引号变量已解析路径的直接 argv
plain-project2,未到达0,通过0,通过0,通过
project with spaces2,未到达2,未到达0,通过0,通过
project's-apostrophe2,未到达0,通过0,通过0,通过
project-$DOLLAR2,未到达0,通过0,通过0,通过

表中每格在项目根和项目外 cwd、探针和格式器两类目标上分别运行。通过的探针必须回传完整参数、原始 stdin 字节和真实 cwd,且菜单不变;通过的格式器必须产出下方固定字节。原 settings command 的 8 组也全部产生同样输出。

这里有两个容易误判的细节。第一,单引号失败返回 2,是因为本例先启动了 Python,Python 再找不到字面量路径;不是格式器已经运行并拒绝输入。第二,这些目录值里的单引号和美元符号没有被再次解析为 shell 语法,所以对应的未加引号样本恰好通过;这不证明未加引号普遍可靠,含空格样本已经失败,也未覆盖通配符等其他输入。

固定菜单输入,UTF-8,末尾有一个换行:

{"title":"周末菜单","servings":2,"items":["米饭","番茄"],"enabled":true}

固定菜单输出,UTF-8,末尾有一个换行:

{
  "title": "周末菜单",
  "servings": 2,
  "items": [
    "米饭",
    "番茄"
  ],
  "enabled": true
}

用字节比较确认内容,并记录以下 SHA-256;不能只看退出码或“看起来差不多”的排版:

before  b18343520a32a08164bdef71d62b9292b05b279f91c32c313e3a878cde25f674
after   dcae3c0828a20e9b1d6a34028432bf19f49fb43d176629b0d8a4d58c3124182f
2026-10-13 格式器  d752bdfd557ee5922365fdcb8e6ceec511013afd8e763858abff07cb5626c9ae
控制用例本地结果目标和菜单
环境变量缺失,shell 加 :? 保护2,shell 提前停止未到达,字节不变
环境变量缺失,实验的 argv 替换器KeyError,returncode 为 null尚未启动,字节不变
Python 能启动,脚本不存在2,Python 找不到脚本未到达,字节不变
shell 要运行的解释器不存在127,shell 找不到命令未到达,字节不变
JSON 配置截断JSONDecodeError,returncode 为 null未派发,字节不变
exec command 错写成解释器加脚本FileNotFoundError,returncode 为 null进程未建立,字节不变
相对脚本路径,从项目根运行0,完整核对通过探针不改菜单;格式器得到固定输出
相对脚本路径,从项目外运行2,Python 找不到脚本未到达,字节不变

缺少环境变量的 shell 用例使用 ${CLAUDE_PROJECT_DIR:?missing project root},在展开阶段就退出,避免误把空前缀解释成系统根下的路径。这个保护是本地负例的设计,不是 Claude 缺失变量行为的断言。表中 null 表示没有拿到子进程退出状态,不能改记成 127。

完整报告记录每组 JSON 原文、原样 command、传给 subprocess 的参数、stdout/stderr、退出码或异常、探针回传、输入字节和文件前后哈希。23 组探针成功且始终只读;31 组格式器成功得到精确输出;34 组预期失败均未出现探针回传,且文件字节不变。

格式器没有添加探针代码:其 observed_argv_exact 与 observed_stdin_exact 保留 null,不能冒充内部观测。报告另记 supplied_stdin_b64;成功到达格式器的证据是紧凑菜单被替换成固定输出。只有探针的回传才直接证明所收到的 argv 和 stdin。二进制 stdin 的传递方式可对照 Python subprocess 参考。

实际结果摘录:空格失败与美元符号成功
[
  {
    "root_name": "project with spaces",
    "mode": "unquoted",
    "target": "probe",
    "returncode": 2,
    "reached": false,
    "observed_argv_exact": null,
    "observed_stdin_exact": null,
    "file_result": "unchanged"
  },
  {
    "root_name": "project with spaces",
    "mode": "unquoted",
    "target": "formatter",
    "returncode": 2,
    "reached": false,
    "observed_argv_exact": null,
    "observed_stdin_exact": null,
    "file_result": "unchanged"
  },
  {
    "root_name": "project-$DOLLAR",
    "mode": "double",
    "target": "probe",
    "returncode": 0,
    "reached": true,
    "observed_argv_exact": true,
    "observed_stdin_exact": true,
    "file_result": "unchanged"
  },
  {
    "root_name": "project-$DOLLAR",
    "mode": "double",
    "target": "formatter",
    "returncode": 0,
    "reached": true,
    "observed_argv_exact": null,
    "observed_stdin_exact": null,
    "file_result": "formatted"
  }
]

14. 接入前复核,并带走完整离线源码

遇到找不到脚本时,按这个顺序缩小范围:

  1. 先核对正在使用的 CLI 版本和实际生效的配置位置;JSON 合法、条目出现与事件真正触发是不同的检查。
  2. 保留从配置读取的原始 command。不要把它粘进另一层带双引号的 shell 命令做“复现”,否则外层可能先展开变量。
  3. 检查解释器是否存在、脚本是否存在,以及运行时的环境变量和进程 cwd。用本节的临时探针确认完整路径有没有作为一个参数到达。
  4. 只有启动层通过后,再核对事件 stdin、格式器固定输出和真实文件差异。没有文件变化不能直接认定为失败;原文已经区分跳过、未变化和已排版。
  5. 最后回到自己授权的可丢弃 Claude 项目,分别验收配置加载、实际 Write/Edit 触发和最终文件。不要因为离线测试通过就宣布真实接入或插件已经修复。

本补充没有启动 Claude Code、模型或插件,没有改动用户配置。它也没有验证 Claude 的占位符替换、当前插件版本、Windows、PowerShell 或 .cmd 行为。Linux 的结果不能替代这些验收。原格式器关于信任根、单一写入者、竞态和文件元数据的限制仍然适用。

完整源码包包含本页当前格式器、原菜单/演示/24 项测试/设置样本、启动实验、输出大小测试、exec-form 审阅示例、README、两轮分目录保存的证据和 SVG。下载后可以离线运行;下面展开的源码与 ZIP 中相应文件逐字一致。

下载完整离线源码 ZIP(58532 字节)。若阅读器阻止 data 链接,可使用本页全部内联源码;原文件在前文,新文件在补充实验节。

ZIP SHA-256:d046b7329401530cd894ce9838da5315d3deb69c0d41ca8999b7c8ea20c97499

15. 2026-10-14 补充:hook 失败时,onFailure 能做什么

原文关于 PostToolUse 不会撤销编辑的结论仍然适用。这里补充的是失败策略:v2.1.295 发布说明新增了 onFailure: "block"。按 2026-10-14 核对的官方参考,该字段用于 command 和 HTTP hook,要求 Claude Code v2.1.295 或以上,默认仍为 "continue";升级不会自动替现有配置打开它。

失败包括无法启动、command 返回 0 和 2 以外的退出码、超时、HTTP 连接失败或非 2xx 状态,以及 JSON 解析或结构校验失败。设置 onFailure: "block" 后,command 即使输出允许操作的 JSON,异常退出码仍算失败;普通文本 stdout 本身不算。HTTP 的 2xx 响应若既非空也非 JSON 对象,也算失败。不要把所有“不是 JSON”的 command 输出都当成解析失败。

block 的效果按事件对应的退出 2 行为处理,PermissionRequest 则拒绝请求。逐事件说明区分得很清楚:PreToolUse 能阻止尚未执行的工具;PostToolUse 只能反馈,已经完成的 Write/Edit 不会因此回滚。它对 Stop、SubagentStop、TaskCompleted、TeammateIdle,以及设置了 async 或 asyncRewake 的后台 command hook 无效。此字段不是文件事务或安全沙箱。原格式器主动返回 2,前文 Python 找不到脚本的离线结果也为 2;本补充不把它们改记为其他退出码,更不把本地进程结果当作 Claude 已识别的证据。

可选人工验收,全部 UNTESTED:下面是独立的 POSIX 演示配置,故意让 Bash 的前置 hook 退出 1,用来观察两种失败策略。它不是格式器配置,也不是生产环境的命令审查器;不要把前面的 format_json.py 改挂到 PreToolUse。先确认已有可用的 python3、CLI 版本满足要求,再由你决定是否在可丢弃项目中合并这一个条目。该演示会拦住每次匹配的 Bash 调用,验收后移除这一个临时条目;不要覆盖已有设置或绕过组织策略。

settings.failure-review.example.json(保留此文件名不会启用 hook)

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": [
              "-c",
              "raise SystemExit(1)"
            ],
            "timeout": 2,
            "onFailure": "block"
          }
        ]
      }
    ]
  }
}

确认配置生效、没有其他匹配 hook 干扰后,请 Claude 仅用 Bash 执行一次 printf 'hook-check\n',不要自动修配置或换工具重试。逐行替换下面的 Python 代码,每行分别比较保留 onFailure 和删掉该字段两次运行;其余配置不变。表中是依据文档推导的预期,不是本文实测结果。“进入正常权限流程”不代表一定获准执行。

只替换 args 中的 Python 代码省略 onFailureonFailure: block
raise SystemExit(0)无控制输出,进入正常权限流程同左
raise SystemExit(1)hook 报错,进入正常权限流程阻止本次 Bash 调用
raise SystemExit(2)按退出 2 阻止本次 Bash 调用同左
import time; time.sleep(5)达到 2 秒超时后,进入正常权限流程超时后阻止本次 Bash 调用
print('{broken}')JSON 解析失败,进入正常权限流程阻止本次 Bash 调用
print('hook probe')普通文本输出,进入正常权限流程同左;普通文本本身不是失败

本次只核对文档、配置 JSON 和示例 Python 语法,没有运行这些片段、Claude Code、模型或 hook,也没有安装依赖或修改用户配置。前文 24/33 项测试与 88 组启动实验仍是原来的离线证据,不验证这个新字段。人工验收应记录实际版本、系统、有效配置、hook 日志和工具是否真正执行;若格式器超时,另查磁盘差异,不能假定它从未写入。

还有需要保留的环境边界:#100891(2026-10-09)报告 Windows PowerShell 5.1 缺失脚本的特殊退出状态被当作成功,使该字段未能拦截;报告者称在 2.1.295 和 2.1.296 上观察到,2026-10-14 查阅时议题仍开放。本文未复现,也不据此判断其他版本或平台。配置写上 block,不等于获得跨平台拦截保证。

16. 2026-10-20 修正:排版后的 UTF-8 输出也要有大小上限

这次修正的是格式器自身的边界,不是 Claude 的失败策略。旧实现只限制读入:一个含 250000 个整数 0 的紧凑数组只有 500002 字节,首次排版却写成 1250003 字节并退出 0;第二次再读同一文件,触发 1 MiB 读入限制并退出 2。这个结果已经在隔离临时项目复现,不能称为可重复成功的排版。

前文 format_json.py 现在在编码完成后检查 len(output) > LIMIT,其中 output 是含末尾换行的 UTF-8 字节,不是 Unicode 字符数。超限抛出受控错误,返回 2;检查发生在创建临时文件和替换目标之前。拒绝时原文件字节、inode 和 mtime 都保持不变,也不回滚更早的工具编辑。恰好 1048576 字节的输出仍可接受。

本地回归样本输入字节候选输出字节修正后结果
250000 个 0 的紧凑数组5000021250003退出 2,原文件不变
补末尾换行后超出 1 字节10485761048577退出 2,原文件不变
含汉字的对象,字符数低于限额10485731048579按 UTF-8 字节拒绝
输出比限额少 1 字节10485741048575退出 0,重跑不替换
输出恰好等于限额10485751048576退出 0,重跑不替换
原来的中文菜单81111退出 0,固定输出哈希不变

新增 5 个 unittest 方法。三个拒绝样本都连续运行两次,核对退出 2、空 stdout、受控 stderr、文件字节哈希、大小、inode、mtime 和无 .format- 残留;接受样本则核对精确字节并确认第二次不替换。它们在旧代码上出现 6 个子用例失败,修正后通过。在 Linux、Python 3.12.14 下,原 24 项、启动层 9 项和新增 5 项合计 38 个方法,普通模式与 -O 模式分别通过;同一启动矩阵仍为 88 组。另以 PYTHONOPTIMIZE=1 让格式器子进程也启用优化,新增 5 项仍通过。

test_output_limit.py:保存到实验根目录,与 test_hook.py 并列。下面是完整文件,ZIP 中逐字相同。

"""Output byte bounds for the local formatter; no Claude runtime or settings changes."""
import hashlib
import json
from pathlib import Path
import shutil
import subprocess
import sys
import tempfile
import unittest

LIMIT = 1024 * 1024
SOURCE = Path(__file__).parent / ".claude/hooks/format_json.py"


class OutputLimitTests(unittest.TestCase):
    def setUp(self):
        self.temp = tempfile.TemporaryDirectory(prefix="hook-output-limit-")
        self.addCleanup(self.temp.cleanup)
        self.root = Path(self.temp.name)
        self.hook = self.root / ".claude/hooks/format_json.py"
        self.hook.parent.mkdir(parents=True)
        shutil.copyfile(SOURCE, self.hook)
        self.file = self.root / "data/menu.json"
        self.file.parent.mkdir()

    def run_hook(self):
        event = {"hook_event_name": "PostToolUse", "tool_name": "Write",
                 "tool_input": {"file_path": str(self.file)}}
        return subprocess.run([sys.executable, str(self.hook)],
                              input=json.dumps(event).encode("utf-8"),
                              capture_output=True, timeout=10)

    def fingerprint(self):
        status = self.file.stat()
        return (status.st_size, hashlib.sha256(self.file.read_bytes()).hexdigest(),
                status.st_ino, status.st_mtime_ns)

    def reject_unchanged(self, original):
        self.assertLessEqual(len(original), LIMIT)
        candidate = (json.dumps(json.loads(original), ensure_ascii=False, indent=2)
                     + "\n").encode("utf-8")
        self.assertGreater(len(candidate), LIMIT)
        self.file.write_bytes(original)
        before = self.fingerprint()
        for attempt in range(2):
            with self.subTest(attempt=attempt):
                result = self.run_hook()
                self.assertEqual(result.returncode, 2)
                self.assertEqual(result.stdout, b"")
                self.assertIn(b"NOT been rolled back", result.stderr)
                self.assertNotIn(b"Traceback", result.stderr)
                self.assertEqual(self.fingerprint(), before)
                self.assertEqual(list(self.file.parent.glob(".format-*")), [])

    def test_compact_array_would_expand_past_limit(self):
        original = ("[" + ",".join(["0"] * 250000) + "]\n").encode("ascii")
        self.assertEqual(len(original), 500002)
        self.reject_unchanged(original)

    def test_newline_alone_would_cross_limit(self):
        self.reject_unchanged(('"' + "x" * (LIMIT - 2) + '"').encode("ascii"))

    def test_multibyte_output_is_counted_in_bytes(self):
        original = json.dumps({"字": "汉" * 349521}, ensure_ascii=False,
                              separators=(",", ":")).encode("utf-8")
        candidate = json.dumps(json.loads(original), ensure_ascii=False, indent=2) + "\n"
        self.assertLess(len(candidate), LIMIT)
        self.assertEqual(len(original), 1048573)
        self.assertEqual(len(candidate.encode("utf-8")), 1048579)
        self.reject_unchanged(original)

    def test_exact_limit_and_one_byte_below_are_idempotent(self):
        for output_size in (LIMIT - 1, LIMIT):
            with self.subTest(output_size=output_size):
                original = ('"' + "x" * (output_size - 3) + '"').encode("ascii")
                self.file.write_bytes(original)
                result = self.run_hook()
                self.assertEqual(result.returncode, 0)
                self.assertEqual((result.stdout, result.stderr), (b"", b""))
                self.assertEqual(self.file.read_bytes(), original + b"\n")
                self.assertEqual(self.file.stat().st_size, output_size)
                before = self.fingerprint()
                again = self.run_hook()
                self.assertEqual(again.returncode, 0)
                self.assertEqual((again.stdout, again.stderr), (b"", b""))
                self.assertEqual(self.fingerprint(), before)
                self.assertEqual(list(self.file.parent.glob(".format-*")), [])

    def test_small_menu_still_has_exact_output(self):
        original = '{"title":"周末菜单","servings":2,"items":["米饭","番茄"],"enabled":true}\n'.encode("utf-8")
        self.file.write_bytes(original)
        result = self.run_hook()
        self.assertEqual(result.returncode, 0)
        self.assertEqual((result.stdout, result.stderr), (b"", b""))
        self.assertEqual(hashlib.sha256(self.file.read_bytes()).hexdigest(),
                         "dcae3c0828a20e9b1d6a34028432bf19f49fb43d176629b0d8a4d58c3124182f")


if __name__ == "__main__":
    unittest.main(verbosity=2)
python3 -B -m unittest -v test_hook test_launch test_output_limit
python3 -B -O -m unittest -v test_hook test_launch test_output_limit

完整 ZIP 已同步当前格式器、启动实验固定哈希和新增测试。files-manifest.json 列出当前源码哈希;evidence/2026-10-13/ 逐字保留旧报告、日志、清单和当时的源码 ZIP;evidence/2026-10-20/ 保存修正前复现、当前测试日志及重跑的 88 组报告。历史 24/33 项证据和 10 月 14 日的人工验收状态不被覆盖。

限制仍然存在:这一检查限制写回文件大小,不是内存或 CPU 配额。完整解析和序列化仍在内存中发生,随后才知道输出字节数;Python JSON 文档提醒,不可信 JSON 可能消耗大量 CPU 和内存。这里没有资源隔离保证,读取也可能影响 atime;本轮没有测试所有文件元数据。本轮没有启动 Claude Code、模型或真实 hook,没有启用配置;Windows、并发写入、崩溃恢复、ACL、浏览器下载交互和移动端布局均未验证。10 月 14 日的 onFailure 人工用例仍全部 UNTESTED。

这是 Claude Code 实际运行的截图和日志吗?

不是。配图是原创示意,运行结果来自本地 Python 脚本与模拟事件;真实 Claude 接入需要单独验收。

所有 JSON 文件都能直接使用吗?

不能。本例只处理固定项目 data 目录内的 .json,拒绝小数、重复键、链接、超长内容等;它不支持 JSONC。

退出 2 会撤销刚才的 Write 吗?

不会。PostToolUse 发生在成功编辑后;本例错误只提示检查,已有文件改动仍然保留。

路径检查等于安全沙箱吗?

不等于。本例假定项目根可信、没有并发写入,仍有检查到替换之间的竞态,也不保留全部文件元数据。