feat(ops-agent): whitelist-config parser + strict command executor

- CommandDef now uses cmd[] array instead of exec string — no shell splitting
- validateArgs() checks every request arg against allowed list; rejects unknown values
- spawn() called with shell:false (execFile semantics); cwd from config
- Audit log (JSON) per call to stdout → captured by systemd journal
- commands.yml.example updated to new schema with 4 read-only commands

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 17:18:45 +02:00
parent 4bccbf28f3
commit d605eb17a5
3 changed files with 92 additions and 11 deletions

View file

@ -1,12 +1,30 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { spawn } from 'child_process';
import { getCommand } from '../whitelist.js';
import { getCommand, validateArgs } from '../whitelist.js';
interface ExecBody {
command_key: string;
args?: string[];
}
function auditLog(
command_key: string,
args: string[],
exit_code: number | null,
duration_ms: number,
): void {
// systemd captures stdout/stderr into the journal
console.log(
JSON.stringify({
audit: true,
command_key,
args,
exit_code,
duration_ms,
}),
);
}
export async function execRoutes(app: FastifyInstance): Promise<void> {
app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => {
const { command_key, args = [] } = req.body;
@ -18,6 +36,11 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
.send({ error: `command_key '${command_key}' is not in the whitelist` });
}
const argError = validateArgs(def, args);
if (argError) {
return reply.status(400).send({ error: argError });
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
@ -28,8 +51,14 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`);
};
const [bin, ...staticArgs] = def.exec.split(' ');
const child = spawn(bin, [...staticArgs, ...args], { shell: false });
const [bin, ...staticArgs] = def.cmd;
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
const child = spawn(bin, [...staticArgs, ...args], {
shell: false,
cwd: def.cwd,
});
const startedAt = Date.now();
child.stdout.on('data', (chunk: Buffer) => {
sendEvent('stdout', chunk.toString());
@ -40,11 +69,13 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
});
child.on('close', (code) => {
auditLog(command_key, args, code, Date.now() - startedAt);
reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`);
reply.raw.end();
});
child.on('error', (err) => {
auditLog(command_key, args, null, Date.now() - startedAt);
reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`);
reply.raw.end();
});

View file

@ -1,9 +1,15 @@
import fs from 'fs';
import yaml from 'js-yaml';
export interface ArgsConfig {
allowed?: string[];
}
export interface CommandDef {
exec: string;
args_allowed?: string[];
cmd: string[];
cwd?: string;
cwd_pattern?: string;
args?: ArgsConfig;
description?: string;
}
@ -15,6 +21,12 @@ export function loadWhitelist(path: string): void {
const raw = fs.readFileSync(path, 'utf8');
const parsed = yaml.load(raw) as { commands: Whitelist };
whitelist = parsed.commands ?? {};
for (const [key, def] of Object.entries(whitelist)) {
if (!Array.isArray(def.cmd) || def.cmd.length === 0) {
throw new Error(`commands.yml: '${key}' must have a non-empty 'cmd' array`);
}
}
}
export function getCommand(key: string): CommandDef | undefined {
@ -24,3 +36,25 @@ export function getCommand(key: string): CommandDef | undefined {
export function listCommands(): string[] {
return Object.keys(whitelist);
}
/**
* Validates request args against a command's allowed list.
* Returns an error string if validation fails, null on success.
*/
export function validateArgs(def: CommandDef, requestArgs: string[]): string | null {
const allowed = def.args?.allowed;
if (requestArgs.length === 0) return null;
if (!allowed || allowed.length === 0) {
return `command does not accept arguments`;
}
for (const arg of requestArgs) {
if (!allowed.includes(arg)) {
return `argument '${arg}' is not in the allowed list`;
}
}
return null;
}