ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

自己动手写Agent Harness【agent safety】:agent的安全边界——审批、权限与沙箱

2026/8/23 8:43:27 拓冰建站 浏览量
自己动手写Agent Harness【agent safety】:agent的安全边界——审批、权限与沙箱 写在前面系列是为了帮助大家更好的去理解Agent Harness基础设施并不是想重复造轮子真实开发建议选择一个成熟的SDK或Harness框架才是最合适的选择~1. 模型会手滑边界是最后防线你的 harness 现在能执行 shell 了——这等于把钥匙交给了模型。模型会手滑。rm -rf、越界读文件、把工作区外面的东西翻个底朝天都不是它「坏」是它会做所有它被允许做的事。边界是最后防线不是可选项。先定你的安全哲学再动手——边界关在哪一层决定你后面所有设计。2. 审批三档每个工具挂一个档位先分清两个概念**审批管「该不该做」沙箱管「能不能做到」。**我们先造管「该不该做」的审批再造管「能不能做到」的沙箱。审批给每个工具挂一个档位三档read-only只读工具直接放行workspace-write写工作区内的文件要问人danger-full-accessshell、删除这类不可逆操作必须人点头这套三档命名不是我发明的。dsh 的SandboxMode就是这三个词Claude Code、Codex 的审批也用同一套分法。下面是可复制的审批器。核心是decide()denylist 一票否决allowlist 显式放行其余按档位给默认danger 默认 denyfail-closed。// 审批器每个工具挂一个档位ask 走配置这里用 env 模拟人deny fail-closed。classApprover{constructor(allowlist{},denylist[]){this.allowlistallowlist// { [toolName]: true } 显式放行this.denylistnewSet(denylist)// 显式拒绝}// 返回 { ok, decision }decision 是 allow | ask | denydecide(toolName,tier){if(this.denylist.has(toolName))return{ok:false,decision:deny}if(this.allowlist[toolName]true)return{ok:true,decision:allow}// 按档位给默认read-only 直接放行其余默认 ask这里无交互就 denyif(tierread-only)return{ok:true,decision:allow}if(tierworkspace-write){returnprocess.env.AUTO_ALLOW1?{ok:true,decision:ask}:{ok:false,decision:deny}}// danger 默认 denyfail-closedreturn{ok:false,decision:deny}}}三态allow / ask / deny两种走法ask走交互allow / deny走配置。这里用环境变量AUTO_ALLOW1模拟「人放行了 workspace-write」——真实 harness 里这格是弹给用户的一次性询问。注意 ask 那格我留了但没接交互当前默认 deny。这是最小版第六节对照 dsh 你会看到完整版的审批长什么样。一个设计要点先记住deny 不是崩溃deny 是结果。工具被拒harness 不报错停机而是把「permission denied」作为工具结果回写进上下文模型读得到「被拒了」从而调整策略。3. 工具级权限危险度由工具自己声明权限不写在审批器里写在工具身上。每个工具声明自己的档位read_file是read-onlyrun_command是danger-full-access。这就是 permission 策略表——一张 name → 档位的映射危险度由工具作者声明审批器照档位处置。constread_file{name:read_file,description:读取指定路径的文本文件内容。当用户想查看文件内容时调用。,parameters:{type:object,properties:{path:{type:string,description:要读取的文件路径}},required:[path]},tier:read-only,asyncexecute(args){constfsawaitimport(node:fs/promises)returnawaitfs.readFile(pathJail.admit(args.path),utf8)// 路径进沙箱},}construn_command{name:run_command,description:在工作区执行一条命令。当用户要求执行命令时调用。,parameters:{type:object,properties:{command:{type:string,description:要执行的命令}},required:[command]},tier:danger-full-access,// 挂最高档审批必须介入asyncexecute(args){const{execSync}awaitimport(node:child_process)constcmdcommandJail.admit(args.command)// 命令进 allowlistreturnexecSync(cmd,{encoding:utf8,cwd:process.cwd()}).trim()},}注意execute里已经埋了沙箱——pathJail.admit和commandJail.admit。工具的定义是「声明档位 内部走沙箱」审批和沙箱在工具这一层汇合。危险工具shell挂最高档普通工具直接执行——工具级权限的全部就这么多。4. 路径沙箱path jail 命令 allowlist审批管「该不该做」沙箱管「能不能做到」。这篇的沙箱是应用层的最小版路径沙箱path jail 命令 allowlist。先讲一个会翻车的地方路径越界不能用字符串前缀判断。你以为「以工作区开头的路径都在里面」但../package.json这种带..的字符串比对会被穿透。真实做法是先resolve成绝对路径再算相对路径看它是不是以..开头。// 路径沙箱所有文件操作必须落在工作区内path jail。// 用真实路径比较不用字符串前缀——字符串..的猫腻会被 resolve 后穿透。import{resolve,relative,sep}fromnode:pathclassPathJail{constructor(workspaceRoot){this.workspaceRootresolve(workspaceRoot)}// 返回合法绝对路径越界抛错调用方转成拒绝结果admit(requestedPath){constabsresolve(this.workspaceRoot,requestedPath)constrelrelative(this.workspaceRoot,abs)// 相对路径以 .. 开头 逃出工作区if(rel..||rel.startsWith(..${sep})){thrownewError(path escape:${requestedPath}resolves outside workspace)}returnabs}}命令那边同样是一个 allowlist只放行白名单内的可执行名。// 命令 allowlist只放行白名单内的可执行名其余拒绝。// 防的是 rm -rf 全家桶 这类不可逆操作——模型提议沙箱处置。constALLOWED_COMMANDSnewSet([whoami,echo,pwd,ls,cat,git status])classCommandJail{constructor(allowed){this.allowedallowed}admit(commandLine){constnamecommandLine.trim().split(/\s/)[0]if(!this.allowed.has(name)){thrownewError(command not allowed:${name})}returncommandLine}}一句话记住模型提议沙箱处置。模型只负责提议「我想读这个文件、我想跑这条命令」能不能做由沙箱的 admit 说了算。admit 抛错调用方转成拒绝结果——和审批的 deny 走同一条回写通道。再把审批闸插进执行流水线顺序是审批该不该做→ pre参数校验→ execute真做内部走沙箱→ post回写。// 0. 审批闸fail-closeddeny 也作为结果回写模型能读到被拒了从而调整constapprovalapprover.decide(tool.name,tool.tier)if(!approval.ok){return{ok:false,error:permission denied (${approval.decision}) for${tool.name}}}记住先定你的安全哲学再动手——边界关在哪一层决定你后面所有设计。你现在写的 PathJail、CommandJail、Approver 全在应用层——这是和 Claude Code 同层的最小实现。但「关在应用层」是不是你想要的取决于你想防什么防模型绕过应用层的字符串规则是纸糊的防人误放行应用层够用。5. 跑通它试着越界给工具加完边界现在故意让模型越界。下面输出是本机真实跑通的node step3-safety/index.jsmock 模型、无 API key不是示意。先给一张边界流程图后面三条拦截就是在这张图的节点上触发的。沙箱层 · 管「能不能做到」审批层 · 管「该不该做」deny · fail-closed缺配置一律拒allow越界 / 黑名单通过模型提议tool-call审批闸 Approver拒绝结果回写pre 校验 → execute沙箱处置PathJail / CommandJailok 结果回写进模型上下文模型读到「被拒了」调整策略第一条拦截让模型读工作区外的文件。[user] 读文件 ../package.json [tool:read_file] - ERR read_file execute failed: path escape: ../package.json resolves outside workspace [assistant] 工具 read_file 返回了read_file execute failed: path escape: ../package.json resol…../package.json想逃出工作区被 path jail 拦下。注意两件事一是错误不崩溃 loop——admit 抛错被转成拒绝结果作为 toolResult 回写二是模型照常总结工具 read_file 返回了...它读到了「被拒了」并继续。这就是「deny 是结果不是崩溃」的现场。第二条拦截让模型跑不可逆命令。[user] 执行命令 rm -rf / [tool:run_command] - ERR permission denied (deny) for run_command [assistant] 工具 run_command 返回了permission denied (deny) for run_commandrm -rf /挂在 danger 档审批直接 deny——命令内容根本没进命令沙箱。第三条拦截最值钱让模型跑一条白名单命令。[user] 执行命令 whoami [tool:run_command] - ERR permission denied (deny) for run_command [assistant] 工具 run_command 返回了permission denied (deny) for run_commandwhoami明明在命令 allowlist 白名单里还是被 deny。为什么因为审批在命令沙箱前面——danger 档连白名单命令都不放审批先于沙箱。一个诚实的小细节mock 模型是确定性的任何「执行命令 X」它都只会生成whoami但这不影响结论——两条「执行命令」都被 deny恰好证明 deny 发生在命令内容被检查之前。三条拦截合起来证明一句话审批管「该不该做」沙箱管「能不能做到」两层顺序不能反。如果把沙箱放在审批前面白名单命令会先被放行、危险命令被沙箱拦——但「危险」的判断只靠沙箱的字符串规则rm -rf换个写法变量拼接、子 shell就绕过去了。审批先于沙箱意思是「该不该做」先由人或配置拍板「能不能做到」才是机器兜底。6. 对照DSH/Claude/CodeX边界关在哪一层边界可以关在三层三家各押了一层。Codex 押内核级据官方文档。Linux 上 Landlock seccompLandlock 是内核 LSM管文件系统访问seccomp 过滤系统调用主要管网络。策略在一个单独的 sandbox 二进制里主程序把策略 JSON 传给它它应用规则后 exec 目标命令——沙箱策略和主程序分开。Landlock 要求 ABI V5内核 5.13老内核走 BestEffort 降级。macOS 走 Seatbeltsandbox-execWindows 走受限令牌/AppContainer。它的哲学边界关在内核层谁都没法绕过——应用层的白名单可以用 shell 编码、子 shell、换解释器绕过内核的 syscall 过滤绕不过。Claude Code 押应用层。权限落在工具级allow / ask / deny 规则deny 优先级绝对PreToolUse 钩子能先于权限步骤拦截、--dangerously-skip-permissions下也生效。OS 沙箱macOS Seatbelt、Linux/WSL2 的 bwrap只包 Bash 工具和它的子进程做最后物理防线autoAllowBashIfSandboxed打开后过了沙箱检查的 Bash 自动放行官方说权限询问能减少约 84%。它的哲学边界可以「随时插进干预」防的是人误放行。dsh 把沙箱做成接缝。SandboxProvider.confinepackages/sandbox/sandbox/src/index.ts:158是抽象服务传入 argv 和策略返回被包过的 argv。真正的执行走平台链——bwrap、landlock、seatbelt、windows-acl按平台挑一个全部 fail-closed。源码注释原话「confine必须返回能执行的 argv或者在包装或 runner 执行时 fail closed——silent unconfined passthrough is forbidden静默的未受控透传是禁止的。」审批是独立三档 read-only / workspace-write / danger-full-access。它的哲学边界本身可替换。三种选型三种想防的东西防「模型绕过你」→ 内核级像 Codex防「人误放行」→ 应用层加审批像 Claude Code你的最小版就在这层多平台一鱼多吃 → 把沙箱做成接缝像 dsh你的 harness 想防什么决定边界关在哪一层。这一条不定后面所有设计都是打补丁。7. 结论边界不可审计 没有边界边界不可审计等于没有边界。审批管「该不该做」靠的是「人是人」这个前提当模型能冒充人回答审批时这一层就失效了。先想清楚你防的是「模型绕过」还是「人误放行」。现在轮到你了。给你的 harness 划一条真边界试着让它越界——跑一遍node step3-safety/index.js看那三条拦截然后给你的工具也挂上档位故意让模型去读工作区外的文件。划完你会理解审批和沙箱不是「加两道锁」是「加两层互不信任的检查」。你的模型哪次越界最惊险评论区聊聊最狠的我都想听。源码下载agent-safety