import { FastifyInstance, FastifyRequest } from 'fastify'; import { spawn } from 'child_process'; 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 { app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => { const { command_key, args = [] } = req.body; const def = getCommand(command_key); if (!def) { return reply .status(403) .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', Connection: 'keep-alive', }); const sendEvent = (event: string, data: string) => { reply.raw.write(`event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`); }; 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()); }); child.stderr.on('data', (chunk: Buffer) => { sendEvent('stderr', chunk.toString()); }); 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(); }); req.raw.on('close', () => { child.kill(); }); }); }