feat(ops-agent): cwd_pattern support + git command whitelist

Add validateCwd() to whitelist.ts for dynamic-cwd validation, update
exec.ts to resolve first arg as cwd when cwd_pattern is set, and extend
commands.yml.example with git_status, git_log_ahead, git_diff, git_fetch.
Add REPO_PATHS to .env.example.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 17:32:50 +02:00
parent 3cc966c70c
commit 4821d29670
4 changed files with 48 additions and 8 deletions

View file

@ -17,9 +17,24 @@ commands:
description: "List running Docker containers"
git_status:
cmd: ["git", "status", "--short"]
cwd: "/srv/ops"
description: "Git status of the ops directory"
cmd: ["git", "status", "--short", "--branch"]
cwd_pattern: "/srv/"
description: "Git status with branch info (first arg = repo path, must start with /srv/)"
git_log_ahead:
cmd: ["git", "log", "@{upstream}..HEAD", "--oneline"]
cwd_pattern: "/srv/"
description: "Local commits not yet pushed (first arg = repo path)"
git_diff:
cmd: ["git", "diff", "HEAD"]
cwd_pattern: "/srv/"
description: "Uncommitted diff against HEAD (first arg = repo path)"
git_fetch:
cmd: ["git", "fetch", "--quiet"]
cwd_pattern: "/srv/"
description: "Fetch all remotes silently (first arg = repo path)"
systemctl_status:
cmd: ["systemctl", "status"]

View file

@ -1,6 +1,6 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { spawn } from 'child_process';
import { getCommand, validateArgs } from '../whitelist.js';
import { getCommand, validateArgs, validateCwd } from '../whitelist.js';
interface ExecBody {
command_key: string;
@ -36,6 +36,11 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
.send({ error: `command_key '${command_key}' is not in the whitelist` });
}
const cwdError = validateCwd(def, args);
if (cwdError) {
return reply.status(400).send({ error: cwdError });
}
const argError = validateArgs(def, args);
if (argError) {
return reply.status(400).send({ error: argError });
@ -52,10 +57,12 @@ export async function execRoutes(app: FastifyInstance): Promise<void> {
};
const [bin, ...staticArgs] = def.cmd;
const cwd = def.cwd_pattern ? args[0] : def.cwd;
const effectiveArgs = def.cwd_pattern ? args.slice(1) : args;
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
const child = spawn(bin, [...staticArgs, ...args], {
const child = spawn(bin, [...staticArgs, ...effectiveArgs], {
shell: false,
cwd: def.cwd,
cwd,
});
const startedAt = Date.now();

View file

@ -8,6 +8,7 @@ export interface ArgsConfig {
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;
@ -37,20 +38,35 @@ 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 (requestArgs.length === 0) return null;
if (args.length === 0) return null;
if (!allowed || allowed.length === 0) {
return `command does not accept arguments`;
}
for (const arg of requestArgs) {
for (const arg of args) {
if (!allowed.includes(arg)) {
return `argument '${arg}' is not in the allowed list`;
}