Published on

Agent Harness 工程:权限与审批

Authors
  • avatar
    Name
    XiaoLeiJun
    Twitter

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

它比直接返回 TrueFalse 更清楚。

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 安全系统。生产环境里应该用更严格的命令白名单、结构化命令参数、沙箱、容器隔离和审计日志。

需要审批:写文件和非常规命令

接下来定义一个权限策略函数。

它把工具调用分成三类:

  1. 工作目录内的只读文件工具,直接放行;
  2. 写文件、编辑文件,先请求用户确认;
  3. 明显危险的命令或越界路径,直接拒绝。
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)

这段代码把一个工具调用拆成了五步:

  1. 读取工具名;
  2. 解析工具参数;
  3. 进行权限判断;
  4. 必要时请求用户确认;
  5. 真正执行工具。

注意,如果权限被拒绝,我们不是抛异常,也不是静默跳过,而是返回一段工具结果文本。这样模型下一轮能看到:工具没有执行,是因为权限被拒绝。

改造后的 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 的基础上,加上了一个最小权限策略层:

  1. PermissionResult 表达 allowaskdeny
  2. 在执行工具前解析参数并检查权限;
  3. 对危险命令和越界路径直接拒绝;
  4. 对写文件、编辑文件和非常规命令请求用户确认;
  5. 把拒绝或审批失败也作为 tool 消息写回模型。

到这里,Agent 已经不只是“能调用工具”,而是开始具备一个更重要的工程特征:能被约束地调用工具

下一篇继续把这个思路抽象出来:如何用 Hook 机制在不反复修改 Agent Loop 的情况下,扩展权限检查、日志记录、调用前后处理和执行观测。