multica/src/agent/tools/exec-approval-cli.ts
yushen 89089ef866 feat(agent): wire exec approval callback into tool execution pipeline
- Add optional onApprovalNeeded callback to exec tool (backward compatible)
- Thread callback through CreateToolsOptions → AgentOptions → resolveTools
- Add ExecApprovalConfig to ProfileConfig for per-profile configuration
- Create CLI terminal approval callback (readline-based) for non-Hub mode
- Export all exec approval types and functions from tools index

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 17:07:07 +08:00

187 lines
5.8 KiB
TypeScript

/**
* CLI Terminal Approval — readline-based approval for CLI mode (no Hub/Gateway)
*/
import readline from "readline";
import type {
ExecApprovalCallback,
ExecApprovalConfig,
ApprovalDecision,
ApprovalResult,
} from "./exec-approval-types.js";
import { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js";
import { evaluateCommandSafety, requiresApproval } from "./exec-safety.js";
import { matchAllowlist, addAllowlistEntry, recordAllowlistUse } from "./exec-allowlist.js";
/** ANSI color helpers */
const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
const dim = (s: string) => `\x1b[2m${s}\x1b[0m`;
/** Risk level color mapping */
function colorRisk(level: string): string {
switch (level) {
case "dangerous": return red(level);
case "needs-review": return yellow(level);
case "safe": return green(level);
default: return level;
}
}
/**
* Callback for persisting allowlist changes.
* The Hub mode uses ProfileManager; CLI callers provide their own persistence.
*/
export type AllowlistPersister = (updatedConfig: ExecApprovalConfig) => void;
/**
* Create a CLI-based approval callback that prompts the user in the terminal.
*
* @param config - Exec approval configuration (security, ask, allowlist, etc.)
* @param onConfigUpdate - Optional callback to persist config changes (e.g., allowlist updates)
*/
export function createCliApprovalCallback(
config: ExecApprovalConfig,
onConfigUpdate?: AllowlistPersister,
): ExecApprovalCallback {
// Mutable copy of config for runtime allowlist updates
const runtimeConfig = { ...config, allowlist: [...(config.allowlist ?? [])] };
return async (command: string, cwd: string | undefined): Promise<ApprovalResult> => {
const security = runtimeConfig.security ?? "allowlist";
const ask = runtimeConfig.ask ?? "on-miss";
const timeoutMs = runtimeConfig.timeoutMs ?? DEFAULT_APPROVAL_TIMEOUT_MS;
// Security: deny blocks everything
if (security === "deny") {
return { approved: false, decision: "deny" };
}
// Security: full allows everything
if (security === "full") {
return { approved: true, decision: "allow-once" };
}
// Evaluate safety
const evaluation = evaluateCommandSafety(command, runtimeConfig);
// Check if approval is needed
const needsApproval = requiresApproval({
ask,
security,
analysisOk: evaluation.analysisOk,
allowlistSatisfied: evaluation.allowlistSatisfied,
});
if (!needsApproval) {
// Auto-approved: record allowlist usage if it was an allowlist match
if (evaluation.allowlistSatisfied) {
const match = matchAllowlist(runtimeConfig.allowlist ?? [], command);
if (match) {
runtimeConfig.allowlist = recordAllowlistUse(runtimeConfig.allowlist ?? [], match, command);
onConfigUpdate?.(runtimeConfig);
}
}
return { approved: true, decision: "allow-once" };
}
// Prompt user in terminal
const decision = await promptTerminal(command, cwd, evaluation.riskLevel, evaluation.reasons, timeoutMs);
if (decision === "allow-always") {
// Extract binary or full command as allowlist pattern
const pattern = extractAllowlistPattern(command);
runtimeConfig.allowlist = addAllowlistEntry(runtimeConfig.allowlist ?? [], pattern);
onConfigUpdate?.(runtimeConfig);
}
return {
approved: decision !== "deny",
decision,
};
};
}
/**
* Extract an allowlist pattern from a command.
* Uses the binary name + "**" for broad matching.
*/
function extractAllowlistPattern(command: string): string {
const trimmed = command.trim();
const binary = trimmed.split(/\s+/)[0];
return binary ? `${binary} **` : trimmed;
}
/**
* Prompt the user for an approval decision via readline.
*/
function promptTerminal(
command: string,
cwd: string | undefined,
riskLevel: string,
reasons: string[],
timeoutMs: number,
): Promise<ApprovalDecision> {
return new Promise((resolve) => {
const rl = readline.createInterface({
input: process.stdin,
output: process.stderr, // Use stderr to avoid mixing with stdout piping
});
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
rl.close();
};
// Timeout: auto-deny
const timer = setTimeout(() => {
if (resolved) return;
process.stderr.write(dim(`\n Approval timed out (${timeoutMs / 1000}s). Denying.\n\n`));
cleanup();
resolve("deny");
}, timeoutMs);
// Display approval prompt
process.stderr.write("\n");
process.stderr.write(bold(" Exec approval required\n"));
process.stderr.write(` ${dim("Command:")} ${command}\n`);
if (cwd) process.stderr.write(` ${dim("CWD:")} ${cwd}\n`);
process.stderr.write(` ${dim("Risk:")} ${colorRisk(riskLevel)}\n`);
if (reasons.length > 0) {
for (const reason of reasons) {
process.stderr.write(` ${dim(" -")} ${reason}\n`);
}
}
process.stderr.write("\n");
rl.question(
` ${bold("[a]")}llow once / ${bold("[A]")}llow always / ${bold("[d]")}eny (default: deny): `,
(answer) => {
clearTimeout(timer);
cleanup();
const trimmed = answer.trim();
if (trimmed === "a" || trimmed === "allow-once") {
resolve("allow-once");
} else if (trimmed === "A" || trimmed === "allow-always") {
resolve("allow-always");
} else {
resolve("deny");
}
},
);
// Handle Ctrl+C gracefully
rl.on("close", () => {
clearTimeout(timer);
if (!resolved) {
resolved = true;
resolve("deny");
}
});
});
}