智能体治理要落到运行时:隔离、策略执行与受控工具访问

2026-07-22 35 预计阅读时间: 1 分钟
来源: docker.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:11 分钟

“不要读取敏感文件”“执行命令前必须确认”“只能访问指定服务”这类提示词,可以指导模型,却不能构成可靠的安全边界。智能体一旦能够调用 shell、浏览器、数据库或内部 API,治理就必须进入运行时:由模型之外的组件拦截操作、检查策略、限制资源,并记录实际执行结果。

关键变化在于,系统不再假设智能体会始终遵守建议,而是假设模型输出可能出错、被提示注入影响,或者在多步任务中偏离原定范围。提示词仍然有用,但它负责表达意图;运行时负责执行不可绕过的约束。

为什么提示词不能承担强制治理

提示词属于模型上下文的一部分。系统消息可以规定行为,但模型看到的网页、邮件、文档和工具返回值也可能包含冲突指令。上下文越长、工具越多,依赖模型自行判断的风险就越高。

运行时治理需要把几个决定从模型手里拿出来:

  • 智能体是否有权调用某个工具。
  • 哪些参数、目录、域名和数据库表可以访问。
  • 单次调用可以消耗多少时间、内存和网络资源。
  • 高风险操作是否需要人工批准。
  • 操作失败或越权时,系统如何终止、降级和审计。

例如,“不要执行 rm -rf”是建议;工具网关根本不提供任意 shell,或者只允许结构化的 list_filesread_report 操作,才是执行。即使模型生成了危险命令,它也没有直达操作系统的路径。

三层运行时边界

1. 隔离执行环境

代码执行、文件解析和浏览器自动化应进入独立容器、微型虚拟机或受限沙箱。隔离层负责控制文件系统、网络、进程、系统调用和资源配额。

容器并不自动等于完整安全沙箱,但可以作为基础限制。下面的命令可以直接运行,用于观察一个只读、无网络、限制资源的 Python 执行环境:

docker run --rm \
  --network none \
  --read-only \
  --cap-drop ALL \
  --security-opt no-new-privileges \
  --pids-limit 64 \
  --memory 256m \
  --cpus 0.5 \
  --tmpfs /tmp:rw,noexec,nosuid,size=32m \
  python:3.12-alpine \
  python -c 'import os; print("sandbox uid:", os.getuid()); print("files:", os.listdir("/tmp"))'

生产环境还需要结合宿主机加固、镜像来源控制、内核隔离方案和出站网络代理。尤其不要因为设置了 --read-only,就默认容器能够抵御所有内核级攻击。

2. 策略执行点

每次工具调用都应经过一个位于模型之外的策略执行点。它接收结构化请求,根据智能体身份、任务、租户、工具、参数和当前风险状态作出允许、拒绝或要求审批的决定。

策略检查应发生在副作用之前。只在执行后写审计日志,可以帮助调查,却无法阻止事故。对于转账、删除数据、发送外部邮件等操作,还应把“生成计划”和“提交变更”拆成两个权限不同的阶段。

3. 受控工具网关

不要把数据库密码、云密钥或通用 shell 直接交给模型。更稳妥的设计是让工具网关持有凭据,并向智能体暴露范围较窄的结构化能力,例如:

{
  "tool": "read_report",
  "arguments": {
    "name": "daily-sales.csv"
  },
  "task_id": "task-42"
}

网关可以校验文件名、绑定租户目录、设置超时、截断过大的输出,并为每次调用生成审计事件。这样,模型拿到的是一项能力,而不是能力背后的长期凭据。

可以这样实践:实现一个最小工具代理

下面是一个仅使用 Python 标准库的最小示例。假设智能体通过 JSON 请求调用工具,代理只允许列出指定目录或读取其中的 .txt 文件。路径校验在实际文件访问前完成,所有决定都会写入标准输出。

将代码保存为 tool_broker.py,然后运行 python tool_broker.py

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

WORKSPACE = Path("agent_workspace").resolve()
MAX_BYTES = 4096


def audit(event: dict[str, Any]) -> None:
    print(json.dumps(event, ensure_ascii=False))


def resolve_allowed_path(raw_path: str) -> Path:
    candidate = (WORKSPACE / raw_path).resolve()
    if candidate != WORKSPACE and WORKSPACE not in candidate.parents:
        raise PermissionError("path escapes the workspace")
    return candidate


def execute(request: dict[str, Any]) -> dict[str, Any]:
    tool = request.get("tool")
    args = request.get("arguments", {})

    if tool == "list_files":
        files = sorted(p.name for p in WORKSPACE.iterdir() if p.is_file())
        return {"files": files}

    if tool == "read_text":
        path = resolve_allowed_path(str(args.get("path", "")))
        if path.suffix != ".txt":
            raise PermissionError("only .txt files are allowed")
        if not path.is_file():
            raise FileNotFoundError(path.name)
        if path.stat().st_size > MAX_BYTES:
            raise PermissionError("file exceeds the read limit")
        return {"path": path.name, "content": path.read_text(encoding="utf-8")}

    raise PermissionError(f"tool is not allowed: {tool}")


def handle(request: dict[str, Any]) -> dict[str, Any]:
    task_id = str(request.get("task_id", "unknown"))
    try:
        result = execute(request)
        audit({"task_id": task_id, "tool": request.get("tool"), "decision": "allow"})
        return {"ok": True, "result": result}
    except (PermissionError, FileNotFoundError, OSError) as exc:
        audit({
            "task_id": task_id,
            "tool": request.get("tool"),
            "decision": "deny",
            "reason": str(exc),
        })
        return {"ok": False, "error": str(exc)}


if __name__ == "__main__":
    WORKSPACE.mkdir(exist_ok=True)
    (WORKSPACE / "status.txt").write_text("build: healthy\n", encoding="utf-8")

    requests = [
        {"task_id": "task-1", "tool": "read_text", "arguments": {"path": "status.txt"}},
        {"task_id": "task-2", "tool": "read_text", "arguments": {"path": "../secret.txt"}},
        {"task_id": "task-3", "tool": "shell", "arguments": {"command": "id"}},
    ]

    for item in requests:
        print(json.dumps(handle(item), ensure_ascii=False))

这个示例不是完整的生产级沙箱,但展示了治理边界应放在哪里:模型只能提交结构化意图,代理负责验证路径、文件类型、大小和工具名称。实际系统可以继续加入身份认证、租户隔离、速率限制、审批状态、调用超时以及不可篡改的审计存储。

需要特别注意符号链接和检查后使用之间的竞态条件。处理敌对文件系统内容时,应使用更强的文件描述符级约束或成熟沙箱,而不能只依赖字符串路径检查。

上线前检查治理是否真的不可绕过

运行时治理的目标不是增加一层形式化审批,而是保证智能体无法绕开执行点。接入生产环境前,可以检查以下事项:

  • 模型是否能够直接获得底层凭据或绕过工具网关。
  • 默认策略是否为拒绝,并按任务授予最小权限。
  • 文件、网络、进程、时间和费用是否都有明确上限。
  • 高风险操作是否需要短期授权或人工确认。
  • 策略判断和真实副作用是否使用同一个身份与请求标识。
  • 日志是否记录策略版本、参数摘要、决策原因和执行结果,同时避免泄露敏感数据。
  • 工具返回内容是否被视为不可信输入,并在重新送入模型前限制大小和类型。
  • 撤销权限、终止任务和轮换凭据是否可以快速完成。

隔离、策略执行和受控工具访问会增加延迟与工程复杂度,也可能限制智能体的灵活性。合理的做法不是让所有调用都经过人工审批,而是按风险分级:只读、低影响操作自动放行;写入和外部通信接受更严格的参数约束;不可逆操作要求显式授权。提示词描述期望行为,运行时决定系统实际上允许发生什么。


相关推荐