文章用构造的 PR 案例分析文档抓取功能:模型输出的网址被直接下载,网页正文进入下一轮提示词,并落盘到源码旁。审查重点是限制网络路径、以固定测试样例验证行为,并让试运行默认关闭网络访问。
一个平静的周二,一位维护者看到了 pull request 218。一个 Agent 给一个小型编程工具添加了文档抓取功能。变更摘要承诺,通过实时文档页面,让回答包含更丰富的信息。
本文是一个构造的审查案例,并非某次具名的故障事件。文中使用了具体的文件名,方便团队复现审查过程。本案例不声称取得了任何生产环境指标,也不涉及客户事故。
这份 diff 修改了 Agent runtime 中的三个文件。一个文件从模型输出的文本中提取 URL,另一个文件下载页面并构建下一轮 prompt。
第三个文件把这个 prompt 写到了源码树旁边。Agent 把这项功能描述为一个上下文辅助工具。这个辅助工具从模型回复中读取 URL。
随后,它把页面正文插入下一轮 prompt。范围明确的文档工作流,可以合理使用一个受到严格限制的辅助工具。但这个补丁让网络访问路径完全敞开了。
可以信任的,应当是纯函数,以及通过固定 fixture 锁定的行为。如果一个分隔符包装函数从不打开 socket,就可以保留。如果一个大小常量的数值被测试锁定,也可以保留。
如果 dry-run 标志只打印执行计划,就可以保留。这个标志必须默认禁止网络访问。Agent 写出的一份全绿摘要,并不能作为证据。
function canTrust(change) {
return change.pure === true
&& change.network === false
&& change.testsPinLimits === true;
}
上面的示意代码只是审查辅助工具,并非实测结果。它本身不能为任何修改过的文件背书。人工审查者仍然必须阅读完整的 diff。
回退对模型指定的任意 URL 进行开放抓取的逻辑。模型回复里出现的名称,不等于 allowlist。语气自信的摘要,也不等于主机访问策略。
立即回退这个 HTTP client 跟随重定向的行为。第一次主机检查完成后,重定向仍然可能把请求转移到其他位置。即使没有列出各种绕过技巧,这项回退也有充分理由。
回退所有将打包后的 prompt 写入仓库目录树的操作。打包文本可能包含工具输出或本地密钥。目录树里的 debug 文件可能被带进下一次 commit。
回退所有把抓取失败转换成“空内容但成功”的代码路径。这种空结果的成功状态,会让日志掩盖上下文包已经损坏的事实。Agent 随后会继续执行,就像已经读取过页面一样。
先在一个一次性分支上检出这个 pull request。在禁用外部网络访问的条件下运行测试套件。一个在失败时默认拒绝访问的辅助工具,更容易得到公正的评估。
git fetch origin pull/218/head:pr-218
git switch --detach pr-218
npm test
上面的三条命令只是审查流程示意,并非一次已完成执行的记录。如果 fork 使用了其他 remote 名称,请替换这里的名称。
保留一个只负责包装文本并执行大小限制的函数。把 fetch 调用和磁盘写入移到一个范围严格受限的接口后面。在单元测试中,不提供这个接口的实现。
export function packUntrusted(label, text, limit) {
const body = String(text).slice(0, limit);
return [
`<untrusted source="${label}">`,
body,
"</untrusted>",
].join("\n");
}
上面的包装函数,是为审查测试框架提出的方案。它不能证明模型会遵守这些边界标记。它只是限制不可信文本的长度,并让这类文本更容易被识别。
只接受人工已经审查过的列表中精确匹配的主机。辅助工具批准的每个 URL 都必须使用 HTTPS。拒绝 userinfo、空主机名,以及所有非 HTTPS scheme。
const ALLOWED = new Set(["docs.example.com"]);
export function approveUrl(raw) {
const url = new URL(raw);
if (url.protocol !== "https:") {
return { ok: false, reason: "scheme" };
}
if (url.username || url.password) {
return { ok: false, reason: "userinfo" };
}
if (!ALLOWED.has(url.hostname)) {
return { ok: false, reason: "host" };
}
return { ok: true, href: url.href };
}
不要只是为了本地使用方便,就添加私有地址。不要用宽松的子字符串检查来匹配主机。本次审查要求的规则,是精确判断主机是否属于这个集合。
测试需要覆盖一个允许的主机和一个被拒绝的主机。还要覆盖响应正文超过规定上限的情况。断言打包后的正文行始终不超过这个上限。
import test from "node:test";
import assert from "node:assert/strict";
import { approveUrl } from "./approve-url.js";
import { packUntrusted } from "./pack.js";
test("rejects a host outside the set", () => {
const verdict = approveUrl("https://evil.example/page");
assert.equal(verdict.ok, false);
assert.equal(verdict.reason, "host");
});
test("rejects a non-HTTPS scheme", () => {
const verdict = approveUrl("http://docs.example.com/guide");
assert.equal(verdict.ok, false);
});
test("caps packed untrusted text", () => {
const packed = packUntrusted("docs", "x".repeat(80), 16);
const lines = packed.split("\n");
assert.equal(lines[1], "x".repeat(16));
assert.equal(lines.length, 3);
});
上面的测试是供审查者参考的示例,尚未执行。它们不能替代在实际分支上的测试运行。只有先落实回退清单,才能把一次全绿的测试结果视为最终结果。
把 debug 输出写到审查者选定的临时目录。拒绝任何解析后位于项目根目录内部的路径。审查会话结束时,删除这个临时目录。
import path from "node:path";
export function assertOutsideRepo(repoRoot, requested) {
if (!path.isAbsolute(requested)) {
throw new Error("debug path must be absolute");
}
const root = path.resolve(repoRoot);
const full = path.resolve(requested);
const rel = path.relative(root, full);
const inside = rel === ""
|| (!rel.startsWith("..") && !path.isAbsolute(rel));
if (inside) {
throw new Error("debug bundle must stay outside the repo");
}
return full;
}
上面的路径辅助函数,只是拟议的审查代码。这份草稿没有提供它的执行日志。信任它之前,先分别用一个临时目录路径和一个仓库路径运行验证。
直接在审查记录里放一张简短的决策表。只有落实回退清单后,才能重新运行模型。第二个模型的意见,不能让已经回退的抓取逻辑重新开放。
缺少 allowlist 时,即使模型收到的是看起来友好的 prompt,访问也会默认放行。模型可能指定一个项目从未审查过的主机。fixture 应当在任何 socket 打开之前拒绝这个主机。
只写在注释里的大小上限会逐渐失效。下一次修改可能增大 slice 的截取长度,却没有任何测试失败。用断言锁定数值,而不是把它写在 pull request 的说明里。
在演示中,把 debug 文件写在源码树旁边,看起来可能无伤大雅。下一次 commit 却可能通过大范围的 add 操作把文件带进去。当目标位置位于根目录内部时,路径测试应当失败。
抓取失败时返回“空内容但成功”,看起来只是一个小小的便利处理。调用方随后却会围绕这个无声的缺口构建下一轮 prompt。测试应当预期抛出错误,或者返回一个明确可见的失败标志。
完成本地回退后,把分支移到一台一次性服务器上。MonkeyCode 可以作为重新运行修补后 Agent 的一个场所。披露:本文是作为 MonkeyCode 产品推广的一部分编写的。
运营方的资料提到了免费模型访问、免费服务器,以及开源属性。资料还提到了接近一千万 tokens 的免费额度。本文没有重新测量这个额度,也没有指明任何模型。
在审查流程依赖这些条件之前,先确认当前有效的条款。把免费服务器当作临时运行环境,而不是信任边界。让这个运行环境使用修补后的分支和 fixture 文件。
生产环境凭据必须完全远离这台一次性机器。通过免费访问使用的模型,可以起草审查记录。但它仍然无权批准主机列表或合并操作。
git clone --branch pr-218-reverted /path/to/local/repo review-218
cd review-218
node --test test/approve-url.test.js
这份草稿尚未验证上面的临时环境命令。如果测试套件要求提供密钥或生产环境主机,就停止执行。不要只是为了让红色测试变绿,就扩大 allowlist。
把失败案例记录在审查记录中,不要隐藏它。获分配免费服务器资源,并不意味着有确定的硬件规格或使用时长承诺。审查会话结束时,删除临时工作区。
当文档主机更名时,allowlist 就会过时。在人工修改集合之前,精确检查会拒绝新域名。主机列表的修改,应当与域名变更放在同一个 pull request 中。
包装函数不能证明模型会遵守分隔符。它只是让不可信文本清晰可见,并限制其长度,方便审查。更广泛的注入研究,不在这次具体审查的范围内。
公开文档中的配额和模型列表,可能在没有通知的情况下发生变化。不要把旧的 token 数值复制到 runbook 中,当成一项保证。在审查当天阅读项目的最新页面。
存储客户数据的仓库,应当跳过这种做法。如果安全审查已经锁定了网络策略,也应当跳过。如果没有人会真正阅读回退清单,同样应当跳过。
不要把免费服务器用作生产运行环境。不要把免费 token 池当作负载测试预算。不要因为模型赞同 Agent 作者,就合并代码。
没有严格落实 fixture 测试的团队,暂时不应采用这个辅助工具。聊天中口头约定的 allowlist,到下一个 sprint 就可能走样。等到拒绝场景的测试真正进入仓库后,再考虑采用。
有价值的部分是文本打包思路,而不是开放的 client。保留纯包装函数和由测试锁定的大小上限。回退抓取、重定向,以及仓库内部的 debug 写入。
在任何模型再次看到这个分支之前,先运行 fixture 测试。拥有临时服务器的读者,可以复现同样的审查流程。先检查当前免费访问条款,再运行拒绝场景的测试。
如果还要采取进一步行动,你可以考虑屏蔽此人,或举报滥用行为。