ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自己动手写Agent Harness【agent safety】:从审批到沙箱,用TaoToken统一Key跑通权限边界验证

自己动手写Agent Harness【agent safety】:从审批到沙箱,用TaoToken统一Key跑通权限边界验证 1. 为什么你的 Agent 需要一个 Harness 安全层Agent Harness 说白了就是给大模型套上手脚的那层壳模型负责想Harness 负责决定它能不能做、做到什么程度。你一旦让模型能调 shell、能读写文件就等于把工作区的钥匙交出去了。模型不是坏它只是会做所有被允许做的事——rm -rf、越界读文件、把工作区外面的东西翻个底朝天都是它「正常发挥」。所以 agent safety 的核心不是让模型变乖而是让边界替它兜底。这篇要落地的是三条主线审批流管「该不该做」权限分级管「谁危险谁安全」沙箱隔离管「能不能做到」。三者叠起来才是一个能跑、能验证、能审计的骨架。适合谁看正在自研 Agent Harness、已经能跑通工具调用、但还没想清楚安全边界怎么关的开发者。如果你还在纠结要不要给模型开 shell那正好这篇就是给你写的。我试过的做法是先把安全哲学定下来再动手写代码。边界关在哪一层直接决定后面所有设计——关在应用层防的是人误放行关在内核层防的是模型绕过。这个前提不定后面全是打补丁。下面我会给出可复制的审批规则配置、权限声明文件和沙箱启动参数并用 TaoToken 统一 Key 发起一次受控工具调用用拒绝和放行两类用例验证边界到底有没有生效。整套流程不需要你改模型只需要在 Harness 里插几道闸。2. TaoToken 前置准备统一 Key 与 API 通道在写安全层之前先把模型通道打通。自研 Harness 最烦的一件事是每换一个模型就要改一遍鉴权代码TaoToken 的价值就在于把 Key 和 API 通道统一掉你 Harness 里只认一个 Base URL 和一个 Key模型切换在服务端完成。你需要准备三样东西Base URL、API Key、Model ID。这三件套在后面的审批配置和沙箱验证里都会用到缺一个都跑不起来。Base URL 用https://taotoken.net/api注意这个地址不带任何查询参数直接填进配置就行。API Key 去控制台生成路径是 console生成后复制出来别提交到 git。Model ID 按你实际要用的模型填比如claude-sonnet-4-5这类具体以 doc 里的模型列表为准。如果你用的是 Claude Code 这类工具可以直接走 ClaudeCodeAnthropic 的接入方式把 Base URL 和 Key 填进去就能用。自研 Harness 的话用标准的 OpenAI 兼容接口调用即可下面给一段最小验证代码。// verify-key.mjs // 用 TaoToken 统一 Key 验证通道是否打通 const BASE_URL https://taotoken.net/api const API_KEY process.env.TAOTOKEN_API_KEY const MODEL_ID process.env.TAOTOKEN_MODEL_ID || claude-sonnet-4-5 async function ping() { const res await fetch(${BASE_URL}/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${API_KEY}, }, body: JSON.stringify({ model: MODEL_ID, messages: [{ role: user, content: 只回复 ok 两个字母 }], max_tokens: 16, }), }) if (!res.ok) { const text await res.text() throw new Error(HTTP ${res.status}: ${text}) } const data await res.json() console.log(model reply:, data.choices?.[0]?.message?.content) } ping().catch((e) { console.error(ping failed:, e.message) process.exit(1) })跑之前先把环境变量设好export TAOTOKEN_API_KEY你的Key export TAOTOKEN_MODEL_IDclaude-sonnet-4-5 node verify-key.mjs看到model reply: ok就说明通道通了。这一步别跳过后面审批和沙箱的验证都依赖这条通道。如果这里就报 401先去看第 5 节的排查表别急着往下写安全层。Key 的管理建议单独放一个.env文件用dotenv加载并且把.env加进.gitignore。我见过太多人把 Key 硬编码进源码然后推到公开仓库第二天就收到额度被刷爆的告警。统一 Key 的好处是只有一个地方要管坏处也是只有一个地方——丢了就是全丢所以权限最小化和定期轮换要跟上。3. 可复制配置审批规则、权限声明与沙箱参数这一节是整篇的核心三份配置直接抄就能用。先说审批规则我用 JSON 写因为 JSON 最通用你换成 TOML 或 YAML 都行结构一样。审批的核心是给每个工具挂一个档位三档read-only直接放行workspace-write要问人danger-full-access必须人点头。这套命名不是我发明的Claude Code、Codex 的审批都用同一套分法你照着用不会错。{ version: 1, defaultDecision: deny, tools: { read_file: { tier: read-only, allowlist: true }, list_dir: { tier: read-only, allowlist: true }, write_file: { tier: workspace-write, allowlist: false }, run_command: { tier: danger-full-access, allowlist: false }, delete_path: { tier: danger-full-access, denylist: true } }, sandbox: { workspaceRoot: ./workspace, allowedCommands: [whoami, echo, pwd, ls, cat, git status], maxOutputBytes: 65536 } }几个关键点。defaultDecision设成deny这是 fail-closed 原则——没明确放行的就是拒绝不是放行。denylist优先级最高一票否决delete_path挂上去之后无论什么档位都直接拒。allowlist: true表示显式放行跳过档位判断直接过。权限声明文件我建议跟工具定义放一起每个工具自己声明档位审批器只负责照档位处置。这样危险度由工具作者声明审批逻辑保持通用。// tools/permissions.mjs // 工具级权限声明name - tier 映射 export const PERMISSIONS { read_file: read-only, list_dir: read-only, write_file: workspace-write, run_command: danger-full-access, delete_path: danger-full-access, } // 审批器denylist 一票否决allowlist 显式放行其余按档位给默认 export class Approver { constructor(config) { this.config config this.denylist new Set( Object.entries(config.tools) .filter(([, v]) v.denylist) .map(([k]) k) ) this.allowlist new Set( Object.entries(config.tools) .filter(([, v]) v.allowlist) .map(([k]) k) ) } decide(toolName, tier) { if (this.denylist.has(toolName)) { return { ok: false, decision: deny } } if (this.allowlist.has(toolName)) { return { ok: true, decision: allow } } if (tier read-only) { return { ok: true, decision: allow } } if (tier workspace-write) { return process.env.AUTO_ALLOW 1 ? { ok: true, decision: ask } : { ok: false, decision: deny } } // danger 默认 denyfail-closed return { ok: false, decision: deny } } }沙箱参数分两块路径沙箱和命令 allowlist。路径沙箱最容易翻车的地方是用字符串前缀判断越界../package.json这种带..的会被穿透。正确做法是先 resolve 成绝对路径再算相对路径看它是不是以..开头。// sandbox/path-jail.mjs import { resolve, relative, sep } from node:path export class PathJail { constructor(workspaceRoot) { this.workspaceRoot resolve(workspaceRoot) } admit(requestedPath) { const abs resolve(this.workspaceRoot, requestedPath) const rel relative(this.workspaceRoot, abs) if (rel .. || rel.startsWith(..${sep})) { throw new Error( path escape: ${requestedPath} resolves outside workspace ) } return abs } }命令沙箱同样是一个 allowlist只放行白名单内的可执行名。防的是rm -rf这类不可逆操作——模型提议沙箱处置。// sandbox/command-jail.mjs export class CommandJail { constructor(allowed) { this.allowed new Set(allowed) } admit(commandLine) { const name commandLine.trim().split(/\s/)[0] if (!this.allowed.has(name)) { throw new Error(command not allowed: ${name}) } return commandLine } }把审批闸插进执行流水线顺序是审批该不该做→ pre参数校验→ execute真做内部走沙箱→ post回写。这个顺序不能反反了就会出现「白名单命令先被放行、危险命令被沙箱拦」的漏洞而沙箱的字符串规则是纸糊的rm -rf换个写法就绕过去了。// pipeline/execute.mjs export async function executeTool(tool, args, ctx) { const { approver, pathJail, commandJail } ctx // 0. 审批闸fail-closeddeny 也作为结果回写 const approval approver.decide(tool.name, tool.tier) if (!approval.ok) { return { ok: false, error: permission denied (${approval.decision}) for ${tool.name}, } } // 1. pre参数校验 沙箱准入 try { if (tool.name read_file || tool.name write_file) { args.path pathJail.admit(args.path) } if (tool.name run_command) { args.command commandJail.admit(args.command) } } catch (e) { return { ok: false, error: ${tool.name} execute failed: ${e.message} } } // 2. execute真做 try { const result await tool.execute(args) return { ok: true, result } } catch (e) { return { ok: false, error: ${tool.name} execute failed: ${e.message} } } }注意deny不是崩溃deny是结果。工具被拒Harness 不报错停机而是把permission denied作为工具结果回写进上下文模型读得到「被拒了」从而调整策略。这一点在下一节验证时会看得很清楚。4. 验证请求拒绝与放行两类用例跑通配置写完现在故意让模型越界看边界有没有生效。下面输出是本机真实跑通的mock 模型、无 API key不是示意。先给一张边界流程图后面三条拦截就是在这张图的节点上触发的。[user] - [approver.decide] - deny? - 回写 permission denied - allow/ask? - [pathJail/commandJail.admit] - 越界? - 回写 path escape / command not allowed - 通过? - [tool.execute] - 回写结果第一条拦截让模型读工作区外的文件。[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 拦下。注意两件事一是错误不崩溃 loopadmit抛错被转成拒绝结果作为 toolResult 回写二是模型照常总结它读到了「被拒了」并继续。这就是「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 发生在命令内容被检查之前。现在验证放行路径。把AUTO_ALLOW1打开让workspace-write档走 ask 分支再跑一次写文件。export AUTO_ALLOW1 node step3-safety/index.js[user] 写文件 notes/todo.md 内容 hello [tool:write_file] - OK wrote 5 bytes to ./workspace/notes/todo.md [assistant] 工具 write_file 返回了wrote 5 bytes放行成功。再试一条越界的写[user] 写文件 ../outside.md 内容 escape [tool:write_file] - ERR write_file execute failed: path escape: ../outside.md resolves outside workspace [assistant] 工具 write_file 返回了write_file execute failed: path escape…审批放行了但沙箱拦住了。这正好说明两层是独立的审批管「该不该做」沙箱管「能不能做到」。审批说可以写沙箱说这个路径不行两层都过才算真放行。三条拦截加两条放行合起来证明一句话审批先于沙箱顺序不能反。如果把沙箱放在审批前面白名单命令会先被放行、危险命令被沙箱拦——但「危险」的判断只靠沙箱的字符串规则rm -rf换个写法变量拼接、子 shell就绕过去了。审批先于沙箱意思是「该不该做」先由人或配置拍板「能不能做到」才是机器兜底。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth跑这套骨架最容易卡在通道和配置上下面按真实报错对照排查。报错触发位置原因处理HTTP 401: Unauthorizedverify-key.mjsKey 没设或写错检查TAOTOKEN_API_KEY去 api-keys 重新生成local proxy failed工具调用阶段Base URL 填错或带了多余路径确认是https://taotoken.net/api不带 UTM 和尾部斜杠Cannot read properties of undefined (reading choices)解析响应返回体不是标准结构通常是鉴权失败返回了错误 JSON先打印res.status和原始 text别直接.json()OAuth token expiredClaude Code 接入用了 OAuth 而不是 API Key改用 API Key 方式走 ClaudeCodeAnthropic 的 Key 配置path escape误报沙箱准入workspaceRoot 用了相对路径resolve 后对不上启动时把 workspaceRoot 转成绝对路径command not allowed: git命令沙箱allowlist 里写的是git status整条但匹配的是第一个词allowlist 只放可执行名参数校验另做reading choices这个错特别常见根因是你在res.ok为 false 的时候还去读data.choices。正确写法是先判断状态码非 2xx 直接抛错并打印原始文本。if (!res.ok) { const text await res.text() throw new Error(HTTP ${res.status}: ${text}) } const data await res.json() const content data.choices?.[0]?.message?.contentlocal proxy failed多半是 Base URL 写成了带路径的形式比如https://taotoken.net/api/v1而 SDK 自己会拼/v1/chat/completions结果变成/api/v1/v1/chat/completions。统一用https://taotoken.net/api让 SDK 去拼。OAuth 那个错是 Claude Code 场景特有的。如果你之前用 OAuth 登录过配置里可能残留了 OAuth 相关字段跟 API Key 冲突。清掉 OAuth 配置只留 Base URL、Key、Model ID 三件套。这三件套在任何接入方式里都是必须的缺一个都跑不通。还有一个隐蔽的坑AUTO_ALLOW1忘了关。这个环境变量一开workspace-write档会走 ask 分支被当成放行你以为审批生效了其实没有。生产环境千万别设这个变量它只是本地验证用的模拟开关。6. 边界关在哪一层选型与后续边界可以关在三层三家各押了一层你的 Harness 想防什么决定你关在哪。Codex 押内核级。Linux 上 Landlock 加 seccompLandlock 是内核 LSM 管文件系统访问seccomp 过滤系统调用主要管网络。策略在一个单独的 sandbox 二进制里主程序把策略 JSON 传给它它应用规则后 exec 目标命令。它的哲学是边界关在内核层谁都没法绕过——应用层的白名单可以用 shell 编码、子 shell、换解释器绕过内核的 syscall 过滤绕不过。Claude Code 押应用层。权限落在工具级allow / ask / deny 规则deny 优先级绝对PreToolUse 钩子能先于权限步骤拦截。OS 沙箱只包 Bash 工具和它的子进程做最后物理防线。它的哲学是边界可以随时插进干预防的是人误放行。dsh 把沙箱做成接缝。SandboxProvider.confine是抽象服务传入 argv 和策略返回被包过的 argv。真正的执行走平台链按平台挑一个全部 fail-closed。它的哲学是边界本身可替换。三种选型对应三种想防的东西防模型绕过你选内核级防人误放行选应用层加审批多平台一鱼多吃把沙箱做成接缝。你现在写的 PathJail、CommandJail、Approver 全在应用层这是和 Claude Code 同层的最小实现。但「关在应用层」是不是你想要的取决于你想防什么。边界不可审计等于没有边界。审批管「该不该做」靠的是「人是人」这个前提当模型能冒充人回答审批时这一层就失效了。先想清楚你防的是「模型绕过」还是「人误放行」。给你的 Harness 划一条真边界试着让它越界——跑一遍node step3-safety/index.js看那三条拦截然后给你的工具也挂上档位故意让模型去读工作区外的文件。划完你会理解审批和沙箱不是「加两道锁」是「加两层互不信任的检查」。如果你要长期跑编码类 Agent建议把 Key 和通道统一到 Coding Plan省得每次换模型都改一遍 Harness。想先验证模型行为用 模型对话 快速试要接自研 Harness直接看 接入文档 和 API Keys。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进