AI 辅助开发

AI 智能体无法 sudo,而这为你划定了界限

让 AI 代理清理生产服务器听起来很冒险,直到你注意到它无法使用 sudo。权限边界会自行将工作划分为代理安全和仅限人类操作两部分。

本文由 AI 模型从英文原文翻译而来,措辞可能与原文有出入。 阅读英文原文

我交给一个 AI 编程代理一项真实的工作:在一台生产服务器被擦除和运走之前,通过 SSH 对其进行深度清理。对此,本能的反应是感到紧张。一个代理在正在运行的机器上执行破坏性命令,这正是人们所警告的那种场景。实际发生的情况是,该代理根本无法执行那部分危险的工作,因为它无法执行 sudo。权限模型将这项工作划分为了一个对代理安全的通道和一个仅限人类操作的通道,而并没有人特意设计这种划分。

代理尝试了什么,以及它在哪里停止

代理首先处理了可回收的杂乱文件。它运行了容器工具来清理已停止的容器、失效的镜像和构建缓存,然后删除了主目录中一个过时的检出。这些操作都不需要提权:容器清理通过登录用户已能访问的守护进程套接字进行,而在主目录下删除文件则是基本的所有权操作。这些空间很快就累加起来了:

回收项 大小
已停止的容器 3.4 GB
失效的镜像 34.5 GB
构建缓存 15.1 GB
主目录垃圾文件 ~1 GB

大约 39 GB 的空间被释放了,全部来自以登录用户身份运行的命令。然后,列表转向了服务层:禁用旧的 systemd 单元,清理无头服务器不需要的软件包,以及将默认启动目标从桌面环境更改掉。这些操作中的每一个都需要 sudo,而代理就在这里停止了。

这次停止不是一个警告或确认提示。它是一条返回了错误并且没有做任何其他事情的命令。提示是代理可以回答的东西,回答它感觉像是一种进展。一个没有后备方案的错误就是一堵墙。

为什么 agent 无法越过那条线

有两件事将 agent 与 root 严格隔离开来,而且这两点都值得保留。

首先,agent 运行命令所用的 shell 没有控制终端。sudo 会从终端设备读取密码,在没有附加终端设备的情况下,它不会静默地回退到使用标准输入,而是会当场失败。根据 sudo 的版本和配置,你会看到以下信息之一:

sudo: no tty present and no askpass program specified
sudo: a terminal is required to read the password

底层工具以其各自的方言失败,并且每条消息都指出了真正的障碍:

Failed to disable unit: Interactive authentication required.
E: Could not open lock file /var/lib/dpkg/lock-frontend - open (13: Permission denied)
E: Unable to acquire the dpkg frontend lock, are you root?

systemctl 将请求路由至 polkit,而 polkit 想要询问人类,却找不到可问之人。apt 永远无法通过 dpkg 锁,因为该锁文件为 root 用户所有。是同一堵墙,只是有三种不同的描述方式。

其次,更重要的一点是,从设计上就不考虑将密码提供给代理。一个能将你的 root 密码输入到提示符中的代理,也可能被说服出于错误的理由而这样做。我们担心的不是恶意模型。代理会读取该机器上的内容:日志、配置文件、README 文件、其他人编写的程序的输出。所有这些都是输入,而输入可以携带指令。代理持有的任何凭据,都可以被代理读取的任何内容所触及。“代理从不直接处理凭据”这条规则,使得第一个限制成为一个特性,而非一个烦恼。

第三个原因事后才会显现:归因。sudo 会在认证日志中记录每一条提权命令及其调用者。当由人来运行需要提权的部分时,记录会记下该人员的姓名和确切的命令。如果为代理设置了无密码规则,那么同一条目只会记录一个服务帐户,而不会说明是谁决定运行该命令。

权限模型所造成的划分

因此,清理工作自然而然地分成了两条路线,且没有任何策略文档对其进行描述:

`sudo` 边界将清理工作拆分为 agent 通道和 human 通道 服务器清理 1 无 `sudo` 2 `sudo` agent 通道 containers, cache, home human 通道 systemd, apt, boot target // 有风险的一半因其构造而落入 human 通道,而非自主选择

人类在另一个终端中通过 ssh host -t sudo ... 执行了第二步操作,其中的 -t 参数会强制分配一个真实的 TTY,以确保密码提示符可以正常工作。代理程序已经完成了大部分清理工作,并留下了一份关于剩余内容的清晰报告,所以轮到人类时,其工作内容是一份简短、经过审查的高权限命令列表,而不是一次开放式的会话。

这个处理顺序是值得借鉴的。耗时的工作是勘查阶段:找出哪些内容可以安全删除,衡量删除后能释放多少空间,检查哪些单元仍处于活动状态。代理程序擅长做这些事,而且这些工作都不需要 root 权限。留给人的工作就是阅读并批准十几行命令。阅读十几行高权限命令只需要一分钟。而监督一次开放式的 root 会话则需要一下午的时间。

为什么这是一个好的默认设置,而不是一个需要修复的限制

将无密码的 sudo 规则交给代理并让它完成全部工作,这很诱人。请抵制这种诱惑。需要 root 权限的命令正是你最希望由人类先审阅的那些:禁用服务、清除软件包、更改机器在启动时的行为。sudo 边界恰好将这些命令交由专人处理,而将可逆的、影响范围小的工作留给代理。

这个边界在代理出错的那天,而不是在它正确的那天,才体现出其价值。代理会犯一些看似合理而非明显疯狂的错误:一个解析为空字符串的路径变量,一次清理操作的目标数据目录在此处是活动的而在指令来源的机器上是过时的,一个根目录层级过高的递归删除。如果没有提权,这些尝试会在本地大声地失败,在遇到第一个 root 拥有的条目时因 Permission denied 而停止。rm -rf / 甚至不会开始执行,因为 rm 会拒绝操作根目录,除非你传递一个表明你确实要这么做的标志。处于风险之中的是登录用户所拥有的东西:会造成实际损害,但可以恢复,并且在任何人需要相信任何人的判断之前,其范围是有限的。

如果你不撤销这个设置,你就能免费获得这种保障。不要让代理接触到凭据,不要添加一揽子的无密码规则,这种分离就会自然而然地实现:

  • 代理在登录用户权限下处理容器清理、缓存回收和文件删除,并报告它无法触及的内容。
  • 任何需要 root 权限的操作都会通过设计进入人工处理通道,通过一个显式的 ssh -t sudo 会话,在代理已经确定范围的简短列表上运行。
  • 失败的命令本身就成了交接项:一个指明单元或软件包名称的错误,比凭记忆写出的摘要更精确。
  • 如果你必须授予临时提权,请将其设置为一个范围狭窄、有时间限制的规则,并在机器脱手前移除它,而不是一个永久的授权。

边界比看起来更薄弱的地方

清理通道无需 sudo 即可工作,因为登录用户可以访问容器守护进程套接字,通常是通过 docker 组的成员身份。该权限比表面上看起来更接近 root。任何能够启动容器的人都可以将主机文件系统挂载到容器中,并以 root 身份写入:

docker run --rm -v /:/host alpine rm -rf /host/etc/systemd/system/some.service

因此,agent 遇到的这堵墙是一个工作流边界,而不是一个旨在抵御主动绕过尝试的安全边界。它之所以能起作用,是因为 agent 运行了该作业的普通命令,收到了一个错误,然后报告了该错误。它无法抵御知道该 socket 存在的攻击者。

这在两个方面都很重要。如果你将 agent 视为不可信,那么在考虑 sudo 策略之前,容器访问权限才是需要堵上的漏洞:使用无根运行时,或使用一个只暴露你所需调用的 socket 代理。而且,如果你想放宽权限以便 agent 能独立完成任务,请注意,授予容器访问权限并不像表面上看起来那么一小步。将用户添加到 docker 组,或在共享主机命名空间的容器内以 root 身份运行 agent,都会导致相同的结果:两条通道合并为一条,那个免费的审查步骤也随之消失。

常见问题解答

代理可以直接使用 sudo -S 并从标准输入读取密码吗? 从机制上讲是可以的,但这是需要避免的途径。将密码通过管道传递给 sudo -S 会将凭据置于命令行、shell 历史文件、共享机器上的进程列表以及代理自身的脚本记录中。你将用一个花费五分钟人力时间的边界,换来一个出现在四个你无法控制地方的 root 秘密。

一个狭窄的 NOPASSWD 规则在任何情况下都是可接受的吗? 有时可以,前提是它既狭窄又临时。陷阱在于,其参数范围比命令名称所暗示的要宽泛。一个针对 systemctl 的 NOPASSWD 条目会涵盖机器上的每个单元。一个针对包装器脚本的条目会将 root 权限授予任何可以编辑该脚本的人,而这可能就是代理本身。应将规则固定到特定的命令和参数上,并在添加它的同一会话中将其删除。

当代理在容器内运行时,这些规则还适用吗? 机制变了,但问题依旧。在许多镜像中,shell 本身就已经是 root,因此不再有 sudo 这道墙可以依赖。取而代之,关键在于你挂载了什么以及授予了哪些能力。一个以读写方式挂载了主机文件系统的容器完全没有边界;而一个使用只读挂载、放弃了能力且没有守护进程套接字的容器则有很强的边界。

如果代理确实需要 root 拥有的数据该怎么办? 授予读取权限,而非写入权限。可以添加一个对特定目录有读取权限的组,或者自己收集转储数据然后交接过去。大多数看似需要 root 的任务,其实是需要查看某些东西而不是更改某些东西,而读取是你可以廉价共享并在之后收回的那一半权限。

让一个代理接近生产服务器最让人放心的一点,是发现它在结构上有多么无能为力。