learn-claude-code s03 深度解析:用三道闸门权限管线为 Agent 工具执行加一道安全门
本篇基于 learn-claude-code(Bash is all you need——从 0 到 1 构建 nano claude-code 风格 agent harness 的教学仓库)第 3 章文档 s03_permission/README.md 与配套源码 s03_permission/code.py 展开。文章将完整覆盖「执行前权限判断(Check Permissions Before Execution)」这一 Harness 层的实现:拒绝列表、规则匹配、用户审批三道闸门的定义、串接方式与插入点,并结合源码逐行印证每道闸门的边界与局限。读完你能掌握如何在自己的 agent loop 中用最小改动(一行插入)建立「硬拒绝 → 软询问 → 放行」的权限决策管线,并理解字符串匹配式 deny list 为什么不能当作完整安全边界。
1. 问题:s02 的 Agent 有 5 个工具,但 bash 没有任何限制
回顾 s02_tool_use 的成果:Agent 拥有 bash、read_file、write_file、edit_file、glob 五个工具。其中文件类工具受 safe_path 保护——s02 源码 s02_tool_use/code.py 中的实现会先对路径做 resolve(),再用 is_relative_to(WORKDIR) 校验,越界即抛 ValueError:
def safe_path(p: str) -> Path:
path = (WORKDIR / p).resolve()
if not path.is_relative_to(WORKDIR):
raise ValueError(f"Path escapes workspace: {p}")
return path
但 bash 工具是放开的:任意 shell 命令经 subprocess.run(command, shell=True, ...) 直接执行。文档给出的反例很直接:让 Agent「清理一下项目」,它可能执行 rm -rf /。
s03 给出的原则是:安全边界不能靠信任模型,必须由代码负责,且判断发生在每次工具执行之前。
2. 解决方案:保留 s02 循环,只插入一行 check_permission()
s03 没有重写任何已有逻辑,s02 的 agent loop 被完整保留。唯一改动是在工具执行前插入 check_permission()——每个 tool_use 块依次经过三道闸门,顺序固定:硬拒绝优先,软询问次之,都没命中就放行。
三道闸门对应三种决策:
| 闸门 | 作用 | 命中后 |
|---|---|---|
| 1. 拒绝列表 | 永远禁止的操作(rm -rf /、sudo) |
直接拒绝,不执行 |
| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、rm 文件) |
交给闸门 3 |
| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
三道都没命中则直接执行——大部分日常操作走这条路。
3. 三道闸门的逐层实现
3.1 闸门 1:硬拒绝列表(DENY_LIST)
先查、命中即阻止。列表与判定函数位于 code.py#L141-L147:
# Gate 1: Hard deny list - always forbidden
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"Blocked: '{pattern}' is on the deny list"
return None
要点与边界:
- 匹配方式是简单的子串包含(
pattern in command),命中任意一个模式就返回阻止信息; - 文档明确声明:这张表使用简单字符串匹配来「说明权限闸门的位置」,不是完整的安全边界。例如
rm -rf /*、rm -rf . --no-preserve-root之类变形可以绕过子串匹配——生产环境应替换为命令解析(AST/tokenize)或更强的沙箱机制; - 闸门 1 只对
bash工具生效(见 3.4 中的if block.name == "bash"条件),文件类工具不查这张表。
3.2 闸门 2:规则匹配(PERMISSION_RULES)
闸门 2 回答的是「什么时候需要问用户」。每条规则由三部分组成:tools(作用于哪些工具)、check(检查条件)、message(呈现给用户的理由)。源码位于 code.py#L151-L164:
# Gate 2: Rule matching - context-dependent checks
PERMISSION_RULES = [
{"tools": ["read_file", "write_file", "edit_file"],
"check": lambda args: not (WORKDIR / args.get("path", "")).resolve().is_relative_to(WORKDIR),
"message": "Writing outside workspace"},
{"tools": ["bash"],
"check": lambda args: any(kw in args.get("command", "") for kw in ["rm ", "> /etc/", "chmod 777"]),
"message": "Potentially destructive command"},
]
def check_rules(tool_name: str, args: dict) -> str | None:
for rule in PERMISSION_RULES:
if tool_name in rule["tools"] and rule"check":
return rule["message"]
return None
两条规则的设计意图:
- 文件工具越界规则:把
path参数与WORKDIR拼接后resolve(),再用is_relative_to(WORKDIR)判断——这复用了 s02 中safe_path的同款校验逻辑(见 s02_tool_use/code.py#L71-L75),但用途从「直接抛错」变成了「触发询问」。绝对路径(如/etc/something)或含..的越界相对路径都会命中; - 破坏性 bash 命令规则:用关键词(
"rm "、"> /etc/"、"chmod 777")做粗筛。注意关键词带尾部空格("rm "),ls -l | grep rm这类不含独立rm的命令不会误报,而rm -i test.txt会命中。
从源码结构看,一个值得注意的设计变化是:s03 的文件工具实现(run_read/run_write/run_edit,见 code.py#L73-L102)不再调用 s02 的 safe_path,而是直接 (WORKDIR / path).resolve() 后读写。也就是说,工作区边界在 s03 里从「工具内部的硬校验」上移成了「执行前闸门里的人工确认点」——用户若回复 y,文件工具确实可以把数据写到工作区外。这符合本章「把危险操作交给用户决策」的教学目标,但也说明三道闸门是审批流而非沙箱:真正的隔离仍依赖闸门 1 的 deny list 与后续章节的机制。
3.3 闸门 3:用户审批(ask_user)
规则命中后暂停整个循环,等待标准输入。实现位于 code.py#L168-L172:
# Gate 3: User approval - wait for confirmation after rule match
def ask_user(tool_name: str, args: dict, reason: str) -> str:
print(f"\n\033[33m[permission] {reason}\033[0m")
print(f" Tool: {tool_name}({args})")
choice = input(" Allow? [y/N] ").strip().lower()
return "allow" if choice in ("y", "yes") else "deny"
三个行为细节:
- 呈现内容包含命中规则的理由(
reason)和完整工具入参(args),保证用户在做决定前能看清「将被执行什么」; - 默认拒绝:只有输入
y或yes才放行,任何其他输入(包括直接回车)都返回"deny"——审批失败时的默认值是安全的; - 返回的
"allow"/"deny"字符串由上层管线消费,ask_user本身不产生副作用。
3.4 管线串接与插入点:check_permission()
三道闸门由 check_permission(block) -> bool 串成固定顺序的管线,位于 code.py#L176-L187:
# Pipeline: all three gates chained
def check_permission(block) -> bool:
if block.name == "bash":
reason = check_deny_list(block.input.get("command", ""))
if reason:
print(f"\n\033[31m[blocked] {reason}\033[0m")
return False
reason = check_rules(block.name, block.input)
if reason:
decision = ask_user(block.name, block.input, reason)
if decision == "deny":
return False
return True
顺序语义:deny list 只对 bash 生效且优先级最高;规则匹配对所有工具生效;只有规则命中才进入用户审批;返回 False 即终止本次调用,返回 True 才允许分发执行。
在 agent loop 中的插入点只有一行,位于 code.py#L204-L219:
for block in response.content:
if block.type != "tool_use":
continue
print(f"\033[36m> {block.name}\033[0m")
# s03 change: run through permission pipeline before executing
if not check_permission(block):
results.append({"type": "tool_result", "tool_use_id": block.id,
"content": "Permission denied."})
continue
handler = TOOL_HANDLERS.get(block.name)
output = handler(**block.input) if handler else f"Unknown: {block.name}"
...
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
注意拒绝路径的处理:被拒的调用不会从对话中消失,而是以 tool_result + "Permission denied." 的形式写回消息历史(results 列表最终在 code.py#L221 以 {"role": "user", "content": results} 追加进 messages)。模型因此能感知「这个操作没被批准」,可以选择换一种方式或向用户解释——这是权限系统与模型协同的关键闭环,而不只是打印一条错误。
4. 相对 s02 的变更与周边实现细节
| 组件 | 之前 (s02) | 之后 (s03) |
|---|---|---|
| 安全模型 | 无(信任模型,仅文件工具有 safe_path) | 三道闸门权限管线 |
| 新增函数 | — | check_deny_list、check_rules、ask_user、check_permission |
| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |
源码里还有几处与权限行为直接相关的设定,值得在复用时留意:
- 系统提示参与安全约定:
SYSTEM = f"You are a coding agent at {WORKDIR}. All destructive operations require user approval."(code.py#L58)。提示词层面的声明与代码层面的闸门互为补充:前者引导模型主动避免危险操作,后者保证即使模型越界也会被拦截。 - 工作区基准是进程启动目录:
WORKDIR = Path.cwd()(code.py#L54),因此所有is_relative_to(WORKDIR)校验的基准随启动位置变化。 - bash 执行有硬超时:
subprocess.run(..., timeout=120),超时返回Error: Timeout (120s)(code.py#L63-L70)——权限闸门之外,执行层本身也限制了单次调用的爆炸半径。 - API 配置:脚本读取
.env(load_dotenv(override=True)),支持ANTHROPIC_BASE_URL指向兼容端点,模型名由环境变量MODEL_ID指定,max_tokens=8000(code.py#L50-L56、code.py#L194-L197)。
5. 亲手试一下
运行前提:Python 3,安装 requirements.txt 中的 anthropic 与 python-dotenv(pip install anthropic python-dotenv),并在 .env 中配置 ANTHROPIC_API_KEY(如需自定义网关可设 ANTHROPIC_BASE_URL 与 MODEL_ID)。
cd learn-claude-code
python s03_permission/code.py
文档给出的四条验证 prompt 分别覆盖管线的三种结果:
Create a file called test.txt in the current directory—— 工作区内写入,不命中任何规则,直接通过;Delete the file test.txt—— bash 命令含rm,命中闸门 2,暂停等待 y/N 确认;What files are in the current directory?—— 只读操作(glob/read_file),全部通过;Try to write a file to /etc/something—— 路径越出工作区,命中闸门 2 的越界规则,暂停等待确认。
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝(比如让 Agent 执行 sudo 或 rm -rf /,应看到红色的 [blocked] 输出而非审批提示)。
6. 延伸:硬编码检查为什么只是过渡方案
s03 已经把权限检查就位,但 check_permission() 是硬编码在 agent_loop 里的一次调用。文档在结尾提出了下一步的动机:想在每次工具执行前后加日志?想在某些写操作后自动 git commit?这些扩展逻辑继续往 loop 里塞,循环很快就会膨胀。
这正是 s04_hooks 要解决的问题:把 check_permission() 从循环体内移到一个 PreToolUse 钩子上,循环只调用 trigger_hooks("PreToolUse", block),由注册表决定执行什么。仓库后续阶段的代码也印证了这条演进路径——例如 tests/test_task_system.py#L81 中断言 lesson.permission_hook in lesson.HOOKS["PreToolUse"],即权限检查最终成为可注册的 hook 而非固定语句。
小结:s03 用约 50 行代码(DENY_LIST + PERMISSION_RULES + 四个函数 + 循环中一行插入)演示了 agent harness 权限层的最小形态——硬拒绝、上下文规则、人工审批三级决策,默认值全部偏向「不执行」。它诚实地标注了字符串匹配的局限,把「审批流 ≠ 沙箱」的边界讲清楚,并自然地把扩展点留给 hooks 机制。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00