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.
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.
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.
Why is a regex allowlist on the command's first word insufficient?