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:
parent
3cc966c70c
commit
4821d29670
4 changed files with 48 additions and 8 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue