做了一套智能运维 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-S1 | subprocess.* 只允许出现在 SafeExecutor,操作系统访问单点收口 |
| INV-S2 | 后端任何位置不得出现 shell=True |
| INV-S3 | 不得使用 os.system / os.popen / eval / exec 等危险原语 |
| INV-S4 | 工具模块不得直接触达 subprocess,必须经由 SafeExecutor |
| INV-S5 | SafeExecutor 中的 subprocess 调用必须显式传入 shell=False |
运行时不变式(用真实请求驱动全部入口,8 条),挑几条关键的:
| 编号 | 不变式 |
|---|---|
| INV-R1 | security_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、用例数和不变式数也都变了,但九份交付文档没跟着改,还在写旧的数字。
读者看到互相矛盾的数字,损失的不是那三个数字的可信度,而是对全部数字的信任。
现在的规则是:数字只有一个来源——实测脚本的输出。文档里凡是声明这些数字的地方,和实测对不上就让构建失败,并指出具体文件和行号。
实施过程中学到的三条:
- 不要拿”一次运行的结果”当文档数字。最初用”通过 N 项”当基准,但跳过数随平台和可选依赖变化,同一提交在不同系统上跳过数量都不同,作为门禁必然误报。改用环境无关的用例收集数;
- 断言之前先确认文档说的是哪个量。门禁第一版把”64 项对抗基准”判为过期,理由是实测执行了 63 条——但基准实际定义 64 条、执行 63 条、跳过 1 条,文档写的完全准确,错的是门禁把执行数当成了唯一口径;
- 新增一类需要同步的数字,加一条匹配规则就行,不用改架构。
安全不是免费的,但可以很便宜
有人会担心护栏拖慢系统。实测下来还好:
| 项目 | 结果 |
|---|---|
| 安全预检本身(输入检查 + 风险评分,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 条不变式,而是逼着自己去面对一些原本可以含糊过去的问题:审计到底能不能被改、护栏会不会误伤、文档里的数字是不是真的。这些问题的答案,有几条在没验证之前,我自己都是错的。