审计日志里那条被拦截的命令,让我出了一身冷汗
有天早上巡检,我在运维 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 禁用 | 永不开放给 Agent | transferFunds、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 + decider | ALLOWED / 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 条规则就是这么攒出来的,这个过程本身也是最有效的团队安全教育。