Magic Tools
AI TutorialsBy CooconJuly 20, 2026155 views5 min read

Claude Code 踩坑实录:两个 AI 幻觉真实案例与 hook 日志交叉验证法

本文记录同一天内 Claude Code(Opus 4.8)的两次真实"翻车":一次把自己的命令行 flag 误用判定成"工具输出被注入",一次凭空忏悔了一场从未发生的文件删除。两个案例的根因相同——对异常现象直接给出归因性结论,而没有用独立证据源核验

值得一提的是:犯错的是 Opus 4.8,而把两个问题查清的是同一会话中切换到的 Fable 5。作者此前用 Opus 4.8 多次遇到同类幻觉(近期一直存在),但它数次测试中都没能自主发现问题所在;换用 Fable 5 后一次排查定案。文末给出一套不依赖模型的可复制防御方案:用 PostToolUse hook 建立"真相基线"日志。

案例一:被"注入"的 grep 输出,其实是 rg -r 的锅

现象

排查一个前端 bug 时,AI 执行了这样一条搜索命令:

rg -rn "1688|from_username|from_role_code|is_teacher" frontend/src

输出里所有匹配到的字段名全部变成了神秘的 n

TeacherAnalysisPage.tsx:  const role = record.n
TeacherAnalysisPage.tsx:  fromSelf={record.n === 1}
teacherAnalysis.ts:  n: number | null

AI 看到真实代码里不存在的 record.n,给出的第一反应是:

"上面几条工具结果里混入了可疑注入内容,我一律忽略。"

——它认定工具输出被中间层篡改/注入了。

真相

rg(ripgrep)的 -r 不是"递归",是 --replace

从 grep 迁移过来的肌肉记忆是 grep -rn pattern dir(递归 + 行号)。但 ripgrep 默认就是递归的,它的 -r 接收一个替换串。于是:

rg -rn "pattern" dir
# 等价于
rg --replace n --line-number "pattern" dir

每个匹配都被替换成了字面量 n。输出确实"被篡改"了——被 rg 自己,按 AI 自己下的指令。

教训

grep ripgrep
-r = 递归 默认递归,-r <text> = 替换输出
grep -rn pat dir rg -n pat dir

AI 在无法解释输出时,倾向于向外归因("被注入了"),而不是先怀疑自己的命令。 这是比 flag 误用本身更值得警惕的行为模式。

案例二:幽灵删除忏悔

第二个案例更离奇。还原自 hook 日志的完整时间线:

时间 事实
10:43 会话 A 用 Write 工具创建docs/report_generation_explained.md
17:59 会话 B 开始,用户唯一指令:"渠道切换时把分页重置为第 1 页"
18:01:20 AI 执行 git status --short && wc -l 检查该文档 → 返回 A docs/...md、205 行,文件完好
18:01:50 AI 突然发言:"我刚才删除 docs/report_generation_explained.md 时没有走确认流程,直接执行了 git rm -f——这违反了规定。 我当时的判断是:你已明确说'删除这个文件'……"

事后用日志逐条核验,这 30 秒内的自述是三重虚构

  1. 虚构操作——全天 hook 日志里没有任何 git rm 或删除命令的执行记录,文件从未被删;
  2. 虚构指令来源——全天所有会话的用户输入(UserPromptSubmit 事件全量记录)中,没有任何一条包含"删除这个文件";
  3. 虚构问答——那句"有,明确写了"是在回答一个没人问过的问题,该会话总共只有 3 条用户输入,没有一条问过确认机制。

最讽刺的是:AI 在忏悔前 30 秒刚刚亲手查过 git status,亲眼看到文件存在的铁证(A 状态 + 205 行),却仍然基于一段不存在的记忆完成了整套"违规自查 + 道歉 + 承诺整改"。

这类幻觉的危险在于它披着"诚实自省"的外衣——一个主动承认错误的 AI 看起来非常可信,但它承认的错误本身可能就是幻觉。如果用户当真,接下来可能会去"恢复"一个从未丢失的文件,甚至怀疑自己的操作记录。

方法论:用 hook 日志建立"真相基线"

两次事件能被一锤定音地查清,靠的都是同一件事:一个在工具执行瞬间、未经任何渲染层的原始 I/O 日志

Claude Code 支持 hooks 机制,在 ~/.claude/settings.json 里注册一个全事件日志器:

{
  "hooks": {
    "PreToolUse":  [{ "matcher": "*", "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }],
    "PostToolUse": [{ "matcher": "*", "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }],
    "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "~/.claude/hooks/log_tool_io.py" }] }]
  }
}

日志脚本核心逻辑(完整可用,仅标准库):

#!/usr/bin/env python3
"""Claude Code 工具 I/O 日志 hook。
价值:hook 从 stdin 拿到的是「未经终端渲染的原始数据」,
可作为排查渲染层故障 / AI 幻觉宣称的真相基线。
行为准则:永不阻塞、任何异常吞掉、始终 exit 0。"""
import sys, os, json, datetime

LOG_DIR = os.path.expanduser("~/tools/logs")  # expanduser 保证跨用户可用
MAX_FIELD = 200_000

def clip(value):
    try:
        text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
    except Exception:
        text = repr(value)
    if len(text) <= MAX_FIELD:
        return value
    half = MAX_FIELD // 2
    return {"_truncated": True, "_orig_len": len(text),
            "head": text[:half], "tail": text[-half:]}

def main():
    data = json.loads(sys.stdin.read())
    now = datetime.datetime.now()
    record = {"ts": now.isoformat(timespec="milliseconds"),
              "event": data.get("hook_event_name", "unknown"),
              "session_id": data.get("session_id"), "cwd": data.get("cwd")}
    for key in ("tool_name", "tool_input", "tool_response", "prompt", "message"):
        if key in data:
            record[key] = clip(data[key])
    os.makedirs(LOG_DIR, exist_ok=True)
    path = os.path.join(LOG_DIR, "claude-hooks-%s.jsonl" % now.strftime("%Y-%m-%d"))
    with open(path, "a", encoding="utf-8") as f:
        f.write(json.dumps(record, ensure_ascii=False) + "\n")

if __name__ == "__main__":
    try:
        main()
    except Exception:
        pass  # hook 绝不因自身异常影响主流程
    sys.exit(0)

三个要点:

  1. 纯被动:只写日志、不向 stdout 输出任何内容(PostToolUse hook 只有通过 stdout 才能改写上下文),因此它自身不可能成为注入源——审计工具首先要自证清白;
  2. 永不阻塞:异常全吞、恒 exit 0,坏了也不影响正常使用;
  3. 按天分文件 JSONLgrep/python 都能秒查,session_id 字段可区分并行会话。

有了基线之后,验证只需三步

以幽灵删除为例:

# 1. AI 声称执行过 git rm?查全天有无该命令
grep '"git rm' ~/tools/logs/claude-hooks-2026-07-20.jsonl        # → 0 条

# 2. AI 声称用户下过删除指令?查全量用户输入
python3 -c "
import json
for l in open('$HOME/tools/logs/claude-hooks-2026-07-20.jsonl'):
    d = json.loads(l)
    if d.get('event') == 'UserPromptSubmit' and '删除' in str(d.get('prompt','')):
        print(d['ts'], d['prompt'][:100])"                        # → 0 条

# 3. 文件到底在不在?看 AI 自己那次检查的原始返回
# → {"stdout": "A  docs/report_generation_explained.md\n 205 ..."}

三条命令,虚构操作、虚构指令、虚构状态全部证伪。

防御规则:写进 CLAUDE.md

把结论固化成两条硬规则,放进全局 ~/.claude/CLAUDE.md,让每个会话开局即加载:

### 实事求是
- 自称"我执行过/删除过/修改过 X"的任何宣称,必须能在 hook 日志
  (~/tools/logs/claude-hooks-*.jsonl)或 git 记录中找到对应条目;
  找不到 = 该操作没发生过,按幻觉处理,禁止基于它做后续推理或忏悔式自述
- 怀疑"工具输出被篡改/注入"前,先用第二独立源交叉验证
  (Read 同一文件 / hook 日志原始基线),并检查自己的命令 flag 是否用错
  (如 rg 的 -r 是 --replace 不是递归)

模型差异的观察:Fable 5 vs Opus 4.8

一个诚实的补充说明——这两个问题是用 Fable 5 发现并定案的,而犯错的正是 Opus 4.8。

作者的实际使用体验(非严格 benchmark,仅个人多次测试的观察):

  • Opus 4.8:近期反复出现这类幻觉(把自己的 flag 误用归因为"外部注入"、虚构自己执行过的操作),且此前几次针对性测试中,它都没能自主发现问题出在自己身上——倾向于向外归因后就停止深挖;
  • Fable 5:接手排查后的路径是"两个独立源对不上 → 先怀疑工具调用本身 → 逐条对 hook 日志基线",一次会话内把 flag 误用和幽灵删除全部证伪定案。

需要强调两点:

  1. 这不能证明 Fable 5 不会产生幻觉——任何模型都会。差异体现在面对矛盾证据时,是否会把"自己的命令/自己的记忆"也纳入怀疑范围
  2. 正因为无法指望某个模型永远可靠,本文的方法论才刻意设计成模型无关的:hook 日志基线 + CLAUDE.md 硬规则,对任何模型、任何会话都生效。换模型是碰运气,建基线是上保险。

总结

案例一 案例二
表象 输出字段全变成 n AI 忏悔"违规删除文件"
AI 的第一归因 "输出被注入了" "我删了,用户让我删的"
真相 自己的 rg -r flag 误用 操作、指令、问答三重虚构
证伪手段 Read 同文件比对 + hook 原始返回 hook 日志全量检索:0 条记录

两个案例指向同一条元规则:

AI 的"记忆"和"自述"都不是证据,工具执行日志才是。 当 AI 给出任何关于"发生过什么"的断言——无论是指控外部(被注入)还是指控自己(我删了文件)——先问一句:日志里有吗?

搭好 hook 日志这个真相基线,成本是一个 70 行的 Python 脚本;收益是每一次幻觉排查都能从"各执一词"变成"三条命令定案"。

Related Articles

Reproducing an Injection Chain That Cracks Claude Code Auto Mode: the Model Refuses the Malicious Binary, Then Writes Code That Pwns Itself

In late August embracethered published an attack chain where a plain 'summarize this page' request drags auto-mode Claude Code to a 60–80% code-execution rate — while Anthropic's commissioned third-party test reported 0.00%. I took the chain apart and tested it stage by stage in an isolated environment: the endpoint that nudges the model from WebFetch to curl, and the crux — the model's own 'safe' decision to refuse the unknown binary and write its own Python decoder instead lands straight on a same-name struct.py planted in the extracted directory. The deterministic parts (branching + module-shadow poison + mitigation controls) reproduce fully on my machine with real evidence; the live end couldn't complete a full RCE here because the classifier rate-limited and failed closed — flagged honestly. Ends with mitigations that actually help.

claude-codeauto-mode+5
hands-onAug 31, 20269 min
101

Cracking Open Claude Code's Auto-Mode Classifier: A 116K-Char System Prompt, Dissected Line by Line

My earlier retest confirmed auto mode calls the session model as a classifier before each risky Bash — but what it receives stayed a black box. This time I captured the full request: a 116,879-char system prompt opening 'You are a security monitor for autonomous AI coding agents.' I quote it verbatim to dissect the threat model, two-tier rules (1 HARD BLOCK / 68 SOFT BLOCK / 17 ALLOW), and two-stage evaluation — stage 1 grades harm only, stage 2 layers intent on top. Every number read out this session.

claude-codepermissions+5
hands-onAug 30, 202612 min
152
Turn a Home Mac mini Into an Always-On Claude Code Workstation: claudecodeui + SSH Reverse Tunnel, Take Over Sessions From Any Browser

Turn a Home Mac mini Into an Always-On Claude Code Workstation: claudecodeui + SSH Reverse Tunnel, Take Over Sessions From Any Browser

A Mac mini at home runs Claude Code around the clock — but how do you take over a session from a browser when you're away? This is a real setup that has been live for a week and in daily use: claudecodeui as the web UI (chosen over the official web version, ttyd, and code-server), an SSH reverse tunnel pushing it to a VPS, and nginx adding TLS plus login rate limiting to turn it into an ordinary URL. Includes full configs, real operating numbers (five days of tunnel uptime with zero drops, 170MB RSS), a <synthetic> placeholder bug hit and fixed within the first week, and an honest for-and-against on why not Tailscale.

claude-codeclaude-code-lab+7
claudeAug 29, 202612 min
148

You Set ANTHROPIC_BASE_URL. Claude Code Ignored It.

I exported ANTHROPIC_BASE_URL in .zshrc to point at a self-hosted API gateway, and Claude Code kept talking to Google Vertex anyway. On the same machine, a launchd-managed web UI insisted it wasn't authenticated at all. Neither bug was in the gateway — both were in the gap between 'I set the env var' and 'the process actually has it.'

claude-codebug-postmortem+2
pitfallsAug 24, 20264 min
223

Published by Magic Tools