Ops-dashboard/ops-agent/src/whitelist.ts
Scrum4Me Agent 234b2d1a58 feat(ops-agent): extend whitelist with destructive commands + preconditions
Adds docker_compose_restart/build/up, git_pull (guarded by
git_status_clean precondition), systemctl_restart (via sudo),
caddy_validate, caddy_reload, and caddy_write_config (atomic
stdin→Caddyfile.new→Caddyfile write).

- CommandDef gains preconditions[] and stdin_from_body fields
- exec route checks git_status_clean before git_pull; returns 409 on
  dirty tree with a clear message
- stdin field in ExecBody is piped to child stdin for caddy_write_config

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-13 17:53:05 +02:00

80 lines
2.3 KiB
TypeScript

import fs from 'fs';
import yaml from 'js-yaml';
export interface ArgsConfig {
allowed?: string[];
}
export interface CommandDef {
cmd: string[];
cwd?: string;
/** Required path prefix for dynamic cwd: caller passes repo path as first arg */
cwd_pattern?: string;
args?: ArgsConfig;
description?: string;
/** Named preconditions that must pass before the command runs (e.g. 'git_status_clean') */
preconditions?: string[];
/** When true, the caller's 'stdin' body field is piped to the child process */
stdin_from_body?: boolean;
}
export type Whitelist = Record<string, CommandDef>;
let whitelist: Whitelist = {};
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 {
return whitelist[key];
}
export function listCommands(): string[] {
return Object.keys(whitelist);
}
/**
* Validates the dynamic cwd arg when cwd_pattern is set.
* Returns an error string if validation fails, null on success.
*/
export function validateCwd(def: CommandDef, requestArgs: string[]): string | null {
if (!def.cwd_pattern) return null;
if (requestArgs.length === 0) return `command requires a repo path as first argument`;
if (!requestArgs[0].startsWith(def.cwd_pattern)) {
return `repo path must start with '${def.cwd_pattern}'`;
}
return null;
}
/**
* Validates request args against a command's allowed list.
* When cwd_pattern is set, skips the first arg (used as cwd).
* Returns an error string if validation fails, null on success.
*/
export function validateArgs(def: CommandDef, requestArgs: string[]): string | null {
const args = def.cwd_pattern ? requestArgs.slice(1) : requestArgs;
const allowed = def.args?.allowed;
if (args.length === 0) return null;
if (!allowed || allowed.length === 0) {
return `command does not accept arguments`;
}
for (const arg of args) {
if (!allowed.includes(arg)) {
return `argument '${arg}' is not in the allowed list`;
}
}
return null;
}