跳到主要内容

内容阅读

沙箱、审批与权限:dsh 怎么安全地放 agent 上机

沙箱、审批与权限:dsh 怎么安全地放 agent 上机

让 agent 安全地操作真实机器,dsh 把它拆成三个各自独立、各自 fail-closed 的子系统:沙箱管文件效果的边界,审批管单次动作的放行,预设把前两个打包成用户能选的名字。三者没有谁能替代谁,也没有谁在拿不准时偷偷放行。两条最值得记住的纪律:沙箱的强制力是一个被报告的事实(full 或 partial),不是一句承诺,拿到 partial 的消费者必须拒绝或明示;一次审批只放行被问的那一个动作,审批系统本身坏了,默认结果是拒绝。 被拒绝不是终点。沙箱拒绝会在输出里留下模式标记,模型可以带理由对同一条命令重试一次更宽的模式,审批通过后,更宽的策略只印在那一次调用上,不延续。这条升级链路的每一环都落在会话日志里,重放可以完整重建。

三个问题,不是一个问题

"别让 agent 干坏事"粗看是一个问题,细想是三个。

它能碰哪些文件?一个 rm -rf 该不该被拦,取决于它写的东西在不在允许范围内,不取决于你当时在不在电脑前。这是效果的边界,答案应该在你离开工位时依然成立。

这一个具体动作,现在能不能放行?哪怕动作在允许范围内,你也可能想在它执行前看一眼、点个头。这是单次决策,问的是此刻的许可,不是永久的许可。

上面两个的组合叫什么名字?用户不想每次分别调两个开关,想选一个"只读模式"或"全权模式"。这是给人用的便捷层。

很多系统把三者揉成一个"权限"概念,结果是逻辑纠缠:调了沙箱模式以为也改了审批,审批拒绝了以为沙箱也收紧了,出问题时说不清是哪个环节放的行。dsh 把它们拆成三个子系统,各有自己的接缝、自己的事件、自己的 fail-closed 语义,再用一个很薄的预设层把前两个打包。拆分的验收标准很朴素:换掉其中任何一个,另外两个的行为不变。后面会看到这个标准是怎么被兑现的。

三个子系统共享一条实现纪律:开关即事件。沙箱模式的一次切换,就是会话日志里的一条 sandbox/mode 事件,没有事件之外的旁路状态;审批策略同理,是 approval/policy 事件。有效值永远是"日志里最后一条这类事件,落空则回退部署默认"。这条纪律的回报是重启免疫和会话隔离:两个并发会话各切各的模式,互不污染,因为各自的有效值来自各自的日志;进程重启后重放日志,开关状态原样回来,不需要一个外部的配置存储来记住上次切到哪了。

沙箱:文件效果的围栏

沙箱接缝回答"能碰哪些文件"。它由两个服务组成:ctx.sandboxPolicy 负责解析这次调用该用什么策略,ctx.sandbox 负责把要跑的命令包进围栏。

三种模式,只管文件

模式只有三个值:read-onlyworkspace-writedanger-full-access。词表管的东西刻意收窄:只管文件系统效果,网络和进程可见性都在词表之外。一个 curl 把数据外发,或者一个 ps 看到别的进程,沙箱不管。收窄不是偷懒,是为了让"沙箱能不能挡住"这个问题有确定答案。一个什么都想管的围栏,结局是什么都管不全,使用者反而无法推理它的边界。

read-only 要求后端拒绝写。POSIX 后端会额外开 shell 需要的 /dev/null 接收端,不然 shell 语法里的重导向会让命令在"只读"模式下莫名其妙地死掉。Windows ACL 后端不开任何可写根,并如实报告 partial 强制。

workspace-write 允许写工作区根目录下面,加后端承诺的临时区。

danger-full-access 绕过围栏。这里有个容易理解错的地方:它不是一个"被围栏放行一切"的模式,而是压根不进围栏。一个 danger-full-access 的消费者直接 spawn 它原来的 argv,根本不调 ctx.sandbox。所以只有前两个模式会送到 provider,围栏的实现只为"确实要围"的场景负责。

强制力是被报告的事实

整个沙箱设计最反常识的一点在这。强制力 SandboxEnforcement 只有两个值:full 和 partial。full 表示后端能管住这个模式承诺的全部文件效果;partial 表示当前后端或老内核只能管住一部分。

partial 不是理论上的客气话,它有具体出处:老的 Landlock ABI 管不全,Windows ACL 后端在 Everyone 组和硬链接的边界上有缺口,macOS 的 Seatbelt 依赖的 sandbox-exec 处在被废弃但仍随系统发布的悬置状态。三个主流平台,没有一个能对所有模式给出无条件的 full。

为什么不把这些缺口藏起来,报个笼统的"已沙箱"?因为沙箱的强制力不是承诺,是事实。一个消费者需要绝对边界,比如它要跑一段可能删东西的代码,拿到 partial 却假装是 full,就是埋一颗"以为挡住了其实没挡住"的雷。把事实报出来,判断权交给消费者:绝对边界的场景拒绝执行,能容忍的场景明示给用户。代价是消费者多写一段分支,回报是没有人误信一道有缺口的墙。

策略按调用解析,不写死在 provider 上

完整的执行策略是 SandboxExecutionPolicy,三个字段:mode、workspaceRoot、sessionId。每次调用,ctx.sandboxPolicy.resolve() 都重新解析一次,优先级是三层:显式批准的模式覆盖最高(升级重试时传入),其次是会话日志里最后一条 sandbox/mode 事件,最后落回部署默认值。

为什么要按调用携带,而不是存成 provider 的状态?因为并发的存在。同一瞬间,bash 可能跑在 read-only 下,而一个被围栏的子 agent 需要它的状态目录可写。如果策略是 provider 的可变状态,这两个消费者就得排队改同一个状态,或者互相覆盖。按调用携带,provider 每次拿到一个完全指定的策略,自己永远不改内部状态,并发自然成立。一个被批准的"提权重试"也因此没有副作用:它是一次带更宽策略的新调用,不是对 provider 状态的一次修改。

policy 里另外两个字段各有用途。workspaceRoot 从调用会话的不可变 cwd 派生,部署配置是无人会话的兜底;规范化顺序是先用文件系统语义解析、再做词法归一,所以 cwd 里带 symlink/.. 的路径会落到真实的运行目录,而不是被字符串拼接骗过。sessionId 用来给后端挂每会话的状态:Windows ACL 后端给每个活跃的会话加工作区对分配一个随机私有临时目录和 SID,工作区级别的常设授权单独保持。会话结束,随它去的临时目录一起回收,常设授权不受影响。

confine:要么围栏,要么不跑

ctx.sandbox.confine(argv, policy) 返回一个替换用的 argv(围栏 runner 加配置,再加调用方原始的 argv),或者抛 SandboxUnavailableError,错误码 SANDBOX_UNAVAILABLE。契约里写死了一条不变量:对一个要求围栏的策略,静默的未围栏直通永远不合法。拿不到围栏就不跑,没有第三种结局。

有一个容易传错的参数形态:argv 是调用方要 spawn 的确切参数数组(程序加参数),不是一段 shell 文本。一个 shell 形态的消费者要传的是 ['bash', '-c', command]。围栏包的是实际要执行的那个进程树,如果传了一段 shell 字符串进去,围栏包住的是把这串文本当参数的 bash,而不是文本展开后的命令,防护就错位了。

stderr 里的两种方言

被围栏的命令跑完,stderr 上会出现两种性质完全不同的文本,区分它们是排查沙箱问题的关键。

第一种是拒绝方言。命令想写的东西在围栏之外,被挡住了,这是沙箱在正常工作。每个后端的方言不同:bwrap 的只读 bind 下报 EROFS 文本,Landlock 下报 EACCES,Seatbelt 下报 EPERM。围栏结果带着当前后端的这些签名,消费者匹配它们就知道"这是策略拒绝"。匹配必须只用当前后端的签名,不能取所有后端的并集,因为并集会声称识别一种这个后端根本不会产生的拒绝,分类永远虚高。

第二种是故障方言。沙箱 runner 自己拒绝启动或半路失败了,命令根本没跑。这要用另一套规则识别:先看退出码门(规则可以限定哪些非零退出码允许参与匹配),再剔除信息性行(整行精确匹配的无害输出),最后在剩余行里做关键子串匹配。退出码本身永远不能证明 runner 失败,必须落到 stderr 的一行证据上;分类过程也从不改写 stderr,命中的行原样保留做错误详情。

两种方言的区分为什么重要?把拒绝当故障,沙箱正确挡下的危险操作会被报成基础设施坏了;把故障当拒绝,沙箱真坏了却被当成命令被拦,无人修。消费者先查故障规则,再查拒绝签名,最后才把输出当成普通任务结果。

要诚实地说这条机制的边界:判定靠的是进程内的退出码加 stderr 文本,这不是密码学意义上的归因。一个被围栏的子进程可以故意模仿一行 runner 的致命文本,让故障归因指错对象。设计上接受了这一点,因为它骗到的是诊断信息,不是围栏本身的强制力,沙箱没有被绕过。

审批:一次动作的门

审批接缝回答"这一个具体动作,现在能不能放行"。它由 packages/interaction/user-approval 提供,服务是 ctx.approval

闭合的结果

ApprovalOutcome 是闭合的,四个值:allowed-oncerejectedcancelledunavailable。唯一的放行是 allowed-once,而且只对被问到的这一个动作有效。剩下三个值在调用方那里都落到拒绝执行。

unavailable 是 fail-closed 的灵魂。一个没被组合的 answerer、一个不归属这个 agent 的 answerer、一个抛了异常的 answerer、一个返回了词表外乱值的 answerer,统统归一成 unavailable,而不是打开闸门。翻译过来:审批系统坏了、缺了、行为怪了,默认答案都是拒绝。

放行为什么不延续?allowed-once 之后 agent 下次做类似的事还得再问。这故意拒绝了"批准一次,这类以后都自动放行"的便利,因为那会悄悄扩大攻击面:第一次批准时你看到的是那条具体命令,之后自动放行的每一次,你都不知道里面跑的是什么。单次性的代价是多几次点击,换来的是每次放行都有一个人看过。

ask 与 never

ApprovalPolicy 只有两个值,决定在交互式回答者出场之前发生什么。

ask 是默认,把问题委托给组合起来的 answerer 链。一个 answerer 要么返回一个结果(认领这次决策),要么把问题交给下一个;第一个返回的结果占据唯一的决策槽。链上一个 answerer 都没组合时,落空到 fail-closed 的 unavailable。链的形状支持真实的产品需求:UI 通道挂一个人来答,自动化桥挂一个一次性机器决策,一个 prepend 的 answerer 可以对某些危险模式自动拒绝。

never 是严格的无人值守姿态(CI、定时任务):每次询问确定性地返回 rejected,一个 answerer 都不会被派发。关键是它在服务内部、链分发之前强制。哪怕事后有人 prepend 注册了一个想放行的 answerer,也绕不过 never。安全策略的优先级高于扩展点,这不是一句口号,是一条执行顺序。

审计对与 turn 边界

ctx.approval.request(req) 要求请求的会话正处在一个打开的 turn 之内,空闲的询问在追加任何东西之前就直接拒绝。为什么卡这么死?因为一次审批必须留下两条审计事件:先 approval/asked,拿到结果后 approval/decided,两条事件靠一个本次询问新铸的请求 id 配对。turn 的提交边界必须能把这对事件完整包住。如果任何一个事件的落盘在提交点之前失败,请求照样拒绝,因为返回一个没记进日志的决策,就破坏了审计对。事件已提交之后,观察者的失败由会话层兜住,不会反过来撤销已经成立的决策。

请求本身有个刻意的省略:不带工具参数,只带 callId。提示挂在已经流式呈现过的 tool call 上,避免渲染出第二份可能漂移的参数副本。用户批准时看到的参数和实际执行的参数是同一份渲染,这一点在安全交互里比省一段代码重要。

审计事件是纯日志,不进模型 transcript。模型看到的,是调用方派生的工具结果加当前的运行时上下文快照。给人看的审计和给模型看的上下文是两个受众,分开记。审批策略的变化也一样:变了就追加一份新的完整快照,历史只增不改。

升级:被拒绝之后怎么走

fail-closed 描述的是静止状态,真实使用里更常见的是动态过程:agent 在 workspace-write 下跑得好好的,突然有一条命令确实需要写工作区外面。直接切会话模式太粗,切完还得切回来;什么都不做让命令一直报错,任务就卡死。dsh 的答案是升级链路。

第一步,拒绝要有信息量。沙箱拒绝命令时,在输出里留下一个带模式名的标记([sandbox: file access denied under <mode> mode]),模型在决策点看到的是"哪个模式挡的",而不是一句裸的权限错误。

第二步,模型可以选择重试。对同一条命令,带上 sandbox_permissionsjustification 两个字段,请求一个严格更宽的模式,重试一次。三个限定缺一不可:同一条命令、一次、严格更宽。请求一个同宽或更窄的模式没有意义,会被执行期检查挡掉。

第三步,审批先于执行。这次重试走正常的 ctx.approval.request,人看到的提示里有命令和升级理由。allowed-once 通过后,更宽的模式只印这一次调用;拒绝、取消、不可用、审批服务缺失,全都 fail-closed,错误文本各不相同。没有任何授权会留存到下一次。

这个流程里有两个"故意不做",都能看出日志的分量。

不在同一次工具调用里自动重试。如果一次 tool/call 背后藏着两次不同策略的执行,会话日志里就只能看到一次调用,重放时无法重建哪次用了什么策略。所以重试是一次新的、被完整记录的调用。

不做命令串的硬匹配去自动关联"这条拒绝和这次重试是同一条命令"。命令文本的同一性太脆:引号风格、工作目录、管道写法,任何一处变化都会让字符串比对失败。真正的边界是人:提示里同时呈现命令和理由,人看过了,就是授权依据。

升级链路还有一个上游设计变更值得一提。早期版本把当前沙箱模式写进系统提示,结果观察到一种"软锁死":模型读了提示里的模式描述,倾向于不去尝试可能被拒的工作,一次手工测试的十二轮里有五轮零工具调用,agent 在空转。修法是把模式叙述从系统提示里拿掉,让拒绝标记在决策点现场携带模式信息。想干活就试,被挡了有信息,升级有路。这个案例值得每个做 agent 产品的人记住:安全提示的呈现方式本身就是行为变量,写多了反而废掉 agent 的行动力。

子 agent 的策略继承也在这条链上。委派发生时,子 agent 对父会话的显式模式覆盖做一次快照,作为一条带来源标记的事件种在子会话日志前缀之后。效果是委派不可能回落到比父会话更宽的默认值:父会话收紧过,孩子就继承收紧,不会因为换了会话就掉回部署默认的宽模式。

权限预设:不带强制力的打包

预设层是最薄的一层。ctx.permissionPresets 把沙箱模式和审批策略这两个独立开关,打包成客户端能作为一个选择器展示的命名预设。

理解它的关键一句话:预设不拥有任何强制力。它是一个可选能力,不在 agent-loop 主干上。执行、提示叙述、回放,读的都是底层两个开关的折叠值;预设切换只是记录意图,再写穿到每个开关自己的 setter。预设层坏了或根本没装,底层开关照常工作。想靠"锁住预设选择器"来保证安全是锁错了地方,要锁就锁底层两个开关。

默认表里只有两个预设:workspace-write(沙箱 workspace-write 加审批 ask)和 danger-full-access(沙箱 danger-full-access 加审批 never)。两个预设的语义取向相反,一个谨慎一个放权,选择器上的每次切换因此都是有意义的表态。

custom 是个特殊值,它是算出来的,不是表里的一项。当前有效值从两个开关的折叠值推导:优先匹配仍然吻合的已记录选择,其次按声明顺序匹配表里的第一项,都不中就返回 custom。用户手动调了某个底层开关,组合脱离任何命名预设,UI 显示 custom 而不是假装还在某个预设里。custom 永远不能是切换目标,也不出现在事件载荷里;配置表里有一项敢叫 custom,插件加载时直接抛错,这个名字被保留了。

切换的落点也讲顺序。set() 解析预设名(未知名字抛错),追加一条纯日志的 permission/preset 事件,然后只有当某个开关的有效值真的变了,才通过它自己的 setter 写穿。选择事件在同一 turn 里先于开关事件;重复选择当前生效的预设,什么都不追加。为什么意图要单独留痕?因为纯日志的选择事件和有模型可见后果的开关事件是两类东西,它让 current() 在两个预设共享同一个组合时仍知道用户选的是哪个;而空转的开关事件只会污染日志。

为什么要有预设这层?独立的双开关对 UI 是灾难:用户面对两个下拉框,要理解笛卡尔积里哪些组合是安全的。一个部署定义的预设把两个开关捆成一体,UI 只暴露一个选择器,一致性由打包保证。反过来,不需要运行时切换的部署可以不装它,比如 ACP 自动化传输就不挂预设服务,自己显式选模式。

从头走一遍

把一次有副作用的工具调用从头串起来,看三个子系统各在哪一步出场。

agent 决定跑一条 bash 命令。工具层先请 ctx.sandboxPolicy 解析这次调用的策略:有显式批准的升级覆盖就用它,否则取会话日志里最后的 sandbox/mode,再落回部署默认;工作区根从会话的不可变 cwd 派生。

模式不是 danger-full-access,工具层调 ctx.sandbox.confine,拿围栏 argv 和强制力事实。拿到 partial 而这个场景要求绝对边界,拒绝执行;拿不到围栏(SANDBOX_UNAVAILABLE),拒绝执行;都过了,才有接下来的事。

审批出场。ctx.approval.request 带着挂在流式 tool call 上的 callId 和理由,策略是 never 直接落 rejected;是 ask 则走 answerer 链,等待期间被取消就落 cancelled。只有 allowed-once 让流程继续,审计对(asked 和 decided)已落进这个 turn 的日志。

命令在围栏 argv 下执行。结束后 stderr 先过 runner 故障规则:沙箱 runner 自己没跑起来,按基础设施故障报。再过拒绝签名:策略拒绝了写,把带模式名的标记给模型,模型可以发起带理由的升级重试,那是一次新的完整调用。两者都不是,输出才作为普通任务结果回给模型。

预设层在整条链上没有出场。它只在用户切换预设的瞬间,把意图翻译成两个开关各自的 setter 调用,然后退场。

权衡与局限

沙箱只管文件。网络外发、进程可见性、系统调用的其他副作用,都不在词表内。要管这些得靠别的机制,比如把执行世界整个换到远程沙箱。范围收窄换来的是可推理性:"沙箱能不能挡住"有确定答案,这个答案的代价就是问题问得窄。

partial 强制是常态而非异常。三大平台各有各的缺口,消费者必须处理"拿到 partial 怎么办"的分支。设计者选了暴露而不是隐瞒,使用者就得接住这份诚实。如果你的场景需要绝对边界,你消费强制力事实的代码就是安全边界的一部分,绕过它的任何"应该没事"都是自欺。

审批的单次性在自动化场景是摩擦。无人值守的部署用 never 全拒,交互场景每次都要人点头。折中方案是挂机器 answerer 做模式化的自动决策,但那等于把人的判断换成规则,规则的漏洞就是新的攻击面。

故障归因是进程内的文本匹配,可被伪装。诊断层面的假归因被接受了,但如果你的运维流程会根据"沙箱故障"自动做重大动作(自动重试、自动切后端),要意识到这条文本通道不是强认证的。

模式切换是会话级的。并发的两个会话各切各的互不影响,这是隔离;但同一个部署下不同会话可能跑在不同模式下,观察全局状态时别假设一致。

结论

三个子系统,三个各自闭合的问题。沙箱管文件效果,模式词表窄而明确,强制力作为 full 或 partial 的事实报告,静默直通被明文禁止。审批管单次放行,结果闭合到只有 allowed-once 一种放行,unavailable 兜住一切异常,审计对被 turn 边界包住。预设只做打包,不拥有强制,custom 是推导出来的诚实状态。

把它们串起来的升级链路是这套设计最有产品味的地方:拒绝带模式信息,重试带理由且严格一次,宽模式只印单次调用,子 agent 继承收紧的快照,一切落日志可重放。两个"故意不做"(同调用自动重试、命令串硬匹配)都是在保护日志的可重建性,这个保护对象比便利更值钱。

换一个沙箱后端,审批逻辑一行不改;换一个 answerer,沙箱后端一行不改;预设层可以整个不装。每个子系统独立推理、独立测试、独立替换,而 fail-closed 和"强制是事实不是承诺"两条纪律保证无论怎么换,安全底线不会被一个 bug 或一个 partial 悄悄击穿。

延伸阅读

上一篇:给 dsh 写一个 LLM 适配器:接 OpenAI 兼容端点 下一篇:dsh 的 Filesystem 接缝:读写编辑与观察策略


GitHub 原文:19-sandbox-approval-permission.md

评论

0
登录后可以参与评论和讨论。

EMPTY

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。