现在的 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 帮忙解析。
blockedEnv
Agent 执行命令前,需要从子进程环境中删除的敏感变量。
这不是完整的密钥管理方案,但至少可以减少普通测试命令意外继承生产凭据的风险。
五、完整 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检查 Diff:
node 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_URL=postgres://production...Agent 执行:
npm test测试脚本可能自动读取DATABASE_URL。
如果项目配置有问题,测试甚至可能连接到生产数据库。
所以网关执行命令时,不应该原样继承全部环境变量。
示例代码中会删除:
DATABASE_URL PRODUCTION_DATABASE_URL AWS_SECRET_ACCESS_KEY OPENAI_API_KEY ANTHROPIC_API_KEY同时设置:
NODE_ENV=test CI=1更稳的做法是准备专门的测试配置:
.env.test内容只包含本地测试资源:
DATABASE_URL=postgres://test:test@localhost:5432/app_test REDIS_URL=redis://localhost:6379/12 NODE_ENV=test代码目录隔离了,并不代表数据库、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 工程基础设施。