安全执行 Shell
你的 bash 工具能用,但描述、安全检查和execSync调用都放在一个大闭包里。只要只有一个 bash 工具,这没问题。一旦你想在这台机器以外的地方运行命令,它就不再正常了。
再过几个模块,我们会用沙箱替换本地执行。遇到这种情况时,你不想重写bash工具。你想给它换个后端,其他部分保持不动。
这就是工厂模式的用途。
学习成果
你有一个createBashTool(operations, safePrefixes)工厂,会返回一个完全配置好的bash工具。面向模型的契约(描述、schema、安全检查)则存放在工厂内。执行后端通过operations对象注入。
快速路径
- 用一种方法定义一个
BashOperations接口,exec(command) - 把现有的 bash 工具包裹起来
createBashTool(operations, safePrefixes) - 构建一个包裹
execSync的localOps对象,然后用它构建工具
动手练习 2.2
把 bash 导出到带有可交换执行后端的工厂函数中。
要求:
- 用
exec(command: string): Promise<{ stdout: string; exitCode: number }>定义BashOperations - 写入返回
tool()的createBashTool(operations: BashOperations, safePrefixes: string[]) - 安全检查在工厂内进行,使用注入
safePrefixes - 构建一个封装
localOps实现execSync - 用
const bash = createBashTool(localOps, SAFE_PREFIXES)替换你现有的bash常数
实现提示:
- 工厂在
operations和safePrefixes期间关闭。execute功能在工具内部调用operations.exec(command),而不是直接execSync - 本地操作员(localops)句柄stdout和错误都一致。无论命令成功与否,返回
{ stdout, exitCode } - 现在不要重构
read。工厂模式确实值得,后端会有所变化,目前这只是一个bash
接缝的走向
现在你的bash工具直接调用 execSync:
execute: async ({ command }) => {
if (!isSafe(command)) return "Blocked...";
const stdout = execSync(command, { cwd, encoding: "utf-8", timeout: 30_000 });
return stdout;
}当你在后面加上一个沙箱时,这就变成了sandbox.exec(command)。想法一样,但后端不同。工厂在这两者之间引入了一条接缝:
interface BashOperations {
exec(command: string): Promise<{ stdout: string; exitCode: number }>;
}模型看到的所有东西都存在于接缝之上。所有实际执行命令的设备都存放在下面。
建工厂
function createBashTool(operations: BashOperations, safePrefixes: string[]) {
function isSafe(command: string): boolean {
return safePrefixes.some((p) => command.trim().startsWith(p));
}
return tool({
description: `Execute a shell command in the working directory.
WHEN TO USE: running build commands, installing packages, running tests,
git operations, directory listings.
WHEN NOT TO USE: reading file contents (use read instead).
Searching for patterns (use grep instead).
DO NOT USE FOR: reading files (use read), searching code (use grep).
USAGE: command is a single shell string. Commands not in the safe-prefix
allowlist are blocked and return a clear error message.`,
inputSchema: z.object({
command: z.string().describe("Shell command to execute"),
}),
execute: async ({ command }) => {
if (!isSafe(command)) {
return `Blocked: "${command}" requires approval.`;
}
const { stdout } = await operations.exec(command);
return stdout || "(no output)";
},
});
}注意消失了什么:没有execSync,没有cwd引用,没有知道Node child_process的错误处理。工厂只知道有个叫operations.exec的东西,然后退stdout。
构建本地后端
localOps对象是实际execSync 调用现在居住的地方:
const localOps: BashOperations = {
exec: async (command) => {
try {
const stdout = execSync(command, {
cwd,
encoding: "utf-8",
timeout: 30_000,
});
return { stdout, exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout || e.stderr || e.message || "",
exitCode: e.status ?? 1,
};
}
},
};
const bash = createBashTool(localOps, SAFE_PREFIXES);当你在模块4中构建沙箱抽象时,交换是一行:
const sandboxOps: BashOperations = {
exec: (command) => sandbox.exec(command),
};
const bash = createBashTool(sandboxOps, SAFE_PREFIXES);同样的工具。不同的后端。描述、schema和安全检查都没有变化。
**注意:为什么只抨击,不读**
你可以把同样的工厂模式套用到read上。我们还没开始。工厂的价值在于后台真正多样化。对read来说,这要到第四模块才会发生。bash,执行后端和安全策略已经在反方向拉扯。有压力时重构,而不是提前。
动手试试
运行一个安全命令,确保出厂时所有布线都正确:
bun run index.ts . "List all files in this directory"输出和之前一样。同样的阻塞命令行为。模型无法察觉任何变化,这正是关键所在。
试试一个 blocked 命令,确保安全检查仍然有效:
bun run index.ts . "Run: rm -rf node_modules"你仍然应该会收到屏蔽通知。
npx tsc --noEmit提交
git add index.ts
git commit -m "refactor(bash): extract createBashTool with operations interface"完成标准
- [ ]
BashOperations接口定义于exec(command) - [ ]
createBashTool(operations, safePrefixes)还用工具 - [ ]
localOps``execSync包好,返回{ stdout, exitCode } - [ ] 安全命令依然运行,被阻挡命令依然返回阻挡消息
- [ ]
npx tsc --noEmit
**注意:画一下沙箱交换**
还没搭建,先写一个不运行任何东西的 mockOps: BashOperations。只要{ stdout: "(pretend output)", exitCode: 0 }任何指令都回去。换localOps换成mockOps,看看Agent所有内容都显示合理但假的输出。这是让模块4中的沙箱抽象在不重写工具的情况下发挥作用的接缝。
参考实现
interface BashOperations {
exec(command: string): Promise<{ stdout: string; exitCode: number }>;
}
function createBashTool(operations: BashOperations, safePrefixes: string[]) {
function isSafe(command: string): boolean {
return safePrefixes.some((p) => command.trim().startsWith(p));
}
return tool({
description: `Execute a shell command in the working directory.
WHEN TO USE: running build commands, installing packages, running tests,
git operations, directory listings.
WHEN NOT TO USE: reading file contents (use read instead).
Searching for patterns (use grep instead).
DO NOT USE FOR: reading files (use read), searching code (use grep).
USAGE: command is a single shell string. Commands not in the safe-prefix
allowlist are blocked and return a clear error message.`,
inputSchema: z.object({
command: z.string().describe("Shell command to execute"),
}),
execute: async ({ command }) => {
if (!isSafe(command)) {
return `Blocked: "${command}" requires approval.`;
}
const { stdout } = await operations.exec(command);
return stdout || "(no output)";
},
});
}
const localOps: BashOperations = {
exec: async (command) => {
try {
const stdout = execSync(command, {
cwd,
encoding: "utf-8",
timeout: 30_000,
});
return { stdout, exitCode: 0 };
} catch (e: any) {
return {
stdout: e.stdout || e.stderr || e.message || "",
exitCode: e.status ?? 1,
};
}
},
};
const bash = createBashTool(localOps, SAFE_PREFIXES);