diff --git a/src/agent/profile/types.ts b/src/agent/profile/types.ts index f03f7d11..44e3f066 100644 --- a/src/agent/profile/types.ts +++ b/src/agent/profile/types.ts @@ -3,6 +3,7 @@ */ import type { ToolsConfig } from "../tools/policy.js"; +import type { ExecApprovalConfig } from "../tools/exec-approval-types.js"; /** Profile filename constants */ export const PROFILE_FILES = { @@ -39,6 +40,8 @@ export interface ProfileConfig { thinkingLevel?: string; /** Reasoning mode: off, on, stream */ reasoningMode?: "off" | "on" | "stream" | undefined; + /** Exec approval configuration (security level, ask mode, allowlist) */ + execApproval?: ExecApprovalConfig | undefined; } /** Agent Profile configuration */ diff --git a/src/agent/tools.ts b/src/agent/tools.ts index ea9d16de..4019a34d 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -10,6 +10,7 @@ import { createMemoryTools } from "./tools/memory/index.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn.js"; import { filterTools } from "./tools/policy.js"; import { isMulticaError, isRetryableError } from "../shared/errors.js"; +import type { ExecApprovalCallback } from "./tools/exec-approval-types.js"; // Re-export resolveModel from providers for backwards compatibility export { resolveModel } from "./providers/index.js"; @@ -25,6 +26,8 @@ export interface CreateToolsOptions { isSubagent?: boolean | undefined; /** Session ID of the agent (passed to sessions_spawn tool) */ sessionId?: string | undefined; + /** Callback invoked when exec tool needs approval before running a command */ + onExecApprovalNeeded?: ExecApprovalCallback | undefined; } type ToolErrorPayload = { @@ -100,7 +103,7 @@ export function createAllTools(options: CreateToolsOptions | string): AgentTool< (tool) => tool.name !== "bash", ) as AgentTool[]; - const execTool = createExecTool(cwd); + const execTool = createExecTool(cwd, opts.onExecApprovalNeeded); const processTool = createProcessTool(cwd); const globTool = createGlobTool(cwd); const webFetchTool = createWebFetchTool(); @@ -153,6 +156,7 @@ export function resolveTools(options: AgentOptions): AgentTool[] { profileBaseDir: options.profileBaseDir, isSubagent: options.isSubagent, sessionId: options.sessionId, + onExecApprovalNeeded: options.onExecApprovalNeeded, }); // Apply policy filtering diff --git a/src/agent/tools/exec-approval-cli.ts b/src/agent/tools/exec-approval-cli.ts new file mode 100644 index 00000000..e62cc01e --- /dev/null +++ b/src/agent/tools/exec-approval-cli.ts @@ -0,0 +1,187 @@ +/** + * 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 => { + 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 { + 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"); + } + }); + }); +} diff --git a/src/agent/tools/exec.ts b/src/agent/tools/exec.ts index cf77d83f..826795f6 100644 --- a/src/agent/tools/exec.ts +++ b/src/agent/tools/exec.ts @@ -7,6 +7,7 @@ import { getFullOutput, PROCESS_REGISTRY, } from "./process-registry.js"; +import type { ExecApprovalCallback } from "./exec-approval-types.js"; const ExecSchema = Type.Object({ command: Type.String({ description: "Shell command to execute." }), @@ -40,7 +41,10 @@ export type ExecResult = { const DEFAULT_YIELD_MS = 10000; // Changed from 5000 to 10000 -export function createExecTool(defaultCwd?: string): AgentTool { +export function createExecTool( + defaultCwd?: string, + onApprovalNeeded?: ExecApprovalCallback, +): AgentTool { return { name: "exec", label: "Exec", @@ -51,6 +55,21 @@ export function createExecTool(defaultCwd?: string): AgentTool { const child = spawn(command, { shell: true, diff --git a/src/agent/tools/index.ts b/src/agent/tools/index.ts index 1e6f6334..700b365d 100644 --- a/src/agent/tools/index.ts +++ b/src/agent/tools/index.ts @@ -32,3 +32,20 @@ export { getSubagentPolicy, wouldToolBeAllowed, } from "./policy.js"; + +// Exec approval system +export type { + ExecSecurity, + ExecAsk, + ApprovalDecision, + ExecApprovalRequest, + ExecApprovalConfig, + ExecAllowlistEntry, + ExecApprovalCallback, + ApprovalResult, + SafetyEvaluation, +} from "./exec-approval-types.js"; +export { DEFAULT_APPROVAL_TIMEOUT_MS } from "./exec-approval-types.js"; +export { evaluateCommandSafety, requiresApproval, minSecurity, maxAsk, DEFAULT_SAFE_BINS } from "./exec-safety.js"; +export { matchAllowlist, addAllowlistEntry, recordAllowlistUse, removeAllowlistEntry, normalizeAllowlist } from "./exec-allowlist.js"; +export { createCliApprovalCallback } from "./exec-approval-cli.js"; diff --git a/src/agent/types.ts b/src/agent/types.ts index 6f7b7806..14d7a676 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -1,6 +1,7 @@ import type { ThinkingLevel } from "@mariozechner/pi-agent-core"; import type { SkillsConfig } from "./skills/types.js"; import type { ToolsConfig } from "./tools/policy.js"; +import type { ExecApprovalCallback, ExecApprovalConfig } from "./tools/exec-approval-types.js"; /** Controls how reasoning/thinking content blocks are handled */ export type ReasoningMode = "off" | "on" | "stream"; @@ -75,6 +76,12 @@ export type AgentOptions = { tools?: ToolsConfig | undefined; /** Whether this is a subagent (applies restricted tool set) */ isSubagent?: boolean | undefined; + + // === Exec Approval Configuration === + /** Callback invoked when exec tool needs approval before running a command */ + onExecApprovalNeeded?: ExecApprovalCallback | undefined; + /** Exec approval configuration (security level, ask mode, allowlist) */ + execApproval?: ExecApprovalConfig | undefined; }; export interface Message {