Tool Permission 与 Sandbox 的安全流水线:Agent 工具系统的工程边界

说明:本文源码是教学用的最小实现样例,不代表任何闭源产品内部实现。涉及 Claude Code、OpenAI Agents SDK、MCP、OWASP 的事实,以文末官方文档为准。

摘要

Agent 真正危险的地方,不是"说错话",而是"带着工具把错误变成现实世界的副作用"。一旦 Agent 能读文件、改代码、跑命令、调用 API、连接数据库,它就必须被放进一套安全流水线里。

这条流水线包括:

text 复制代码
模型提出工具调用
  -> schema 校验
  -> 风险分类
  -> 权限策略
  -> 审批
  -> 沙箱执行
  -> 输出清洗
  -> Trace 审计

Claude Code 官方文档强调默认严格只读、编辑和命令执行需要授权;OpenAI Agents SDK 提供 guardrails、tool approval、tool execution config;MCP 官方文档也专门讨论授权和 confused deputy 等安全问题。这些设计共同说明:工具安全不能靠 prompt,必须靠代码和运行环境。

一、模型只负责"想",系统负责"准不准做"

错误设计:

python 复制代码
tool_call = llm(messages).tool_call
result = tools[tool_call.name](**tool_call.args)

这等于把执行权直接交给模型。

正确设计:

text 复制代码
LLM output 是"候选动作"
Policy engine 决定"是否允许"
Sandbox 决定"影响范围"
Tool runtime 决定"如何执行"
Audit log 决定"事后能否解释"

一句话:模型可以建议,系统必须裁决。

二、源码示例:工具注册不要裸函数

每个工具都应该声明输入、输出、副作用、权限等级和运行限制。

python 复制代码
from dataclasses import dataclass
from enum import Enum
from typing import Any, Callable


class RiskLevel(str, Enum):
    READ_ONLY = "read_only"
    LOW_WRITE = "low_write"
    HIGH_WRITE = "high_write"
    EXTERNAL_SIDE_EFFECT = "external_side_effect"


@dataclass
class ToolSpec:
    name: str
    description: str
    input_schema: dict[str, Any]
    risk_level: RiskLevel
    timeout_seconds: int
    allowed_roots: list[str]
    executor: Callable[[dict[str, Any]], Any]


class ToolRegistry:
    def __init__(self):
        self._tools: dict[str, ToolSpec] = {}

    def register(self, spec: ToolSpec) -> None:
        if spec.name in self._tools:
            raise ValueError(f"duplicate tool: {spec.name}")
        self._tools[spec.name] = spec

    def get(self, name: str) -> ToolSpec:
        if name not in self._tools:
            raise KeyError(f"unknown tool: {name}")
        return self._tools[name]

工具注册的核心不是"把函数放进字典",而是把工具的契约写清楚。OpenAI Agents SDK 的 function tools 会基于函数签名和类型信息生成 schema,这也是同一个思路。

三、源码示例:Schema 校验是第一道门

模型输出的 JSON 不能直接相信。即使模型很强,也可能产生缺字段、错类型、路径逃逸等问题。

python 复制代码
import jsonschema


def validate_tool_call(spec: ToolSpec, args: dict[str, Any]) -> None:
    jsonschema.validate(instance=args, schema=spec.input_schema)

示例工具 schema:

python 复制代码
read_file_schema = {
    "type": "object",
    "properties": {
        "path": {"type": "string"},
        "offset": {"type": "integer", "minimum": 0},
        "limit": {"type": "integer", "minimum": 1, "maximum": 500},
    },
    "required": ["path"],
    "additionalProperties": False,
}

additionalProperties: False 很重要。它能防止模型塞入未定义参数,例如 {"path": "...", "sudo": true}

四、源码示例:路径安全不能只做字符串判断

错误写法:

python 复制代码
if ".." in path:
    raise ValueError("bad path")

这种判断很容易被编码、符号链接、绝对路径绕过。

更稳妥的写法:

python 复制代码
from pathlib import Path


def resolve_under_root(path: str, allowed_root: str) -> Path:
    root = Path(allowed_root).resolve()
    target = (root / path).resolve()

    if root not in target.parents and target != root:
        raise PermissionError(f"path escapes root: {path}")

    return target

工具执行前必须把路径解析成规范绝对路径,再判断是否仍在允许根目录下。

五、源码示例:权限策略采用 deny 优先

权限规则建议按"默认策略 + allow + ask + deny"设计,并保证 deny 永远优先。

python 复制代码
import fnmatch
from dataclasses import dataclass


@dataclass
class PolicyRule:
    pattern: str
    decision: str  # allow | ask | deny


class PermissionPolicy:
    def __init__(self, default: str, rules: list[PolicyRule]):
        self.default = default
        self.rules = rules

    def evaluate_path(self, path: str) -> str:
        matched = [rule for rule in self.rules if fnmatch.fnmatch(path, rule.pattern)]

        if any(rule.decision == "deny" for rule in matched):
            return "deny"

        if any(rule.decision == "ask" for rule in matched):
            return "ask"

        if any(rule.decision == "allow" for rule in matched):
            return "allow"

        return self.default

示例策略:

python 复制代码
policy = PermissionPolicy(
    default="ask",
    rules=[
        PolicyRule("src/**/*.ts", "allow"),
        PolicyRule("tests/**/*.ts", "allow"),
        PolicyRule("**/.env", "deny"),
        PolicyRule("**/secrets/**", "deny"),
        PolicyRule("infra/prod/**", "deny"),
    ],
)

普通人可以这样理解:allow 是通行证,ask 是人工检查,deny 是红线。红线必须压倒所有通行证。

六、源码示例:Shell 命令的风险分类

Bash 是最危险的工具之一。简单正则不够,但即使没有完整 AST 解析,也至少要先做命令级分类。

python 复制代码
import shlex


HIGH_RISK_COMMANDS = {"rm", "dd", "mkfs", "chmod", "chown", "sudo", "curl", "wget", "scp"}
READ_ONLY_COMMANDS = {"ls", "pwd", "cat", "rg", "grep", "git"}


def classify_shell(command: str) -> RiskLevel:
    parts = shlex.split(command)
    if not parts:
        raise ValueError("empty command")

    binary = parts[0]

    if binary in HIGH_RISK_COMMANDS:
        return RiskLevel.HIGH_WRITE

    if binary in READ_ONLY_COMMANDS:
        return RiskLevel.READ_ONLY

    return RiskLevel.LOW_WRITE

这只是最低配。真正严肃的系统应该使用解析器识别管道、重定向、命令替换、子 shell、环境变量展开等结构。原因很简单:echo safe && rm -rf x 不是一个简单命令。

七、源码示例:沙箱执行的最小接口

沙箱可以是临时目录、Git worktree、容器、VM 或远程隔离环境。应用层最好不要关心具体实现,只依赖统一接口。

python 复制代码
from dataclasses import dataclass
from typing import Protocol


@dataclass
class SandboxResult:
    stdout: str
    stderr: str
    exit_code: int
    timed_out: bool


class Sandbox(Protocol):
    def run(self, command: list[str], cwd: str, timeout_seconds: int) -> SandboxResult:
        ...

一个简单的本地受限实现:

python 复制代码
import subprocess


class LocalSandbox:
    def __init__(self, root: str):
        self.root = str(Path(root).resolve())

    def run(self, command: list[str], cwd: str, timeout_seconds: int) -> SandboxResult:
        safe_cwd = resolve_under_root(cwd, self.root)
        try:
            proc = subprocess.run(
                command,
                cwd=safe_cwd,
                capture_output=True,
                text=True,
                timeout=timeout_seconds,
                env={},  # 简化示例:真实系统应传入最小环境变量
            )
            return SandboxResult(proc.stdout, proc.stderr, proc.returncode, False)
        except subprocess.TimeoutExpired as exc:
            return SandboxResult(exc.stdout or "", exc.stderr or "", -1, True)

注意:本地沙箱只适合低风险场景。涉及未知依赖、外部脚本、生产凭证时,应使用容器、VM 或远程隔离环境。

八、源码示例:工具输出也要清洗

工具输出会回到模型上下文,所以它本身也是攻击入口。

python 复制代码
import re


SECRET_PATTERNS = [
    re.compile(r"sk-[A-Za-z0-9_-]{20,}"),
    re.compile(r"AKIA[0-9A-Z]{16}"),
    re.compile(r"(?i)(password|secret|token)\s*=\s*['\"]?[^'\"\s]+"),
]


def sanitize_output(text: str, max_chars: int = 8000) -> str:
    for pattern in SECRET_PATTERNS:
        text = pattern.sub("[REDACTED]", text)

    if len(text) > max_chars:
        return text[:max_chars] + "\n...[truncated]"

    return text

不要把网页、邮件、issue、日志里的内容直接当系统指令。它们只是数据。

九、MCP 工具的额外边界

MCP 让工具生态更标准,但也引入新的信任边界。一个 MCP server 不应该天然获得所有权限。

建议:

  • 每个 MCP server 视为独立第三方组件。
  • 每个 tool 单独授权。
  • 不把长期凭证直接交给 MCP server。
  • 所有 MCP 调用进入统一审计。
  • 工具描述也要审查,防止 tool poisoning。

MCP 官方安全最佳实践明确讨论 confused deputy、token audience validation、token passthrough 等问题。这些不是理论风险,而是协议集成时必须处理的边界。

十、完整工具执行流程

把上面的部件串起来:

python 复制代码
def execute_tool_call(call: dict[str, Any], registry: ToolRegistry, policy: PermissionPolicy, sandbox: Sandbox):
    spec = registry.get(call["name"])
    args = call.get("args", {})

    validate_tool_call(spec, args)

    if "path" in args:
        safe_path = resolve_under_root(args["path"], spec.allowed_roots[0])
        decision = policy.evaluate_path(str(safe_path))
    else:
        decision = "ask" if spec.risk_level != RiskLevel.READ_ONLY else "allow"

    if decision == "deny":
        raise PermissionError("tool call denied")

    if decision == "ask":
        return {"status": "needs_approval", "tool": call}

    raw_result = spec.executor(args)
    return {
        "status": "ok",
        "content": sanitize_output(str(raw_result)),
    }

这段代码表达的是架构思想:模型输出之后,必须经过系统裁决。

十一、落地检查表

  • 每个工具是否有 schema?
  • 是否关闭额外参数?
  • 是否声明副作用等级?
  • 是否采用 deny 优先?
  • 路径是否做规范化和根目录约束?
  • Shell 是否识别高危结构?
  • 高风险工具是否需要审批?
  • 沙箱是否覆盖文件、网络、进程、凭证?
  • 工具输出是否脱敏、截断、标注?
  • MCP 工具是否逐项授权?
  • 是否记录完整工具调用 trace?

结论

工具系统是 Agent 接触现实世界的手。没有权限和沙箱,Agent 就是把不确定推理直接接到生产系统上。可靠的 Harness 不会问"模型会不会乱来",而是假设模型一定可能乱来,然后用代码把风险关在边界内。

相关推荐
rururunu1 小时前
Windows 下切换 Java 环境太复杂了,我做了个 cli 工具,可以快速安装,切换 Java 版本
java
小李子呢02111 小时前
大模型是什么?
大模型·agent
qq_452396231 小时前
第十一篇:《性能压测基础:JMeter线程模型与压测策略设计》
java·开发语言·jmeter
sensen_kiss1 小时前
CAN302 Technologies for E-Commerce 电子商务技术 Pt.8 网络安全(Secure the Web)
网络·学习·安全·web安全
weixin_444012931 小时前
如何在MongoDB中实现按时间跨度的分片路由_时间序列范围分片与冷热节点架构
jvm·数据库·python
澈2071 小时前
二叉搜索树:高效增删查的秘诀
java·开发语言·算法
青云计划1 小时前
Spring
java·后端·spring
yychen_java2 小时前
深度解析电力交易系统的“硬核”战场
java·能源