Claude Fable 5 的极度主动性
Simon Willison··作者 Simon Willison
关键信息
Willison 怀疑这个滚动条问题来自某个依赖,可能是 Datasette 本身,所以让 Claude 先检查依赖。之后他观察到模型打开浏览器窗口,并通过 Python、PyObjC Quartz API 和 macOS 的 `screencapture` 命令自定义了一套截图流程,按窗口编号捕获 Safari 窗口图像。
资讯摘要
在使用 Claude Fable 5 两天后,Simon Willison 认为,最贴切的描述就是“极度主动”。他指出,这个模型掌握了很多技巧,只要有助于实现目标,几乎什么办法都会用上。为了说明这一点,他举了一个在 Datasette Agent 中调试界面问题的例子:他发现跳转菜单的聊天输入框里出现了不该有的横向滚动条。Willison 截下了这个 bug 的截图,并让 Claude 先查看依赖项,帮助分析为什么会出现滚动条。 他当时怀疑问题可能出在 Datasette Agent 的某个依赖里,也许就是 Datasette 本身,因此预期模型会去检查已安装包或本地源码。
后来他离开电脑几分钟,回来时却发现机器自动打开了浏览器窗口,并跳转到了相关对话界面。接着他看到 Claude 继续做实验,甚至打开了 Safari,并执行了一段 Bash 命令,结合 Python、PyObjC Quartz 和 `screencapture`,先找到 Safari 窗口再抓取 PNG 截图。Willison 解释说,模型实际上是自己拼出了一套截图方法:先枚举系统中的窗口,再筛选出名称里包含类似“textarea”字符串的 Safari 窗口,最后用窗口编号来截图。除此之外,模型还在写临时 HTML 页面,尝试复现这个 bug,然后打开这些页面在 Safari 中进行验证。

资讯正文
在使用 <a href="https://simonwillison.net/2026/Jun/9/claude-fable-5/">Claude Fable 5</a> 两天之后,我认为最贴切的形容就是它“锲而不舍地主动”。它掌握了大量技巧,而为了达成目标,它几乎会把所有能用的招式都用上。
我用一个例子来说明。今天我在折腾 <a href="https://agent.datasette.io/">Datasette Agent</a> 时注意到一个小故障:在 jump menu 的聊天提示框里,出现了一个不该有的水平滚动条。我截了这样一张图:
然后我在我的 <code>datasette-agent</code> 检出目录里新开了一个 <code>claude</code> 会话,把这张截图拖进去,并告诉它:
<code>查看依赖项,帮我弄清楚为什么这里会有一个水平滚动条</code>
我猜问题可能出在 Datasette Agent 的某个依赖里(很可能是 Datasette 本身),而且我知道 Fable 很擅长钻研依赖代码——它要么会检查自己虚拟环境 <code>site-packages</code> 里已安装的文件,要么会引用磁盘上的本地检出副本。让它从依赖项入手,我觉得是个不错的判断。
后来我被一件家务分了心,离开了电脑。
几分钟后我回来时,看到我的机器在我平时使用的 Firefox 里<em>打开了一个浏览器窗口</em>,然后又<em>跳转到了那个对话框</em>。我并没有告诉 Claude Code 使用任何浏览器自动化,而且我相当确定它不可能在某个窗口里触发鼠标移动或键盘快捷键,那它究竟是怎么做到的?
我一边惊讶地看着它继续探索,一边又看到它把 Safari 窗口打开了,而不是 Firefox。我还从 Claude 终端里截取了这张快照:
<p><img alt="暗色终端界面中两次 Bash 工具调用的截图。第一次:Bash(open -a Safari /tmp/textarea-scrollbar-test.html && sleep 4 && uv run --with pyobjc-framework-Quartz python - <<'EOF' import Quartz wins = Quartz.CGWindowListCopyWindowInfo(Quartz.kCGWindowListOptionOnScreenOnly, Quartz.kCGNullWindowID) for w in wins: if (w.get('kCGWindowOwnerName') or '') == 'Safari' and 'textarea' in (w.get('kCGWindowName') or '').lower(): print(w.get('kCGWindowNumber')) EOF) 的输出为 153551。第二次:Bash(screencapture -x -o -l 153551 /tmp/safari-cases.png && echo ok) 的输出为 ok。" src="https://static.simonwillison.net/static/2026/fable-bash-pyobjc.jpg" /></p>
<p>它在那里用 <code>uv run --with pyobjc-framework-Quartz</code> 是在做什么?</p>
<p>原来,Fable 自己拼出了一套用于截取浏览器窗口截图的模式。它用 Python 遍历我机器上所有可用窗口,然后筛选出 Safari 窗口,并匹配窗口名中包含 <code>"textarea"</code> 之类的预期字符串。借此它能找到窗口编号——像 153551 这样的整数——然后再把这个编号传给 <code>screencapture</code> 命令行工具,以抓取 PNG 图片。</p>
<p>好吧,这确实是一种挺巧妙的截图方式。但它到底在截什么图?</p>
<p>事实证明,它一直在写自己的临时 HTML 页面,试图复现这个 bug,然后打开 Safari 并截屏。</p>
<p>下面是它创建的这个 <a href="https://static.simonwillison.net/static/2026/textarea-scrollbar-test.html">/tmp/textarea-scrollbar-test.html</a> 页面,以及它使用 <code>screencapture -x -o -l 153551 /tmp/safari-cases.png</code> 截下来的那张图:</p>
(我开的标签页也太多了!)</p>
<p>好吧,我现在能看明白它是在打开测试页面并截屏了,可它究竟是怎么触发那个本应接受测试的模态对话框的?这东西只能通过点击或键盘快捷键来唤出,而我看不出它在 Safari 里有办法执行这些操作。</p>
<p>我最终弄明白了它是怎么做的。</p>
<p>Claude 运行在一个包含应用程序源代码的文件夹里。它对 <a href="https://datasette.io/">Datasette</a> 了解得足够多,知道可以运行一个本地开发服务器。原来它在直接修改 Datasette 自己的模板,往里面加 JavaScript,以便在窗口一打开时就触发正确的键盘快捷键,代码大致像这样:</p>
<div class="highlight highlight-text-html-basic"><pre><span class="pl-kos"><</span><span class="pl-ent">script</span><span class="pl-kos">></span>
<span class="pl-smi">window</span><span class="pl-kos">.</span><span class="pl-en">addEventListener</span><span class="pl-kos">(</span><span class="pl-s">"load"</span><span class="pl-kos">,</span> <span class="pl-k">function</span> <span class="pl-kos">(</span><span class="pl-kos">)</span> <span class="pl-kos">{</span>
<span class="pl-en">setTimeout</span><span class="pl-kos">(</span><span class="pl-k">function</span> <span class="pl-kos">(</span><span class="pl-kos">)</span> <span class="pl-kos">{</span>
1.2 秒后窗口打开时,这段代码会触发一个模拟的“/”按键,而这正是打开该模态对话框的键盘快捷键。
还剩下最后一个挑战。为了弄清楚到底发生了什么,Claude 需要在页面上运行 JavaScript,为自己进行测量。
它编写了自己的自定义 Web 应用,通过 CORS 捕获信息,然后将其作为本地服务器运行,并打开一个带有 JavaScript 的页面,让该页面直接向它 POST 数据!
以下是它编写的 Python Web 应用,使用的是标准库中的 http.server 包:
<span class="pl-s1">self</span>.<span class="pl-c1">send_header</span>(<span class="pl-s">"Access-Control-Allow-Origin"</span>, <span class="pl-s">"*"</span>)
<span class="pl-s1">self</span>.<span class="pl-c1">send_header</span>(<span class="pl-s">"Access-Control-Allow-Headers"</span>, <span class="pl-s">"*"</span>)
<span class="pl-s1">self</span>.<span class="pl-c1">end_headers</span>()
def <span class="pl-en">log_message</span>(<span class="pl-s1">self</span>, <span class="pl-c1">*</span><span class="pl-s1">a</span>): <span class="pl-c"># quiet</span>
pass
<span class="pl-en">HTTPServer</span>((<span class="pl-s">"127.0.0.1"</span>, <span class="pl-c1">9999</span>), <span class="pl-c1">H</span>).<span class="pl-c1">serve_forever</span>()</pre>
这段代码所做的,只是接收一个装满 JSON 的 POST 请求,并将其写入 <code>/tmp/diag.json</code> 文件。它会发送 <code>Access-Control-Allow-Origin: *</code> 头部(包括对 <code>OPTIONS</code> 请求的响应),这样运行在另一个域上的代码仍然可以与它通信。
然后,Claude 把这段代码注入到了它在浏览器中加载的模板里:
<div class="highlight highlight-source-js"><pre><span class="pl-k">const</span> <span class="pl-s1">host</span> <span class="pl-c1">=</span> <span class="pl-smi">document</span><span class="pl-kos">.</span><span class="pl-en">querySelector</span><span class="pl-kos">(</span><span class="pl-s">"navigation-search"</span><span class="pl-kos">)</span><span class="pl-kos">;</span>
<span class="pl-k">const</span> <span class="pl-s1">ta</span> <span class="pl-c1">=</span> <span class="pl-s1">host</span><span class="pl-kos">.</span><span class="pl-c1">shadowRoot</span><span class="pl-kos">.</span><span class="pl-en">querySelector</span><span class="pl-kos">(</span><span class="pl-s">"textarea"</span><span class="pl-kos">)</span><span class="pl-kos">;</span>
<span class="pl-k">const</span> <span class="pl-s1">cs</span> <span class="pl-c1">=</span> <span class="pl-en">getComputedStyle</span><span class="pl-kos">(</span><span class="pl-s1">ta</span><span class="pl-kos">)</span><span class="pl-kos">;</span>
<span class="pl-en">fetch</span><span class="pl-kos">(</span><span class="pl-s">"http://127.0.0.1:9999/diag"</span><span class="pl-kos">,</span> <span class="pl-kos">{</span>
<span class="pl-c1">method</span>: <span class="pl-s">"POST"</span><span class="pl-kos">,</span>
<span class="pl-c1">body</span>: <span class="pl-c1">JSON</span><span class="pl-kos">.</span><span class="pl-en">stringify</span><span class="pl-kos">(</span><span class="pl-kos">{</span>
<span class="pl-c1">dpr</span>: <span class="pl-smi">window</span><span class="pl-kos">.</span><span class="pl-c1">devicePixelRatio</span><span class="pl-kos">,</span>
<span class="pl-c1">scrollWidth</span>: <span class="pl-s1">ta</span><span class="pl-kos">.</span><span class="pl-c1">scrollWidth</span><span class="pl-kos">,</span> <span class="pl-c1">clientWidth</span>: <span class="pl-s1">ta</span><span class="pl-kos">.</span><span class="pl-c1">clientWidth</span><span class="pl-kos">,</span>
这段代码测量了 <code><navigation-search></code> 这个 Web Component 内部的 <code><textarea></code>,并把这些测量结果发送到服务器,服务器再把它们写入磁盘上的文件里,这样 Claude 就能够读取它。
在弄清楚了所有这些技巧之后,Fable……撞上了某个看不见的保护边界,自己降级成了 Opus。幸好 Opus 可以访问完整的对话记录,并且能够继续使用 Fable 开创的那些技巧;不久之后,它找到了、测试并验证了<a href="https://github.com/datasette/datasette-agent/commit/a75a8b727b42c30ced1fc41dc8add7eb9f04fefe">这个修复</a>。
我提示 Opus:
<code>Write a report in /tmp/automation-report.md where you note down all of the tricks you have used in this session to test against real browsers on my computer, include runnable code examples</code>
它生成了<a href="https://gist.github.com/simonw/aef7f7db9ac992643110a74e43d6d42f">这份报告</a>,对于我拼凑这篇文章中发生了什么细节来说,它非常有价值。
我也分享了这段 Claude Code 会话的<a href="https://gisthost.github.io/?cc14774f6d37eb67bf089f3ac3925f8f">完整终端转录</a>。
#### 它所做的一切回顾
根据一张截图和一行提示,Claude Fable 5 + Claude Code:
- 想出了运行本地开发服务器的方案(还使用了必要的伪环境变量来让它跑起来)
- 启动了一个 Playwright Chrome 会话
- 为 Chrome 打开了可见滚动条设置:<code>defaults write com.google.chrome.for.testing AppleShowScrollBars Always</code>(之后它又把这个设置关掉了)
- 在 Playwright 里也轮流尝试了 Firefox 和 WebKit,但都没能复现这个 bug
- 发现我的默认浏览器是 Safari
- 构建了一个 <code>textarea-scrollbar-test.html</code> HTML 文档
- 在真正的(不是 Playwright 的)Firefox 中打开了它
- 发现 <code>osascript -e 'tell application "System Events" to tell process "firefox" to id of window 1'</code> 被阻止了,因为“osascript is not allowed assistive access”
- 想出了前面提到的 <code>uv run --with pyobjc-framework-Quartz python</code> 变通办法
- 在网站模板中加入 JavaScript,以便触发 <code>/</code> 键
- 自己搭建了一个小型 Python CORS Web 服务器来捕获 JSON 数据
- 改写了模板以捕获这些数据并发送到服务器
- 通过 Web Component 的 shadow DOM 编写脚本,获取它需要的信息
</ul>
<li>打开 Safari 以确认漏洞的来源</li>
<li>修改它的自定义模板,硬塞进一个潜在修复方案</li>
<li>确认这个“硬塞”的修复方案有效</li>
<li>回报如何修复这个问题</li>
<p>就像我说的,它真是锲而不舍、主动得离谱!</p>
<h4 id="an-estimate-of-the-cost">成本估算</h4>
<p>我目前订阅的是每月 100 美元的 Claude Max 套餐,其中为 Fable 提供了相当充裕的额度,直到 6 月 22 日;Anthropic 说从那之后他们会开始按完整 API 价格收费。</p>
<p>我用 <a href="https://www.agentsview.io">AgentsView</a> 来跟踪我的支出(参见 <a href="https://til.simonwillison.net/llms/agentsview-custom-model-price">这篇 TIL</a>)。以下是 AgentsView 对这个会话的估算:如果我要按完整价格付费,大概会花多少钱:</p>
<pre><code>~ % uvx agentsview session usage be8850a7-6119-46a0-b5d6-79c7fff5ae2b
Session: be8850a7-6119-46a0-b5d6-79c7fff5ae2b
Agent: claude
Output: 68606
Peak ctx: 113178
Cost: ~$12.11 (claude-fable-5, claude-opus-4-8)
</code></pre>
<p>如果你不密切盯着它,Fable 完全会心安理得地烧掉 12 美元的 token,只为想出新的方式来调试你的 CSS。</p>
<h4 id="i-really-need-to-lock-this-thing-down">我真的需要把这东西管严一点</h4>
<p>一方面,看着 Fable 为了获取调试最终只是两行 CSS 修复所需的信息而大费周章,确实令人 <em>着迷</em>。</p>
<p>但另一方面……这也有力地提醒我们,编码代理能做任何你通过在终端里输入命令就能做的事——而前沿模型知道书上所有招数,显然还知道一些从未被任何人写下来过的招数。</p>
<p>如果 Fable 接收到的是恶意指令——比如隐藏在代码或 issue 讨论串里的 prompt injection 攻击,或者我不小心粘贴进终端里的某些内容——那么它为了窃取数据或制造其他恶作剧,究竟能走多远,想想都让人不寒而栗。</p>
<p>让编码代理在沙箱之外运行,向来是个坏主意——按照 Johann Rehberger 在 <a href="https://embracethered.com/blog/posts/2025/the-normalization-of-deviance-in-ai/">The Normalization of Deviance in AI</a> 中的说法,这最像我心目中的一次“挑战者号”灾难级事故。</p>
<p>Fable 也许更聪明,因此对潜在的恶意指令会更警惕。但这种聪明本身是一把双刃剑:如果它 <em>真的</em> 被指令操纵了,凭借它那种不知疲倦的主动性,它所能造成的破坏将会非常可怕。</p>
标签:ai、prompt-injection、generative-ai、llms、ai-assisted-programming、coding-agents、claude-code、claude-mythos
来源与参考
收录于 2026-06-13