Administrator
发布于 2025-08-12 / 2446 阅读
50

Agent 工具调用的安全边界与权限控制

审计日志里那条被拦截的命令,让我出了一身冷汗

有天早上巡检,我在运维 Agent 的审计日志里翻到这么一条:

{
  "ts": "2025-07-29T03:14:22.118Z",
  "sessionId": "sess-7f3a91c0",
  "userId": "u_zhangwei",
  "tool": "execShell",
  "args": { "cmd": "rm -rf /data/apps/order-service/logs/*" },
  "decision": "BLOCKED",
  "reason": "risk-level=DANGEROUS, requires human approval, none granted",
  "latencyMs": 3
}

时间是凌晨三点,用户是张伟(我们组同事)。他在睡前下了个任务:「晚上把磁盘清理一下,/data 已经 91% 了」。Agent 规划了几步,其中一步就是上面这条。

真正让我后背发凉的不是这条命令本身——删日志确实是他要的。而是如果那天晚上我们的拦截器没生效,这条命令就被执行了,而没有任何人知道。Agent 的执行日志默认是 INFO 级别,写的是「执行工具 execShell」,参数在 DEBUG 里。

从那天起我们把 Agent 的工具安全重做了一遍。这篇是完整的方案和踩坑记录。

第一层:给工具打风险标签

之前我们只有「允许 / 禁止」两个状态,太粗了。运维场景里大量工具是「通常安全但偶尔致命」的,比如 execShell 执行 df -h 和执行 rm -rf 是同一个工具。

我们把工具按风险分成四级:

级别定义我们的工具举例策略
L0 只读纯查询,无副作用queryMetrics、getLogTail、searchDocs直接执行
L1 可逆写有副作用但可撤销restartService、scaleReplicas执行 + 记录,事后可回滚
L2 危险不可逆或影响面大execShell、dropTable、modifyConfig必须人工确认
L3 禁用永不开放给 AgenttransferFunds、deleteUser、grantPermission注册时直接拒绝

实现方式是在 Spring AI 的 @Tool 之上加了一层注解:

@Retention(RUNTIME)
@Target(METHOD)
public @interface ToolRisk {
    RiskLevel level();
    String reason() default "";
    boolean requiresApproval() default false;
}

@Component
class ShellTools {
    @Tool(description = "在指定主机上执行 shell 命令")
    @ToolRisk(level = RiskLevel.L2, requiresApproval = true,
              reason = "shell 命令不可逆,需人工确认")
    public String execShell(
            @ToolParam(description = "shell 命令") String cmd,
            @ToolParam(description = "目标主机") String host) {
        ...
    }
}

注册工具时对 L3 直接抛异常,让应用启动失败。我坚持要 fail-fast 而不是运行时拒绝——这类配置错误应该在 CI 阶段就暴露,而不是等 Agent 某天真的调到了。

第二层:参数级校验,比工具级更细

光有工具级标签不够。execShell 是 L2,但 cat /etc/hosts 明显不需要确认。而且更现实的问题是:Agent 会绕过我们的意图。你标注 rm 危险,它就改成 find ... -delete

我们加了一个参数解析器 + 规则引擎,对命令做静态分析后再定级:

public class ShellRiskAnalyzer implements ArgumentAnalyzer {

    // 危险模式库,命中即升级到 L2
    private static final List<Pattern> DANGEROUS = List.of(
        Pattern.compile("\\brm\\b\\s+.*(-rf?|--recursive)"),
        Pattern.compile("\\b(find|fd)\\b.*-delete"),
        Pattern.compile("\\bdd\\s+if="),
        Pattern.compile(":\\(\\)\\{.*\\|.*&\\s*\\}"),      // fork bomb
        Pattern.compile(">\\s*/dev/sd"),
        Pattern.compile("\\bchmod\\b\\s+(-R\\s+)?777"),
        Pattern.compile("\\b(kill|pkill)\\s+-9\\s+(java|mysqld|redis)"),
        Pattern.compile("(?s).*(\\$\\(|`|&&\\s*rm|;\\s*rm).*")   // 命令注入/拼接
    );

    // 白名单,命中直接降级到 L0
    private static final List<Pattern> SAFE = List.of(
        Pattern.compile("^(df|du|free|top|ps|netstat|ss|iostat|vmstat|uptime|date)\\b.*")
    );

    public RiskAssessment analyze(String tool, Map<String,Object> args) {
        if (!"execShell".equals(tool)) return RiskAssessment.pass();
        String cmd = (String) args.get("cmd");
        if (SAFE.stream().anyMatch(p -> p.matcher(cmd).matches()))
            return RiskAssessment.of(L0, "命中只读白名单");
        var hit = DANGEROUS.stream().filter(p -> p.matcher(cmd).find()).toList();
        if (!hit.isEmpty())
            return RiskAssessment.of(L2, "命中危险模式: " + hit.size());
        return RiskAssessment.of(L1, "未分类命令");
    }
}

这套规则库是从三个月的审计日志里攒出来的,一开始只有 3 条,现在是 27 条。每次拦截(无论是误拦还是真拦)我们都会回看,把模式补进去。

误拦率目前是 2.1%,主要是 grep 里带分号的复杂管道。可以接受,毕竟确认一下成本很低。

第三层:确认不是弹个框就完事

最开始我们的人机确认做得很朴素——Agent 返回一个特殊标记,前端弹窗让用户点「同意」。用了两周发现两个问题:

一是用户根本不看内容直接点同意。我们统计过,平均确认耗时 1.4 秒,这个时间读完一条命令都不够。二是 Agent 会「重试」——被拒绝后换个说法再申请一次,用户第二次往往就点了。

改版后的确认流程加了三个约束:

public class ApprovalGate {

    private static final int MAX_PENDING_PER_SESSION = 3;
    private static final Duration TTL = Duration.ofMinutes(10);

    public Approval request(ToolCall call, RiskAssessment risk) {
        // 1. 确认必须有明确的意图匹配,用户不能盲同意
        String digest = DigestUtils.sha256Hex(call.canonicalForm());

        // 2. 同一参数的工具调用,一次会话内最多申请 3 次
        long attempts = approvalRepo.countBySessionAndDigest(sessionId, digest);
        if (attempts >= MAX_PENDING_PER_SESSION) {
            return Approval.rejectedPermanently("重复申请超过上限,已终止该步骤");
        }

        // 3. 关键:展示「影响预览」,而不只是命令原文
        String preview = impactEstimator.estimate(call);
        return approvalRepo.save(new Approval(digest, preview, TTL));
    }
}

第三条改动效果最明显。impactEstimator 会把 rm -rf /data/apps/order-service/logs/* 翻译成:「将删除 order-service 服务 3 台主机上共 1,247 个日志文件(约 8.4 GB),删除后不可恢复」。用户看到这个数字,才会真的思考。

加上影响预览后,拒绝率从 4% 升到 19%,平均确认耗时从 1.4 秒变成 7.8 秒。这个变化让我确信之前那些「同意」大部分是无效的。

第四层:能沙箱就别在生产上跑

权限分级和人工确认本质上是「防君子」。对于真正的不确定性,我们把执行环境换成了沙箱。

运维 Agent 的所有 shell 命令现在跑在一个 gVisor 容器里,通过只读挂载 + 网络策略限制:

apiVersion: v1
kind: Pod
metadata:
  name: agent-sandbox
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    seccompProfile: { type: RuntimeDefault }
  containers:
  - name: exec
    image: sandbox-base:1.4
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true       # 根文件系统只读
      capabilities: { drop: ["ALL"] }
    volumeMounts:
    - name: workspace
      mountPath: /workspace             # 唯一的可写区,每次任务重建
    - name: host-mirror
      mountPath: /mirror
      readOnly: true                     # 目标主机的目录以只读方式映射进来
    resources:
      limits: { cpu: "1", memory: "512Mi" }

关键设计是目标主机目录以只读方式挂载进沙箱。Agent 在沙箱里看到的目录结构和生产一模一样,但所有写操作都落在一个临时 overlay 上。真正要执行写操作时,命令会被回传到目标主机的 agent-daemon 上执行,而 daemon 侧还有一套独立的白名单。

这个架构的代价是执行延迟增加约 120ms(沙箱启动 + 目录映射),以及部分依赖绝对路径的脚本要改。我们评估下来值得——沙箱上线后,有一次 Agent 生成了 chmod -R 777 /(因为提示词里写了「解决权限问题」),在沙箱里跑完什么都没发生,只是 audit log 多了一条 L2 拦截。

第五层:审计日志要记到能复盘的程度

最后说说日志。Agent 的审计日志和普通操作日志要求完全不同,因为你需要能还原模型当时「为什么这么做」,而不只是「做了什么」。

我们定的必填字段:

字段用途
traceId / sessionId / stepIndex串起一次任务的完整决策链
userId + impersonation谁发起的,Agent 以谁的身份执行
tool + canonicalArgs规范化后的参数,用于去重和模式匹配
riskLevel + matchedRule为什么是这个风险级别
decision + deciderALLOWED / BLOCKED / APPROVED,谁批的
modelRawOutput模型原始输出,用于事后分析意图
result + duration执行结果和耗时

modelRawOutput 这一项一开始被安全同事要求脱敏后存储,我们做了个折中:原文加密存 OSS,日志里只存 hash 和长度。真要复盘时按流程申请解密。

还有一点:审计日志必须独立存储、不可篡改。我们写到了一个只有 DBA 有写权限的独立库,应用账号只有 INSERT 权限。这不是不信任团队,是因为 Agent 出事后第一件事往往是怀疑日志被动过。

身份:Agent 到底以谁的权限在跑

上面五层都在管「能做什么」,还有一层是「以谁的身份做」。这个问题我们一开始处理得很粗糙——Agent 用自己的服务账号执行所有操作,理由是「方便审计」。实际上这带来两个问题:

一是权限过大。服务账号有全部服务的操作权限,而实际使用者可能只是个实习生。我们通过 Agent 做的每一件事,权限都远超这个用户自己能做的范围。

二是审计失真。日志里记的操作人全是 svc-ops-agent,出了问题查不到是谁发起的。

改成了 impersonation 模式:Agent 拿用户的 token 去换取一个权限不超过该用户的短期凭证,用这个凭证执行操作。关键实现是在网关层做权限交集:

public class EffectivePermission {

    /**
     * Agent 的有效权限 = 用户权限 ∩ Agent 能力白名单
     * 两边都要有,缺一不可
     */
    public Set<String> resolve(UserToken user, AgentManifest agent) {
        Set<String> userPerms = iam.permissionsOf(user.userId());
        Set<String> agentPerms = agent.declaredPermissions();

        Set<String> effective = new HashSet<>(userPerms);
        effective.retainAll(agentPerms);

        if (effective.isEmpty()) {
            throw new NoEffectivePermissionException(
                "用户 %s 与 Agent %s 无权限交集".formatted(user.userId(), agent.name()));
        }
        return effective;
    }
}

取交集这个设计是有意为之——它意味着Agent 永远不会比发起者拥有更多权限。一个只有只读权限的用户,通过 Agent 也做不了写操作。这条原则帮我们挡掉了几个越权场景,包括一次运维同学试图用 Agent 去查他本不该访问的财务系统数据。

代价是每次任务要多一次 IAM 调用(约 40ms),以及复杂一点的凭证管理。我们给凭证设了 30 分钟有效期,任务超时需要重新申请。

上线三个月的数字

指标数值
工具调用总数184,329
L2 触发人工确认2,417(1.31%)
人工拒绝459(占确认量 19.0%)
规则误拦51(2.1%)
沙箱内被拦截的破坏性操作7
因工具安全导致的生产事故0

那 459 次拒绝里,我抽看了 50 条,大约有 30 条确实是命令有问题(路径写错、范围过大),20 条是用户觉得没必要执行。这说明人工确认这一层是真在起作用,不是摆设。

小结

Agent 安全和传统系统安全的最大区别在于:攻击者可能不是外部人员,而是我们自己系统的「正常输出」。模型不会恶意,但它会在满足目标函数的过程中,自然地选择最短路径——而最短路径经常是危险的。

所以防护不能只靠提示词里写「请谨慎操作」。要把安全做成架构的一部分:工具分级、参数校验、强制确认、沙箱执行、完整审计,五层缺一层都会被打穿。我们这次运气好,那条 rm -rf 被拦住了,但我清楚地知道那是运气——在加参数级校验之前,我们的拦截只覆盖了工具名,同类的命令换个写法就能过去。

最后一条建议:把风险规则库当成活的东西维护。我们每周会花半小时回看拦截记录,27 条规则就是这么攒出来的,这个过程本身也是最有效的团队安全教育。

参考