别让 AI Agent 随便跑 Shell:用 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 帮忙解析。
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 决定。
如果命令被拒绝:
不得尝试使用其他命令绕过;
不得修改策略文件;
说明希望执行的命令、目的和风险;
等待人工审核。
任务提示词也可以这样写:
请修复登录接口超时问题。
限制:
只修改 src/auth 和对应测试;
不安装新依赖;
不修改 agent-command-policy.json;
不直接执行 Shell;
所有命令必须通过 agent-safe-run.mjs;
被拒绝的命令不得换一种方式绕过;
完成后输出修改文件、测试结果和未解决风险。
这里需要注意:
提示词只是行为约束,不是安全边界。
真正的安全边界仍然应该由权限、沙箱、容器、系统账号和命令网关共同实现。
十一、策略文件本身也需要保护
当前脚本会从仓库读取:
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 执行命令”。
而是:
只让它执行当前任务真正需要的最小命令集合。
十六、工具订阅不是安全配置
长期使用 ChatGPT Plus、Claude Pro、Cursor、Kiro 等工具时,可以通过 gpt68.com 了解相关第三方 AI 会员充值服务。
需要说明的是,gpt68.com 不是相关工具的官方网站或官方授权合作方,也不提供共享账号。使用前应看清套餐说明、账号要求、到账说明和售后规则。
但开通工具,只代表获得了使用权限。
它不会自动完成:
项目隔离
命令审批
密钥保护
测试环境配置
代码审查
上线风险控制
Agent 能力越强,工程边界反而越需要提前建立。
总结
当 AI Agent 只能生成代码时,主要风险是代码质量。
当它开始执行 Shell 命令后,风险会扩展到:
文件系统
本地凭据
测试数据
依赖配置
云端资源
部署流程
因此,不要只在提示词里写:
不要执行危险命令。
更可靠的做法是把规则落实成代码:
默认拒绝
精确白名单
不使用 Shell
清理敏感环境变量
限制运行时间
记录审计日志
本文的 Node.js 网关适合充当项目内的第二层防护。
它不能替代 Codex、Claude Code 等工具自身的权限控制,也不能替代容器和操作系统沙箱。
但它能让团队明确回答一个非常关键的问题:
这个项目里的 AI Agent,究竟被允许执行哪些命令?
当这个答案不再依赖口头约定,AI Agent 才更接近一个可以管理、审计和控制的工程协作者。
更多推荐
所有评论(0)