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
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue