aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
102 lines (94 loc) • 3.89 kB
JavaScript
/**
* AIWG MCP Tool: aiwg-command-run
*
* Single dispatch MCP tool that routes to the ~94 AIWG CLI commands.
* Replaces the `workflow-run` stub.
*
* Security model:
* - Allow-list validated against `src/extensions/commands/definitions.ts`
* (loaded lazily at first call)
* - `args` is a string array — passed as argv, never shell-interpreted
* - `shell: false` enforced in spawn
* - Destructive commands require `confirmed: true`
*
* @architecture @.aiwg/architecture/sketch-hermes-mcp-parity.md DD-4
* @issues #1312 (S1)
* @rules @token-security, @anti-laziness
*/
import { z } from 'zod';
import {
runAiwgCli,
loadCommandAllowList,
isDestructive,
mcpError,
mcpJson,
} from '../helpers.mjs';
export function registerCommandRunTool(server) {
server.registerTool('command-run', {
title: 'Run AIWG CLI Command',
description: 'Dispatch to any allow-listed AIWG CLI command (`aiwg <command> [args...]`). The full surface of ~94 commands is reachable. Destructive commands require `confirmed: true`.',
inputSchema: {
command: z.string().describe('Command name from `command-list` (e.g. "use", "discover", "doctor")'),
args: z.array(z.string()).default([]).describe('Argument array (never shell-interpreted)'),
project_dir: z.string().optional().describe('Working directory for the command'),
confirmed: z.boolean().default(false).describe('Required for destructive commands (true to proceed)'),
timeout_ms: z.number().int().min(1000).max(600_000).default(120_000).describe('Timeout (1s–10min, default 2min)'),
},
annotations: {
destructiveHint: true, // conservative default; many subcommands are read-only but the surface is broad
openWorldHint: true, // touches filesystem and project state
},
}, async ({ command, args, project_dir, confirmed, timeout_ms }) => {
try {
// 1. Allow-list validation
const allowList = await loadCommandAllowList();
if (allowList.size === 0) {
return mcpError(
'command-run: cannot load command allow-list from AIWG_ROOT. ' +
'AIWG installation may be incomplete.',
{ remediation: 'Run `aiwg doctor` to verify AIWG_ROOT is set correctly.' }
);
}
if (!allowList.has(command)) {
return mcpError(
`command-run: command "${command}" not in allow-list.`,
{ remediation: 'Call `command-list` (or `discover --type command`) to see available commands.' }
);
}
// 2. Destructive-op gate
if (isDestructive(command) && !confirmed) {
return mcpError(
`command-run: "${command}" is a destructive command. Re-invoke with confirmed=true to proceed.`,
{
remediation: 'Set confirmed=true after surfacing the impact to the user.',
requiresConfirmation: true,
}
);
}
// 3. Argument sanity (no shell metacharacters smuggled via array elements
// — these are passed as argv so they're safe, but reject NUL bytes which
// would truncate argv at the OS level)
for (const a of args) {
if (typeof a !== 'string' || a.includes('\0')) {
return mcpError('command-run: invalid argument (NUL byte or non-string)');
}
}
// 4. Log path only — never log the args content (token-security)
console.error(`[AIWG MCP] command-run: aiwg ${command} (${args.length} args)`);
// 5. Spawn with shell: false (runAiwgCli enforces this)
const { stdout, stderr, code } = await runAiwgCli(
[command, ...args],
{ cwd: project_dir, timeoutMs: timeout_ms }
);
return mcpJson({
command,
exit_code: code,
stdout,
stderr,
confirmed,
});
} catch (err) {
return mcpError(`command-run: ${err.message}`);
}
});
}