Cursor Router 上线后,我用 Node.js 实现了一个可解释的模型路由器

现在的 AI 编程工具,早已不只是生成几段代码。

它们可以读取项目、修改文件、运行测试、安装依赖,甚至执行部署和数据库相关命令。

权限扩大以后,一个很现实的问题随之出现:

当 AI Agent 提议执行一条 Shell 命令时,我们到底应该允许它做什么?

OpenAI 当前的 Codex 文档将安全控制拆分为两个部分:沙箱决定命令可以接触哪些文件和网络资源,审批策略决定哪些操作必须暂停并询问用户。Claude Code 也支持基于 allow、deny 的权限规则,并可通过 Hooks 在工具执行前返回允许、拒绝或要求确认等结果。

这些原生机制应该优先启用。

但在团队项目里,我仍然建议再增加一层项目级控制:

复制代码
AI Agent
   ↓
项目命令网关
   ↓
lint / test / typecheck / git diff

这层网关不负责替代操作系统沙箱,而是解决三个更具体的问题:

  1. 团队明确规定 Agent 可以运行哪些命令;
  2. 所有执行记录都可以审计;
  3. 不同 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/**             medium

2. 统计不同模型的历史成功率

例如:

复制代码
文档任务:
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 工程基础设施。

相关推荐
半个落月5 小时前
用 Node.js 搭建 EPUB 问答助手:从文本切片、向量检索到 RAG
人工智能·node.js
meilindehuzi_a7 小时前
Node.js + LangChain.js + Milvus 实战:从 EPUB 入库到《天龙八部》RAG 问答系统
javascript·langchain·node.js
行走的陀螺仪12 小时前
从 nvm 到 fnm:更快、更省心的 Node.js 版本管理器迁移指南
rust·node.js·nvm·fnm
安冬的码畜日常12 小时前
【工欲善其事】深入理解 Node.js 带并发上限的异步任务批量执行逻辑
javascript·设计模式·node.js·ai编程·异步编程·并发执行
ToolReel1 天前
Node.js 查询 Google Analytics 4 数据:GA Lite API 接入实战
node.js
寒水馨1 天前
Linux下载、安装 Bun v1.3.14(附安装包bun-linux-x64.zip)
linux·javascript·typescript·node.js·bun·运行时·包管理器
兮动人1 天前
Linux 安装 Claude Code 实战:Node.js、npm、GLM 配置一次跑通
linux·npm·node.js·cc·claude code
csdn2015_1 天前
怎么更换node.js版本
node.js
在水一缸1 天前
深入浅出:Node.js 下一代 ORM 架构设计与实战解析
数据库·微服务·云原生·node.js·orm·架构设计