From 234b2d1a58c2c18f6d1be90a34e6f35463a45fb8 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 17:53:05 +0200 Subject: [PATCH] feat(ops-agent): extend whitelist with destructive commands + preconditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ops-agent/commands.yml.example | 69 ++++++++++++++++++++++++++++++++++ ops-agent/src/routes/exec.ts | 37 +++++++++++++++++- ops-agent/src/whitelist.ts | 4 ++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/ops-agent/commands.yml.example b/ops-agent/commands.yml.example index 1414578..cfa99d9 100644 --- a/ops-agent/commands.yml.example +++ b/ops-agent/commands.yml.example @@ -70,3 +70,72 @@ commands: - -c - "for f in /data/caddy/certificates/*/*.crt; do [ -f \"$f\" ] || continue; echo \"CERTFILE:$f\"; openssl x509 -noout -subject -issuer -dates -in \"$f\" 2>&1; echo \"CERTEND\"; done" description: "List TLS cert info (subject, issuer, validity dates) from Caddy certificate store" + + # ── Destructive / write commands ────────────────────────────────────────── + + docker_compose_restart: + cmd: ["docker", "compose", "restart"] + cwd: "/srv/scrum4me/compose" + args: + allowed: + - scrum4me-web + - worker-idea + - ops-dashboard + - caddy + - postgres + description: "Restart a docker compose service (ops-agent user must be in the docker group)" + + docker_compose_build: + cmd: ["docker", "compose", "build"] + cwd: "/srv/scrum4me/compose" + args: + allowed: + - scrum4me-web + - worker-idea + - ops-dashboard + description: "Build a docker compose service image" + + docker_compose_up: + cmd: ["docker", "compose", "up", "-d"] + cwd: "/srv/scrum4me/compose" + args: + allowed: + - scrum4me-web + - worker-idea + - ops-dashboard + description: "Start or recreate a docker compose service in detached mode" + + git_pull: + cmd: ["git", "pull", "--ff-only"] + cwd_pattern: "/srv/" + preconditions: + - git_status_clean + description: "Fast-forward pull — refused when working tree is dirty" + + systemctl_restart: + # Requires /etc/sudoers.d/ops-agent (see deploy/ops-agent/sudoers). + cmd: ["sudo", "/usr/bin/systemctl", "restart"] + args: + allowed: + - scrum4me-web + - ops-agent + - caddy + description: "Restart an allowed systemd service via sudo" + + caddy_validate: + cmd: ["caddy", "validate", "--config", "/srv/scrum4me/caddy/Caddyfile"] + description: "Validate /srv/scrum4me/caddy/Caddyfile without reloading" + + caddy_reload: + cmd: ["caddy", "reload", "--config", "/srv/scrum4me/caddy/Caddyfile"] + description: "Reload Caddy with /srv/scrum4me/caddy/Caddyfile" + + caddy_write_config: + # Writes stdin to Caddyfile.new first; mv is atomic on the same filesystem. + # ops-agent user must own /srv/scrum4me/caddy/. + cmd: + - sh + - -c + - "cat > /srv/scrum4me/caddy/Caddyfile.new && mv /srv/scrum4me/caddy/Caddyfile.new /srv/scrum4me/caddy/Caddyfile" + stdin_from_body: true + description: "Atomically replace /srv/scrum4me/caddy/Caddyfile (write stdin to .new, then mv)" diff --git a/ops-agent/src/routes/exec.ts b/ops-agent/src/routes/exec.ts index 22f3e3d..9a393ec 100644 --- a/ops-agent/src/routes/exec.ts +++ b/ops-agent/src/routes/exec.ts @@ -5,6 +5,20 @@ import { getCommand, validateArgs, validateCwd } from '../whitelist.js'; interface ExecBody { command_key: string; args?: string[]; + /** Config content piped to stdin for commands with stdin_from_body: true */ + stdin?: string; +} + +function checkGitStatusClean(cwd: string | undefined): Promise { + return new Promise((resolve) => { + const child = spawn('git', ['status', '--porcelain'], { shell: false, cwd }); + let output = ''; + child.stdout.on('data', (chunk: Buffer) => { + output += chunk.toString(); + }); + child.on('close', () => resolve(output.trim() === '')); + child.on('error', () => resolve(false)); + }); } function auditLog( @@ -27,7 +41,7 @@ function auditLog( 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 { command_key, args = [], stdin } = req.body; const def = getCommand(command_key); if (!def) { @@ -46,6 +60,21 @@ export async function execRoutes(app: FastifyInstance): Promise { return reply.status(400).send({ error: argError }); } + const cwd = def.cwd_pattern ? args[0] : def.cwd; + + if (def.preconditions) { + for (const precondition of def.preconditions) { + if (precondition === 'git_status_clean') { + const clean = await checkGitStatusClean(cwd); + if (!clean) { + return reply.status(409).send({ + error: 'working tree is not clean; commit or stash changes before pulling', + }); + } + } + } + } + reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', @@ -57,7 +86,6 @@ 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, ...effectiveArgs], { @@ -65,6 +93,11 @@ export async function execRoutes(app: FastifyInstance): Promise { cwd, }); + if (def.stdin_from_body && stdin != null) { + child.stdin!.write(stdin, 'utf8'); + } + child.stdin!.end(); + const startedAt = Date.now(); child.stdout.on('data', (chunk: Buffer) => { diff --git a/ops-agent/src/whitelist.ts b/ops-agent/src/whitelist.ts index ac3069f..f5bc57a 100644 --- a/ops-agent/src/whitelist.ts +++ b/ops-agent/src/whitelist.ts @@ -12,6 +12,10 @@ export interface CommandDef { 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;