Files
waggle-os/packages/agent/src/cli-tools.ts
Oleg Maslov b20b138fe4 moving
2026-09-02 10:14:22 +02:00

277 lines
10 KiB
TypeScript

/**
* CLI-Anything — make any CLI tool available to the agent with governance.
*
* cli_discover: Scans PATH for known CLIs, returns available programs.
* cli_execute: Runs an allowed CLI program with arguments and timeout.
*
* Governance: User controls which CLIs the agent can use via an allowlist
* in config.json. All executions are logged to the audit trail.
*/
import type { ToolDefinition } from './tools.js';
import { win32 as pathWin32 } from 'node:path';
import {
resolveToolCommandInvocation,
resolveToolCommandInvocationFromPath,
type ToolCommandInvocation,
} from './tool-command.js';
import { createSanitizedEnv, execFileWithTreeTimeout } from './system-tools-helpers.js';
async function execCliInvocation(
invocation: ToolCommandInvocation,
env: NodeJS.ProcessEnv,
timeoutMs: number,
): Promise<{ stdout: string; stderr: string }> {
const result = await execFileWithTreeTimeout(invocation.binary, invocation.args, {
cwd: process.cwd(),
env,
maxBuffer: 1024 * 1024,
windowsHide: true,
windowsVerbatimArguments: invocation.windowsVerbatimArguments === true,
}, timeoutMs);
if (result.timedOut) {
throw Object.assign(new Error(`Killed after ${timeoutMs / 1000}s timeout`), {
cleanupDegraded: result.cleanupDegraded,
killed: true,
stdout: result.stdout,
stderr: result.stderr,
});
}
if (result.errorMessage) {
throw Object.assign(new Error(result.errorMessage), {
cleanupDegraded: result.cleanupDegraded,
code: result.errorCode ?? undefined,
stdout: result.stdout,
stderr: result.stderr,
});
}
return { stdout: result.stdout, stderr: result.stderr };
}
function isBareWindowsCommand(program: string): boolean {
return process.platform === 'win32' && !pathWin32.isAbsolute(program) && !/[\\/]/.test(program);
}
async function execCliFile(
program: string,
args: string[],
timeoutMs: number,
): Promise<{ stdout: string; stderr: string }> {
const env = createSanitizedEnv();
if (isBareWindowsCommand(program)) {
const resolved = await resolveToolCommandInvocationFromPath(
program,
args,
process.platform,
{ env },
);
return execCliInvocation(resolved, env, timeoutMs);
}
const direct = resolveToolCommandInvocation(program, args, process.platform, { env });
try {
return await execCliInvocation(direct, env, timeoutMs);
} catch (err) {
const code = (err as { code?: string | number }).code;
if (process.platform !== 'win32' || code !== 'ENOENT') throw err;
const resolved = await resolveToolCommandInvocationFromPath(
program,
args,
process.platform,
{ env },
);
if (resolved.binary === direct.binary && resolved.args === direct.args) throw err;
return execCliInvocation(resolved, env, timeoutMs);
}
}
/** Well-known CLIs to detect on the system */
const KNOWN_CLIS = [
{ name: 'git', versionFlag: '--version' },
{ name: 'node', versionFlag: '--version' },
{ name: 'npm', versionFlag: '--version' },
{ name: 'npx', versionFlag: '--version' },
{ name: 'python', versionFlag: '--version' },
{ name: 'python3', versionFlag: '--version' },
{ name: 'pip', versionFlag: '--version' },
{ name: 'docker', versionFlag: '--version' },
{ name: 'docker-compose', versionFlag: '--version' },
{ name: 'aws', versionFlag: '--version' },
{ name: 'gcloud', versionFlag: '--version' },
{ name: 'az', versionFlag: '--version' },
{ name: 'kubectl', versionFlag: 'version --client --short' },
{ name: 'gh', versionFlag: '--version' },
{ name: 'cargo', versionFlag: '--version' },
{ name: 'rustc', versionFlag: '--version' },
{ name: 'go', versionFlag: 'version' },
{ name: 'java', versionFlag: '-version' },
{ name: 'mvn', versionFlag: '--version' },
{ name: 'dotnet', versionFlag: '--version' },
{ name: 'terraform', versionFlag: '--version' },
{ name: 'helm', versionFlag: 'version --short' },
{ name: 'curl', versionFlag: '--version' },
{ name: 'wget', versionFlag: '--version' },
{ name: 'jq', versionFlag: '--version' },
{ name: 'ffmpeg', versionFlag: '-version' },
];
const CLI_DISCOVERY_CONCURRENCY = 8;
export interface CliToolsConfig {
/** Programs the agent is allowed to execute (empty = none allowed) */
allowlist: string[];
/** Optional live source used when `/cli` changes the persisted allowlist. */
getAllowlist?: () => string[];
/** Audit logger for tracking CLI executions */
auditLog?: (entry: { actionType: string; description: string }) => void;
}
export function createCliTools(config: CliToolsConfig): ToolDefinition[] {
const getAllowlist = config.getAllowlist ?? (() => config.allowlist);
return [
{
name: 'cli_discover',
description: 'Discover available CLI tools on the system. Returns name, version, and whether each is in the allowlist.',
parameters: {
type: 'object',
properties: {},
},
execute: async () => {
type CliResult = { name: string; version: string; allowed: boolean };
const allowlist = getAllowlist();
const allowSet = new Set(allowlist.map(s => s.toLowerCase()));
// Bound concurrent probes because each Windows invocation owns a
// Worker. Batching preserves KNOWN_CLIS output order.
const settled: Array<CliResult | null> = [];
for (let offset = 0; offset < KNOWN_CLIS.length; offset += CLI_DISCOVERY_CONCURRENCY) {
const batch = await Promise.all(
KNOWN_CLIS.slice(offset, offset + CLI_DISCOVERY_CONCURRENCY)
.map(async (cli): Promise<CliResult | null> => {
const args = cli.versionFlag.split(' ');
const env = createSanitizedEnv();
let resolvedFromPath = false;
try {
const invocation = await resolveToolCommandInvocationFromPath(
cli.name,
args,
process.platform,
{ env, fallbackToWhere: false },
);
resolvedFromPath = invocation.binary !== cli.name;
const { stdout, stderr } = await execCliInvocation(invocation, env, 2_000);
return {
name: cli.name,
version: (stdout || stderr).trim().split(/\r?\n/)[0],
allowed: allowSet.has('*') || allowSet.has(cli.name),
};
} catch {
if (process.platform === 'win32' && resolvedFromPath) {
return {
name: cli.name,
version: 'Installed (version probe unavailable)',
allowed: allowSet.has('*') || allowSet.has(cli.name),
};
}
return null; // CLI not found - skip
}
}),
);
settled.push(...batch);
}
const results = settled.filter((r): r is CliResult => r !== null);
return JSON.stringify({
found: results.length,
programs: results,
allowlist,
});
},
},
{
name: 'cli_execute',
description: 'Execute a CLI program with arguments. Only programs in the allowlist are permitted. Configure the allowlist in Settings.',
parameters: {
type: 'object',
properties: {
program: { type: 'string', description: 'CLI program name (e.g., "gh", "aws", "docker")' },
args: { type: 'array', items: { type: 'string' }, description: 'Arguments to pass to the program' },
timeout: { type: 'number', description: 'Timeout in seconds (default: 30, max: 120)' },
},
required: ['program'],
},
execute: async (params: Record<string, unknown>) => {
const program = String(params.program ?? '').trim();
const args = Array.isArray(params.args) ? params.args.map(String) : [];
const requestedTimeout = Number(params.timeout);
const timeoutSec = Number.isFinite(requestedTimeout) && requestedTimeout > 0
? Math.min(requestedTimeout, 120)
: 30;
const allowlist = getAllowlist();
const allowSet = new Set(allowlist.map(s => s.toLowerCase()));
if (!program) {
return JSON.stringify({ success: false, error: 'program is required' });
}
// Check allowlist
const isAllowed = allowSet.has('*') || allowSet.has(program.toLowerCase());
if (!isAllowed) {
return JSON.stringify({
success: false,
error: `Program "${program}" is not in the CLI allowlist. Add it in Settings > CLI Allowlist to enable.`,
allowlist,
});
}
// Audit log
config.auditLog?.({
actionType: `cli.execute.${program}`,
description: `CLI: ${program} ${args.join(' ')}`,
});
try {
const { stdout, stderr } = await execCliFile(program, args, timeoutSec * 1000);
return JSON.stringify({
success: true,
program,
args,
exitCode: 0,
stdout: stdout.trim(),
stderr: stderr.trim(),
});
} catch (err: unknown) {
const execErr = err as {
cleanupDegraded?: boolean;
code?: string | number;
killed?: boolean;
signal?: string;
stdout?: string;
stderr?: string;
};
const cleanupWarning = execErr.cleanupDegraded
? ' Process-tree cleanup degraded to the root process; descendants may still be running.'
: '';
return JSON.stringify({
success: false,
program,
args,
exitCode: execErr.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER'
? -1
: (typeof execErr.code === 'number' ? execErr.code : 1),
error: execErr.killed
? `Killed after ${timeoutSec}s timeout.${cleanupWarning}`
: `${err instanceof Error ? err.message : String(err)}${cleanupWarning}`,
stdout: execErr.stdout?.trim() ?? '',
stderr: execErr.stderr?.trim() ?? '',
});
}
},
},
];
}