- Published on
Agent Harness 工程:权限与审批
- Authors

- Name
- XiaoLeiJun
Agent Harness 工程:权限与审批
上一篇我们把单工具 Agent 扩展成了多工具 Agent,并用 TOOL_HANDLERS 把工具名映射到真实处理函数。这样 Agent Loop 不再需要关心具体有哪些工具,只需要遍历模型返回的 tool_calls,再交给 Harness 分发执行。
工具系统一旦变强,下一个问题马上出现:不是所有工具调用都应该直接执行。
比如 read_file("README.md") 通常是低风险的;write_file("src/app.py", "...") 已经会修改工作区;bash("rm -rf .") 这种命令则应该直接拒绝。工具越多、能力越强,权限控制就越不能靠“模型自己小心一点”。
所以这一篇继续沿着前两篇的代码,把一个权限策略层加进 Agent Loop。
为什么权限必须在 Harness 里做
一个常见误区是:既然模型足够聪明,那能不能让模型自己判断哪些操作危险?
模型可以帮忙解释风险,但不能成为最后一道安全边界。原因很简单:
- 模型可能误判;
- 模型可能被用户提示诱导;
- 模型生成的工具参数不一定可信;
- 真正执行动作的是 Harness,不是模型。
所以权限控制应该发生在 Harness 里。模型提出工具调用,Harness 解析参数,然后在执行真实函数之前做权限判断。
权限策略可以先分成三类:低风险直接放行,中风险请求确认,高风险直接拒绝。
在教学版 Agent 里,我们先用三种结果表达权限判断:
| 结果 | 含义 | 例子 |
|---|---|---|
allow | 可以直接执行 | 读取工作目录内的普通文本文件 |
ask | 需要用户确认后才能执行 | 写文件、编辑文件、非常规命令 |
deny | 直接拒绝,不进入真实工具函数 | 删除文件、提权命令、越界路径 |
真实生产环境会更复杂,比如角色权限、租户隔离、审批流、审计日志、回滚机制、环境分级。但最小实现里,先把这三类跑通就够了。
定义权限判断结果
先定义一个小的数据结构,用来表达权限检查结果:
import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Literal
PermissionAction = Literal["allow", "ask", "deny"]
@dataclass
class PermissionResult:
action: PermissionAction
reason: str
它比直接返回 True 或 False 更清楚。
True 只能表达“能不能执行”,但权限系统通常还要知道“为什么”。比如:
- 因为是只读工具,所以放行;
- 因为要写文件,所以需要用户确认;
- 因为路径逃出工作目录,所以拒绝;
- 因为命令包含危险关键字,所以拒绝。
reason 会被展示给用户,也会在拒绝或审批失败时作为工具结果写回给模型。
先解析工具参数
在上一篇里,run_tool_call() 里负责解析 tool_call.function.arguments。现在要做权限检查,就需要在执行工具之前先拿到参数。
所以我们先抽出一个参数解析函数:
def parse_tool_arguments(tool_call: Any) -> tuple[dict[str, Any] | None, str | None]:
try:
arguments = json.loads(tool_call.function.arguments or "{}")
except json.JSONDecodeError as exc:
return None, f"Error: invalid JSON arguments - {exc}"
if not isinstance(arguments, dict):
return None, "Error: tool arguments must be a JSON object."
return arguments, None
这里继续保留上一篇的原则:模型生成的参数不能直接信任。即使 TOOLS 里已经写了 parameters,运行时还是要解析和校验。
直接拒绝:危险命令和越界路径
先处理最危险的情况:命令明显危险,或者路径逃出工作目录。
上一篇我们已经有了 safe_path():
def safe_path(path: str) -> Path:
target = (WORKDIR / path).resolve()
if not target.is_relative_to(WORKDIR):
raise ValueError(f"Unsafe path: {path}")
return target
权限层可以复用它来判断路径是否安全:
FILE_TOOLS = {"list_dir", "read_file", "write_file", "edit_file"}
MUTATING_FILE_TOOLS = {"write_file", "edit_file"}
DANGEROUS_COMMAND_RE = re.compile(
r"(^|[\s;&|])(rm|sudo|shutdown|reboot|init|poweroff)\b"
)
def path_is_inside_workdir(path: str) -> bool:
try:
safe_path(path)
return True
except Exception:
return False
def command_has_dangerous_pattern(command: str) -> bool:
return bool(DANGEROUS_COMMAND_RE.search(command))
这里仍然是教学版实现,不是生产级 shell 安全系统。生产环境里应该用更严格的命令白名单、结构化命令参数、沙箱、容器隔离和审计日志。
需要审批:写文件和非常规命令
接下来定义一个权限策略函数。
它把工具调用分成三类:
- 工作目录内的只读文件工具,直接放行;
- 写文件、编辑文件,先请求用户确认;
- 明显危险的命令或越界路径,直接拒绝。
SAFE_BASH_PREFIXES = (
"pwd",
"ls",
"git status",
"git diff",
)
def check_permission(tool_name: str, arguments: dict[str, Any]) -> PermissionResult:
if tool_name in FILE_TOOLS:
path = arguments.get("path", ".")
if not isinstance(path, str):
return PermissionResult("deny", "file path must be a string")
if not path_is_inside_workdir(path):
return PermissionResult("deny", f"path is outside WORKDIR: {path}")
if tool_name in MUTATING_FILE_TOOLS:
return PermissionResult("ask", f"{tool_name} will modify {path}")
return PermissionResult("allow", f"{tool_name} is read-only")
if tool_name == "bash":
command = arguments.get("command", "")
if not isinstance(command, str) or not command.strip():
return PermissionResult("deny", "bash command must be a non-empty string")
if command_has_dangerous_pattern(command):
return PermissionResult("deny", f"dangerous bash command: {command}")
if command.startswith(SAFE_BASH_PREFIXES):
return PermissionResult("allow", f"safe bash command: {command}")
return PermissionResult("ask", f"bash command requires confirmation: {command}")
return PermissionResult("deny", f"unknown tool: {tool_name}")
这里有几个细节。
第一,未知工具默认拒绝。模型可能幻觉一个不存在的工具名,不能因为模型请求了就执行。
第二,只读不等于完全无风险。比如读取密钥文件就有风险。本文为了演示,把“工作目录内的读操作”暂时视为低风险。真实系统应该继续细分路径、文件类型和敏感内容。
第三,写文件和编辑文件默认进入 ask。这并不是说它们一定危险,而是因为它们会改变环境。只要会改变环境,就应该有更明确的权限边界。
第四,bash 除了少数安全前缀外,默认请求确认。bash 的表达能力太强,不适合轻易全量放行。
向用户请求确认
教学版可以先用命令行 input() 做审批:
def ask_user_for_permission(tool_name: str, arguments: dict[str, Any], reason: str) -> bool:
print("\nPermission required")
print(f"Tool: {tool_name}")
print(f"Reason: {reason}")
print(f"Arguments: {json.dumps(arguments, ensure_ascii=False, indent=2)}")
answer = input("Allow this action? [y/N]: ").strip().lower()
return answer in {"y", "yes"}
真实产品里,这一步可以变成 UI 弹窗、命令行确认、审批工单、企业策略引擎,或者由用户预先配置的权限规则。
但无论界面怎么变,核心语义都是一样的:Harness 在执行真实动作之前停下来,让用户或策略系统决定是否允许。
权限门位于参数解析之后、真实工具执行之前。拒绝或用户拒批也要作为 tool 结果回到 messages。
把权限检查放进工具执行流程
上一篇的 run_tool_call() 负责根据工具名找到处理函数并执行。现在我们稍微改一下,让它接收已经解析好的参数:
def run_tool_call(tool_name: str, arguments: dict[str, Any]) -> str:
handler = TOOL_HANDLERS.get(tool_name)
if handler is None:
return f"Error: unknown tool {tool_name}"
try:
return handler(**arguments)
except TypeError as exc:
return f"Error: invalid arguments for {tool_name} - {exc}"
except Exception as exc:
return f"Error: tool {tool_name} failed - {exc}"
然后再增加一个带权限检查的执行函数:
def run_tool_call_with_permission(tool_call: Any) -> str:
tool_name = tool_call.function.name
arguments, error = parse_tool_arguments(tool_call)
if error:
return error
permission = check_permission(tool_name, arguments)
if permission.action == "deny":
return f"Error: permission denied. {permission.reason}"
if permission.action == "ask":
allowed = ask_user_for_permission(tool_name, arguments, permission.reason)
if not allowed:
return f"Error: user denied permission. {permission.reason}"
return run_tool_call(tool_name, arguments)
这段代码把一个工具调用拆成了五步:
- 读取工具名;
- 解析工具参数;
- 进行权限判断;
- 必要时请求用户确认;
- 真正执行工具。
注意,如果权限被拒绝,我们不是抛异常,也不是静默跳过,而是返回一段工具结果文本。这样模型下一轮能看到:工具没有执行,是因为权限被拒绝。
改造后的 Agent Loop
现在再看 Agent Loop,它的结构仍然和前两篇保持一致。
真正变化的地方只有一行:执行工具时,从 run_tool_call(tool_call) 变成 run_tool_call_with_permission(tool_call)。
SYSTEM_PROMPT = f"""
你是一个编程助手,工作在当前目录:{WORKDIR}。
你可以使用工具读取文件、列出目录、写入文件、编辑文件和执行必要的 shell 命令。
如果工具调用被拒绝,请根据拒绝原因调整方案,不要反复请求同一个危险操作。
"""
def run_loop(user_message: str, max_steps: int = 8) -> str:
messages: list[dict[str, Any]] = [{"role": "user", "content": user_message}]
for _ in range(max_steps):
response = client.chat.completions.create(
model=MODEL,
messages=[{"role": "system", "content": SYSTEM_PROMPT}] + messages,
tools=TOOLS,
)
assistant_message = response.choices[0].message
if not assistant_message.tool_calls:
final_answer = assistant_message.content or ""
messages.append({"role": "assistant", "content": final_answer})
return final_answer
messages.append(
{
"role": "assistant",
"content": assistant_message.content or "",
"tool_calls": [
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
for tool_call in assistant_message.tool_calls
],
}
)
for tool_call in assistant_message.tool_calls:
tool_result = run_tool_call_with_permission(tool_call)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result,
}
)
return "达到最大循环次数,任务仍未完成。"
这里一定要继续保留上一篇的消息结构:模型发出的 assistant tool_calls 要先进入历史,每个工具结果都要用对应的 tool_call_id 写回。
即使权限拒绝,也要写回:
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": "Error: permission denied. dangerous bash command: rm -rf .",
}
这样模型才知道发生了什么,并在下一轮选择更安全的替代方案。比如它可以改成解释为什么不能删除文件,或者建议用户手动确认后再执行。
权限策略不要只靠硬编码
上面的代码已经能表达一个最小权限系统,但它仍然很粗糙。
真实 Harness 里的权限系统通常会继续拆出更多维度:
- 工具维度:不同工具有不同风险等级;
- 参数维度:同一个工具,不同参数风险不同;
- 路径维度:普通源码、配置文件、密钥文件不应该同权;
- 环境维度:本地开发环境、测试环境、生产环境策略不同;
- 用户维度:不同用户、团队、角色能批准的动作不同;
- 审计维度:谁在什么时候批准了什么动作,要能追踪。
也可以让另一个模型辅助做风险解释,比如把工具名、参数和上下文交给一个审核模型,让它生成“为什么这个操作需要审批”。但最终是否允许执行,不应该只由 LLM 决定。权限边界要由确定性的 Harness 策略兜底。
小结
这篇在多工具 Agent 的基础上,加上了一个最小权限策略层:
- 用
PermissionResult表达allow、ask、deny; - 在执行工具前解析参数并检查权限;
- 对危险命令和越界路径直接拒绝;
- 对写文件、编辑文件和非常规命令请求用户确认;
- 把拒绝或审批失败也作为
tool消息写回模型。
到这里,Agent 已经不只是“能调用工具”,而是开始具备一个更重要的工程特征:能被约束地调用工具。
下一篇继续把这个思路抽象出来:如何用 Hook 机制在不反复修改 Agent Loop 的情况下,扩展权限检查、日志记录、调用前后处理和执行观测。