说明:本文源码是教学用的最小实现样例,不代表任何闭源产品内部实现。涉及 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 不会问"模型会不会乱来",而是假设模型一定可能乱来,然后用代码把风险关在边界内。