我不让大模型直接碰 Shell:一个安全运维 Agent 的 13 条不变式

发布于 2026-09-20 13:00 3290 字 17 min read

浅唱ヾ落雨殇 avatar

浅唱ヾ落雨殇

落赋韶华 —— 浅唱ヾ落雨殇的个人站点:AI / 理工科 / 开源折腾

做了一套智能运维 Agent:用自然语言问服务器状态、让模型规划诊断步骤、自动跑只读工具、把根因说清楚。但有一条底线我从一开始就定死了——模型不能直接碰 Shell。 这篇记录怎么把这条底线做成可验证的东西,以及过程中几个不太体面的发现。 先说我不做什么 市面上的方案大致两类,各有一处硬伤: 全权限运维面板:功能全、上手快,但以 root 运行、可任意执行命令。被攻破等于交出整台机器; AI...

做了一套智能运维 Agent:用自然语言问服务器状态、让模型规划诊断步骤、自动跑只读工具、把根因说清楚。但有一条底线我从一开始就定死了——模型不能直接碰 Shell。

这篇记录怎么把这条底线做成可验证的东西,以及过程中几个不太体面的发现。

先说我不做什么

市面上的方案大致两类,各有一处硬伤:

  • 全权限运维面板:功能全、上手快,但以 root 运行、可任意执行命令。被攻破等于交出整台机器;
  • AI 运维助手:能理解自然语言,但要么不碰真实系统(只给建议),要么直接开放 Shell——等于把 root 交给一个概率模型。

我走第三条路:最小权限运维台。同等的监控可视化与诊断能力,但任何写操作都必须经过风险评分、dry-run、人工确认和审计留痕。

一句话概括:运维台的便利性,不必用无限权限来换。

单点收口:全后端只有 1 处 subprocess

安全的第一条不是”写规则去拦”,而是让绕过在结构上不可能。

做法是让所有系统命令只有一个出口:SafeExecutor。它只接受 list[str],禁止字符串命令,禁止 shell=True,主命令必须在 allowlist 里,整条命令检查危险 token,带 timeout、截断输出、返回结构化结果。

然后我去验证它:用 AST 解析全部后端源码,扫描每一个 subprocess.* 调用点。

结果是这样的:

backend/executor/safe_executor.py:51  subprocess.run
共 1 处,全部位于 SafeExecutor。
另有 14 个工具模块,均不直接触达 subprocess。

整个后端 62 个文件,操作系统访问只有这一个出口。这个数字比任何”我们加了安全检查”都有说服力,因为任何人可以当场重新跑一遍。

13 条不变式:把”我们有安全护栏”变成可判定的属性

每一套安全方案都会声称自己有护栏。但这是主观声明——被追问”你怎么证明没有绕过通道”时,通常只能回答”你看代码”。

我想把它变成机器能判定的东西,分两类:

静态不变式(AST 源码结构证明,5 条):

编号不变式
INV-S1subprocess.* 只允许出现在 SafeExecutor,操作系统访问单点收口
INV-S2后端任何位置不得出现 shell=True
INV-S3不得使用 os.system / os.popen / eval / exec 等危险原语
INV-S4工具模块不得直接触达 subprocess,必须经由 SafeExecutor
INV-S5SafeExecutor 中的 subprocess 调用必须显式传入 shell=False

运行时不变式(用真实请求驱动全部入口,8 条),挑几条关键的:

编号不变式
INV-R1security_decision=reject 的请求,executed 恒为 false
INV-R2每一次请求(含被拒绝的)都必须产生可回放的审计记录
INV-R3高危输入必须在模型调用之前被拦截,而非事后否决
INV-R4所有入口(HTTP /chat、/tools/call、MCP adapter)收敛到同一条安全链路
INV-R5工具输出中的危险内容必须被二次拦截
INV-R6蜜罐会话在密码学上无法被验证为真实会话
INV-R7审计库、溯源证据、控制台永远不在自动清理可达范围内

验证失败时脚本以非零状态码退出,可以直接当发布门禁。

有个容易忽略的点:验证器同时守两个方向——它既证明危险请求被拒,也证明正常运维请求没有被误拒。一个”全部拒绝”的护栏能通过前者,但过不了后者。这也是为什么报告里专门列出正常请求的放行记录。

拦截要发生在模型之前

顺序很重要。Guardrail 是本地前置预检,优先级高于模型调用,命中就拒绝,不进入模型上下文。

覆盖的东西包括:危险命令(删除、格式化、覆写、重启、关停)、下载后执行(curl | sh)、受保护路径(/etc/shadow、磁盘设备)、Prompt Injection(忽略规则、输出 system prompt、绕过审计)、以及工具参数和输出里的危险模式。

对抗基准 64 条合成用例,实测误报 0、漏报 0。

演示时我用 rm -rf /、Prompt Injection 和读 /etc/shadow 三组请求打进去,三次都是风险分 100、executed=false、原因 blocked_by_precheck——系统命令一条都没执行,请求根本没送到模型。

审计这块,我发现自己原来在自欺

这是最不体面但最值得写的一段。

文档里 11 处写着审计”append-only、不可清除”。但实现是什么?清理接口返回一句”不可清除”——只是应用层不提供删除接口而已。审计库是明文 SQLite,任何能写这个文件的人都能 UPDATE / DELETE 而且不留痕迹。

同时有条不变式要求”每次请求都必须产生可回放的审计记录”,但审计写入失败时只打一行警告就返回,请求照常成功。也就是说:这条不变式在测试环境成立,在磁盘满或权限错误时不成立。

两件事叠在一起,结论很难看:审计这个核心卖点,只要被问一句”我直接改数据库呢”,就不成立了。

改法是把两个性质分开实现,也分开陈述:

性质机制能证明不能证明
完整性SHA-256 哈希链(每条记录摘要并链接前一条)改写、删除、重排、整表丢失,且能定位到行有写权限者整体重建一条自洽的链
真实性可选 HMAC-SHA256 签名上述整体重建不可行持有密钥者的伪造

同时把审计写入改成 fail-closed:写入失败就抛错,工具执行前先做 preflight 检查,审计不可写就拒绝执行。

哈希链挡不住持有密钥者的伪造——这句话我写在设计文档里,而不是藏着。

三个有”运维味”的设计

变更 → 故障的时序因果关联

生产环境绝大多数故障由变更引起,但常见的根因分析只看当前状态快照——能说”sshd 挂了”,答不出”是哪次改动导致的”。

我给配置漂移检测加了时间线持久化,然后做双轴对齐:

  • 实体轴:变更的配置文件 → 它配置的服务(sshd_config → sshd,共 21 条映射);
  • 时间轴:变更检出时间 → 故障发生时间,1 小时窗口内视为强相关。

输出长这样:

症状:服务异常(sshd)伴随关键配置变更
根因:/etc/ssh/sshd_config 发生配置漂移,且其对应服务 sshd 当前处于异常状态,
      变更在约 12.0 分钟前被检出;疑似该次变更直接导致故障
置信度:0.9

防误报做了两条:变更了但服务健康 → 不生成根因链(变更本身不是故障);服务故障但没有相关变更 → 不生成(不冤枉无关文件)。

删对了,也要知道后果

有个高频陷阱:删除仍被进程持有句柄的日志文件,磁盘空间不会释放。inode 在进程关闭或重启描述符之前一直活着。

运维人员 rm 掉大日志、发现 df 纹丝不动、开始怀疑人生——这是最典型的无效清理。

所以清理决策之前先推演完整影响面:谁持有句柄、属于哪个服务、关联哪些监听端口、有没有句柄泄漏风险,然后给动作建议。对追加写日志,明确提示不要直接 rm,应该用 truncate 或 logrotate 的 copytruncate,让进程继续写同一个 inode,空间才会真正释放。

影响面还分了级:无进程持有可以直接走 dry-run 流程;被单个服务持有要评估;被多个服务同时持有属于 critical,必须人工介入。

同一个数字,在不同主机上含义不同

原来用的是固定阈值:usage >= 85% 判异常。这是整个项目里最”不智能”的一块——

一台常年跑 90% 内存的数据库服务器,90% 是正常的,固定阈值天天误报;一台平时 20% 的机器突然到 60%,远没到 85%,固定阈值完全漏报。

改成让每台主机从自己的历史学正常区间,统计上用中位数 + MAD(中位绝对偏差)。选它们是因为对离群值稳健——历史数据里必然包含我们想检测的尖峰,用均值/标准差建基线会被尖峰拉高,最后失去检测能力,中位数和 MAD 不会。

判定用两条独立触发:基线偏离,以及绝对告警线。第二条是必要的补充——一台整个采样窗口都不健康的主机,它的”正常区间”本身就是病态的,这时候只有绝对线能救。

开发中撞到一个真缺陷:一个完全稳定的指标(MAD 恰好为 0)永远无法触发告警。内存长期精确停在 37%,MAD = 0,稳健 z 分数在数学上未定义,基于 z 阈值的判定条件恒不成立——即使内存跳到 91% 也不告警,而界面上画出的正常区间明明显示 91% 在区间之外。

修正:判定改成基于区间而不是基于 z 分数,并补了回归测试。

文档数字不许手写

这个坑很典型:新增功能后,工具数从 16 变成 17、用例数和不变式数也都变了,但九份交付文档没跟着改,还在写旧的数字。

读者看到互相矛盾的数字,损失的不是那三个数字的可信度,而是对全部数字的信任。

现在的规则是:数字只有一个来源——实测脚本的输出。文档里凡是声明这些数字的地方,和实测对不上就让构建失败,并指出具体文件和行号。

实施过程中学到的三条:

  1. 不要拿”一次运行的结果”当文档数字。最初用”通过 N 项”当基准,但跳过数随平台和可选依赖变化,同一提交在不同系统上跳过数量都不同,作为门禁必然误报。改用环境无关的用例收集数;
  2. 断言之前先确认文档说的是哪个量。门禁第一版把”64 项对抗基准”判为过期,理由是实测执行了 63 条——但基准实际定义 64 条、执行 63 条、跳过 1 条,文档写的完全准确,错的是门禁把执行数当成了唯一口径;
  3. 新增一类需要同步的数字,加一条匹配规则就行,不用改架构。

安全不是免费的,但可以很便宜

有人会担心护栏拖慢系统。实测下来还好:

项目结果
安全预检本身(输入检查 + 风险评分,500 次微基准)正常请求平均 0.145 ms,高危请求 0.161 ms
端到端 · 正常只读请求19.84 ms(预检 → 规划 → 工具执行 → 诊断 → 审计写入)
端到端 · 危险请求拦截15.63 ms(预检命中即拒,不进模型)
16 并发QPS 68.6,成功率 100%,5xx 错误 0 个
进程峰值内存96.3 MB

拦截一条危险请求比正常处理一条请求还快,因为它根本不用往后走。

明确的边界

验证报告里我专门写了一节”这份报告不能证明什么”:它不构成形式化数学证明,只是对一组明确列举的可判定属性的验证;也不覆盖操作系统自身、解释器或第三方依赖的安全性。

另外这些是设计选择而不是未完成项:不做永久删除、自动重启、自动修复(不可逆或无人确认的写操作违背安全可控原则,系统只提供 dry-run 计划、人工确认和可恢复隔离);不做多租户和 RBAC(产品定位是单机运维 Agent);不做内核级修改或 eBPF(用户态运行,不需要 root)。

已知的工程约束也一并写着:审计写入用进程内锁串行化,单进程并发有上限,多进程部署需要换共享存储后端;会话和确认令牌存在进程内存里,多进程部署需要共享状态。


把安全从”我声明”变成”可验证”,最大的收获不是那 13 条不变式,而是逼着自己去面对一些原本可以含糊过去的问题:审计到底能不能被改、护栏会不会误伤、文档里的数字是不是真的。这些问题的答案,有几条在没验证之前,我自己都是错的。

© 2025 - 2026 浅唱ヾ落雨殇
Powered by theme astro-koharu · Inspired by Shoka