现在的 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 决定。
如果命令被拒绝:
不得尝试使用其他命令绕过;
不得修改策略文件;
说明希望执行的命令、目的和风险;
等待人工审核。

任务提示词也可以这样写:

请修复登录接口超时问题。
限制:
只修改 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 才更接近一个可以管理、审计和控制的工程协作者。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐