第 3 章 / 共 21 章
三道权限闸门:把危险挡在执行之前
上一章留下的问题:现在模型手里有 bash。你说一句”帮我清理一下这个项目”,它完全可能生成 rm -rf /——不是因为它有恶意,而是因为它对”这个项目”的边界理解和你不一样。文件工具有 safe_path 挡着,bash 什么都没有。
很多人第一反应是在系统提示词里写”禁止执行危险命令”。这不管用,原因很简单:**提示词是请求,不是约束。**模型可能听,也可能在长上下文里把它忘了,还可能被输入里的内容带偏。安全必须由运行时的代码来强制。
3.1 三道闸门
把管控拆成三级,从最硬到最软:
| 闸门 | 判断什么 | 命中后 |
|---|---|---|
| 一:硬拒绝 | 命令里有永不允许的模式(rm -rf /、sudo、mkfs、dd if=、shutdown……) | 直接拒绝,不给任何人商量的余地 |
| 二:规则匹配 | 上下文相关的敏感操作(往工作目录外写文件、命令里含 rm 、chmod 777……) | 交给第三道闸门 |
| 三:人工确认 | 由人来判断 | 用户输入 y / n 决定放行还是拒绝 |
三道都没命中的操作,直接执行。这个”deny → ask → allow”的渐进结构,是在安全和可用之间取平衡:全部都问,人会疲劳到闭眼按 y;全部不问,等于没装。

3.2 三段代码
DENY_LIST = ["rm -rf /", "sudo", "shutdown", "reboot", "mkfs", "dd if=", "> /dev/sda"]
def check_deny_list(command: str) -> str | None:
for pattern in DENY_LIST:
if pattern in command:
return f"已阻止:'{pattern}' 在硬拒绝名单中"
return None
PERMISSION_RULES = [
{
"tools": ["write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "正在向工作目录之外写入",
},
{
"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "可能具有破坏性的命令",
},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule["check"](args):
return rule["message"]
return None
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n⚠ {reason}")
print(f" 工具:{tool_name}({args})")
choice = input(" 允许执行吗?[y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
串成一个判定函数,插进循环:
def check_permission(block) -> bool:
if block.name == "bash": # 闸门一
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n⛔ {reason}")
return False
reason = check_rules(block.name, block.input) # 闸门二
if reason:
if ask_user(block.name, block.input, reason) == "deny": # 闸门三
return False
return True
# agent_loop 里:
for block in response.content:
if block.type == "tool_use":
if not check_permission(block):
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": "用户拒绝了此操作。"})
continue
output = TOOL_HANDLERS[block.name](**block.input)
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
**注意被拒绝时那条 tool_result。**很多人会直接 continue 掉,结果 tool_use 没有配对的结果,API 直接报错。更重要的是:把”用户拒绝了”这句话回给模型,它才知道要换个方案,而不是傻等或重试同一条命令。拒绝也是一种反馈。
3.3 走一遍:一次被拦下的清理
任务:“帮我清理一下这个项目里的临时文件。”
| 轮次 | 模型的动作 | 闸门判定 | 回给模型的内容 |
|---|---|---|---|
| 1 | bash: find . -name '*.tmp' | 三道全过 | 12 个文件路径 |
| 2 | bash: rm -rf ./ | 闸门一命中?否(没有 rm -rf / 字面串)→ 闸门二命中(含 rm )→ 问人 | 用户按 n → “用户拒绝了此操作。” |
| 3 | bash: rm ./cache/a.tmp ./cache/b.tmp ... | 闸门二命中 → 问人 → 用户按 y | 执行成功 |
第 2 轮是关键:模型的第一反应过于粗暴,闸门二把它交给了人,人拦下了;模型看到拒绝之后,第 3 轮自己改成了逐个删除具体文件。这就是”拒绝作为反馈”的实际效果。
同时也暴露了一个真实缺陷:rm -rf ./ 并没有命中硬拒绝名单,因为名单里存的是 rm -rf / 这个字面串。下一节就说这件事。
3.4 工程现实:字符串匹配远远不够
教学版的黑名单是朴素的子串匹配,很容易被绕开:rm -rf / 可以写成 rm -fr /、rm -rf /(多个空格)、rm -rf $HOME/..、bash -c 'rm -rf /',甚至 base64 编码后再 eval。真实系统需要解析命令结构、识别管道与子 shell、对路径做规范化,而不是找字符串。
还有一条更重要的工程原则:**hook 或规则给出的”允许”,不能覆盖配置层面的”禁止”。**也就是说,权限系统要有明确的优先级——配置里写死禁用的工具,任何运行时逻辑都不能把它放行。否则你的安全边界就成了”最宽松的那条规则说了算”。
真正的隔离靠的还是容器、只读挂载、独立用户、网络白名单这些操作系统级手段。权限闸门是在正确的环境里减少误伤,不是在错误的环境里制造安全感。