From 4821d29670c4d971d23257b4d0c25d6747170969 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:32:50 +0200 Subject: [PATCH] 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 --- .env.example | 2 ++ ops-agent/commands.yml.example | 21 ++++++++++++++++++--- ops-agent/src/routes/exec.ts | 13 ++++++++++--- ops-agent/src/whitelist.ts | 20 ++++++++++++++++++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index 48a9a2a..ab4a19c 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ SEED_USER_EMAIL="admin@example.com" SEED_USER_PASSWORD="changeme" OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" 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" diff --git a/ops-agent/commands.yml.example b/ops-agent/commands.yml.example index ae082e3..9928977 100644 --- a/ops-agent/commands.yml.example +++ b/ops-agent/commands.yml.example @@ -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"] diff --git a/ops-agent/src/routes/exec.ts b/ops-agent/src/routes/exec.ts index 915c3ee..22f3e3d 100644 --- a/ops-agent/src/routes/exec.ts +++ b/ops-agent/src/routes/exec.ts @@ -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 { .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 { }; 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(); diff --git a/ops-agent/src/whitelist.ts b/ops-agent/src/whitelist.ts index c3a64fa..ac3069f 100644 --- a/ops-agent/src/whitelist.ts +++ b/ops-agent/src/whitelist.ts @@ -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`; }