执行前的一道门:learn-claude-code 中 s03 权限管线的三道闸门设计与实现
本文基于 learn-claude-code 课程的第 s03 章(Permission),完整讲解如何在 Claude Code 风格的 Agent Harness 中,于工具执行之前插入一条"硬拒绝 → 规则匹配 → 用户审批"的三道闸门权限管线。读完后你将理解:权限边界为什么必须由 harness 代码而不是模型自觉来负责、三道闸门各自的命中逻辑与代码实现、管线如何只加一行代码就接入 s02 的 agent loop,以及该模式在仓库后续课程(s04 Hooks、s15 集成 Harness)中的演化方向。
一、问题:s02 的 Agent 有工具,却没有 bash 安全边界
回顾 s02_tool_use/code.py:Agent 拥有 bash、read_file、write_file、edit_file、glob 五个工具。其中文件类工具受 safe_path() 保护——任何解析后逃出工作区的路径都会抛出 ValueError:
# s02_tool_use/code.py L71-75
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 工具不受任何限制:run_bash() 直接以 shell=True 执行模型给出的任意命令。此时如果你让 Agent"清理一下项目",它在理论上是可能执行 rm -rf / 的。
s03 要解决的核心命题是:安全边界由代码负责,且判断必须发生在工具执行之前。模型可能给出危险指令,harness 有责任在执行前拦截或升级确认,而不能依赖系统提示词或模型的"自觉"。
二、解决方案:在工具执行前插入 check_permission()
s02 的循环完全保留,唯一的变动是在工具执行前插入 check_permission()。每个工具调用依次经过三道闸门:硬拒绝优先,软询问次之,都没命中就放行。三道闸门对应三种决策:
| 闸门 | 作用 | 命中后 |
|---|---|---|
| 1. 拒绝列表 | 永远禁止的操作(rm -rf /、sudo) |
直接拒绝,不执行 |
| 2. 规则匹配 | 取决于上下文的操作(读/写工作区外、rm 文件) |
交给闸门 3 |
| 3. 用户审批 | 闸门 2 命中后,暂停等用户确认 | 用户决定允许或拒绝 |
三道都没命中 → 直接执行。大部分日常操作(读文件、列目录、写入工作区内文件)走这条路,因此权限管线不会拖慢正常流程。
三、闸门 1:硬拒绝表(Hard Deny List)
第一张表是"永远禁止"的黑名单,先查,命中就返回阻止信息。实现见 s03_permission/code.py:
# 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),目的是把权限闸门的位置演示清楚;官方文档明确指出"这张表使用简单字符串匹配来说明权限闸门的位置,不能视为完整的安全边界"。子串匹配存在误报的可能(例如echo "sudo usage"也会被拦下),这是刻意保留的、便于理解的教学实现。 - 闸门 1 只对
bash工具生效——在check_permission()内部有if block.name == "bash"的前置判断,文件工具不会进入黑名单检查。 - 命中后返回一段带命中模式的说明(
Blocked: 'sudo' is on the deny list),随后管线直接返回False,工具不会执行。
四、闸门 2:规则匹配(Context-Dependent Rules)
黑名单解决"永远不行",但还有一类操作是"看情况"的:写工作区外的路径、执行 rm、改系统文件权限……这些操作不该一刀切禁止,而应触发用户确认。PERMISSION_RULES 用数据驱动的方式描述"什么时候需要问用户",每条规则指定适用工具列表、检查条件(lambda)和触发原因,见 s03_permission/code.py:
# 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
两条内置规则逐条拆解:
- 工作区外访问规则(
read_file/write_file/edit_file):把工具参数中的path拼到WORKDIR下并resolve()解引用符号链接,再用is_relative_to(WORKDIR)判断是否仍在工作区内;只要"不相对"(即逃出去了),就命中。这条规则等价于把 s02 中safe_path()的"硬抛异常"改造成了"软询问"——同一个resolve() + is_relative_to()判定,在 s02 里直接让工具报错,在 s03 里则升级为用户可见的审批提示。 - 破坏性 bash 命令规则:命令中若包含
rm、> /etc/、chmod 777任一片段,判定为"潜在破坏性命令"。
check_rules() 按序遍历规则表,返回第一条命中规则的 message 作为原因字符串;没有任何规则命中则返回 None(放行)。这种"工具 + lambda 条件 + 原因文案"的规则结构,后续扩展新检查只需追加字典项,无需改动管线代码。
五、闸门 3:用户审批(Pause & Ask)
规则命中后,管线暂停执行,把"原因 + 具体工具 + 完整参数"打印出来,等待用户在终端输入确认,见 s03_permission/code.py:
# 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"
实现细节:
- 输出使用 ANSI 黄色高亮
[permission] {reason},并原样展示工具名与完整参数字典——用户审批时必须看到"Agent 到底想对什么路径/什么命令动手"。 - 默认拒绝:
input()提示为[y/N],只有输入y/yes才返回allow,其余任何输入(包括直接回车)都返回deny。这是审批类交互的标准安全默认值。 - 返回的是字符串
"allow"/"deny",由上层check_permission()决定是否继续执行。
六、管线组装:check_permission() 与 agent_loop 的一行接入
三道闸门串成一个纯函数式的判断入口,见 s03_permission/code.py:
# 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
执行顺序是严格的短路链:
- 仅当工具为
bash时先查闸门 1,命中即打印红色[blocked]并return False; - 所有工具都查闸门 2,命中后进入闸门 3 等待审批;用户拒绝则
return False; - 全部未命中或用户批准 →
return True,工具正常执行。
接入点只在 agent_loop() 的工具分发循环里加了一个判断,见 s03_permission/code.py:
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}"
print(str(output)[:200])
results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
messages.append({"role": "user", "content": results})
这里有两个容易被忽略但很关键的设计:
- 拒绝不是终止,而是回传给模型。被拒的工具调用会以
tool_result: "Permission denied."的形式写回消息历史,模型在下一轮会"看到"自己被拒了哪个操作、原因语境如何,从而自行换一种合规做法或向用户解释——权限结果成为 agent loop 里正常的一等公民消息,与工具正常输出走同一条通道。 - s02 的循环形态零改动:仍是
client.messages.create(...)→ 追加 assistant 消息 → 判断stop_reason != "tool_use"则返回 → 收集results追加为 user 消息。权限系统是一个"可插拔的前置过滤器",这为 s04 把它抽成 Hook 埋下了伏笔。
此外,系统提示词也从"软"层面同步了这条边界(s03_permission/code.py#L58):
SYSTEM = f"You are a coding agent at {WORKDIR}. All destructive operations require user approval."
即:提示词负责让模型"知道"审批的存在(减少无谓的危险尝试),代码闸门负责在模型犯错时"拦住"它。软硬两层各司其职。
七、从源码结构看:s02 到 s03 的安全模型迁移
对比 s02_tool_use/code.py 与 s03_permission/code.py,可以观察到一次安全模型的位置迁移:
| 组件 | 之前 (s02) | 之后 (s03) |
|---|---|---|
| 安全模型 | 无统一模型(文件工具靠 safe_path 硬抛异常,bash 无限制) |
三道闸门权限管线 |
| 新函数 | — | check_deny_list、check_rules、ask_user、check_permission |
| 循环 | 直接执行所有工具 | 执行前插入 check_permission() |
| 越界路径 | 工具内部 raise ValueError,模型只能看到报错文本 |
闸门 2 先命中 → 用户显式审批,路径仍可被允许 |
| bash | 完全不受限 | deny list + 破坏性命令关键词 + 审批三重防护 |
从源码结构看,s03 的文件工具实现(如 run_read、run_write)已不再调用 s02 的 safe_path(),而是直接 (WORKDIR / path).resolve() 读写——路径越界的判定统一上移到了闸门 2。这意味着同一类越界行为从"工具直接失败"变成了"先问人",给合法跨目录操作留了口子,同时把判断逻辑收敛到一处(PERMISSION_RULES),不再散落在各工具内部。
八、运行与实测:四个能看出三道闸门差异的 prompt
8.1 环境准备
前置依赖见 s03_permission/code.py 文件头 docstring:
cd learn-claude-code
pip install anthropic python-dotenv
# .env 中配置:
# ANTHROPIC_API_KEY=<你的密钥>
# MODEL_ID=<模型名>
# ANTHROPIC_BASE_URL=<可选,自定义网关地址>
代码入口还会处理两个细节:load_dotenv(override=True) 加载 .env;若设置了 ANTHROPIC_BASE_URL 则移除 ANTHROPIC_AUTH_TOKEN(见 s03_permission/code.py#L50-L56)。bash 工具有 120 秒超时,输出截断到 50000 字符;每次 API 调用 max_tokens=8000(s03_permission/code.py#L63-L70)。
8.2 启动
python s03_permission/code.py
进入交互式终端(提示符 s03 >>,输入 q 或 exit 退出)。
8.3 建议依次尝试的 prompt 与预期闸门行为
Create a file called test.txt in the current directory—— 写入工作区内,三道闸门全部未命中,直接通过;Delete the file test.txt—— 模型会调用bash执行rm test.txt,rm关键词命中闸门 2,弹出[permission] Potentially destructive command等待y/N;What files are in the current directory?—— 只读glob/bash ls,全部放行;Try to write a file to /etc/something——write_file的path解析后逃出WORKDIR,命中闸门 2 的工作区外规则,进入用户审批。
观察重点:哪些操作直接通过?哪些需要你确认?哪些被直接拒绝(如让 Agent 执行 sudo reboot,会被闸门 1 以红色 [blocked] 直接拦下)?把实际交互对照 s03_permission/README.zh.md 中的三道闸门表验证一遍,就建立了完整的权限心智模型。
九、局限性与边界说明
- 字符串黑名单不是安全边界:闸门 1 的子串匹配只用于教学定位,真实系统需要命令解析(tokenize/AST 级)才能识别
$(...)、反引号、变量展开等绕过形态。 - 规则覆盖是白名单式的枚举:
chmod 777、> /etc/等关键词只覆盖了常见破坏动作,新攻击面需要往PERMISSION_RULES里加条目。 - 审批交互依赖终端:
ask_user()直接读 stdin,在无人值守(后台任务、定时任务)场景不可用——这正是仓库后续章节要解决的问题。
十、后续演化:从内联判断到 Hook 与运行时策略
s03 的权限检查目前每次都硬编码在循环里。如果在每次工具执行前后加日志?如果想在某些操作后自动触发 git commit?扩展逻辑散落在 loop 里,循环很快就会膨胀。s04_hooks/README.zh.md 的解法是给循环加钩子:check_permission 这类逻辑从循环体迁出,挂到 PreToolUse 钩子上,循环保持干净。
仓库的测试与集成代码印证了这条演化链:
- tests/test_task_system.py 中
assert lesson.permission_hook in lesson.HOOKS["PreToolUse"]——确认权限检查已注册为PreToolUse钩子; - tests/test_background_tasks.py 的
test_background_bash_passes_permission_before_dispatch——后台 bash 任务在分发前同样要过权限关; - tests/test_cron_scheduler.py 的
test_scheduled_turn_never_reads_interactive_permission_input——定时任务轮次绝不能读交互式审批输入,说明非交互场景需要替代的权限策略; - s15_integrated_harness/code.py 中,
DENY_LIST与permission_hook(block)被重新定义并通过register_hook("PreToolUse", permission_hook)挂入集成 Harness,MCP 工具也复用同一权限策略(参见 tests/test_agent_teams_runtime.py 中test_mcp_permission_uses_host_policy与test_integrated_permission_requires_approval_for_every_shell_command)。
十一、核心要点回顾
- 权限判断必须发生在工具执行之前,由 harness 代码负责,不依赖模型自觉;提示词只做软层提醒。
- 三道闸门是短路链:deny list(硬拒绝)→ 规则匹配(上下文判断)→ 用户审批(默认拒绝),全部未命中才放行。
- 管线以函数形式插入 agent loop 只需一行,拒绝结果以
tool_result: "Permission denied."回传模型,让模型在循环内自我修正。 - 安全机制的位置可以迁移:s02 的工具内硬校验(
safe_path)在 s03 中上移为规则层的软询问,判断逻辑从散落的工具内部收敛到统一的PERMISSION_RULES。 - 内联判断是过渡形态:s04 之后权限检查演化为
PreToolUse钩子,并贯穿后台任务、定时任务与 MCP 工具的统一策略。
相关文档:s03_permission/README.zh.md(本课中文原文)、s02_tool_use/README.zh.md(前置章节:多工具与分发)、s04_hooks/README.zh.md(后续章节:钩子机制)。
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