代码智能体不应直接获得文件系统、Shell 和网络的完整能力,而应通过一个受控执行层调用工具。本文用 Node.js 实现可运行的最小版本,并说明应用级策略能够防住什么,以及何时必须升级到容器或系统级沙箱。
先定义威胁模型
当模型只能生成文本时,错误通常停留在回答层;一旦接入工具,提示注入、模型误判或恶意输入就可能变成真实副作用。例如读取工作目录外的密钥、执行带有破坏性的命令,或者把源码发送到外部地址。
这里不尝试判断模型“是否可信”,而是假设每次工具参数都可能有问题,并在模型与操作系统之间增加执行层:
- 默认拒绝,没有明确策略的工具不能运行。
- 参数必须结构化,命令执行不经过 Shell 拼接。
- 权限按工具分别授予,而不是给智能体一个万能终端。
- 高风险动作执行前确认,执行前后都留下审计记录。
- 输出、运行时间和响应体大小都要有限制。
本文示例只提供只读文件工具。文件写入、删除和重命名应设计成独立工具,分别配置目录、扩展名、覆盖规则和确认流程,不要复用一个任意文件操作接口。
三类工具的策略
执行层应先把自然语言意图收敛为少量、稳定的工具协议,再逐项校验参数。
| 工具 | 默认策略 | 主要限制 |
|---|---|---|
read_file | 可自动执行 | 仅工作目录内,只读指定扩展名,限制文件大小 |
run_command | 人工确认 | 命令和参数组合白名单,禁用 Shell,限制时间与输出 |
http_get | 人工确认 | 仅 HTTPS 和指定主机,禁止自动重定向,限制响应体 |
命令白名单不能只检查前缀。例如允许 git 并不等于所有 git 子命令都安全;更稳妥的方式是把“查看状态”“读取差异”定义成不同动作,并为每个动作固定参数形状。网络白名单也要检查协议、主机、端口和重定向,否则允许的站点可能把请求转向未授权地址。
实现最小执行层
下面示例只使用 Node.js 内置 API,要求 Node.js 20 或更高版本。保存为 index.mjs 即可运行。
import { appendFile, mkdir, readFile, realpath, stat } from 'node:fs/promises';
import { createHash, randomUUID } from 'node:crypto';
import { extname, isAbsolute, relative, resolve } from 'node:path';
import { spawn } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
const ROOT = resolve(process.env.AGENT_ROOT ?? './workspace');
const AUDIT_FILE = resolve(process.env.AUDIT_FILE ?? './audit.ndjson');
const MAX_BYTES = 1024 * 1024;
const TIMEOUT_MS = 5000;
const ALLOWED_EXTENSIONS = new Set(['.txt', '.md', '.json']);
const ALLOWED_HOSTS = new Set(['example.com']);
await mkdir(ROOT, { recursive: true });
const REAL_ROOT = await realpath(ROOT);
function assertInsideRoot(target) {
const rel = relative(REAL_ROOT, target);
if (rel === '..' || rel.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(rel)) {
throw new Error('路径超出工作目录');
}
}
async function audit(event) {
await appendFile(AUDIT_FILE, `${JSON.stringify({
time: new Date().toISOString(),
...event
})}\n`, { encoding: 'utf8', mode: 0o600 });
}
function requestView(tool, input) {
if (tool === 'http_get') {
const url = new URL(input.url);
return { url: `${url.origin}${url.pathname}` };
}
return input;
}
function resultView(result) {
const data = JSON.stringify(result);
return {
bytes: Buffer.byteLength(data),
sha256: createHash('sha256').update(data).digest('hex')
};
}
async function readFileTool(input) {
if (typeof input.path !== 'string') throw new Error('path 必须是字符串');
const candidate = resolve(REAL_ROOT, input.path);
assertInsideRoot(candidate);
const target = await realpath(candidate);
assertInsideRoot(target);
if (!ALLOWED_EXTENSIONS.has(extname(target).toLowerCase())) {
throw new Error('文件扩展名不在白名单');
}
const info = await stat(target);
if (!info.isFile() || info.size > MAX_BYTES) throw new Error('文件类型或大小不允许');
return { path: relative(REAL_ROOT, target), content: await readFile(target, 'utf8') };
}
async function runCommandTool(input) {
const allowed = input.command === 'node-version'
&& Array.isArray(input.args)
&& input.args.length === 0;
if (!allowed) throw new Error('命令或参数不在白名单');
return await new Promise((resolvePromise, reject) => {
const child = spawn(process.execPath, ['--version'], {
cwd: REAL_ROOT,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
env: { PATH: process.env.PATH ?? '' }
});
const stdout = [];
const stderr = [];
let size = 0;
let reason = '';
const collect = (list, chunk) => {
size += chunk.length;
if (size > MAX_BYTES) {
reason = '命令输出超过限制';
child.kill('SIGKILL');
} else {
list.push(chunk);
}
};
child.stdout.on('data', chunk => collect(stdout, chunk));
child.stderr.on('data', chunk => collect(stderr, chunk));
child.on('error', reject);
const timer = setTimeout(() => {
reason = '命令执行超时';
child.kill('SIGKILL');
}, TIMEOUT_MS);
child.on('close', code => {
clearTimeout(timer);
if (reason) return reject(new Error(reason));
resolvePromise({
exitCode: code,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8')
});
});
});
}
async function httpGetTool(input) {
if (typeof input.url !== 'string') throw new Error('url 必须是字符串');
const url = new URL(input.url);
if (url.protocol !== 'https:' || url.username || url.password) throw new Error('只允许无凭据 HTTPS URL');
if (!ALLOWED_HOSTS.has(url.hostname) || (url.port && url.port !== '443')) {
throw new Error('目标主机或端口不在白名单');
}
const response = await fetch(url, {
method: 'GET',
redirect: 'manual',
signal: AbortSignal.timeout(TIMEOUT_MS),
headers: { 'user-agent': 'auditable-agent-layer/1.0' }
});
if (response.status >= 300 && response.status < 400) throw new Error('不允许自动重定向');
if (!response.body) return { status: response.status, body: '' };
const reader = response.body.getReader();
const chunks = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > MAX_BYTES) {
await reader.cancel();
throw new Error('网络响应超过限制');
}
chunks.push(Buffer.from(value));
}
return {
status: response.status,
contentType: response.headers.get('content-type'),
body: Buffer.concat(chunks).toString('utf8')
};
}
async function confirm(tool, input) {
if (!process.stdin.isTTY) return false;
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(`允许执行 ${tool} ${JSON.stringify(requestView(tool, input))}?[y/N] `);
rl.close();
return answer.trim().toLowerCase() === 'y';
}
async function execute(request) {
const id = randomUUID();
const started = Date.now();
const { tool, input = {} } = request;
await audit({ id, phase: 'requested', tool, input: requestView(tool, input) });
try {
if (!['read_file', 'run_command', 'http_get'].includes(tool)) throw new Error('未知工具');
if (tool !== 'read_file' && !await confirm(tool, input)) throw new Error('人工确认未通过');
const handlers = { read_file: readFileTool, run_command: runCommandTool, http_get: httpGetTool };
const result = await handlers[tool](input);
await audit({ id, phase: 'completed', tool, durationMs: Date.now() - started, result: resultView(result) });
return result;
} catch (error) {
await audit({ id, phase: 'failed', tool, durationMs: Date.now() - started, error: String(error.message) });
throw error;
}
}
if (!process.argv[2]) {
console.error('用法:node index.mjs <JSON 请求>');
process.exit(1);
}
try {
console.log(JSON.stringify(await execute(JSON.parse(process.argv[2])), null, 2));
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
运行、确认与审计
先创建测试文件,再发起只读请求:
node -e "require('fs').mkdirSync('workspace',{recursive:true});require('fs').writeFileSync('workspace/hello.txt','hello\n')"
node index.mjs '{"tool":"read_file","input":{"path":"hello.txt"}}'
命令和网络请求会进入人工确认流程:
node index.mjs '{"tool":"run_command","input":{"command":"node-version","args":[]}}'
node index.mjs '{"tool":"http_get","input":{"url":"https://example.com/"}}'
每次请求至少写入一条 requested 记录,完成或失败后再写一条结果记录。结果正文不直接进入日志,只记录字节数和 SHA-256,避免把文件内容或网络响应复制到审计系统。URL 查询参数也被移除,因为其中经常包含令牌和用户数据。
生产环境还应记录调用者、会话、模型、策略版本和审批人,并把日志发送到智能体无权修改的外部存储。示例中的本地 NDJSON 便于演示,但进程崩溃、磁盘故障以及日志文件被宿主用户修改等问题,不能靠 appendFile 彻底解决。
应用级防护的边界
这套执行层适合权限较小、工具集合明确的智能体,但它不是安全沙箱。工作目录校验能拦截常见的 ../ 和符号链接逃逸,却难以彻底消除路径检查与文件打开之间的竞态条件。命令超时调用 kill,也不保证所有平台上由目标程序派生的子进程都会一起退出。
网络层还存在 DNS 重绑定、代理配置、IPv6、服务端内容变化等问题。若要访问内网,应在网络出口实施独立策略,而不是只依赖 URL 字符串检查。
出现以下情况时,应使用容器、虚拟机或操作系统级沙箱:
- 允许模型生成并运行任意代码或任意命令;
- 多租户共享宿主机,租户之间必须强隔离;
- 工作负载会处理密钥、生产数据或不可信压缩包;
- 需要可靠限制 CPU、内存、进程数、磁盘和网络;
- 需要阻止系统调用、挂载、设备访问或子进程逃逸。
即使采用容器,应用级策略仍然有价值:容器负责限制最坏结果,工具层负责表达业务许可、人工审批和审计语义,两者不是替代关系。
总结
- 用少量结构化工具替代万能 Shell,并坚持默认拒绝。
- 文件访问同时检查规范路径、真实路径、类型、扩展名和大小。
- 命令采用动作白名单,关闭 Shell,并限制时间与输出。
- 网络请求限制 HTTPS、主机、端口、重定向和响应体。
- 高风险操作执行前人工确认,执行前后写入脱敏审计日志。
- 应用执行层不能提供强隔离;面对任意代码、多租户或敏感数据时,应叠加容器或系统级沙箱。
评论