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

@ -3,3 +3,5 @@ SEED_USER_EMAIL="admin@example.com"
SEED_USER_PASSWORD="changeme" SEED_USER_PASSWORD="changeme"
OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret"
OPS_AGENT_URL="http://127.0.0.1:3099" OPS_AGENT_URL="http://127.0.0.1:3099"
# Comma-separated list of absolute repo paths to show on the /git page
REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard"

View file

@ -17,9 +17,24 @@ commands:
description: "List running Docker containers" description: "List running Docker containers"
git_status: git_status:
cmd: ["git", "status", "--short"] cmd: ["git", "status", "--short", "--branch"]
cwd: "/srv/ops" cwd_pattern: "/srv/"
description: "Git status of the ops directory" 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: systemctl_status:
cmd: ["systemctl", "status"] cmd: ["systemctl", "status"]

View file

@ -1,6 +1,6 @@
import { FastifyInstance, FastifyRequest } from 'fastify'; import { FastifyInstance, FastifyRequest } from 'fastify';
import { spawn } from 'child_process'; import { spawn } from 'child_process';
import { getCommand, validateArgs } from '../whitelist.js'; import { getCommand, validateArgs, validateCwd } from '../whitelist.js';
interface ExecBody { interface ExecBody {
command_key: string; 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` }); .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); const argError = validateArgs(def, args);
if (argError) { if (argError) {
return reply.status(400).send({ error: 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 [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) // 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, shell: false,
cwd: def.cwd, cwd,
}); });
const startedAt = Date.now(); const startedAt = Date.now();

View file

@ -8,6 +8,7 @@ export interface ArgsConfig {
export interface CommandDef { export interface CommandDef {
cmd: string[]; cmd: string[];
cwd?: string; cwd?: string;
/** Required path prefix for dynamic cwd: caller passes repo path as first arg */
cwd_pattern?: string; cwd_pattern?: string;
args?: ArgsConfig; args?: ArgsConfig;
description?: string; description?: string;
@ -37,20 +38,35 @@ export function listCommands(): string[] {
return Object.keys(whitelist); 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. * 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. * Returns an error string if validation fails, null on success.
*/ */
export function validateArgs(def: CommandDef, requestArgs: string[]): string | null { export function validateArgs(def: CommandDef, requestArgs: string[]): string | null {
const args = def.cwd_pattern ? requestArgs.slice(1) : requestArgs;
const allowed = def.args?.allowed; const allowed = def.args?.allowed;
if (requestArgs.length === 0) return null; if (args.length === 0) return null;
if (!allowed || allowed.length === 0) { if (!allowed || allowed.length === 0) {
return `command does not accept arguments`; return `command does not accept arguments`;
} }
for (const arg of requestArgs) { for (const arg of args) {
if (!allowed.includes(arg)) { if (!allowed.includes(arg)) {
return `argument '${arg}' is not in the allowed list`; return `argument '${arg}' is not in the allowed list`;
} }