Cursor Router 上线后,我用 Node.js 实现了一个可解释的模型路由器 现在的 AI 编程工具早已不只是生成几段代码。它们可以读取项目、修改文件、运行测试、安装依赖甚至执行部署和数据库相关命令。权限扩大以后一个很现实的问题随之出现当 AI Agent 提议执行一条 Shell 命令时我们到底应该允许它做什么OpenAI 当前的 Codex 文档将安全控制拆分为两个部分沙箱决定命令可以接触哪些文件和网络资源审批策略决定哪些操作必须暂停并询问用户。Claude Code 也支持基于 allow、deny 的权限规则并可通过 Hooks 在工具执行前返回允许、拒绝或要求确认等结果。这些原生机制应该优先启用。但在团队项目里我仍然建议再增加一层项目级控制AI Agent ↓ 项目命令网关 ↓ lint / test / typecheck / git diff这层网关不负责替代操作系统沙箱而是解决三个更具体的问题团队明确规定 Agent 可以运行哪些命令所有执行记录都可以审计不同 AI 工具共用同一套项目规则。本文用 Node.js 实现一个简单但可运行的版本。一、先确定需要防什么假设我们允许 Agent 自由执行命令它可能产生以下风险。1. 误删除文件rm -rf dist rm -rf .第一条可能只是清理构建目录。第二条可能直接删除当前工作区。仅靠“Agent 应该能理解命令危险”并不可靠。2. 误操作生产环境npm run deploy kubectl apply -f k8s/ terraform apply这些命令本身不一定有问题但不应该由普通代码修改任务自动触发。3. 读取或传递敏感环境变量本地终端可能存在AWS_SECRET_ACCESS_KEY OPENAI_API_KEY ANTHROPIC_API_KEY DATABASE_URL即使 Agent 只运行一段普通脚本子进程也可能继承当前环境变量。4. 使用组合命令绕过限制例如npm test npm run deploy表面上以测试开头后面却连接了部署命令。因此不能只检查命令字符串是不是以npm test开头。5. 命令长时间不退出测试进程、开发服务器或者等待输入的脚本可能一直占用终端npm run dev python server.py命令网关必须有超时限制。二、采用“默认拒绝”而不是维护危险命令黑名单一种常见做法是维护黑名单禁止 rm 禁止 sudo 禁止 deploy 禁止 kubectl问题是危险操作不只有这些形式。例如删除文件还可以通过find . -delete node cleanup.js python remove_files.py如果依赖黑名单很难穷举全部危险情况。更稳的策略是没有明确允许的命令一律拒绝。例如只允许 Agent 运行git status --short git diff --stat git diff --check npm run lint npm run typecheck npm test即使 Agent 请求npm test -- --updateSnapshot也会被拒绝。因为它和白名单里的npm test不是完全相同的命令。这种方式不够灵活但安全边界更清晰。三、项目目录结构在项目根目录增加以下文件your-project/ ├── agent-command-policy.json ├── scripts/ │ └── agent-safe-run.mjs ├── .agent-audit/ │ └── commands.jsonl ├── .gitignore ├── package.json └── src/把审计日志加入.gitignore.agent-audit/审计日志通常只保留在本地或交给内部日志系统不建议直接提交到代码仓库。四、编写命令策略文件创建agent-command-policy.json内容如下{ timeoutMs: 120000, maxOutputBytes: 1048576, allowedCommands: [ [git, status, --short], [git, diff, --stat], [git, diff, --check], [npm, run, lint], [npm, run, typecheck], [npm, test] ], blockedEnv: [ AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, OPENAI_API_KEY, ANTHROPIC_API_KEY, DATABASE_URL, PRODUCTION_DATABASE_URL ] }这里有四类配置。timeoutMs单条命令最长运行时间。示例设置为两分钟timeoutMs: 120000超时后子进程会被终止。maxOutputBytes限制命令输出大小避免测试日志或异常输出占用过多内存。allowedCommands允许执行的完整命令。每条命令都拆成数组[npm, run, lint]而不是写成npm run lint这样后续可以直接使用spawnSync传递命令和参数不需要 Shell 帮忙解析。blockedEnvAgent 执行命令前需要从子进程环境中删除的敏感变量。这不是完整的密钥管理方案但至少可以减少普通测试命令意外继承生产凭据的风险。五、完整 Node.js 命令网关创建scripts/agent-safe-run.mjs写入以下代码#!/usr/bin/env node import { spawnSync } from node:child_process; import crypto from node:crypto; import fs from node:fs; import path from node:path; import process from node:process; function fail(message, exitCode 1) { console.error(拒绝执行${message}); process.exit(exitCode); } function run(command, args, options {}) { return spawnSync(command, args, { encoding: utf8, shell: false, ...options, }); } function getRepoRoot() { const result run( git, [rev-parse, --show-toplevel], ); if (result.status ! 0) { fail(当前目录不是 Git 仓库); } return result.stdout.trim(); } function loadPolicy(repoRoot) { const policyPath path.join( repoRoot, agent-command-policy.json, ); if (!fs.existsSync(policyPath)) { fail(缺少策略文件${policyPath}); } let policy; try { policy JSON.parse( fs.readFileSync(policyPath, utf8), ); } catch (error) { fail(策略文件无法解析${error.message}); } if (!Array.isArray(policy.allowedCommands)) { fail(allowedCommands 必须是数组); } return { timeoutMs: Number(policy.timeoutMs) || 120000, maxOutputBytes: Number(policy.maxOutputBytes) || 1048576, allowedCommands: policy.allowedCommands, blockedEnv: Array.isArray(policy.blockedEnv) ? policy.blockedEnv : [], }; } function parseRequest(argv) { const separatorIndex argv.indexOf(--); if ( separatorIndex -1 || separatorIndex argv.length - 1 ) { fail( 用法node scripts/agent-safe-run.mjs [--dry-run] -- command [args...], ); } const flags argv.slice(0, separatorIndex); const unknownFlag flags.find( (flag) flag ! --dry-run, ); if (unknownFlag) { fail(未知参数${unknownFlag}); } return { dryRun: flags.includes(--dry-run), commandParts: argv.slice(separatorIndex 1), }; } function isExactAllowed( commandParts, allowedCommands, ) { return allowedCommands.some( (allowed) Array.isArray(allowed) allowed.length commandParts.length allowed.every( (value, index) value commandParts[index], ), ); } function sanitizeEnvironment(blockedEnv) { const env { ...process.env, }; for (const key of blockedEnv) { delete env[key]; } env.NODE_ENV env.NODE_ENV || test; env.CI env.CI || 1; return env; } function appendAudit(repoRoot, record) { const auditDir path.join( repoRoot, .agent-audit, ); const auditFile path.join( auditDir, commands.jsonl, ); fs.mkdirSync(auditDir, { recursive: true, }); fs.appendFileSync( auditFile, ${JSON.stringify(record)}\n, utf8, ); } function hashRequest(commandParts) { return crypto .createHash(sha256) .update(JSON.stringify(commandParts)) .digest(hex); } const repoRoot getRepoRoot(); const policy loadPolicy(repoRoot); const { dryRun, commandParts, } parseRequest( process.argv.slice(2), ); const allowed isExactAllowed( commandParts, policy.allowedCommands, ); const startedAt Date.now(); const requestHash hashRequest(commandParts); if (!allowed) { appendAudit(repoRoot, { time: new Date().toISOString(), allowed: false, command: commandParts[0] || , requestHash, reason: not_in_allowlist, }); fail(命令不在白名单中); } if (dryRun) { appendAudit(repoRoot, { time: new Date().toISOString(), allowed: true, dryRun: true, command: commandParts, requestHash, }); console.log( 允许执行${commandParts.join( )}, ); process.exit(0); } const [command, ...args] commandParts; const result run(command, args, { cwd: repoRoot, env: sanitizeEnvironment( policy.blockedEnv, ), timeout: policy.timeoutMs, maxBuffer: policy.maxOutputBytes, }); const durationMs Date.now() - startedAt; const timedOut result.error?.code ETIMEDOUT; appendAudit(repoRoot, { time: new Date().toISOString(), allowed: true, dryRun: false, command: commandParts, requestHash, exitCode: result.status, signal: result.signal, timedOut, durationMs, }); if (result.stdout) { process.stdout.write(result.stdout); } if (result.stderr) { process.stderr.write(result.stderr); } if (result.error) { console.error( 命令执行失败${result.error.message}, ); } process.exit(result.status ?? 1);这段脚本包含以下安全处理只在 Git 仓库中运行从项目根目录读取统一策略使用完整参数精确匹配命令不通过 Shell 解析命令清理指定敏感环境变量设置命令执行超时限制最大输出记录允许和拒绝的请求拒绝日志不保存完整参数只保存命令名和请求哈希。我使用 Node.js 22 对脚本进行了语法检查并验证了允许命令、实际执行和拒绝非白名单命令的流程。六、运行允许的命令先用--dry-run检查不实际执行node scripts/agent-safe-run.mjs \ --dry-run \ -- git status --short输出允许执行git status --short正式执行node scripts/agent-safe-run.mjs \ -- git status --short执行代码检查node scripts/agent-safe-run.mjs \ -- npm run lint运行测试node scripts/agent-safe-run.mjs \ -- npm test检查 Diffnode scripts/agent-safe-run.mjs \ -- git diff --check七、危险命令会被直接拒绝例如node scripts/agent-safe-run.mjs \ -- rm -rf .输出拒绝执行命令不在白名单中下面这条也不会通过node scripts/agent-safe-run.mjs \ -- npm test npm run deploy在正常终端里会被当前 Shell 提前解析。所以在给 Agent 使用时不要让它通过外部 Shell 拼接整条字符串而应该把命令网关作为唯一执行入口。网关自身使用的是shell: false并且白名单采用完整参数数组。即使参数中包含 | ;也不会被当成 Shell 运算符解释。不过由于它们不在完整白名单中最终仍会被拒绝。八、查看审计日志日志位置.agent-audit/commands.jsonl成功执行记录示例{ time: 2026-07-26T14:12:01.704Z, allowed: true, dryRun: false, command: [ git, status, --short ], requestHash: 6622718a50ed..., exitCode: 0, signal: null, timedOut: false, durationMs: 3 }拒绝记录示例{ time: 2026-07-26T14:12:01.753Z, allowed: false, command: rm, requestHash: 7eb47d49a346..., reason: not_in_allowlist }拒绝请求没有记录完整参数。这样做是为了避免有人把令牌、密码或其他敏感信息放进命令参数后又被原样写入日志。requestHash可以用来判断两次请求是否相同但不能从日志中直接恢复原始命令。九、为什么不支持模糊匹配为了方便有人可能会把规则写成允许所有 npm test 开头的命令例如使用正则/^npm test/但这会放行npm test -- --updateSnapshot npm test -- --runInBand npm test -- unexpected-argument这些参数不一定危险但已经超出了原始审批范围。更糟糕的是如果直接对完整 Shell 字符串做前缀判断还可能遇到npm test npm run deploy因此这套基础版本只支持精确匹配。需要新增命令时明确添加[ npm, test, --, --runInBand ]而不是添加一个范围过大的通配规则。在安全控制里少写一条规则只会让 Agent 多请求一次。规则写得过宽则可能让不该执行的命令直接通过。十、如何交给 AI Agent 使用可以在项目的 Agent 规则文件中加入你不能直接运行项目命令。 需要执行 Git、测试、lint 或类型检查时 必须通过下面的命令网关 node scripts/agent-safe-run.mjs -- command [args...] 允许的命令由 agent-command-policy.json 决定。 如果命令被拒绝 1. 不得尝试使用其他命令绕过 2. 不得修改策略文件 3. 说明希望执行的命令、目的和风险 4. 等待人工审核。任务提示词也可以这样写请修复登录接口超时问题。 限制 1. 只修改 src/auth 和对应测试 2. 不安装新依赖 3. 不修改 agent-command-policy.json 4. 不直接执行 Shell 5. 所有命令必须通过 agent-safe-run.mjs 6. 被拒绝的命令不得换一种方式绕过 7. 完成后输出修改文件、测试结果和未解决风险。这里需要注意提示词只是行为约束不是安全边界。真正的安全边界仍然应该由权限、沙箱、容器、系统账号和命令网关共同实现。十一、策略文件本身也需要保护当前脚本会从仓库读取agent-command-policy.json如果 Agent 可以自行修改这个文件它完全可以把危险命令加入白名单。所以还需要采取至少一种措施。方案一明确禁止修改在 Agent 权限规则中拒绝编辑agent-command-policy.json scripts/agent-safe-run.mjs方案二执行前检查 Git 状态在脚本中增加策略文件完整性检查例如核对文件哈希。方案三将策略放在仓库外例如~/.config/company-agent/policy.json由开发环境或企业配置统一管理。方案四设置文件系统权限让运行 Agent 的普通账号只有读取权限没有修改权限。团队项目中更推荐把项目规则和组织级规则分开组织级规则绝对禁止部署、生产数据库和凭据访问 项目级规则允许哪些测试、lint 和 Git 检查命令十二、为什么还要清理环境变量假设本地已经配置export DATABASE_URLpostgres://production...Agent 执行npm test测试脚本可能自动读取DATABASE_URL。如果项目配置有问题测试甚至可能连接到生产数据库。所以网关执行命令时不应该原样继承全部环境变量。示例代码中会删除DATABASE_URL PRODUCTION_DATABASE_URL AWS_SECRET_ACCESS_KEY OPENAI_API_KEY ANTHROPIC_API_KEY同时设置NODE_ENVtest CI1更稳的做法是准备专门的测试配置.env.test内容只包含本地测试资源DATABASE_URLpostgres://test:testlocalhost:5432/app_test REDIS_URLredis://localhost:6379/12 NODE_ENVtest代码目录隔离了并不代表数据库、Redis、对象存储和云账号也自动隔离。十三、这层网关不能解决什么这套脚本只是项目级控制不是完整安全沙箱。它不能解决以下问题。1. 允许命令自身存在恶意逻辑白名单里允许npm test但如果 Agent 修改了package.json{ scripts: { test: rm -rf important-directory } }此时执行的仍然是白名单命令但实际行为已经改变。因此 Agent 不应该被允许随意修改package.json Makefile 测试启动脚本 CI 配置 命令网关 策略文件或者在执行前检查这些文件的 Diff。2. 无法提供真正的操作系统隔离脚本仍然运行在当前用户权限下。当前用户能访问的文件子进程原则上也可能访问。真正需要隔离时应结合容器独立低权限用户只读挂载网络限制临时工作目录工具原生沙箱。Codex 官方文档也明确区分了审批与沙箱审批决定什么时候询问而沙箱决定命令实际能够接触哪些资源。3. 无法判断业务逻辑是否正确命令通过白名单只能说明它被允许执行。测试通过也不能证明权限逻辑正确接口兼容数据迁移安全异常场景完整线上可以直接发布。最终仍然需要人工 Review。十四、推荐的三层安全结构更完整的 AI Agent 开发环境可以分成三层。第一层工具原生权限负责文件读写权限 网络访问权限 高风险操作审批 工具调用限制Codex 可通过沙箱与审批策略限制能力Claude Code 可使用权限规则和 Hooks 控制工具调用。第二层项目命令网关负责精确命令白名单 敏感环境变量清理 执行超时 输出大小限制 JSONL 审计日志也就是本文实现的部分。第三层运行环境隔离负责测试数据库 独立 Redis DB 临时凭据 容器网络 只读文件 低权限系统账号三层结合才能把风险真正限制在项目测试范围内。十五、适合直接采用的安全清单在允许 AI Agent 执行命令前至少检查以下事项[ ] 默认拒绝未知命令 [ ] 没有通过 Shell 执行整段字符串 [ ] 白名单匹配完整命令和参数 [ ] 策略文件不能被 Agent 修改 [ ] package.json 等命令入口受到保护 [ ] 敏感环境变量不会传给子进程 [ ] 使用测试数据库和测试凭据 [ ] 命令设置执行超时 [ ] 执行结果写入审计日志 [ ] 部署和数据库迁移必须人工审批 [ ] Agent 在独立分支或 Worktree 工作 [ ] 合并前人工检查 Diff这里最重要的原则不是“绝对不让 Agent 执行命令”。而是只让它执行当前任务真正需要的最小命令集合。十六、后续可以怎样升级1. 根据 Git 路径自动识别风险src/payment/** high src/auth/** high docs/** low tests/** medium2. 统计不同模型的历史成功率例如文档任务 Luna 成功率 98% 普通 Bug Terra 成功率 91% 大型重构 Sol 成功率 88% Terra 成功率 63%用真实项目数据调整阈值。3. 引入任务分类器先用低成本模型将任务分类为documentation bugfix test refactor security migration architecture再进入规则路由。但分类器失败也会影响最终路由因此仍需要高风险保护规则。4. 加入预算熔断例如单次任务预算 单用户每日预算 项目月度预算 Frontier 模型调用次数限制预算不足时不应该悄悄降低高风险任务的模型。更合理的是暂停任务并提示当前预算不足以满足该任务的质量下限。5. 建立回放测试集保存一批真实任务简单文档修改 普通接口 Bug 跨模块重构 数据库迁移 权限漏洞检查每次调整规则后重新运行观察路由结果是否发生非预期变化。十七、会员订阅和 API 调用不是一回事本文代码演示的是开发者 API 模型路由。ChatGPT Plus、Claude Pro、Cursor、Kiro 等会员订阅与 API 调用额度、API Key 和按量计费通常属于不同体系不能因为开通了聊天或 IDE 会员就默认获得对应的开发者 API 额度。长期使用相关会员工具时也可以通过 gpt68.com 了解第三方 AI 会员充值服务。需要说明的是gpt68.com 不是相关工具的官方网站或官方授权合作方也不提供共享账号。使用前应看清套餐说明、账号要求、到账说明和售后规则。无论通过什么方式使用工具都不要把聊天会员、IDE 会员和 API 账单混为一谈。总结AI 编程工具开始自动选择模型背后的核心逻辑并不神秘简单任务使用高效模型 日常开发使用均衡模型 复杂和高风险任务使用能力更强的模型真正困难的部分是确定什么叫简单 什么叫复杂 失败代价有多高 质量下限在哪里 成本偏好是什么本文实现的 Node.js 路由器使用文件数量 上下文长度 任务关键词 业务风险 优化模式生成一个可解释的复杂度评分再选择 Fast、Balanced 或 Frontier 模型。它的优势不是算法有多先进而是规则可以查看 阈值可以修改 结果可以解释 决策可以审计 错误可以回放对于刚开始建设多模型应用的团队这通常比一开始就训练复杂路由模型更容易落地。先建立一套可工作的基线。再用真实任务成功率、总成本和人工返工数据不断调整。模型路由器才会从“自动选模型的小工具”逐渐变成真正的 AI 工程基础设施。