跳到正文

安全执行 Shell

你的 bash 工具能用,但描述、安全检查和execSync调用都放在一个大闭包里。只要只有一个 bash 工具,这没问题。一旦你想在这台机器以外的地方运行命令,它就不再正常了。

再过几个模块,我们会用沙箱替换本地执行。遇到这种情况时,你不想重写bash工具。你想给它换个后端,其他部分保持不动。

这就是工厂模式的用途。

学习成果

你有一个createBashTool(operations, safePrefixes)工厂,会返回一个完全配置好的bash工具。面向模型的契约(描述、schema、安全检查)则存放在工厂内。执行后端通过operations对象注入。

快速路径

  1. 用一种方法定义一个BashOperations接口,exec(command)
  2. 把现有的 bash 工具包裹起来createBashTool(operations, safePrefixes)
  3. 构建一个包裹execSynclocalOps对象,然后用它构建工具

动手练习 2.2

把 bash 导出到带有可交换执行后端的工厂函数中。

要求:

  1. exec(command: string): Promise<{ stdout: string; exitCode: number }>定义BashOperations
  2. 写入返回tool()createBashTool(operations: BashOperations, safePrefixes: string[])
  3. 安全检查在工厂内进行,使用注入safePrefixes
  4. 构建一个封装localOps实现execSync
  5. const bash = createBashTool(localOps, SAFE_PREFIXES)替换你现有的bash常数

实现提示:

  • 工厂在operationssafePrefixes期间关闭。execute功能在工具内部调用 operations.exec(command),而不是直接execSync
  • 本地操作员(localops)句柄stdout和错误都一致。无论命令成功与否,返回{ stdout, exitCode }
  • 现在不要重构read。工厂模式确实值得,后端会有所变化,目前这只是一个bash

接缝的走向

现在你的bash工具直接调用 execSync

ts
execute: async ({ command }) => {
  if (!isSafe(command)) return "Blocked...";
  const stdout = execSync(command, { cwd, encoding: "utf-8", timeout: 30_000 });
  return stdout;
}

当你在后面加上一个沙箱时,这就变成了sandbox.exec(command)。想法一样,但后端不同。工厂在这两者之间引入了一条接缝:

ts
interface BashOperations {
  exec(command: string): Promise<{ stdout: string; exitCode: number }>;
}

模型看到的所有东西都存在于接缝之上。所有实际执行命令的设备都存放在下面。

建工厂

ts
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 调用现在居住的地方:

ts
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中构建沙箱抽象时,交换是一行:

ts
const sandboxOps: BashOperations = {
  exec: (command) => sandbox.exec(command),
};

const bash = createBashTool(sandboxOps, SAFE_PREFIXES);

同样的工具。不同的后端。描述、schema和安全检查都没有变化。

**注意:为什么只抨击,不读**

你可以把同样的工厂模式套用到read上。我们还没开始。工厂的价值在于后台真正多样化。对read来说,这要到第四模块才会发生。bash,执行后端和安全策略已经在反方向拉扯。有压力时重构,而不是提前。

动手试试

运行一个安全命令,确保出厂时所有布线都正确:

bash
bun run index.ts . "List all files in this directory"

输出和之前一样。同样的阻塞命令行为。模型无法察觉任何变化,这正是关键所在。

试试一个 blocked 命令,确保安全检查仍然有效:

bash
bun run index.ts . "Run: rm -rf node_modules"

你仍然应该会收到屏蔽通知。

bash
npx tsc --noEmit

提交

bash
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中的沙箱抽象在不重写工具的情况下发挥作用的接缝。

参考实现

ts
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);

非官方简体中文翻译 · 原课程来自 Vercel Academy