diff --git a/codi-rs/README.md b/codi-rs/README.md index 27461f1..7d62053 100644 --- a/codi-rs/README.md +++ b/codi-rs/README.md @@ -2,9 +2,9 @@ Rust implementation of Codi - Your AI coding wingman. -## 🚨 **ALL PHASES COMPLETE!** 🚨 +## Status -The entire Rust implementation of Codi is now **feature-complete**! All roadmap phases have been successfully implemented and integrated. +Core feature parity with the TypeScript CLI is in place, and ongoing work is tracked in `docs/ROADMAP.md`. ### What's Now Complete: @@ -24,24 +24,9 @@ The entire Rust implementation of Codi is now **feature-complete**! All roadmap ✅ **Multi-Agent Orchestration** - Git worktree-based parallel workers with IPC permission bubbling -✅ **Test Suite** - Comprehensive 440+ test suite ensuring reliability across all components +✅ **Test Suite** - Comprehensive 500+ test suite ensuring reliability across all components -## Status: All Phases Complete ✅ - -The migration roadmap has been successfully completed: - -| Phase | Description | Status | -|-------|-------------|--------| -| **0** | Foundation - types, errors, config, CLI shell | ✅ Complete | -| **1** | Tool layer - file tools, grep, glob, bash | ✅ Complete | -| **2** | Provider layer - Anthropic, OpenAI, Ollama | ✅ Complete | -| **3** | Agent loop - core agentic orchestration | ✅ Complete | -| **4** | Symbol index - tree-sitter based code navigation | ✅ Complete | -| **5** | RAG system - vector search with embeddings | ✅ Complete | -| **6** | Terminal UI - ratatui based interface | ✅ Complete | -| **7** | Multi-agent - IPC-based worker orchestration | ✅ Complete | - -This release marks full feature parity with the TypeScript implementation, ending the migration period. +## Phase Status | Phase | Description | Status | |-------|-------------|--------| diff --git a/codi-rs/docs/ROADMAP.md b/codi-rs/docs/ROADMAP.md new file mode 100644 index 0000000..eef6ac9 --- /dev/null +++ b/codi-rs/docs/ROADMAP.md @@ -0,0 +1,64 @@ +# Codi-RS Roadmap + +This roadmap focuses on the Rust CLI (`codi-rs`) and its TUI/orchestration stack. It complements the broader Codi roadmap in `docs/ROADMAP.md`. + +## Status (2026-02-06) + +- Core parity with the TypeScript CLI is in place (agent loop, tools, providers, symbol index, RAG, TUI, multi-agent). +- Remaining work clusters around cross-platform support, orchestration robustness, and TUI workflow polish. + +## P0: Stability and Cross-Platform Foundations + +1) Cross-platform IPC for multi-agent +- Replace Unix domain sockets with an IPC abstraction. +- Implement Windows named pipes (or a transport-agnostic layer). +- Ensure commander/worker handshake is deterministic with explicit timeouts. + +2) Cancellation and lifecycle correctness +- Wire the TUI cancel flow to actual worker cancellation. +- Track tool_count and token usage for child agents. +- Add tests for cancellation and reconnection scenarios. + +3) Windows support parity +- Audit file/path handling and shell execution behavior. +- Add Windows-specific tests for tool execution and config loading. +- Ensure multi-agent mode degrades gracefully when unsupported. + +## P1: Workflow and Model UX + +1) TUI workflow improvements +- Context summarization for long sessions. +- Model listing and switching from the TUI. +- Display active provider/model in session header. +- Worktree list/explorer surfaced in the TUI. + +2) Model map integration +- Connect embeddings selection to model_map configuration. +- Expose errors and misconfigurations in `codi models` output. + +## P2: Indexing and Retrieval Quality + +1) Symbol index maintenance +- Cleanup of deleted/renamed files in the index. +- Usage detection and dependency graph traversal. + +2) RAG reliability and performance +- Safer incremental index updates. +- Caching and pooling of embedding providers. + +3) Syntax highlighting polish +- Upgrade tree-sitter-markdown when dependency compatibility allows. + +## P3: Security and Observability + +1) Execution policy improvements +- Extend dangerous pattern handling to a configurable policy engine. +- Safer defaults in multi-agent auto-approve scenarios. + +2) Telemetry and diagnostics +- Surface per-worker metrics and error summaries in the TUI. + +## Notes + +- This roadmap prioritizes correctness and portability; features should not regress Windows support. +- Items are grouped by priority, not by release version. diff --git a/codi-rs/src/orchestrate/child_agent.rs b/codi-rs/src/orchestrate/child_agent.rs index caddbba..e2b5fff 100644 --- a/codi-rs/src/orchestrate/child_agent.rs +++ b/codi-rs/src/orchestrate/child_agent.rs @@ -66,6 +66,8 @@ pub struct ChildAgent { workspace: WorkspaceInfo, /// Auto-approved tools from handshake. auto_approve: Vec, + /// Dangerous patterns from handshake. + dangerous_patterns: Vec, /// Timeout from handshake. timeout_ms: u64, } @@ -107,6 +109,7 @@ impl ChildAgent { let ipc = Arc::new(Mutex::new(ipc)); let auto_approve = ack.auto_approve.clone(); + let dangerous_patterns = ack.dangerous_patterns.clone(); // Create agent let mut child_agent = Self { @@ -114,6 +117,7 @@ impl ChildAgent { config, workspace, auto_approve, + dangerous_patterns, timeout_ms: ack.timeout_ms, }; @@ -183,7 +187,7 @@ impl ChildAgent { let callbacks = AgentCallbacks { on_confirm: Some(Arc::new(move |confirmation: ToolConfirmation| { // Check auto-approve list - if auto_approve.contains(&confirmation.tool_name) { + if !confirmation.is_dangerous && auto_approve.contains(&confirmation.tool_name) { return ConfirmationResult::Approve; } @@ -241,7 +245,7 @@ impl ChildAgent { extract_tools_from_text: true, auto_approve_all: false, auto_approve_tools: self.auto_approve.clone(), - dangerous_patterns: Vec::new(), + dangerous_patterns: self.dangerous_patterns.clone(), }; let mut agent = Agent::new(AgentOptions { diff --git a/codi-rs/src/orchestrate/commander.rs b/codi-rs/src/orchestrate/commander.rs index 1f6aeb8..0e30e95 100644 --- a/codi-rs/src/orchestrate/commander.rs +++ b/codi-rs/src/orchestrate/commander.rs @@ -271,8 +271,21 @@ impl Commander { .unwrap_or(300_000) }; + let dangerous_patterns = { + let workers = workers.read().await; + workers + .get(&worker_id) + .map(|w| w.config.dangerous_patterns.clone()) + .unwrap_or_default() + }; + // Send ack - let ack = CommanderMessage::handshake_ack(true, auto_approve, timeout_ms); + let ack = CommanderMessage::handshake_ack( + true, + auto_approve, + dangerous_patterns, + timeout_ms + ); if let Err(e) = self.server.send(&worker_id, &ack).await { error!("Failed to send handshake ack: {}", e); } diff --git a/codi-rs/src/orchestrate/ipc/client.rs b/codi-rs/src/orchestrate/ipc/client.rs index 712fd90..a3074ba 100644 --- a/codi-rs/src/orchestrate/ipc/client.rs +++ b/codi-rs/src/orchestrate/ipc/client.rs @@ -55,10 +55,16 @@ pub enum IpcClientError { /// Handshake acknowledgment from commander. #[derive(Debug, Clone)] pub struct HandshakeAck { + /// Whether the handshake was accepted. + pub accepted: bool, /// Tools that can be auto-approved. pub auto_approve: Vec, + /// Dangerous patterns for tool inputs. + pub dangerous_patterns: Vec, /// Timeout in milliseconds. pub timeout_ms: u64, + /// Optional rejection reason. + pub reason: Option, } /// Pending permission request. @@ -81,6 +87,8 @@ pub struct IpcClient { cancel_tx: Option>, /// Whether we've been cancelled. cancelled: Arc>, + /// Latest handshake acknowledgement. + handshake_ack: Arc>>, } impl IpcClient { @@ -93,6 +101,7 @@ impl IpcClient { pending_permissions: Arc::new(Mutex::new(HashMap::new())), cancel_tx: None, cancelled: Arc::new(Mutex::new(false)), + handshake_ack: Arc::new(Mutex::new(None)), } } @@ -109,6 +118,8 @@ impl IpcClient { let (cancel_tx, mut cancel_rx) = mpsc::channel::<()>(1); self.cancel_tx = Some(cancel_tx); + let handshake_ack = Arc::clone(&self.handshake_ack); + tokio::spawn(async move { let mut reader = BufReader::new(read_half); let mut line = String::new(); @@ -123,7 +134,12 @@ impl IpcClient { } Ok(_) => { if let Ok(msg) = decode::(&line) { - Self::handle_commander_message(msg, &pending, &cancelled).await; + Self::handle_commander_message( + msg, + &pending, + &cancelled, + &handshake_ack + ).await; } line.clear(); } @@ -150,8 +166,26 @@ impl IpcClient { msg: CommanderMessage, pending: &Arc>>, cancelled: &Arc>, + handshake_ack: &Arc>>, ) { match msg { + CommanderMessage::HandshakeAck { + accepted, + auto_approve, + dangerous_patterns, + timeout_ms, + reason, + .. + } => { + let mut ack = handshake_ack.lock().await; + *ack = Some(HandshakeAck { + accepted, + auto_approve, + dangerous_patterns, + timeout_ms, + reason, + }); + } CommanderMessage::PermissionResponse { request_id, result, .. } => { let mut pending = pending.lock().await; if let Some(req) = pending.remove(&request_id) { @@ -202,13 +236,63 @@ impl IpcClient { writer.write_all(encoded.as_bytes()).await?; writer.flush().await?; - // Wait for handshake ack (with timeout) - // Note: The actual ack comes through the reader task, but for simplicity - // we'll just return the config values - Ok(HandshakeAck { - auto_approve: config.auto_approve.clone(), - timeout_ms: config.timeout_ms, + let ack = self + .wait_for_handshake_ack(Duration::from_secs(2)) + .await; + + if let Some(ack) = ack { + if !ack.accepted { + return Err(IpcClientError::HandshakeFailed( + ack.reason.unwrap_or_else(|| "Handshake rejected".to_string()) + )); + } + + // If commander didn't provide values, fall back to local config + let auto_approve = if ack.auto_approve.is_empty() { + config.auto_approve.clone() + } else { + ack.auto_approve + }; + let dangerous_patterns = if ack.dangerous_patterns.is_empty() { + config.dangerous_patterns.clone() + } else { + ack.dangerous_patterns + }; + let timeout_ms = if ack.timeout_ms == 0 { config.timeout_ms } else { ack.timeout_ms }; + + Ok(HandshakeAck { + accepted: true, + auto_approve, + dangerous_patterns, + timeout_ms, + reason: None, + }) + } else { + warn!("Handshake ack not received; using local config defaults"); + Ok(HandshakeAck { + accepted: true, + auto_approve: config.auto_approve.clone(), + dangerous_patterns: config.dangerous_patterns.clone(), + timeout_ms: config.timeout_ms, + reason: None, + }) + } + } + + async fn wait_for_handshake_ack(&self, timeout: Duration) -> Option { + match tokio::time::timeout(timeout, async { + loop { + if let Some(ack) = self.handshake_ack.lock().await.take() { + return ack; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } }) + .await + { + Ok(ack) => Some(ack), + Err(_) => None, + } } /// Request permission for a tool operation. diff --git a/codi-rs/src/orchestrate/ipc/protocol.rs b/codi-rs/src/orchestrate/ipc/protocol.rs index ac11ed1..b64ae2f 100644 --- a/codi-rs/src/orchestrate/ipc/protocol.rs +++ b/codi-rs/src/orchestrate/ipc/protocol.rs @@ -308,6 +308,8 @@ pub enum CommanderMessage { accepted: bool, /// Tools to auto-approve. auto_approve: Vec, + /// Dangerous patterns for tool inputs. + dangerous_patterns: Vec, /// Timeout in milliseconds. timeout_ms: u64, /// Rejection reason (if not accepted). @@ -377,12 +379,18 @@ pub enum PermissionResult { impl CommanderMessage { /// Create a handshake acknowledgment. - pub fn handshake_ack(accepted: bool, auto_approve: Vec, timeout_ms: u64) -> Self { + pub fn handshake_ack( + accepted: bool, + auto_approve: Vec, + dangerous_patterns: Vec, + timeout_ms: u64 + ) -> Self { Self::HandshakeAck { id: generate_message_id(), timestamp: now(), accepted, auto_approve, + dangerous_patterns, timeout_ms, reason: None, } @@ -395,6 +403,7 @@ impl CommanderMessage { timestamp: now(), accepted: false, auto_approve: Vec::new(), + dangerous_patterns: Vec::new(), timeout_ms: 0, reason: Some(reason.into()), } @@ -577,7 +586,12 @@ mod tests { #[test] fn test_commander_messages() { - let ack = CommanderMessage::handshake_ack(true, vec!["read_file".to_string()], 60000); + let ack = CommanderMessage::handshake_ack( + true, + vec!["read_file".to_string()], + vec![], + 60000 + ); assert!(ack.is_handshake_ack()); let cancel = CommanderMessage::cancel(Some("User requested".to_string())); diff --git a/codi-rs/src/orchestrate/types.rs b/codi-rs/src/orchestrate/types.rs index 1c00f9e..b86233b 100644 --- a/codi-rs/src/orchestrate/types.rs +++ b/codi-rs/src/orchestrate/types.rs @@ -38,6 +38,9 @@ pub struct WorkerConfig { /// Tools to auto-approve without permission requests. #[serde(default)] pub auto_approve: Vec, + /// Dangerous patterns for tool inputs (passed to workers). + #[serde(default)] + pub dangerous_patterns: Vec, /// Maximum iterations before stopping. #[serde(default = "default_max_iterations")] pub max_iterations: u32, @@ -64,6 +67,7 @@ impl WorkerConfig { model: None, provider: None, auto_approve: Vec::new(), + dangerous_patterns: Vec::new(), max_iterations: default_max_iterations(), timeout_ms: default_timeout_ms(), } @@ -87,6 +91,12 @@ impl WorkerConfig { self } + /// Set dangerous patterns for tool inputs. + pub fn with_dangerous_patterns(mut self, patterns: Vec) -> Self { + self.dangerous_patterns = patterns; + self + } + /// Check if a tool should be auto-approved. pub fn should_auto_approve(&self, tool_name: &str) -> bool { self.auto_approve.iter().any(|t| t == tool_name) diff --git a/codi-rs/src/tui/app.rs b/codi-rs/src/tui/app.rs index 1504908..13f30ea 100644 --- a/codi-rs/src/tui/app.rs +++ b/codi-rs/src/tui/app.rs @@ -1201,7 +1201,12 @@ impl App { // Generate worker ID from branch let worker_id = branch.replace('/', "-"); - let config = WorkerConfig::new(&worker_id, branch, task); + let mut config = WorkerConfig::new(&worker_id, branch, task); + if let Some(ref resolved) = self.config { + config = config + .with_auto_approve(resolved.auto_approve.clone()) + .with_dangerous_patterns(resolved.dangerous_patterns.clone()); + } commander .spawn_worker(config) diff --git a/src/agent.ts b/src/agent.ts index c76d95f..9767049 100644 --- a/src/agent.ts +++ b/src/agent.ts @@ -1060,52 +1060,59 @@ Always use tools to interact with the filesystem rather than asking the user to } } - // Check if this tool requires confirmation - let needsConfirmation = !this.shouldAutoApprove(toolCall.name) && - TOOL_CATEGORIES.DESTRUCTIVE.has(toolCall.name) && - this.callbacks.onConfirm; - - // For bash commands, also check approved patterns/categories - if (needsConfirmation && toolCall.name === 'bash') { - const command = toolCall.input.command as string; - if (command && this.shouldAutoApproveBash(command)) { - needsConfirmation = false; + // Check for dangerous bash commands (including custom patterns) + let isDangerous = false; + let dangerReason: string | undefined; + + if (toolCall.name === 'bash') { + const command = toolCall.input.command as string | undefined; + if (command) { + // Check built-in dangerous patterns + const danger = checkDangerousBash(command); + isDangerous = danger.isDangerous; + dangerReason = danger.reason; + + // Check custom dangerous patterns if not already flagged + if (!isDangerous && this.customDangerousPatterns.length > 0) { + for (const { pattern, description } of this.customDangerousPatterns) { + if (pattern.test(command)) { + isDangerous = true; + dangerReason = description; + break; + } + } + } } } - // For file tools, also check approved path patterns/categories - if (needsConfirmation && Agent.FILE_TOOLS.has(toolCall.name)) { - const filePath = toolCall.input.path as string; - if (filePath && this.shouldAutoApproveFilePath(toolCall.name, filePath)) { - needsConfirmation = false; - } - } + // Check if this tool requires confirmation + let needsConfirmation = TOOL_CATEGORIES.DESTRUCTIVE.has(toolCall.name) && this.callbacks.onConfirm; if (needsConfirmation) { - // Check for dangerous bash commands (including custom patterns) - let isDangerous = false; - let dangerReason: string | undefined; + if (!isDangerous) { + if (this.shouldAutoApprove(toolCall.name)) { + needsConfirmation = false; + } - if (toolCall.name === 'bash') { - const command = toolCall.input.command as string | undefined; - if (command) { - // Check built-in dangerous patterns - const danger = checkDangerousBash(command); - isDangerous = danger.isDangerous; - dangerReason = danger.reason; - - // Check custom dangerous patterns if not already flagged - if (!isDangerous && this.customDangerousPatterns.length > 0) { - for (const { pattern, description } of this.customDangerousPatterns) { - if (pattern.test(command)) { - isDangerous = true; - dangerReason = description; - break; - } - } + // For bash commands, also check approved patterns/categories + if (needsConfirmation && toolCall.name === 'bash') { + const command = toolCall.input.command as string; + if (command && this.shouldAutoApproveBash(command)) { + needsConfirmation = false; + } + } + + // For file tools, also check approved path patterns/categories + if (needsConfirmation && Agent.FILE_TOOLS.has(toolCall.name)) { + const filePath = toolCall.input.path as string; + if (filePath && this.shouldAutoApproveFilePath(toolCall.name, filePath)) { + needsConfirmation = false; } } } + } + + if (needsConfirmation) { // Generate diff preview for file operations let diffPreview: DiffResult | undefined; diff --git a/src/tools/bash.ts b/src/tools/bash.ts index b0d36e0..1b59306 100644 --- a/src/tools/bash.ts +++ b/src/tools/bash.ts @@ -1,7 +1,7 @@ // Copyright 2026 Layne Penney // SPDX-License-Identifier: AGPL-3.0-or-later -import { exec } from 'child_process'; +import { execFile } from 'child_process'; import { BaseTool } from './base.js'; import type { ToolDefinition } from '../types.js'; import type { ExecErrorWithOutput } from '../types/extended.js'; @@ -37,7 +37,9 @@ export class BashTool extends BaseTool { async execute(input: Record): Promise { const rawCommand = input.command; - const command = this.normalizeCommandInput(rawCommand); + const normalized = this.normalizeCommandInput(rawCommand); + const command = normalized.command; + const preferBash = normalized.preferBash; const cwd = (input.cwd as string) || process.cwd(); if (!command) { @@ -54,7 +56,7 @@ export class BashTool extends BaseTool { const startTime = Date.now(); try { - const result = await this.execCommand(command, cwd); + const result = await this.execCommand(command, cwd, preferBash); const duration = ((Date.now() - startTime) / 1000).toFixed(2); return this.formatOutput({ @@ -91,57 +93,94 @@ export class BashTool extends BaseTool { /** * Execute command and return stdout/stderr. */ - private execCommand(command: string, cwd: string): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - exec( - command, - { - cwd, - timeout: TIMEOUT_MS, - maxBuffer: 10 * 1024 * 1024, // 10MB buffer - }, - (error, stdout, stderr) => { - if (error) { - // Attach stdout/stderr to error for access in catch block - const errorWithOutput = error as ExecErrorWithOutput; - errorWithOutput.stdout = stdout; - errorWithOutput.stderr = stderr; - reject(errorWithOutput); - } else { - resolve({ stdout, stderr }); + private async execCommand( + command: string, + cwd: string, + preferBash: boolean + ): Promise<{ stdout: string; stderr: string }> { + const run = (file: string, args: string[]) => + new Promise<{ stdout: string; stderr: string }>((resolve, reject) => { + execFile( + file, + args, + { + cwd, + timeout: TIMEOUT_MS, + maxBuffer: 10 * 1024 * 1024, // 10MB buffer + }, + (error, stdout, stderr) => { + if (error) { + // Attach stdout/stderr to error for access in catch block + const errorWithOutput = error as ExecErrorWithOutput; + errorWithOutput.stdout = stdout; + errorWithOutput.stderr = stderr; + reject(errorWithOutput); + } else { + resolve({ stdout, stderr }); + } } + ); + }); + + const isWindows = process.platform === 'win32'; + + if (isWindows) { + const windowsShell = process.env.ComSpec || 'cmd.exe'; + if (preferBash) { + try { + return await run('bash', ['-lc', command]); + } catch (error) { + if (this.isCommandNotFound(error)) { + return await run(windowsShell, ['/C', command]); + } + throw error; } - ); - }); + } + return await run(windowsShell, ['/C', command]); + } + + try { + return await run('bash', ['-lc', command]); + } catch (error) { + if (this.isCommandNotFound(error)) { + return await run('sh', ['-c', command]); + } + throw error; + } } - private normalizeCommandInput(command: unknown): string | null { + private normalizeCommandInput(command: unknown): { command: string | null; preferBash: boolean } { if (command === null || command === undefined) { - return null; + return { command: null, preferBash: false }; } if (typeof command === 'string') { - return command; + return { command, preferBash: false }; } if (Array.isArray(command)) { const parts = command.filter((part): part is string => typeof part === 'string' && part.trim() !== ''); if (parts.length === 0) { - return this.stringifyCommand(command); + return { command: this.stringifyCommand(command), preferBash: false }; } - if (parts[0] === 'bash' && parts[1] === '-lc') { - const script = parts.slice(2).join(' '); + if (parts[0] === 'bash') { + const startIndex = (parts[1] === '-lc' || parts[1] === '-c') ? 2 : 1; + const script = parts.slice(startIndex).join(' '); if (!script.trim()) { - return this.stringifyCommand(parts); + return { command: this.stringifyCommand(parts), preferBash: true }; } - return `bash -lc ${JSON.stringify(script)}`; + return { command: script, preferBash: true }; } - return parts.join(' '); + return { command: parts.join(' '), preferBash: false }; } - return this.stringifyCommand(command); + return { command: this.stringifyCommand(command), preferBash: false }; + } + + private isCommandNotFound(error: unknown): boolean { + return (error as NodeJS.ErrnoException)?.code === 'ENOENT'; } private stringifyCommand(command: unknown): string { diff --git a/tests/agent.test.ts b/tests/agent.test.ts index dbe04ee..d3a5a88 100644 --- a/tests/agent.test.ts +++ b/tests/agent.test.ts @@ -957,6 +957,28 @@ describe('Agent', () => { expect(confirmation.dangerReason).toBeDefined(); }); + it('requires confirmation for dangerous bash even when auto-approved', async () => { + const toolProvider = createMockProvider([ + mockToolResponse([mockToolCall('bash', { command: 'rm -rf /' })]), + mockTextResponse('Dangerous command handled.'), + ]); + + const onConfirm = vi.fn().mockResolvedValue('deny' as ConfirmationResult); + + const agent = new Agent({ + provider: toolProvider, + toolRegistry: registry, + autoApprove: true, + onConfirm, + }); + + await agent.chat('Delete everything'); + + expect(onConfirm).toHaveBeenCalled(); + const confirmation = onConfirm.mock.calls[0][0] as ToolConfirmation; + expect(confirmation.isDangerous).toBe(true); + }); + it('flags custom dangerous patterns', async () => { const toolProvider = createMockProvider([ mockToolResponse([mockToolCall('bash', { command: 'deploy --prod' })]), @@ -981,6 +1003,32 @@ describe('Agent', () => { expect(confirmation.isDangerous).toBe(true); expect(confirmation.dangerReason).toBe('Production deployment'); }); + + it('requires confirmation for custom dangerous patterns even when bash is auto-approved', async () => { + const toolProvider = createMockProvider([ + mockToolResponse([mockToolCall('bash', { command: 'deploy --prod' })]), + mockTextResponse('Deploy handled.'), + ]); + + const onConfirm = vi.fn().mockResolvedValue('deny' as ConfirmationResult); + + const agent = new Agent({ + provider: toolProvider, + toolRegistry: registry, + autoApprove: ['bash'], + customDangerousPatterns: [ + { pattern: /deploy --prod/, description: 'Production deployment' }, + ], + onConfirm, + }); + + await agent.chat('Deploy to prod'); + + expect(onConfirm).toHaveBeenCalled(); + const confirmation = onConfirm.mock.calls[0][0] as ToolConfirmation; + expect(confirmation.isDangerous).toBe(true); + expect(confirmation.dangerReason).toBe('Production deployment'); + }); }); describe('diff preview generation', () => {