Skip to main content

punch_runtime/
tool_executor.rs

1//! Tool execution engine.
2//!
3//! Executes built-in tools (moves) with capability checking, timeout
4//! enforcement, and SSRF protection for network-facing tools.
5
6use std::collections::HashMap;
7use std::net::IpAddr;
8use std::path::{Path, PathBuf};
9use std::sync::{Arc, LazyLock};
10use std::time::Instant;
11
12use dashmap::DashMap;
13use tokio::process::Command;
14use tracing::{debug, instrument, warn};
15
16use punch_extensions::plugin::PluginRegistry;
17use punch_memory::MemorySubstrate;
18use punch_types::{
19    AgentCoordinator, ApprovalDecision, BrowserPool, Capability, ChannelNotifier, FighterId,
20    PolicyEngine, PunchError, PunchResult, SandboxEnforcer, Sensitivity, ShellBleedDetector,
21    ToolResult, capability::capability_matches,
22};
23
24use crate::automation::{self, AutomationBackend, UiSelector};
25use crate::mcp::McpClient;
26
27/// Context passed to every tool execution.
28pub struct ToolExecutionContext {
29    /// Working directory for filesystem and shell operations.
30    pub working_dir: PathBuf,
31    /// The fighter invoking the tool.
32    pub fighter_id: FighterId,
33    /// Memory substrate for memory/knowledge tools.
34    pub memory: Arc<MemorySubstrate>,
35    /// Optional agent coordinator for inter-agent tools (agent_spawn, agent_message, agent_list).
36    /// This is `None` when the fighter does not have agent coordination capabilities.
37    pub coordinator: Option<Arc<dyn AgentCoordinator>>,
38    /// Optional policy engine for approval-gated tool execution.
39    /// When present, every tool call is checked against the configured policies
40    /// before dispatching. The referee must approve the move.
41    pub approval_engine: Option<Arc<PolicyEngine>>,
42    /// Optional subprocess sandbox (containment ring) for shell and filesystem tools.
43    /// When present, commands are validated and environments are sanitized before execution.
44    pub sandbox: Option<Arc<SandboxEnforcer>>,
45    /// Optional shell bleed detector — scans shell commands for leaked secrets
46    /// before the move lands. If a Secret or Confidential bleed is detected,
47    /// the command is blocked.
48    pub bleed_detector: Option<Arc<ShellBleedDetector>>,
49    /// Optional browser session pool for browser automation tools.
50    /// When present, browser scouting moves (navigate, screenshot, click, etc.)
51    /// can manage sessions through the pool. The actual CDP driver is plugged in
52    /// separately — without it, browser tools report "browser not available".
53    pub browser_pool: Option<Arc<BrowserPool>>,
54    /// Optional plugin registry for WASM plugin invocation.
55    /// When present, the `wasm_invoke` tool can dispatch calls to loaded
56    /// WASM plugins (imported techniques). Without it, the tool reports
57    /// "plugin runtime not configured".
58    pub plugin_registry: Option<Arc<PluginRegistry>>,
59    /// Active MCP server clients, keyed by server name.
60    /// When present, tools prefixed with `mcp_{server}_` are routed to the
61    /// corresponding MCP server for execution.
62    pub mcp_clients: Option<Arc<DashMap<String, Arc<McpClient>>>>,
63    /// Optional channel notifier for proactive outbound messaging.
64    /// When present, the `channel_notify` tool can send messages to
65    /// connected channels (Telegram, Slack, Discord, etc.).
66    pub channel_notifier: Option<Arc<dyn ChannelNotifier>>,
67    /// Optional desktop automation backend for screenshot, OCR, and UI tools.
68    /// When present, automation tools (sys_screenshot, ui_click, etc.) can
69    /// interact with the desktop. Without it, automation tools report
70    /// "automation backend not configured".
71    pub automation_backend: Option<Arc<dyn AutomationBackend>>,
72}
73
74/// Default per-tool timeout in seconds.
75const DEFAULT_TIMEOUT_SECS: u64 = 120;
76
77/// Execute a tool by name with the given input, checking capabilities first.
78///
79/// Returns a [`ToolResult`] with success/failure status and output.
80#[instrument(skip(input, capabilities, context), fields(tool = %name, fighter = %context.fighter_id))]
81pub async fn execute_tool(
82    name: &str,
83    input: &serde_json::Value,
84    capabilities: &[Capability],
85    context: &ToolExecutionContext,
86) -> PunchResult<ToolResult> {
87    let start = Instant::now();
88
89    let result = tokio::time::timeout(
90        std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS),
91        execute_tool_inner(name, input, capabilities, context),
92    )
93    .await;
94
95    let duration_ms = start.elapsed().as_millis() as u64;
96
97    match result {
98        Ok(Ok(mut tool_result)) => {
99            tool_result.duration_ms = duration_ms;
100            Ok(tool_result)
101        }
102        Ok(Err(e)) => Ok(ToolResult {
103            success: false,
104            output: serde_json::json!(null),
105            error: Some(e.to_string()),
106            duration_ms,
107        }),
108        Err(_) => Err(PunchError::ToolTimeout {
109            tool: name.to_string(),
110            timeout_ms: DEFAULT_TIMEOUT_SECS * 1000,
111        }),
112    }
113}
114
115/// Inner dispatch without timeout wrapper.
116async fn execute_tool_inner(
117    name: &str,
118    input: &serde_json::Value,
119    capabilities: &[Capability],
120    context: &ToolExecutionContext,
121) -> PunchResult<ToolResult> {
122    // --- Approval gate: check the referee before throwing the move ---
123    if let Some(ref engine) = context.approval_engine {
124        let decision = engine.evaluate(name, input, &context.fighter_id).await?;
125        match decision {
126            ApprovalDecision::Allow => {
127                // Move approved — proceed to dispatch.
128            }
129            ApprovalDecision::Deny(reason) => {
130                debug!(tool = %name, reason = %reason, "tool call denied by approval policy");
131                return Ok(ToolResult {
132                    success: false,
133                    output: serde_json::json!(null),
134                    error: Some(format!("denied by policy: {}", reason)),
135                    duration_ms: 0,
136                });
137            }
138            ApprovalDecision::NeedsApproval(reason) => {
139                debug!(tool = %name, reason = %reason, "tool call needs approval");
140                return Ok(ToolResult {
141                    success: false,
142                    output: serde_json::json!(null),
143                    error: Some(format!("approval required: {}", reason)),
144                    duration_ms: 0,
145                });
146            }
147        }
148    }
149
150    match name {
151        "file_read" => tool_file_read(input, capabilities, context).await,
152        "file_write" => tool_file_write(input, capabilities, context).await,
153        "file_list" => tool_file_list(input, capabilities, context).await,
154        "shell_exec" => tool_shell_exec(input, capabilities, context).await,
155        "web_search" => tool_web_search(input).await,
156        "web_fetch" => tool_web_fetch(input, capabilities).await,
157        "memory_store" => tool_memory_store(input, capabilities, context).await,
158        "memory_recall" => tool_memory_recall(input, capabilities, context).await,
159        "knowledge_add_entity" => tool_knowledge_add_entity(input, capabilities, context).await,
160        "knowledge_add_relation" => tool_knowledge_add_relation(input, capabilities, context).await,
161        "knowledge_query" => tool_knowledge_query(input, capabilities, context).await,
162        "agent_spawn" => tool_agent_spawn(input, capabilities, context).await,
163        "agent_message" => tool_agent_message(input, capabilities, context).await,
164        "agent_list" => tool_agent_list(capabilities, context).await,
165        "patch_apply" => tool_patch_apply(input, capabilities, context).await,
166        "browser_navigate" => tool_browser_navigate(input, capabilities, context).await,
167        "browser_screenshot" => tool_browser_screenshot(input, capabilities, context).await,
168        "browser_click" => tool_browser_click(input, capabilities, context).await,
169        "browser_type" => tool_browser_type(input, capabilities, context).await,
170        "browser_content" => tool_browser_content(input, capabilities, context).await,
171        // Git / Source Control
172        "git_status" => tool_git_status(input, capabilities, context).await,
173        "git_diff" => tool_git_diff(input, capabilities, context).await,
174        "git_log" => tool_git_log(input, capabilities, context).await,
175        "git_commit" => tool_git_commit(input, capabilities, context).await,
176        "git_branch" => tool_git_branch(input, capabilities, context).await,
177        // Container
178        "docker_ps" => tool_docker_ps(input, capabilities).await,
179        "docker_run" => tool_docker_run(input, capabilities).await,
180        "docker_build" => tool_docker_build(input, capabilities, context).await,
181        "docker_logs" => tool_docker_logs(input, capabilities).await,
182        // HTTP
183        "http_request" => tool_http_request(input, capabilities).await,
184        "http_post" => tool_http_post(input, capabilities).await,
185        // Data manipulation
186        "json_query" => tool_json_query(input, capabilities).await,
187        "json_transform" => tool_json_transform(input, capabilities).await,
188        "yaml_parse" => tool_yaml_parse(input, capabilities).await,
189        "regex_match" => tool_regex_match(input, capabilities).await,
190        "regex_replace" => tool_regex_replace(input, capabilities).await,
191        // Process
192        "process_list" => tool_process_list(input, capabilities, context).await,
193        "process_kill" => tool_process_kill(input, capabilities).await,
194        // Schedule
195        "schedule_task" => tool_schedule_task(input, capabilities, context).await,
196        "schedule_list" => tool_schedule_list(capabilities).await,
197        "schedule_cancel" => tool_schedule_cancel(input, capabilities).await,
198        // Code analysis
199        "code_search" => tool_code_search(input, capabilities, context).await,
200        "code_symbols" => tool_code_symbols(input, capabilities, context).await,
201        // Archive
202        "archive_create" => tool_archive_create(input, capabilities, context).await,
203        "archive_extract" => tool_archive_extract(input, capabilities, context).await,
204        "archive_list" => tool_archive_list(input, capabilities, context).await,
205        // Template
206        "template_render" => tool_template_render(input, capabilities).await,
207        // Crypto / Hash
208        "hash_compute" => tool_hash_compute(input, capabilities, context).await,
209        "hash_verify" => tool_hash_verify(input, capabilities, context).await,
210        // Environment
211        "env_get" => tool_env_get(input, capabilities).await,
212        "env_list" => tool_env_list(input, capabilities).await,
213        // Text
214        "text_diff" => tool_text_diff(input, capabilities).await,
215        "text_count" => tool_text_count(input, capabilities).await,
216        // File (extended)
217        "file_search" => tool_file_search(input, capabilities, context).await,
218        "file_info" => tool_file_info(input, capabilities, context).await,
219        // WASM Plugin
220        "wasm_invoke" => tool_wasm_invoke(input, capabilities, context).await,
221        // A2A delegation
222        "a2a_delegate" => tool_a2a_delegate(input, capabilities).await,
223        // Channel notification
224        "channel_notify" => tool_channel_notify(input, capabilities, context).await,
225        // Self-configuration
226        "heartbeat_add" => tool_heartbeat_add(input, capabilities, context).await,
227        "heartbeat_list" => tool_heartbeat_list(capabilities, context).await,
228        "heartbeat_remove" => tool_heartbeat_remove(input, capabilities, context).await,
229        "creed_view" => tool_creed_view(capabilities, context).await,
230        "skill_list" => tool_skill_list(capabilities).await,
231        "skill_recommend" => tool_skill_recommend(input, capabilities).await,
232        // Desktop automation
233        "sys_screenshot" => tool_sys_screenshot(input, capabilities, context).await,
234        "ui_screenshot" => tool_ui_screenshot(input, capabilities, context).await,
235        "app_ocr" => tool_app_ocr(input, capabilities, context).await,
236        "ui_find_elements" => tool_ui_find_elements(input, capabilities, context).await,
237        "ui_click" => tool_ui_click(input, capabilities, context).await,
238        "ui_type_text" => tool_ui_type_text(input, capabilities, context).await,
239        "ui_list_windows" => tool_ui_list_windows(capabilities, context).await,
240        "ui_read_attribute" => tool_ui_read_attribute(input, capabilities, context).await,
241        // MCP server tools — dispatched by `mcp_{server}_{tool}` prefix.
242        _ if name.starts_with("mcp_") => tool_mcp_call(name, input, capabilities, context).await,
243        _ => Err(PunchError::ToolNotFound(name.to_string())),
244    }
245}
246
247// ---------------------------------------------------------------------------
248// Capability helpers
249// ---------------------------------------------------------------------------
250
251/// Check that at least one granted capability satisfies the requirement.
252fn require_capability(capabilities: &[Capability], required: &Capability) -> PunchResult<()> {
253    if capabilities
254        .iter()
255        .any(|granted| capability_matches(granted, required))
256    {
257        Ok(())
258    } else {
259        Err(PunchError::CapabilityDenied(format!(
260            "missing capability: {}",
261            required
262        )))
263    }
264}
265
266/// Resolve a path relative to the working directory.
267fn resolve_path(working_dir: &Path, requested: &str) -> PunchResult<PathBuf> {
268    let path = if Path::new(requested).is_absolute() {
269        PathBuf::from(requested)
270    } else {
271        working_dir.join(requested)
272    };
273
274    Ok(path)
275}
276
277// ---------------------------------------------------------------------------
278// Sensitive path detection
279// ---------------------------------------------------------------------------
280
281/// Paths that are considered sensitive and should be flagged by the bleed
282/// detector when accessed. These are common locations for secrets, credentials,
283/// and private keys.
284static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
285    vec![
286        ".env",
287        ".ssh/",
288        ".gnupg/",
289        ".aws/credentials",
290        ".aws/config",
291        ".npmrc",
292        ".pypirc",
293        ".docker/config.json",
294        ".kube/config",
295        ".netrc",
296        "id_rsa",
297        "id_ed25519",
298        "id_ecdsa",
299        "credentials.json",
300        "service_account.json",
301        "secrets.yaml",
302        "secrets.yml",
303        "secrets.json",
304        "/etc/shadow",
305        "/etc/passwd",
306    ]
307});
308
309/// Check whether a path matches any known sensitive pattern.
310fn is_sensitive_path(path: &str) -> bool {
311    let normalized = path.replace('\\', "/");
312    SENSITIVE_PATH_PATTERNS
313        .iter()
314        .any(|pattern| normalized.contains(pattern))
315}
316
317// ---------------------------------------------------------------------------
318// Channel notification
319// ---------------------------------------------------------------------------
320
321/// Send a proactive message to an external channel (Telegram, Slack, Discord, etc.).
322async fn tool_channel_notify(
323    input: &serde_json::Value,
324    capabilities: &[Capability],
325    context: &ToolExecutionContext,
326) -> PunchResult<ToolResult> {
327    require_capability(capabilities, &Capability::ChannelNotify)?;
328
329    let notifier = context
330        .channel_notifier
331        .as_ref()
332        .ok_or_else(|| PunchError::Tool {
333            tool: "channel_notify".into(),
334            message: "channel notifier not configured — no channel adapters are available".into(),
335        })?;
336
337    let channel = input["channel"].as_str().ok_or_else(|| PunchError::Tool {
338        tool: "channel_notify".into(),
339        message: "missing 'channel' parameter (e.g., \"telegram\", \"discord\", \"slack\")".into(),
340    })?;
341
342    let chat_id = input["chat_id"].as_str().ok_or_else(|| PunchError::Tool {
343        tool: "channel_notify".into(),
344        message: "missing 'chat_id' parameter (the channel/conversation ID to send to)".into(),
345    })?;
346
347    let message = input["message"].as_str().ok_or_else(|| PunchError::Tool {
348        tool: "channel_notify".into(),
349        message: "missing 'message' parameter (the text to send)".into(),
350    })?;
351
352    debug!(
353        channel = %channel,
354        chat_id = %chat_id,
355        message_len = message.len(),
356        "channel_notify: sending proactive message"
357    );
358
359    notifier.notify(channel, chat_id, message).await?;
360
361    Ok(ToolResult {
362        success: true,
363        output: serde_json::json!({
364            "sent": true,
365            "channel": channel,
366            "chat_id": chat_id,
367            "message_length": message.len(),
368        }),
369        error: None,
370        duration_ms: 0,
371    })
372}
373
374// ---------------------------------------------------------------------------
375// MCP tool dispatch
376// ---------------------------------------------------------------------------
377
378/// Route a tool call to the appropriate MCP server.
379///
380/// Tool names follow the convention `mcp_{server}_{tool}`. This function
381/// finds the matching server, checks the `McpAccess` capability, strips the
382/// namespace prefix, and forwards the call.
383async fn tool_mcp_call(
384    name: &str,
385    input: &serde_json::Value,
386    capabilities: &[Capability],
387    context: &ToolExecutionContext,
388) -> PunchResult<ToolResult> {
389    let clients = context.mcp_clients.as_ref().ok_or_else(|| {
390        PunchError::ToolNotFound(format!(
391            "MCP tool '{}' requested but no MCP servers are configured",
392            name
393        ))
394    })?;
395
396    // Find the matching MCP server by trying each client's strip_namespace.
397    let mut matched_client: Option<Arc<McpClient>> = None;
398    let mut raw_tool_name: Option<String> = None;
399
400    for entry in clients.iter() {
401        if let Some(stripped) = entry.value().strip_namespace(name) {
402            // Check capability: the fighter must have McpAccess for this server.
403            require_capability(capabilities, &Capability::McpAccess(entry.key().clone()))?;
404            matched_client = Some(Arc::clone(entry.value()));
405            raw_tool_name = Some(stripped.to_string());
406            break;
407        }
408    }
409
410    let client = matched_client.ok_or_else(|| {
411        PunchError::ToolNotFound(format!("no MCP server matches tool '{}'", name))
412    })?;
413    let raw_name = raw_tool_name.unwrap();
414
415    debug!(
416        server = %client.server_name(),
417        tool = %raw_name,
418        "dispatching MCP tool call"
419    );
420
421    match client.call_tool(&raw_name, input.clone()).await {
422        Ok(result) => {
423            // Extract text content from MCP response format.
424            let output = if let Some(content) = result.get("content") {
425                if let Some(arr) = content.as_array() {
426                    arr.iter()
427                        .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
428                        .collect::<Vec<_>>()
429                        .join("\n")
430                } else {
431                    serde_json::to_string_pretty(&result).unwrap_or_default()
432                }
433            } else {
434                serde_json::to_string_pretty(&result).unwrap_or_default()
435            };
436
437            let is_error = result
438                .get("isError")
439                .and_then(|v| v.as_bool())
440                .unwrap_or(false);
441
442            Ok(ToolResult {
443                success: !is_error,
444                output: serde_json::Value::String(output),
445                error: if is_error {
446                    Some("MCP tool returned error".to_string())
447                } else {
448                    None
449                },
450                duration_ms: 0,
451            })
452        }
453        Err(e) => Ok(ToolResult {
454            success: false,
455            output: serde_json::json!(null),
456            error: Some(format!("MCP call failed: {}", e)),
457            duration_ms: 0,
458        }),
459    }
460}
461
462// ---------------------------------------------------------------------------
463// Built-in tool implementations
464// ---------------------------------------------------------------------------
465
466async fn tool_file_read(
467    input: &serde_json::Value,
468    capabilities: &[Capability],
469    context: &ToolExecutionContext,
470) -> PunchResult<ToolResult> {
471    let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
472        tool: "file_read".into(),
473        message: "missing 'path' parameter".into(),
474    })?;
475
476    let path = resolve_path(&context.working_dir, path_str)?;
477    let path_display = path.display().to_string();
478
479    require_capability(capabilities, &Capability::FileRead(path_display.clone()))?;
480
481    // If a sandbox is active, validate the path through the containment ring.
482    if let Some(ref sandbox) = context.sandbox {
483        sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
484            tool: "file_read".into(),
485            message: v.to_string(),
486        })?;
487    }
488
489    // Sensitive path detection: flag reads of known secret/credential locations.
490    if is_sensitive_path(&path_display) {
491        warn!(
492            path = %path_display,
493            fighter = %context.fighter_id,
494            "sensitive path access detected during file_read"
495        );
496
497        // If a bleed detector is active, block reads of sensitive paths.
498        if context.bleed_detector.is_some() {
499            return Ok(ToolResult {
500                success: false,
501                output: serde_json::json!(null),
502                error: Some(format!(
503                    "security: read of sensitive path '{}' blocked by bleed detector",
504                    path_display
505                )),
506                duration_ms: 0,
507            });
508        }
509    }
510
511    // Cap file reads to avoid blowing up context. ~100K chars ≈ ~25K tokens.
512    const MAX_FILE_CHARS: usize = 100_000;
513
514    match tokio::fs::read_to_string(&path).await {
515        Ok(content) => {
516            // Scan file content for leaked secrets if bleed detector is active.
517            if let Some(ref detector) = context.bleed_detector {
518                let warnings = detector.scan_command(&content);
519                let secret_warnings: Vec<_> = warnings
520                    .iter()
521                    .filter(|w| w.severity >= Sensitivity::Confidential)
522                    .collect();
523                if !secret_warnings.is_empty() {
524                    warn!(
525                        path = %path_display,
526                        warning_count = secret_warnings.len(),
527                        "file content contains potential secrets"
528                    );
529                }
530            }
531
532            let total_bytes = content.len();
533            let (output_content, was_truncated) = if content.len() > MAX_FILE_CHARS {
534                let truncated: String = content.chars().take(MAX_FILE_CHARS).collect();
535                (truncated, true)
536            } else {
537                (content, false)
538            };
539
540            debug!(path = %path_display, bytes = total_bytes, truncated = was_truncated, "file read");
541
542            let output = if was_truncated {
543                serde_json::json!(format!(
544                    "{}\n\n--- TRUNCATED: showing first {} of {} bytes. Use shell_exec with head/tail/grep to inspect specific sections. ---",
545                    output_content, MAX_FILE_CHARS, total_bytes
546                ))
547            } else {
548                serde_json::json!(output_content)
549            };
550
551            Ok(ToolResult {
552                success: true,
553                output,
554                error: None,
555                duration_ms: 0,
556            })
557        }
558        Err(e) => Ok(ToolResult {
559            success: false,
560            output: serde_json::json!(null),
561            error: Some(format!("failed to read '{}': {}", path_display, e)),
562            duration_ms: 0,
563        }),
564    }
565}
566
567async fn tool_file_write(
568    input: &serde_json::Value,
569    capabilities: &[Capability],
570    context: &ToolExecutionContext,
571) -> PunchResult<ToolResult> {
572    let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
573        tool: "file_write".into(),
574        message: "missing 'path' parameter".into(),
575    })?;
576    let content = input["content"].as_str().ok_or_else(|| PunchError::Tool {
577        tool: "file_write".into(),
578        message: "missing 'content' parameter".into(),
579    })?;
580
581    let path = resolve_path(&context.working_dir, path_str)?;
582    let path_display = path.display().to_string();
583
584    require_capability(capabilities, &Capability::FileWrite(path_display.clone()))?;
585
586    // If a sandbox is active, validate the path through the containment ring.
587    if let Some(ref sandbox) = context.sandbox {
588        sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
589            tool: "file_write".into(),
590            message: v.to_string(),
591        })?;
592    }
593
594    // Ensure parent directory exists.
595    if let Some(parent) = path.parent()
596        && !parent.exists()
597    {
598        tokio::fs::create_dir_all(parent)
599            .await
600            .map_err(|e| PunchError::Tool {
601                tool: "file_write".into(),
602                message: format!("failed to create directory '{}': {}", parent.display(), e),
603            })?;
604    }
605
606    match tokio::fs::write(&path, content).await {
607        Ok(()) => {
608            debug!(path = %path_display, bytes = content.len(), "file written");
609            Ok(ToolResult {
610                success: true,
611                output: serde_json::json!(format!(
612                    "wrote {} bytes to {}",
613                    content.len(),
614                    path_display
615                )),
616                error: None,
617                duration_ms: 0,
618            })
619        }
620        Err(e) => Ok(ToolResult {
621            success: false,
622            output: serde_json::json!(null),
623            error: Some(format!("failed to write '{}': {}", path_display, e)),
624            duration_ms: 0,
625        }),
626    }
627}
628
629/// Apply a unified diff patch to a file — execute a combo correction move.
630///
631/// Reads the target file, parses the diff, validates it against the current
632/// content, applies the patch, and writes the result back.
633async fn tool_patch_apply(
634    input: &serde_json::Value,
635    capabilities: &[Capability],
636    context: &ToolExecutionContext,
637) -> PunchResult<ToolResult> {
638    let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
639        tool: "patch_apply".into(),
640        message: "missing 'path' parameter".into(),
641    })?;
642    let diff_text = input["diff"].as_str().ok_or_else(|| PunchError::Tool {
643        tool: "patch_apply".into(),
644        message: "missing 'diff' parameter".into(),
645    })?;
646
647    let path = resolve_path(&context.working_dir, path_str)?;
648    let path_display = path.display().to_string();
649
650    // Patch application requires file write capability.
651    require_capability(capabilities, &Capability::FileWrite(path_display.clone()))?;
652
653    // Validate path through sandbox if active.
654    if let Some(ref sandbox) = context.sandbox {
655        sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
656            tool: "patch_apply".into(),
657            message: v.to_string(),
658        })?;
659    }
660
661    // Parse the diff.
662    let patch_set = punch_types::parse_unified_diff(diff_text).map_err(|e| PunchError::Tool {
663        tool: "patch_apply".into(),
664        message: format!("failed to parse diff: {}", e),
665    })?;
666
667    if patch_set.patches.is_empty() {
668        return Ok(ToolResult {
669            success: false,
670            output: serde_json::json!(null),
671            error: Some("diff contains no file patches".into()),
672            duration_ms: 0,
673        });
674    }
675
676    // Use the first patch in the set (the tool operates on a single file).
677    let file_patch = &patch_set.patches[0];
678
679    // Read the current file content (empty string for new files).
680    let original = if file_patch.is_new_file {
681        String::new()
682    } else {
683        tokio::fs::read_to_string(&path)
684            .await
685            .map_err(|e| PunchError::Tool {
686                tool: "patch_apply".into(),
687                message: format!("failed to read '{}': {}", path_display, e),
688            })?
689    };
690
691    // Validate before applying.
692    let conflicts = punch_types::validate_patch(&original, file_patch);
693    if !conflicts.is_empty() {
694        let conflict_desc: Vec<String> = conflicts
695            .iter()
696            .map(|c| {
697                format!(
698                    "hunk {}: line {} — expected {:?}, found {:?} ({:?})",
699                    c.hunk_index + 1,
700                    c.line_number,
701                    c.expected_line,
702                    c.actual_line,
703                    c.conflict_type
704                )
705            })
706            .collect();
707
708        // Try fuzzy application with a small fuzz factor.
709        match punch_types::apply_patch_fuzzy(&original, file_patch, 3) {
710            Ok(patched) => {
711                // Fuzzy succeeded — write with a warning.
712                if let Some(parent) = path.parent()
713                    && !parent.exists()
714                {
715                    tokio::fs::create_dir_all(parent)
716                        .await
717                        .map_err(|e| PunchError::Tool {
718                            tool: "patch_apply".into(),
719                            message: format!(
720                                "failed to create directory '{}': {}",
721                                parent.display(),
722                                e
723                            ),
724                        })?;
725                }
726                tokio::fs::write(&path, &patched)
727                    .await
728                    .map_err(|e| PunchError::Tool {
729                        tool: "patch_apply".into(),
730                        message: format!("failed to write '{}': {}", path_display, e),
731                    })?;
732                debug!(path = %path_display, "patch applied with fuzzy matching");
733                return Ok(ToolResult {
734                    success: true,
735                    output: serde_json::json!(format!(
736                        "patch applied to {} with fuzzy matching (offset adjustments needed). Warnings: {}",
737                        path_display,
738                        conflict_desc.join("; ")
739                    )),
740                    error: None,
741                    duration_ms: 0,
742                });
743            }
744            Err(_) => {
745                return Ok(ToolResult {
746                    success: false,
747                    output: serde_json::json!(null),
748                    error: Some(format!(
749                        "patch conflicts detected: {}",
750                        conflict_desc.join("; ")
751                    )),
752                    duration_ms: 0,
753                });
754            }
755        }
756    }
757
758    // Clean application.
759    let patched =
760        punch_types::apply_patch(&original, file_patch).map_err(|e| PunchError::Tool {
761            tool: "patch_apply".into(),
762            message: format!("failed to apply patch: {}", e),
763        })?;
764
765    // Ensure parent directory exists.
766    if let Some(parent) = path.parent()
767        && !parent.exists()
768    {
769        tokio::fs::create_dir_all(parent)
770            .await
771            .map_err(|e| PunchError::Tool {
772                tool: "patch_apply".into(),
773                message: format!("failed to create directory '{}': {}", parent.display(), e),
774            })?;
775    }
776
777    tokio::fs::write(&path, &patched)
778        .await
779        .map_err(|e| PunchError::Tool {
780            tool: "patch_apply".into(),
781            message: format!("failed to write '{}': {}", path_display, e),
782        })?;
783
784    debug!(path = %path_display, "patch applied cleanly");
785    Ok(ToolResult {
786        success: true,
787        output: serde_json::json!(format!("patch applied cleanly to {}", path_display)),
788        error: None,
789        duration_ms: 0,
790    })
791}
792
793async fn tool_file_list(
794    input: &serde_json::Value,
795    _capabilities: &[Capability],
796    context: &ToolExecutionContext,
797) -> PunchResult<ToolResult> {
798    let path_str = input["path"].as_str().unwrap_or(".");
799    let path = resolve_path(&context.working_dir, path_str)?;
800
801    let mut entries = Vec::new();
802    let mut dir = tokio::fs::read_dir(&path)
803        .await
804        .map_err(|e| PunchError::Tool {
805            tool: "file_list".into(),
806            message: format!("failed to list '{}': {}", path.display(), e),
807        })?;
808
809    while let Some(entry) = dir.next_entry().await.map_err(|e| PunchError::Tool {
810        tool: "file_list".into(),
811        message: format!("failed to read entry: {}", e),
812    })? {
813        let file_type = entry.file_type().await.ok();
814        let is_dir = file_type.as_ref().map(|ft| ft.is_dir()).unwrap_or(false);
815        let name = entry.file_name().to_string_lossy().to_string();
816        entries.push(serde_json::json!({
817            "name": name,
818            "is_directory": is_dir,
819        }));
820    }
821
822    Ok(ToolResult {
823        success: true,
824        output: serde_json::json!(entries),
825        error: None,
826        duration_ms: 0,
827    })
828}
829
830async fn tool_shell_exec(
831    input: &serde_json::Value,
832    capabilities: &[Capability],
833    context: &ToolExecutionContext,
834) -> PunchResult<ToolResult> {
835    let command_str = input["command"].as_str().ok_or_else(|| PunchError::Tool {
836        tool: "shell_exec".into(),
837        message: "missing 'command' parameter".into(),
838    })?;
839
840    require_capability(
841        capabilities,
842        &Capability::ShellExec(command_str.to_string()),
843    )?;
844
845    // Shell bleed detection: scan the command for leaked secrets before the
846    // punch lands. Secret and Confidential bleeds block the move outright.
847    if let Some(ref detector) = context.bleed_detector {
848        let warnings = detector.scan_command(command_str);
849        let blocked: Vec<_> = warnings
850            .iter()
851            .filter(|w| w.severity >= Sensitivity::Confidential)
852            .collect();
853
854        if !blocked.is_empty() {
855            let details: Vec<String> = blocked
856                .iter()
857                .map(|w| {
858                    format!(
859                        "[{}] {} (severity: {})",
860                        w.pattern_name, w.location, w.severity
861                    )
862                })
863                .collect();
864            return Ok(ToolResult {
865                success: false,
866                output: serde_json::json!(null),
867                error: Some(format!(
868                    "shell bleed detected — command blocked: {}",
869                    details.join("; ")
870                )),
871                duration_ms: 0,
872            });
873        }
874
875        // Internal-severity warnings: log but allow execution.
876        for w in &warnings {
877            if w.severity == Sensitivity::Internal {
878                tracing::warn!(
879                    pattern = %w.pattern_name,
880                    location = %w.location,
881                    "shell bleed warning (internal severity) — allowing execution"
882                );
883            }
884        }
885    }
886
887    // Note: Shell execution is capability-gated. The command string comes from
888    // the LLM and is validated via the ShellExec capability pattern before
889    // execution. This is intentional for an agent runtime that needs to run
890    // arbitrary commands on behalf of the user.
891    //
892    // If a sandbox is active, the command enters the containment ring:
893    // validated, environment-sanitized, and directory-restricted.
894    let output = if let Some(ref sandbox) = context.sandbox {
895        let mut cmd = sandbox
896            .build_command(command_str)
897            .map_err(|v| PunchError::Tool {
898                tool: "shell_exec".into(),
899                message: v.to_string(),
900            })?;
901        cmd.current_dir(&context.working_dir);
902        cmd.output().await.map_err(|e| PunchError::Tool {
903            tool: "shell_exec".into(),
904            message: format!("failed to execute command: {}", e),
905        })?
906    } else {
907        Command::new("sh")
908            .arg("-c")
909            .arg(command_str)
910            .current_dir(&context.working_dir)
911            .output()
912            .await
913            .map_err(|e| PunchError::Tool {
914                tool: "shell_exec".into(),
915                message: format!("failed to execute command: {}", e),
916            })?
917    };
918
919    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
920    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
921    let exit_code = output.status.code().unwrap_or(-1);
922
923    debug!(exit_code = exit_code, "shell exec complete");
924
925    // Post-execution output scanning: check stdout and stderr for leaked secrets.
926    if let Some(ref detector) = context.bleed_detector {
927        let stdout_warnings = detector.scan_command(&stdout);
928        let stderr_warnings = detector.scan_command(&stderr);
929
930        let all_warnings: Vec<_> = stdout_warnings
931            .iter()
932            .chain(stderr_warnings.iter())
933            .filter(|w| w.severity >= Sensitivity::Confidential)
934            .collect();
935
936        if !all_warnings.is_empty() {
937            let details: Vec<String> = all_warnings
938                .iter()
939                .map(|w| {
940                    format!(
941                        "[{}] {} (severity: {})",
942                        w.pattern_name, w.location, w.severity
943                    )
944                })
945                .collect();
946            warn!(
947                warning_count = all_warnings.len(),
948                details = %details.join("; "),
949                "shell output contains potential secrets — flagging security event"
950            );
951        }
952    }
953
954    Ok(ToolResult {
955        success: output.status.success(),
956        output: serde_json::json!({
957            "stdout": stdout,
958            "stderr": stderr,
959            "exit_code": exit_code,
960        }),
961        error: if output.status.success() {
962            None
963        } else {
964            Some(format!("command exited with code {}", exit_code))
965        },
966        duration_ms: 0,
967    })
968}
969
970async fn tool_web_search(input: &serde_json::Value) -> PunchResult<ToolResult> {
971    let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
972        tool: "web_search".into(),
973        message: "missing 'query' parameter".into(),
974    })?;
975
976    let client = reqwest::Client::builder()
977        .timeout(std::time::Duration::from_secs(15))
978        .redirect(reqwest::redirect::Policy::limited(5))
979        .build()
980        .map_err(|e| PunchError::Tool {
981            tool: "web_search".into(),
982            message: format!("failed to create HTTP client: {}", e),
983        })?;
984
985    let url = format!(
986        "https://html.duckduckgo.com/html/?q={}",
987        urlencoding::encode(query)
988    );
989
990    let response = client
991        .get(&url)
992        .header("User-Agent", "Mozilla/5.0 (compatible; PunchAgent/1.0)")
993        .send()
994        .await
995        .map_err(|e| PunchError::Tool {
996            tool: "web_search".into(),
997            message: format!("search request failed: {}", e),
998        })?;
999
1000    let body = response.text().await.map_err(|e| PunchError::Tool {
1001        tool: "web_search".into(),
1002        message: format!("failed to read search response: {}", e),
1003    })?;
1004
1005    let results = parse_duckduckgo_results(&body);
1006
1007    Ok(ToolResult {
1008        success: true,
1009        output: serde_json::json!(results),
1010        error: None,
1011        duration_ms: 0,
1012    })
1013}
1014
1015/// Parse DuckDuckGo HTML search results to extract titles and URLs.
1016fn parse_duckduckgo_results(html: &str) -> Vec<serde_json::Value> {
1017    let mut results = Vec::new();
1018    let mut remaining = html;
1019
1020    // DuckDuckGo HTML results contain links with class "result__a".
1021    // We parse them with simple string scanning rather than pulling in
1022    // a full HTML parser dependency.
1023    while results.len() < 5 {
1024        // Look for result links: <a rel="nofollow" class="result__a" href="..."
1025        let marker = "class=\"result__a\"";
1026        let Some(pos) = remaining.find(marker) else {
1027            break;
1028        };
1029        remaining = &remaining[pos + marker.len()..];
1030
1031        // Extract href.
1032        let href = if let Some(href_pos) = remaining.find("href=\"") {
1033            let start = href_pos + 6;
1034            let href_rest = &remaining[start..];
1035            if let Some(end) = href_rest.find('"') {
1036                let raw_href = &href_rest[..end];
1037                // DuckDuckGo wraps URLs in a redirect; extract the actual URL.
1038                if let Some(uddg_pos) = raw_href.find("uddg=") {
1039                    let encoded = &raw_href[uddg_pos + 5..];
1040                    let decoded = urlencoding::decode(encoded)
1041                        .unwrap_or_else(|_| encoded.into())
1042                        .to_string();
1043                    // Strip any trailing &rut= parameter.
1044                    decoded.split('&').next().unwrap_or(&decoded).to_string()
1045                } else {
1046                    raw_href.to_string()
1047                }
1048            } else {
1049                continue;
1050            }
1051        } else {
1052            continue;
1053        };
1054
1055        // Extract title text (content between > and </a>).
1056        let title = if let Some(gt_pos) = remaining.find('>') {
1057            let after_gt = &remaining[gt_pos + 1..];
1058            if let Some(end_tag) = after_gt.find("</a>") {
1059                let raw_title = &after_gt[..end_tag];
1060                // Strip any HTML tags from the title text.
1061                strip_html_tags(raw_title).trim().to_string()
1062            } else {
1063                "Untitled".to_string()
1064            }
1065        } else {
1066            "Untitled".to_string()
1067        };
1068
1069        if !title.is_empty() && !href.is_empty() {
1070            results.push(serde_json::json!({
1071                "title": title,
1072                "url": href,
1073            }));
1074        }
1075    }
1076
1077    results
1078}
1079
1080/// Strip HTML tags from a string, returning only text content.
1081fn strip_html_tags(s: &str) -> String {
1082    let mut result = String::with_capacity(s.len());
1083    let mut in_tag = false;
1084    for c in s.chars() {
1085        match c {
1086            '<' => in_tag = true,
1087            '>' => in_tag = false,
1088            _ if !in_tag => result.push(c),
1089            _ => {}
1090        }
1091    }
1092    result
1093}
1094
1095async fn tool_web_fetch(
1096    input: &serde_json::Value,
1097    capabilities: &[Capability],
1098) -> PunchResult<ToolResult> {
1099    let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
1100        tool: "web_fetch".into(),
1101        message: "missing 'url' parameter".into(),
1102    })?;
1103
1104    let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
1105        tool: "web_fetch".into(),
1106        message: format!("invalid URL: {}", e),
1107    })?;
1108
1109    // SSRF protection: block private/loopback IPs.
1110    if let Some(host) = parsed_url.host_str() {
1111        require_capability(capabilities, &Capability::Network(host.to_string()))?;
1112
1113        if let Ok(ip) = host.parse::<IpAddr>()
1114            && is_private_ip(&ip)
1115        {
1116            return Ok(ToolResult {
1117                success: false,
1118                output: serde_json::json!(null),
1119                error: Some(format!(
1120                    "SSRF protection: requests to private IP {} are blocked",
1121                    ip
1122                )),
1123                duration_ms: 0,
1124            });
1125        }
1126
1127        // Also check resolved addresses for hostnames.
1128        if let Ok(addrs) = tokio::net::lookup_host(format!("{}:80", host)).await {
1129            for addr in addrs {
1130                if is_private_ip(&addr.ip()) {
1131                    return Ok(ToolResult {
1132                        success: false,
1133                        output: serde_json::json!(null),
1134                        error: Some(format!(
1135                            "SSRF protection: hostname '{}' resolves to private IP {}",
1136                            host,
1137                            addr.ip()
1138                        )),
1139                        duration_ms: 0,
1140                    });
1141                }
1142            }
1143        }
1144    }
1145
1146    let client = reqwest::Client::builder()
1147        .timeout(std::time::Duration::from_secs(30))
1148        .redirect(reqwest::redirect::Policy::limited(5))
1149        .build()
1150        .map_err(|e| PunchError::Tool {
1151            tool: "web_fetch".into(),
1152            message: format!("failed to create HTTP client: {}", e),
1153        })?;
1154
1155    let response = client
1156        .get(url_str)
1157        .send()
1158        .await
1159        .map_err(|e| PunchError::Tool {
1160            tool: "web_fetch".into(),
1161            message: format!("request failed: {}", e),
1162        })?;
1163
1164    let status = response.status().as_u16();
1165    let body = response.text().await.map_err(|e| PunchError::Tool {
1166        tool: "web_fetch".into(),
1167        message: format!("failed to read response body: {}", e),
1168    })?;
1169
1170    // Truncate very large responses.
1171    let truncated = if body.len() > 100_000 {
1172        format!(
1173            "{}... [truncated, {} total bytes]",
1174            &body[..100_000],
1175            body.len()
1176        )
1177    } else {
1178        body
1179    };
1180
1181    Ok(ToolResult {
1182        success: (200..300).contains(&(status as usize)),
1183        output: serde_json::json!({
1184            "status": status,
1185            "body": truncated,
1186        }),
1187        error: None,
1188        duration_ms: 0,
1189    })
1190}
1191
1192async fn tool_memory_store(
1193    input: &serde_json::Value,
1194    capabilities: &[Capability],
1195    context: &ToolExecutionContext,
1196) -> PunchResult<ToolResult> {
1197    require_capability(capabilities, &Capability::Memory)?;
1198
1199    let key = input["key"].as_str().ok_or_else(|| PunchError::Tool {
1200        tool: "memory_store".into(),
1201        message: "missing 'key' parameter".into(),
1202    })?;
1203    let value = input["value"].as_str().ok_or_else(|| PunchError::Tool {
1204        tool: "memory_store".into(),
1205        message: "missing 'value' parameter".into(),
1206    })?;
1207    let confidence = input["confidence"].as_f64().unwrap_or(0.9);
1208
1209    context
1210        .memory
1211        .store_memory(&context.fighter_id, key, value, confidence)
1212        .await?;
1213
1214    Ok(ToolResult {
1215        success: true,
1216        output: serde_json::json!(format!("stored memory '{}'", key)),
1217        error: None,
1218        duration_ms: 0,
1219    })
1220}
1221
1222async fn tool_memory_recall(
1223    input: &serde_json::Value,
1224    capabilities: &[Capability],
1225    context: &ToolExecutionContext,
1226) -> PunchResult<ToolResult> {
1227    require_capability(capabilities, &Capability::Memory)?;
1228
1229    let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
1230        tool: "memory_recall".into(),
1231        message: "missing 'query' parameter".into(),
1232    })?;
1233    let limit = input["limit"].as_u64().unwrap_or(10) as u32;
1234
1235    let memories = context
1236        .memory
1237        .recall_memories(&context.fighter_id, query, limit)
1238        .await?;
1239
1240    let entries: Vec<serde_json::Value> = memories
1241        .iter()
1242        .map(|m| {
1243            serde_json::json!({
1244                "key": m.key,
1245                "value": m.value,
1246                "confidence": m.confidence,
1247            })
1248        })
1249        .collect();
1250
1251    Ok(ToolResult {
1252        success: true,
1253        output: serde_json::json!(entries),
1254        error: None,
1255        duration_ms: 0,
1256    })
1257}
1258
1259async fn tool_knowledge_add_entity(
1260    input: &serde_json::Value,
1261    capabilities: &[Capability],
1262    context: &ToolExecutionContext,
1263) -> PunchResult<ToolResult> {
1264    require_capability(capabilities, &Capability::KnowledgeGraph)?;
1265
1266    let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1267        tool: "knowledge_add_entity".into(),
1268        message: "missing 'name' parameter".into(),
1269    })?;
1270    let entity_type = input["entity_type"]
1271        .as_str()
1272        .ok_or_else(|| PunchError::Tool {
1273            tool: "knowledge_add_entity".into(),
1274            message: "missing 'entity_type' parameter".into(),
1275        })?;
1276    let properties = input
1277        .get("properties")
1278        .cloned()
1279        .unwrap_or(serde_json::json!({}));
1280
1281    context
1282        .memory
1283        .add_entity(&context.fighter_id, name, entity_type, &properties)
1284        .await?;
1285
1286    Ok(ToolResult {
1287        success: true,
1288        output: serde_json::json!(format!("added entity '{}' ({})", name, entity_type)),
1289        error: None,
1290        duration_ms: 0,
1291    })
1292}
1293
1294async fn tool_knowledge_add_relation(
1295    input: &serde_json::Value,
1296    capabilities: &[Capability],
1297    context: &ToolExecutionContext,
1298) -> PunchResult<ToolResult> {
1299    require_capability(capabilities, &Capability::KnowledgeGraph)?;
1300
1301    let from = input["from"].as_str().ok_or_else(|| PunchError::Tool {
1302        tool: "knowledge_add_relation".into(),
1303        message: "missing 'from' parameter".into(),
1304    })?;
1305    let relation = input["relation"].as_str().ok_or_else(|| PunchError::Tool {
1306        tool: "knowledge_add_relation".into(),
1307        message: "missing 'relation' parameter".into(),
1308    })?;
1309    let to = input["to"].as_str().ok_or_else(|| PunchError::Tool {
1310        tool: "knowledge_add_relation".into(),
1311        message: "missing 'to' parameter".into(),
1312    })?;
1313    let properties = input
1314        .get("properties")
1315        .cloned()
1316        .unwrap_or(serde_json::json!({}));
1317
1318    context
1319        .memory
1320        .add_relation(&context.fighter_id, from, relation, to, &properties)
1321        .await?;
1322
1323    Ok(ToolResult {
1324        success: true,
1325        output: serde_json::json!(format!("{} --[{}]--> {}", from, relation, to)),
1326        error: None,
1327        duration_ms: 0,
1328    })
1329}
1330
1331async fn tool_knowledge_query(
1332    input: &serde_json::Value,
1333    capabilities: &[Capability],
1334    context: &ToolExecutionContext,
1335) -> PunchResult<ToolResult> {
1336    require_capability(capabilities, &Capability::KnowledgeGraph)?;
1337
1338    let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
1339        tool: "knowledge_query".into(),
1340        message: "missing 'query' parameter".into(),
1341    })?;
1342
1343    let entities = context
1344        .memory
1345        .query_entities(&context.fighter_id, query)
1346        .await?;
1347
1348    let entity_results: Vec<serde_json::Value> = entities
1349        .iter()
1350        .map(|e| {
1351            serde_json::json!({
1352                "name": e.name,
1353                "type": e.entity_type,
1354                "properties": e.properties,
1355            })
1356        })
1357        .collect();
1358
1359    // Also query relations for any matched entity.
1360    let mut all_relations = Vec::new();
1361    for entity in &entities {
1362        let relations = context
1363            .memory
1364            .query_relations(&context.fighter_id, &entity.name)
1365            .await?;
1366        for r in relations {
1367            all_relations.push(serde_json::json!({
1368                "from": r.from_entity,
1369                "relation": r.relation,
1370                "to": r.to_entity,
1371                "properties": r.properties,
1372            }));
1373        }
1374    }
1375
1376    Ok(ToolResult {
1377        success: true,
1378        output: serde_json::json!({
1379            "entities": entity_results,
1380            "relations": all_relations,
1381        }),
1382        error: None,
1383        duration_ms: 0,
1384    })
1385}
1386
1387// ---------------------------------------------------------------------------
1388// Agent coordination tools
1389// ---------------------------------------------------------------------------
1390
1391/// Helper to get the coordinator or return an error.
1392fn get_coordinator(context: &ToolExecutionContext) -> PunchResult<&dyn AgentCoordinator> {
1393    context
1394        .coordinator
1395        .as_deref()
1396        .ok_or_else(|| PunchError::Tool {
1397            tool: "agent".into(),
1398            message: "agent coordinator not available in this context".into(),
1399        })
1400}
1401
1402async fn tool_agent_spawn(
1403    input: &serde_json::Value,
1404    capabilities: &[Capability],
1405    context: &ToolExecutionContext,
1406) -> PunchResult<ToolResult> {
1407    require_capability(capabilities, &Capability::AgentSpawn)?;
1408
1409    let coordinator = get_coordinator(context)?;
1410
1411    let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1412        tool: "agent_spawn".into(),
1413        message: "missing 'name' parameter".into(),
1414    })?;
1415
1416    let system_prompt = input["system_prompt"]
1417        .as_str()
1418        .ok_or_else(|| PunchError::Tool {
1419            tool: "agent_spawn".into(),
1420            message: "missing 'system_prompt' parameter".into(),
1421        })?;
1422
1423    let description = input["description"]
1424        .as_str()
1425        .unwrap_or("Spawned by another agent");
1426
1427    // Build a manifest for the new fighter. We use sensible defaults
1428    // and let the coordinator (Ring) handle persistence and model config.
1429    use punch_types::{FighterManifest, ModelConfig, Provider, WeightClass};
1430
1431    // Parse capabilities for the child agent if provided.
1432    let child_capabilities: Vec<punch_types::Capability> =
1433        if let Some(caps) = input.get("capabilities") {
1434            serde_json::from_value(caps.clone()).unwrap_or_default()
1435        } else {
1436            Vec::new()
1437        };
1438
1439    let manifest = FighterManifest {
1440        name: name.to_string(),
1441        description: description.to_string(),
1442        model: ModelConfig {
1443            provider: Provider::Ollama,
1444            model: "gpt-oss:20b".to_string(),
1445            api_key_env: None,
1446            base_url: Some("http://localhost:11434".to_string()),
1447            max_tokens: Some(4096),
1448            temperature: Some(0.7),
1449        },
1450        system_prompt: system_prompt.to_string(),
1451        capabilities: child_capabilities,
1452        weight_class: WeightClass::Featherweight,
1453        tenant_id: None,
1454    };
1455
1456    let fighter_id = coordinator.spawn_fighter(manifest).await?;
1457
1458    debug!(fighter_id = %fighter_id, name = %name, "agent_spawn: fighter spawned");
1459
1460    Ok(ToolResult {
1461        success: true,
1462        output: serde_json::json!({
1463            "fighter_id": fighter_id.0.to_string(),
1464            "name": name,
1465        }),
1466        error: None,
1467        duration_ms: 0,
1468    })
1469}
1470
1471async fn tool_agent_message(
1472    input: &serde_json::Value,
1473    capabilities: &[Capability],
1474    context: &ToolExecutionContext,
1475) -> PunchResult<ToolResult> {
1476    require_capability(capabilities, &Capability::AgentMessage)?;
1477
1478    let coordinator = get_coordinator(context)?;
1479
1480    // Accept either "fighter_id" or "name" to identify the target.
1481    let target_id = if let Some(id_str) = input["fighter_id"].as_str() {
1482        let uuid = uuid::Uuid::parse_str(id_str).map_err(|e| PunchError::Tool {
1483            tool: "agent_message".into(),
1484            message: format!("invalid fighter_id '{}': {}", id_str, e),
1485        })?;
1486        punch_types::FighterId(uuid)
1487    } else if let Some(name) = input["name"].as_str() {
1488        coordinator
1489            .find_fighter_by_name(name)
1490            .await?
1491            .ok_or_else(|| PunchError::Tool {
1492                tool: "agent_message".into(),
1493                message: format!("no fighter found with name '{}'", name),
1494            })?
1495    } else {
1496        return Err(PunchError::Tool {
1497            tool: "agent_message".into(),
1498            message: "must provide either 'fighter_id' or 'name' parameter".into(),
1499        });
1500    };
1501
1502    let message = input["message"]
1503        .as_str()
1504        .ok_or_else(|| PunchError::Tool {
1505            tool: "agent_message".into(),
1506            message: "missing 'message' parameter".into(),
1507        })?
1508        .to_string();
1509
1510    debug!(
1511        target = %target_id,
1512        from = %context.fighter_id,
1513        "agent_message: sending inter-agent message"
1514    );
1515
1516    let result = coordinator
1517        .send_message_to_agent(&target_id, message)
1518        .await?;
1519
1520    Ok(ToolResult {
1521        success: true,
1522        output: serde_json::json!({
1523            "response": result.response,
1524            "tokens_used": result.tokens_used,
1525        }),
1526        error: None,
1527        duration_ms: 0,
1528    })
1529}
1530
1531async fn tool_agent_list(
1532    capabilities: &[Capability],
1533    context: &ToolExecutionContext,
1534) -> PunchResult<ToolResult> {
1535    require_capability(capabilities, &Capability::AgentMessage)?;
1536
1537    let coordinator = get_coordinator(context)?;
1538
1539    let agents = coordinator.list_fighters().await?;
1540
1541    let agent_list: Vec<serde_json::Value> = agents
1542        .iter()
1543        .map(|a| {
1544            serde_json::json!({
1545                "id": a.id.0.to_string(),
1546                "name": a.name,
1547                "status": format!("{}", a.status),
1548            })
1549        })
1550        .collect();
1551
1552    Ok(ToolResult {
1553        success: true,
1554        output: serde_json::json!(agent_list),
1555        error: None,
1556        duration_ms: 0,
1557    })
1558}
1559
1560// ---------------------------------------------------------------------------
1561// SSRF protection
1562// ---------------------------------------------------------------------------
1563
1564/// Check if an IP address is in a private/reserved range.
1565fn is_private_ip(ip: &IpAddr) -> bool {
1566    match ip {
1567        IpAddr::V4(v4) => {
1568            v4.is_loopback()           // 127.0.0.0/8
1569                || v4.is_private()     // 10/8, 172.16/12, 192.168/16
1570                || v4.is_link_local()  // 169.254/16
1571                || v4.is_broadcast()   // 255.255.255.255
1572                || v4.is_unspecified() // 0.0.0.0
1573        }
1574        IpAddr::V6(v6) => {
1575            v6.is_loopback()           // ::1
1576                || v6.is_unspecified() // ::
1577        }
1578    }
1579}
1580
1581// ---------------------------------------------------------------------------
1582// Browser automation tool handlers — ring-side scouting moves
1583// ---------------------------------------------------------------------------
1584
1585/// Helper: check BrowserControl capability and verify the browser pool is available.
1586fn require_browser_pool<'a>(
1587    capabilities: &[Capability],
1588    context: &'a ToolExecutionContext,
1589) -> PunchResult<&'a Arc<BrowserPool>> {
1590    require_capability(capabilities, &Capability::BrowserControl)?;
1591    context
1592        .browser_pool
1593        .as_ref()
1594        .ok_or_else(|| PunchError::Tool {
1595            tool: "browser".into(),
1596            message: "browser not available — no CDP driver configured".into(),
1597        })
1598}
1599
1600async fn tool_browser_navigate(
1601    input: &serde_json::Value,
1602    capabilities: &[Capability],
1603    context: &ToolExecutionContext,
1604) -> PunchResult<ToolResult> {
1605    let _pool = require_browser_pool(capabilities, context)?;
1606
1607    let url = input["url"].as_str().ok_or_else(|| PunchError::Tool {
1608        tool: "browser_navigate".into(),
1609        message: "missing 'url' parameter".into(),
1610    })?;
1611
1612    debug!(url = %url, "browser_navigate requested (no CDP driver)");
1613
1614    Ok(ToolResult {
1615        success: false,
1616        output: serde_json::json!({
1617            "action": "navigate",
1618            "url": url,
1619            "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable navigation"
1620        }),
1621        error: Some("no CDP driver configured".into()),
1622        duration_ms: 0,
1623    })
1624}
1625
1626async fn tool_browser_screenshot(
1627    input: &serde_json::Value,
1628    capabilities: &[Capability],
1629    context: &ToolExecutionContext,
1630) -> PunchResult<ToolResult> {
1631    let _pool = require_browser_pool(capabilities, context)?;
1632
1633    let full_page = input["full_page"].as_bool().unwrap_or(false);
1634
1635    debug!(full_page = %full_page, "browser_screenshot requested (no CDP driver)");
1636
1637    Ok(ToolResult {
1638        success: false,
1639        output: serde_json::json!({
1640            "action": "screenshot",
1641            "full_page": full_page,
1642            "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable screenshots"
1643        }),
1644        error: Some("no CDP driver configured".into()),
1645        duration_ms: 0,
1646    })
1647}
1648
1649async fn tool_browser_click(
1650    input: &serde_json::Value,
1651    capabilities: &[Capability],
1652    context: &ToolExecutionContext,
1653) -> PunchResult<ToolResult> {
1654    let _pool = require_browser_pool(capabilities, context)?;
1655
1656    let selector = input["selector"].as_str().ok_or_else(|| PunchError::Tool {
1657        tool: "browser_click".into(),
1658        message: "missing 'selector' parameter".into(),
1659    })?;
1660
1661    debug!(selector = %selector, "browser_click requested (no CDP driver)");
1662
1663    Ok(ToolResult {
1664        success: false,
1665        output: serde_json::json!({
1666            "action": "click",
1667            "selector": selector,
1668            "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable clicking"
1669        }),
1670        error: Some("no CDP driver configured".into()),
1671        duration_ms: 0,
1672    })
1673}
1674
1675async fn tool_browser_type(
1676    input: &serde_json::Value,
1677    capabilities: &[Capability],
1678    context: &ToolExecutionContext,
1679) -> PunchResult<ToolResult> {
1680    let _pool = require_browser_pool(capabilities, context)?;
1681
1682    let selector = input["selector"].as_str().ok_or_else(|| PunchError::Tool {
1683        tool: "browser_type".into(),
1684        message: "missing 'selector' parameter".into(),
1685    })?;
1686    let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
1687        tool: "browser_type".into(),
1688        message: "missing 'text' parameter".into(),
1689    })?;
1690
1691    debug!(selector = %selector, text_len = text.len(), "browser_type requested (no CDP driver)");
1692
1693    Ok(ToolResult {
1694        success: false,
1695        output: serde_json::json!({
1696            "action": "type",
1697            "selector": selector,
1698            "text_length": text.len(),
1699            "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable typing"
1700        }),
1701        error: Some("no CDP driver configured".into()),
1702        duration_ms: 0,
1703    })
1704}
1705
1706async fn tool_browser_content(
1707    input: &serde_json::Value,
1708    capabilities: &[Capability],
1709    context: &ToolExecutionContext,
1710) -> PunchResult<ToolResult> {
1711    let _pool = require_browser_pool(capabilities, context)?;
1712
1713    let selector = input["selector"].as_str();
1714
1715    debug!(selector = ?selector, "browser_content requested (no CDP driver)");
1716
1717    Ok(ToolResult {
1718        success: false,
1719        output: serde_json::json!({
1720            "action": "get_content",
1721            "selector": selector,
1722            "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable content extraction"
1723        }),
1724        error: Some("no CDP driver configured".into()),
1725        duration_ms: 0,
1726    })
1727}
1728
1729// ---------------------------------------------------------------------------
1730// Git / Source Control tool implementations
1731// ---------------------------------------------------------------------------
1732
1733async fn tool_git_status(
1734    _input: &serde_json::Value,
1735    capabilities: &[Capability],
1736    context: &ToolExecutionContext,
1737) -> PunchResult<ToolResult> {
1738    require_capability(capabilities, &Capability::SourceControl)?;
1739
1740    let output = Command::new("git")
1741        .args(["status", "--porcelain"])
1742        .current_dir(&context.working_dir)
1743        .output()
1744        .await
1745        .map_err(|e| PunchError::Tool {
1746            tool: "git_status".into(),
1747            message: format!("failed to run git status: {}", e),
1748        })?;
1749
1750    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1751    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1752
1753    Ok(ToolResult {
1754        success: output.status.success(),
1755        output: serde_json::json!({
1756            "status": stdout,
1757            "stderr": stderr,
1758        }),
1759        error: if output.status.success() {
1760            None
1761        } else {
1762            Some(stderr)
1763        },
1764        duration_ms: 0,
1765    })
1766}
1767
1768async fn tool_git_diff(
1769    input: &serde_json::Value,
1770    capabilities: &[Capability],
1771    context: &ToolExecutionContext,
1772) -> PunchResult<ToolResult> {
1773    require_capability(capabilities, &Capability::SourceControl)?;
1774
1775    let staged = input["staged"].as_bool().unwrap_or(false);
1776    let mut args = vec!["diff".to_string()];
1777    if staged {
1778        args.push("--staged".to_string());
1779    }
1780    if let Some(path) = input["path"].as_str() {
1781        args.push("--".to_string());
1782        args.push(path.to_string());
1783    }
1784
1785    let output = Command::new("git")
1786        .args(&args)
1787        .current_dir(&context.working_dir)
1788        .output()
1789        .await
1790        .map_err(|e| PunchError::Tool {
1791            tool: "git_diff".into(),
1792            message: format!("failed to run git diff: {}", e),
1793        })?;
1794
1795    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1796    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1797
1798    Ok(ToolResult {
1799        success: output.status.success(),
1800        output: serde_json::json!(stdout),
1801        error: if output.status.success() {
1802            None
1803        } else {
1804            Some(stderr)
1805        },
1806        duration_ms: 0,
1807    })
1808}
1809
1810async fn tool_git_log(
1811    input: &serde_json::Value,
1812    capabilities: &[Capability],
1813    context: &ToolExecutionContext,
1814) -> PunchResult<ToolResult> {
1815    require_capability(capabilities, &Capability::SourceControl)?;
1816
1817    let count = input["count"].as_u64().unwrap_or(10);
1818    let output = Command::new("git")
1819        .args(["log", "--oneline", "-n", &count.to_string()])
1820        .current_dir(&context.working_dir)
1821        .output()
1822        .await
1823        .map_err(|e| PunchError::Tool {
1824            tool: "git_log".into(),
1825            message: format!("failed to run git log: {}", e),
1826        })?;
1827
1828    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1829    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1830
1831    Ok(ToolResult {
1832        success: output.status.success(),
1833        output: serde_json::json!(stdout),
1834        error: if output.status.success() {
1835            None
1836        } else {
1837            Some(stderr)
1838        },
1839        duration_ms: 0,
1840    })
1841}
1842
1843async fn tool_git_commit(
1844    input: &serde_json::Value,
1845    capabilities: &[Capability],
1846    context: &ToolExecutionContext,
1847) -> PunchResult<ToolResult> {
1848    require_capability(capabilities, &Capability::SourceControl)?;
1849
1850    let message = input["message"].as_str().ok_or_else(|| PunchError::Tool {
1851        tool: "git_commit".into(),
1852        message: "missing 'message' parameter".into(),
1853    })?;
1854
1855    // Stage files if specified.
1856    if let Some(files) = input["files"].as_array() {
1857        let file_args: Vec<&str> = files.iter().filter_map(|f| f.as_str()).collect();
1858        if !file_args.is_empty() {
1859            let mut add_args = vec!["add"];
1860            add_args.extend(file_args);
1861            let add_output = Command::new("git")
1862                .args(&add_args)
1863                .current_dir(&context.working_dir)
1864                .output()
1865                .await
1866                .map_err(|e| PunchError::Tool {
1867                    tool: "git_commit".into(),
1868                    message: format!("failed to stage files: {}", e),
1869                })?;
1870
1871            if !add_output.status.success() {
1872                let stderr = String::from_utf8_lossy(&add_output.stderr);
1873                return Ok(ToolResult {
1874                    success: false,
1875                    output: serde_json::json!(null),
1876                    error: Some(format!("git add failed: {}", stderr)),
1877                    duration_ms: 0,
1878                });
1879            }
1880        }
1881    }
1882
1883    let output = Command::new("git")
1884        .args(["commit", "-m", message])
1885        .current_dir(&context.working_dir)
1886        .output()
1887        .await
1888        .map_err(|e| PunchError::Tool {
1889            tool: "git_commit".into(),
1890            message: format!("failed to run git commit: {}", e),
1891        })?;
1892
1893    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1894    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1895
1896    Ok(ToolResult {
1897        success: output.status.success(),
1898        output: serde_json::json!(stdout),
1899        error: if output.status.success() {
1900            None
1901        } else {
1902            Some(stderr)
1903        },
1904        duration_ms: 0,
1905    })
1906}
1907
1908async fn tool_git_branch(
1909    input: &serde_json::Value,
1910    capabilities: &[Capability],
1911    context: &ToolExecutionContext,
1912) -> PunchResult<ToolResult> {
1913    require_capability(capabilities, &Capability::SourceControl)?;
1914
1915    let action = input["action"].as_str().unwrap_or("list");
1916
1917    let output = match action {
1918        "list" => {
1919            Command::new("git")
1920                .args(["branch", "--list"])
1921                .current_dir(&context.working_dir)
1922                .output()
1923                .await
1924        }
1925        "create" => {
1926            let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1927                tool: "git_branch".into(),
1928                message: "missing 'name' parameter for create".into(),
1929            })?;
1930            Command::new("git")
1931                .args(["branch", name])
1932                .current_dir(&context.working_dir)
1933                .output()
1934                .await
1935        }
1936        "switch" => {
1937            let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1938                tool: "git_branch".into(),
1939                message: "missing 'name' parameter for switch".into(),
1940            })?;
1941            Command::new("git")
1942                .args(["checkout", name])
1943                .current_dir(&context.working_dir)
1944                .output()
1945                .await
1946        }
1947        other => {
1948            return Ok(ToolResult {
1949                success: false,
1950                output: serde_json::json!(null),
1951                error: Some(format!(
1952                    "unknown action '{}', expected list/create/switch",
1953                    other
1954                )),
1955                duration_ms: 0,
1956            });
1957        }
1958    }
1959    .map_err(|e| PunchError::Tool {
1960        tool: "git_branch".into(),
1961        message: format!("failed to run git branch: {}", e),
1962    })?;
1963
1964    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1965    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1966
1967    Ok(ToolResult {
1968        success: output.status.success(),
1969        output: serde_json::json!(stdout),
1970        error: if output.status.success() {
1971            None
1972        } else {
1973            Some(stderr)
1974        },
1975        duration_ms: 0,
1976    })
1977}
1978
1979// ---------------------------------------------------------------------------
1980// Container tool implementations
1981// ---------------------------------------------------------------------------
1982
1983async fn tool_docker_ps(
1984    input: &serde_json::Value,
1985    capabilities: &[Capability],
1986) -> PunchResult<ToolResult> {
1987    require_capability(capabilities, &Capability::Container)?;
1988
1989    let show_all = input["all"].as_bool().unwrap_or(false);
1990    let mut args = vec![
1991        "ps",
1992        "--format",
1993        "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}",
1994    ];
1995    if show_all {
1996        args.push("-a");
1997    }
1998
1999    let output = Command::new("docker")
2000        .args(&args)
2001        .output()
2002        .await
2003        .map_err(|e| PunchError::Tool {
2004            tool: "docker_ps".into(),
2005            message: format!("failed to run docker ps: {}", e),
2006        })?;
2007
2008    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2009    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2010
2011    let containers: Vec<serde_json::Value> = stdout
2012        .lines()
2013        .filter(|l| !l.is_empty())
2014        .map(|line| {
2015            let parts: Vec<&str> = line.splitn(4, '\t').collect();
2016            serde_json::json!({
2017                "id": parts.first().unwrap_or(&""),
2018                "image": parts.get(1).unwrap_or(&""),
2019                "status": parts.get(2).unwrap_or(&""),
2020                "name": parts.get(3).unwrap_or(&""),
2021            })
2022        })
2023        .collect();
2024
2025    Ok(ToolResult {
2026        success: output.status.success(),
2027        output: serde_json::json!(containers),
2028        error: if output.status.success() {
2029            None
2030        } else {
2031            Some(stderr)
2032        },
2033        duration_ms: 0,
2034    })
2035}
2036
2037async fn tool_docker_run(
2038    input: &serde_json::Value,
2039    capabilities: &[Capability],
2040) -> PunchResult<ToolResult> {
2041    require_capability(capabilities, &Capability::Container)?;
2042
2043    let image = input["image"].as_str().ok_or_else(|| PunchError::Tool {
2044        tool: "docker_run".into(),
2045        message: "missing 'image' parameter".into(),
2046    })?;
2047
2048    let detach = input["detach"].as_bool().unwrap_or(false);
2049    let mut args = vec!["run".to_string()];
2050
2051    if detach {
2052        args.push("-d".to_string());
2053    }
2054
2055    if let Some(name) = input["name"].as_str() {
2056        args.push("--name".to_string());
2057        args.push(name.to_string());
2058    }
2059
2060    if let Some(env) = input["env"].as_object() {
2061        for (key, val) in env {
2062            args.push("-e".to_string());
2063            args.push(format!("{}={}", key, val.as_str().unwrap_or_default()));
2064        }
2065    }
2066
2067    if let Some(ports) = input["ports"].as_array() {
2068        for port in ports {
2069            if let Some(p) = port.as_str() {
2070                args.push("-p".to_string());
2071                args.push(p.to_string());
2072            }
2073        }
2074    }
2075
2076    args.push(image.to_string());
2077
2078    if let Some(cmd) = input["command"].as_str() {
2079        // Split the command string on whitespace for the container command.
2080        for part in cmd.split_whitespace() {
2081            args.push(part.to_string());
2082        }
2083    }
2084
2085    let output = Command::new("docker")
2086        .args(&args)
2087        .output()
2088        .await
2089        .map_err(|e| PunchError::Tool {
2090            tool: "docker_run".into(),
2091            message: format!("failed to run docker run: {}", e),
2092        })?;
2093
2094    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2095    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2096
2097    Ok(ToolResult {
2098        success: output.status.success(),
2099        output: serde_json::json!({
2100            "stdout": stdout.trim(),
2101            "stderr": stderr.trim(),
2102        }),
2103        error: if output.status.success() {
2104            None
2105        } else {
2106            Some(stderr)
2107        },
2108        duration_ms: 0,
2109    })
2110}
2111
2112async fn tool_docker_build(
2113    input: &serde_json::Value,
2114    capabilities: &[Capability],
2115    context: &ToolExecutionContext,
2116) -> PunchResult<ToolResult> {
2117    require_capability(capabilities, &Capability::Container)?;
2118
2119    let build_path = input["path"].as_str().unwrap_or(".");
2120    let resolved_path = resolve_path(&context.working_dir, build_path)?;
2121
2122    let mut args = vec!["build".to_string()];
2123
2124    if let Some(tag) = input["tag"].as_str() {
2125        args.push("-t".to_string());
2126        args.push(tag.to_string());
2127    }
2128
2129    if let Some(dockerfile) = input["dockerfile"].as_str() {
2130        args.push("-f".to_string());
2131        args.push(dockerfile.to_string());
2132    }
2133
2134    args.push(resolved_path.display().to_string());
2135
2136    let output = Command::new("docker")
2137        .args(&args)
2138        .output()
2139        .await
2140        .map_err(|e| PunchError::Tool {
2141            tool: "docker_build".into(),
2142            message: format!("failed to run docker build: {}", e),
2143        })?;
2144
2145    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2146    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2147
2148    // Truncate long build output.
2149    let truncated_stdout = if stdout.len() > 10_000 {
2150        format!("{}... [truncated]", &stdout[..10_000])
2151    } else {
2152        stdout
2153    };
2154
2155    Ok(ToolResult {
2156        success: output.status.success(),
2157        output: serde_json::json!({
2158            "stdout": truncated_stdout,
2159            "stderr": stderr,
2160        }),
2161        error: if output.status.success() {
2162            None
2163        } else {
2164            Some(stderr)
2165        },
2166        duration_ms: 0,
2167    })
2168}
2169
2170async fn tool_docker_logs(
2171    input: &serde_json::Value,
2172    capabilities: &[Capability],
2173) -> PunchResult<ToolResult> {
2174    require_capability(capabilities, &Capability::Container)?;
2175
2176    let container = input["container"]
2177        .as_str()
2178        .ok_or_else(|| PunchError::Tool {
2179            tool: "docker_logs".into(),
2180            message: "missing 'container' parameter".into(),
2181        })?;
2182
2183    let tail = input["tail"].as_u64().unwrap_or(100);
2184
2185    let output = Command::new("docker")
2186        .args(["logs", "--tail", &tail.to_string(), container])
2187        .output()
2188        .await
2189        .map_err(|e| PunchError::Tool {
2190            tool: "docker_logs".into(),
2191            message: format!("failed to run docker logs: {}", e),
2192        })?;
2193
2194    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2195    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2196
2197    Ok(ToolResult {
2198        success: output.status.success(),
2199        output: serde_json::json!({
2200            "logs": format!("{}{}", stdout, stderr),
2201        }),
2202        error: if output.status.success() {
2203            None
2204        } else {
2205            Some(format!("docker logs failed: {}", stderr))
2206        },
2207        duration_ms: 0,
2208    })
2209}
2210
2211// ---------------------------------------------------------------------------
2212// HTTP tool implementations
2213// ---------------------------------------------------------------------------
2214
2215async fn tool_http_request(
2216    input: &serde_json::Value,
2217    capabilities: &[Capability],
2218) -> PunchResult<ToolResult> {
2219    let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
2220        tool: "http_request".into(),
2221        message: "missing 'url' parameter".into(),
2222    })?;
2223
2224    let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
2225        tool: "http_request".into(),
2226        message: format!("invalid URL: {}", e),
2227    })?;
2228
2229    if let Some(host) = parsed_url.host_str() {
2230        require_capability(capabilities, &Capability::Network(host.to_string()))?;
2231    }
2232
2233    let method_str = input["method"].as_str().unwrap_or("GET");
2234    let timeout_secs = input["timeout_secs"].as_u64().unwrap_or(30);
2235
2236    let client = reqwest::Client::builder()
2237        .timeout(std::time::Duration::from_secs(timeout_secs))
2238        .redirect(reqwest::redirect::Policy::limited(5))
2239        .build()
2240        .map_err(|e| PunchError::Tool {
2241            tool: "http_request".into(),
2242            message: format!("failed to create HTTP client: {}", e),
2243        })?;
2244
2245    let method = method_str
2246        .parse::<reqwest::Method>()
2247        .map_err(|e| PunchError::Tool {
2248            tool: "http_request".into(),
2249            message: format!("invalid HTTP method '{}': {}", method_str, e),
2250        })?;
2251
2252    let mut req = client.request(method, url_str);
2253
2254    if let Some(headers) = input["headers"].as_object() {
2255        for (key, val) in headers {
2256            if let Some(v) = val.as_str() {
2257                req = req.header(key.as_str(), v);
2258            }
2259        }
2260    }
2261
2262    if let Some(body) = input["body"].as_str() {
2263        req = req.body(body.to_string());
2264    }
2265
2266    let response = req.send().await.map_err(|e| PunchError::Tool {
2267        tool: "http_request".into(),
2268        message: format!("request failed: {}", e),
2269    })?;
2270
2271    let status = response.status().as_u16();
2272    let resp_headers: HashMap<String, String> = response
2273        .headers()
2274        .iter()
2275        .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
2276        .collect();
2277
2278    let body = response.text().await.map_err(|e| PunchError::Tool {
2279        tool: "http_request".into(),
2280        message: format!("failed to read response body: {}", e),
2281    })?;
2282
2283    let truncated = if body.len() > 100_000 {
2284        format!(
2285            "{}... [truncated, {} total bytes]",
2286            &body[..100_000],
2287            body.len()
2288        )
2289    } else {
2290        body
2291    };
2292
2293    Ok(ToolResult {
2294        success: (200..300).contains(&(status as usize)),
2295        output: serde_json::json!({
2296            "status": status,
2297            "headers": resp_headers,
2298            "body": truncated,
2299        }),
2300        error: None,
2301        duration_ms: 0,
2302    })
2303}
2304
2305async fn tool_http_post(
2306    input: &serde_json::Value,
2307    capabilities: &[Capability],
2308) -> PunchResult<ToolResult> {
2309    let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
2310        tool: "http_post".into(),
2311        message: "missing 'url' parameter".into(),
2312    })?;
2313
2314    let json_body = input.get("json").ok_or_else(|| PunchError::Tool {
2315        tool: "http_post".into(),
2316        message: "missing 'json' parameter".into(),
2317    })?;
2318
2319    let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
2320        tool: "http_post".into(),
2321        message: format!("invalid URL: {}", e),
2322    })?;
2323
2324    if let Some(host) = parsed_url.host_str() {
2325        require_capability(capabilities, &Capability::Network(host.to_string()))?;
2326    }
2327
2328    let client = reqwest::Client::builder()
2329        .timeout(std::time::Duration::from_secs(30))
2330        .redirect(reqwest::redirect::Policy::limited(5))
2331        .build()
2332        .map_err(|e| PunchError::Tool {
2333            tool: "http_post".into(),
2334            message: format!("failed to create HTTP client: {}", e),
2335        })?;
2336
2337    let mut req = client.post(url_str).json(json_body);
2338
2339    if let Some(headers) = input["headers"].as_object() {
2340        for (key, val) in headers {
2341            if let Some(v) = val.as_str() {
2342                req = req.header(key.as_str(), v);
2343            }
2344        }
2345    }
2346
2347    let response = req.send().await.map_err(|e| PunchError::Tool {
2348        tool: "http_post".into(),
2349        message: format!("request failed: {}", e),
2350    })?;
2351
2352    let status = response.status().as_u16();
2353    let body = response.text().await.map_err(|e| PunchError::Tool {
2354        tool: "http_post".into(),
2355        message: format!("failed to read response body: {}", e),
2356    })?;
2357
2358    let truncated = if body.len() > 100_000 {
2359        format!(
2360            "{}... [truncated, {} total bytes]",
2361            &body[..100_000],
2362            body.len()
2363        )
2364    } else {
2365        body
2366    };
2367
2368    Ok(ToolResult {
2369        success: (200..300).contains(&(status as usize)),
2370        output: serde_json::json!({
2371            "status": status,
2372            "body": truncated,
2373        }),
2374        error: None,
2375        duration_ms: 0,
2376    })
2377}
2378
2379// ---------------------------------------------------------------------------
2380// Data manipulation tool implementations
2381// ---------------------------------------------------------------------------
2382
2383/// Traverse a JSON value along a dot-separated path.
2384fn json_path_query(data: &serde_json::Value, path: &str) -> serde_json::Value {
2385    let mut current = data;
2386    for segment in path.split('.') {
2387        if segment.is_empty() {
2388            continue;
2389        }
2390        // Try as array index first.
2391        if let Ok(idx) = segment.parse::<usize>()
2392            && let Some(val) = current.get(idx)
2393        {
2394            current = val;
2395            continue;
2396        }
2397        // Try as object key.
2398        if let Some(val) = current.get(segment) {
2399            current = val;
2400        } else {
2401            return serde_json::json!(null);
2402        }
2403    }
2404    current.clone()
2405}
2406
2407async fn tool_json_query(
2408    input: &serde_json::Value,
2409    capabilities: &[Capability],
2410) -> PunchResult<ToolResult> {
2411    require_capability(capabilities, &Capability::DataManipulation)?;
2412
2413    let path = input["path"].as_str().ok_or_else(|| PunchError::Tool {
2414        tool: "json_query".into(),
2415        message: "missing 'path' parameter".into(),
2416    })?;
2417
2418    let data = input.get("data").ok_or_else(|| PunchError::Tool {
2419        tool: "json_query".into(),
2420        message: "missing 'data' parameter".into(),
2421    })?;
2422
2423    // If data is a string, try to parse it as JSON.
2424    let parsed_data = if let Some(s) = data.as_str() {
2425        serde_json::from_str(s).unwrap_or_else(|_| serde_json::json!(s))
2426    } else {
2427        data.clone()
2428    };
2429
2430    let result = json_path_query(&parsed_data, path);
2431
2432    Ok(ToolResult {
2433        success: true,
2434        output: result,
2435        error: None,
2436        duration_ms: 0,
2437    })
2438}
2439
2440async fn tool_json_transform(
2441    input: &serde_json::Value,
2442    capabilities: &[Capability],
2443) -> PunchResult<ToolResult> {
2444    require_capability(capabilities, &Capability::DataManipulation)?;
2445
2446    let data = input.get("data").ok_or_else(|| PunchError::Tool {
2447        tool: "json_transform".into(),
2448        message: "missing 'data' parameter".into(),
2449    })?;
2450
2451    // If data is a string, try to parse it as JSON.
2452    let mut parsed_data = if let Some(s) = data.as_str() {
2453        serde_json::from_str(s).unwrap_or_else(|_| serde_json::json!(s))
2454    } else {
2455        data.clone()
2456    };
2457
2458    // Apply key extraction.
2459    if let Some(extract_keys) = input["extract"].as_array() {
2460        let keys: Vec<&str> = extract_keys.iter().filter_map(|k| k.as_str()).collect();
2461        if let Some(arr) = parsed_data.as_array() {
2462            let filtered: Vec<serde_json::Value> = arr
2463                .iter()
2464                .map(|item| {
2465                    let mut obj = serde_json::Map::new();
2466                    for key in &keys {
2467                        if let Some(val) = item.get(*key) {
2468                            obj.insert(key.to_string(), val.clone());
2469                        }
2470                    }
2471                    serde_json::Value::Object(obj)
2472                })
2473                .collect();
2474            parsed_data = serde_json::json!(filtered);
2475        } else if let Some(obj) = parsed_data.as_object() {
2476            let mut result = serde_json::Map::new();
2477            for key in &keys {
2478                if let Some(val) = obj.get(*key) {
2479                    result.insert(key.to_string(), val.clone());
2480                }
2481            }
2482            parsed_data = serde_json::Value::Object(result);
2483        }
2484    }
2485
2486    // Apply key renaming.
2487    if let Some(rename_map) = input["rename"].as_object() {
2488        if let Some(arr) = parsed_data.as_array() {
2489            let renamed: Vec<serde_json::Value> = arr
2490                .iter()
2491                .map(|item| {
2492                    if let Some(obj) = item.as_object() {
2493                        let mut new_obj = serde_json::Map::new();
2494                        for (k, v) in obj {
2495                            let new_key = rename_map.get(k).and_then(|r| r.as_str()).unwrap_or(k);
2496                            new_obj.insert(new_key.to_string(), v.clone());
2497                        }
2498                        serde_json::Value::Object(new_obj)
2499                    } else {
2500                        item.clone()
2501                    }
2502                })
2503                .collect();
2504            parsed_data = serde_json::json!(renamed);
2505        } else if let Some(obj) = parsed_data.as_object() {
2506            let mut new_obj = serde_json::Map::new();
2507            for (k, v) in obj {
2508                let new_key = rename_map.get(k).and_then(|r| r.as_str()).unwrap_or(k);
2509                new_obj.insert(new_key.to_string(), v.clone());
2510            }
2511            parsed_data = serde_json::Value::Object(new_obj);
2512        }
2513    }
2514
2515    // Apply array filtering.
2516    if let Some(filter_key) = input["filter_key"].as_str()
2517        && let Some(filter_value) = input["filter_value"].as_str()
2518        && let Some(arr) = parsed_data.as_array()
2519    {
2520        let filtered: Vec<serde_json::Value> = arr
2521            .iter()
2522            .filter(|item| {
2523                item.get(filter_key)
2524                    .and_then(|v| v.as_str())
2525                    .is_some_and(|s| s == filter_value)
2526            })
2527            .cloned()
2528            .collect();
2529        parsed_data = serde_json::json!(filtered);
2530    }
2531
2532    Ok(ToolResult {
2533        success: true,
2534        output: parsed_data,
2535        error: None,
2536        duration_ms: 0,
2537    })
2538}
2539
2540async fn tool_yaml_parse(
2541    input: &serde_json::Value,
2542    capabilities: &[Capability],
2543) -> PunchResult<ToolResult> {
2544    require_capability(capabilities, &Capability::DataManipulation)?;
2545
2546    let content = input["content"].as_str().ok_or_else(|| PunchError::Tool {
2547        tool: "yaml_parse".into(),
2548        message: "missing 'content' parameter".into(),
2549    })?;
2550
2551    let parsed: serde_json::Value =
2552        serde_yaml::from_str(content).map_err(|e| PunchError::Tool {
2553            tool: "yaml_parse".into(),
2554            message: format!("failed to parse YAML: {}", e),
2555        })?;
2556
2557    Ok(ToolResult {
2558        success: true,
2559        output: parsed,
2560        error: None,
2561        duration_ms: 0,
2562    })
2563}
2564
2565async fn tool_regex_match(
2566    input: &serde_json::Value,
2567    capabilities: &[Capability],
2568) -> PunchResult<ToolResult> {
2569    require_capability(capabilities, &Capability::DataManipulation)?;
2570
2571    let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2572        tool: "regex_match".into(),
2573        message: "missing 'pattern' parameter".into(),
2574    })?;
2575    let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
2576        tool: "regex_match".into(),
2577        message: "missing 'text' parameter".into(),
2578    })?;
2579    let global = input["global"].as_bool().unwrap_or(false);
2580
2581    let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2582        tool: "regex_match".into(),
2583        message: format!("invalid regex: {}", e),
2584    })?;
2585
2586    if global {
2587        let matches: Vec<serde_json::Value> = re
2588            .captures_iter(text)
2589            .map(|cap| {
2590                let groups: Vec<serde_json::Value> = cap
2591                    .iter()
2592                    .map(|m| m.map_or(serde_json::json!(null), |m| serde_json::json!(m.as_str())))
2593                    .collect();
2594                serde_json::json!(groups)
2595            })
2596            .collect();
2597
2598        Ok(ToolResult {
2599            success: true,
2600            output: serde_json::json!({ "matches": matches }),
2601            error: None,
2602            duration_ms: 0,
2603        })
2604    } else if let Some(cap) = re.captures(text) {
2605        let groups: Vec<serde_json::Value> = cap
2606            .iter()
2607            .map(|m| m.map_or(serde_json::json!(null), |m| serde_json::json!(m.as_str())))
2608            .collect();
2609
2610        Ok(ToolResult {
2611            success: true,
2612            output: serde_json::json!({ "matched": true, "groups": groups }),
2613            error: None,
2614            duration_ms: 0,
2615        })
2616    } else {
2617        Ok(ToolResult {
2618            success: true,
2619            output: serde_json::json!({ "matched": false, "groups": [] }),
2620            error: None,
2621            duration_ms: 0,
2622        })
2623    }
2624}
2625
2626async fn tool_regex_replace(
2627    input: &serde_json::Value,
2628    capabilities: &[Capability],
2629) -> PunchResult<ToolResult> {
2630    require_capability(capabilities, &Capability::DataManipulation)?;
2631
2632    let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2633        tool: "regex_replace".into(),
2634        message: "missing 'pattern' parameter".into(),
2635    })?;
2636    let replacement = input["replacement"]
2637        .as_str()
2638        .ok_or_else(|| PunchError::Tool {
2639            tool: "regex_replace".into(),
2640            message: "missing 'replacement' parameter".into(),
2641        })?;
2642    let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
2643        tool: "regex_replace".into(),
2644        message: "missing 'text' parameter".into(),
2645    })?;
2646
2647    let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2648        tool: "regex_replace".into(),
2649        message: format!("invalid regex: {}", e),
2650    })?;
2651
2652    let result = re.replace_all(text, replacement).to_string();
2653
2654    Ok(ToolResult {
2655        success: true,
2656        output: serde_json::json!(result),
2657        error: None,
2658        duration_ms: 0,
2659    })
2660}
2661
2662// ---------------------------------------------------------------------------
2663// Process tool implementations
2664// ---------------------------------------------------------------------------
2665
2666async fn tool_process_list(
2667    input: &serde_json::Value,
2668    capabilities: &[Capability],
2669    context: &ToolExecutionContext,
2670) -> PunchResult<ToolResult> {
2671    require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
2672
2673    let filter = input["filter"].as_str();
2674
2675    // Use `ps` to get process list in a portable manner.
2676    let output = Command::new("ps")
2677        .args(["aux"])
2678        .current_dir(&context.working_dir)
2679        .output()
2680        .await
2681        .map_err(|e| PunchError::Tool {
2682            tool: "process_list".into(),
2683            message: format!("failed to run ps: {}", e),
2684        })?;
2685
2686    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2687    let lines: Vec<&str> = stdout.lines().collect();
2688
2689    let header = lines.first().copied().unwrap_or("");
2690    let processes: Vec<serde_json::Value> = lines
2691        .iter()
2692        .skip(1)
2693        .filter(|line| {
2694            if let Some(f) = filter {
2695                line.contains(f)
2696            } else {
2697                true
2698            }
2699        })
2700        .take(100) // Limit output
2701        .map(|line| {
2702            let parts: Vec<&str> = line.split_whitespace().collect();
2703            serde_json::json!({
2704                "user": parts.first().unwrap_or(&""),
2705                "pid": parts.get(1).unwrap_or(&""),
2706                "cpu": parts.get(2).unwrap_or(&""),
2707                "mem": parts.get(3).unwrap_or(&""),
2708                "command": parts.get(10..).map(|s| s.join(" ")).unwrap_or_default(),
2709            })
2710        })
2711        .collect();
2712
2713    Ok(ToolResult {
2714        success: output.status.success(),
2715        output: serde_json::json!({
2716            "header": header,
2717            "processes": processes,
2718            "count": processes.len(),
2719        }),
2720        error: None,
2721        duration_ms: 0,
2722    })
2723}
2724
2725async fn tool_process_kill(
2726    input: &serde_json::Value,
2727    capabilities: &[Capability],
2728) -> PunchResult<ToolResult> {
2729    require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
2730
2731    let pid = input["pid"].as_u64().ok_or_else(|| PunchError::Tool {
2732        tool: "process_kill".into(),
2733        message: "missing 'pid' parameter".into(),
2734    })?;
2735
2736    let signal = input["signal"].as_str().unwrap_or("TERM");
2737
2738    let output = Command::new("kill")
2739        .args([&format!("-{}", signal), &pid.to_string()])
2740        .output()
2741        .await
2742        .map_err(|e| PunchError::Tool {
2743            tool: "process_kill".into(),
2744            message: format!("failed to run kill: {}", e),
2745        })?;
2746
2747    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2748
2749    Ok(ToolResult {
2750        success: output.status.success(),
2751        output: serde_json::json!({
2752            "pid": pid,
2753            "signal": signal,
2754            "killed": output.status.success(),
2755        }),
2756        error: if output.status.success() {
2757            None
2758        } else {
2759            Some(format!("kill failed: {}", stderr))
2760        },
2761        duration_ms: 0,
2762    })
2763}
2764
2765// ---------------------------------------------------------------------------
2766// Schedule tool implementations (in-memory DashMap scheduler)
2767// ---------------------------------------------------------------------------
2768
2769/// Represents a scheduled task entry.
2770#[derive(Clone, Debug, serde::Serialize)]
2771struct ScheduledTask {
2772    id: String,
2773    name: String,
2774    command: String,
2775    delay_secs: u64,
2776    interval_secs: Option<u64>,
2777    status: String,
2778}
2779
2780/// Global in-memory task registry.
2781static SCHEDULED_TASKS: LazyLock<DashMap<String, ScheduledTask>> = LazyLock::new(DashMap::new);
2782
2783/// Global cancellation token registry.
2784static TASK_CANCELLERS: LazyLock<DashMap<String, tokio::sync::watch::Sender<bool>>> =
2785    LazyLock::new(DashMap::new);
2786
2787async fn tool_schedule_task(
2788    input: &serde_json::Value,
2789    capabilities: &[Capability],
2790    context: &ToolExecutionContext,
2791) -> PunchResult<ToolResult> {
2792    require_capability(capabilities, &Capability::Schedule)?;
2793
2794    let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
2795        tool: "schedule_task".into(),
2796        message: "missing 'name' parameter".into(),
2797    })?;
2798    let command = input["command"].as_str().ok_or_else(|| PunchError::Tool {
2799        tool: "schedule_task".into(),
2800        message: "missing 'command' parameter".into(),
2801    })?;
2802    let delay_secs = input["delay_secs"]
2803        .as_u64()
2804        .ok_or_else(|| PunchError::Tool {
2805            tool: "schedule_task".into(),
2806            message: "missing 'delay_secs' parameter".into(),
2807        })?;
2808    let interval_secs = input["interval_secs"].as_u64();
2809
2810    let task_id = uuid::Uuid::new_v4().to_string();
2811    let task = ScheduledTask {
2812        id: task_id.clone(),
2813        name: name.to_string(),
2814        command: command.to_string(),
2815        delay_secs,
2816        interval_secs,
2817        status: "scheduled".to_string(),
2818    };
2819
2820    SCHEDULED_TASKS.insert(task_id.clone(), task);
2821
2822    let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
2823    TASK_CANCELLERS.insert(task_id.clone(), cancel_tx);
2824
2825    // Spawn the delayed task.
2826    let task_id_clone = task_id.clone();
2827    let command_owned = command.to_string();
2828    let working_dir = context.working_dir.clone();
2829
2830    tokio::spawn(async move {
2831        tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
2832
2833        loop {
2834            if *cancel_rx.borrow() {
2835                break;
2836            }
2837
2838            // Execute the command.
2839            let _output = Command::new("sh")
2840                .arg("-c")
2841                .arg(&command_owned)
2842                .current_dir(&working_dir)
2843                .output()
2844                .await;
2845
2846            // Update task status.
2847            if let Some(mut entry) = SCHEDULED_TASKS.get_mut(&task_id_clone) {
2848                entry.status = "executed".to_string();
2849            }
2850
2851            // If not recurring, exit.
2852            let Some(interval) = interval_secs else {
2853                break;
2854            };
2855
2856            // Wait for the next interval or cancellation.
2857            tokio::select! {
2858                _ = tokio::time::sleep(std::time::Duration::from_secs(interval)) => {}
2859                _ = cancel_rx.changed() => {
2860                    break;
2861                }
2862            }
2863        }
2864
2865        // Clean up on completion.
2866        if interval_secs.is_none() {
2867            SCHEDULED_TASKS.remove(&task_id_clone);
2868            TASK_CANCELLERS.remove(&task_id_clone);
2869        }
2870    });
2871
2872    Ok(ToolResult {
2873        success: true,
2874        output: serde_json::json!({
2875            "task_id": task_id,
2876            "name": name,
2877            "delay_secs": delay_secs,
2878            "interval_secs": interval_secs,
2879        }),
2880        error: None,
2881        duration_ms: 0,
2882    })
2883}
2884
2885async fn tool_schedule_list(capabilities: &[Capability]) -> PunchResult<ToolResult> {
2886    require_capability(capabilities, &Capability::Schedule)?;
2887
2888    let tasks: Vec<serde_json::Value> = SCHEDULED_TASKS
2889        .iter()
2890        .map(|entry| {
2891            let task = entry.value();
2892            serde_json::json!({
2893                "id": task.id,
2894                "name": task.name,
2895                "command": task.command,
2896                "delay_secs": task.delay_secs,
2897                "interval_secs": task.interval_secs,
2898                "status": task.status,
2899            })
2900        })
2901        .collect();
2902
2903    Ok(ToolResult {
2904        success: true,
2905        output: serde_json::json!(tasks),
2906        error: None,
2907        duration_ms: 0,
2908    })
2909}
2910
2911async fn tool_schedule_cancel(
2912    input: &serde_json::Value,
2913    capabilities: &[Capability],
2914) -> PunchResult<ToolResult> {
2915    require_capability(capabilities, &Capability::Schedule)?;
2916
2917    let task_id = input["task_id"].as_str().ok_or_else(|| PunchError::Tool {
2918        tool: "schedule_cancel".into(),
2919        message: "missing 'task_id' parameter".into(),
2920    })?;
2921
2922    // Send cancellation signal.
2923    if let Some(sender) = TASK_CANCELLERS.get(task_id) {
2924        let _ = sender.send(true);
2925    }
2926
2927    // Remove from registries.
2928    let removed = SCHEDULED_TASKS.remove(task_id).is_some();
2929    TASK_CANCELLERS.remove(task_id);
2930
2931    Ok(ToolResult {
2932        success: removed,
2933        output: serde_json::json!({
2934            "task_id": task_id,
2935            "cancelled": removed,
2936        }),
2937        error: if removed {
2938            None
2939        } else {
2940            Some(format!("task '{}' not found", task_id))
2941        },
2942        duration_ms: 0,
2943    })
2944}
2945
2946// ---------------------------------------------------------------------------
2947// Code analysis tool implementations
2948// ---------------------------------------------------------------------------
2949
2950async fn tool_code_search(
2951    input: &serde_json::Value,
2952    capabilities: &[Capability],
2953    context: &ToolExecutionContext,
2954) -> PunchResult<ToolResult> {
2955    require_capability(capabilities, &Capability::CodeAnalysis)?;
2956
2957    let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2958        tool: "code_search".into(),
2959        message: "missing 'pattern' parameter".into(),
2960    })?;
2961    let search_path = input["path"].as_str().unwrap_or(".");
2962    let file_pattern = input["file_pattern"].as_str();
2963    let max_results = input["max_results"].as_u64().unwrap_or(50) as usize;
2964
2965    let resolved_path = resolve_path(&context.working_dir, search_path)?;
2966
2967    let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2968        tool: "code_search".into(),
2969        message: format!("invalid regex: {}", e),
2970    })?;
2971
2972    let file_glob = file_pattern.and_then(|p| glob::Pattern::new(p).ok());
2973
2974    let mut results = Vec::new();
2975
2976    for entry in walkdir::WalkDir::new(&resolved_path)
2977        .follow_links(false)
2978        .into_iter()
2979        .filter_map(|e| e.ok())
2980    {
2981        if results.len() >= max_results {
2982            break;
2983        }
2984
2985        let path = entry.path();
2986        if !path.is_file() {
2987            continue;
2988        }
2989
2990        // Apply file pattern filter.
2991        if let Some(ref glob_pat) = file_glob
2992            && let Some(name) = path.file_name().and_then(|n| n.to_str())
2993            && !glob_pat.matches(name)
2994        {
2995            continue;
2996        }
2997
2998        // Skip binary files (check first 512 bytes).
2999        let Ok(content) = std::fs::read_to_string(path) else {
3000            continue;
3001        };
3002
3003        for (line_num, line) in content.lines().enumerate() {
3004            if results.len() >= max_results {
3005                break;
3006            }
3007            if re.is_match(line) {
3008                let rel_path = path
3009                    .strip_prefix(&resolved_path)
3010                    .unwrap_or(path)
3011                    .display()
3012                    .to_string();
3013                results.push(serde_json::json!({
3014                    "file": rel_path,
3015                    "line": line_num + 1,
3016                    "text": line.chars().take(200).collect::<String>(),
3017                }));
3018            }
3019        }
3020    }
3021
3022    Ok(ToolResult {
3023        success: true,
3024        output: serde_json::json!({
3025            "matches": results,
3026            "count": results.len(),
3027        }),
3028        error: None,
3029        duration_ms: 0,
3030    })
3031}
3032
3033async fn tool_code_symbols(
3034    input: &serde_json::Value,
3035    capabilities: &[Capability],
3036    context: &ToolExecutionContext,
3037) -> PunchResult<ToolResult> {
3038    require_capability(capabilities, &Capability::CodeAnalysis)?;
3039
3040    let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
3041        tool: "code_symbols".into(),
3042        message: "missing 'path' parameter".into(),
3043    })?;
3044
3045    let path = resolve_path(&context.working_dir, path_str)?;
3046
3047    let content = tokio::fs::read_to_string(&path)
3048        .await
3049        .map_err(|e| PunchError::Tool {
3050            tool: "code_symbols".into(),
3051            message: format!("failed to read '{}': {}", path.display(), e),
3052        })?;
3053
3054    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
3055
3056    // Regex patterns for common languages.
3057    let patterns: Vec<(&str, &str)> = match ext {
3058        "rs" => vec![
3059            ("function", r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)"),
3060            ("struct", r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)"),
3061            ("enum", r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)"),
3062            ("trait", r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)"),
3063            ("impl", r"(?m)^\s*impl(?:<[^>]*>)?\s+(\w+)"),
3064        ],
3065        "py" => vec![
3066            ("function", r"(?m)^\s*def\s+(\w+)"),
3067            ("class", r"(?m)^\s*class\s+(\w+)"),
3068        ],
3069        "js" | "ts" | "jsx" | "tsx" => vec![
3070            (
3071                "function",
3072                r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)",
3073            ),
3074            ("class", r"(?m)^\s*(?:export\s+)?class\s+(\w+)"),
3075            (
3076                "const_fn",
3077                r"(?m)^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>",
3078            ),
3079        ],
3080        "go" => vec![
3081            ("function", r"(?m)^func\s+(?:\([^)]*\)\s+)?(\w+)"),
3082            ("type", r"(?m)^type\s+(\w+)\s+struct"),
3083            ("interface", r"(?m)^type\s+(\w+)\s+interface"),
3084        ],
3085        "java" | "kt" => vec![
3086            (
3087                "class",
3088                r"(?m)^\s*(?:public|private|protected)?\s*(?:static\s+)?class\s+(\w+)",
3089            ),
3090            (
3091                "method",
3092                r"(?m)^\s*(?:public|private|protected)?\s*(?:static\s+)?\w+\s+(\w+)\s*\(",
3093            ),
3094        ],
3095        _ => vec![
3096            (
3097                "function",
3098                r"(?m)^\s*(?:pub\s+)?(?:async\s+)?(?:fn|function|def)\s+(\w+)",
3099            ),
3100            ("class", r"(?m)^\s*(?:pub\s+)?(?:class|struct|enum)\s+(\w+)"),
3101        ],
3102    };
3103
3104    let mut symbols = Vec::new();
3105
3106    for (kind, pattern) in patterns {
3107        if let Ok(re) = regex::Regex::new(pattern) {
3108            for cap in re.captures_iter(&content) {
3109                if let Some(name_match) = cap.get(1) {
3110                    // Find line number.
3111                    let byte_offset = name_match.start();
3112                    let line_num = content[..byte_offset].matches('\n').count() + 1;
3113                    symbols.push(serde_json::json!({
3114                        "kind": kind,
3115                        "name": name_match.as_str(),
3116                        "line": line_num,
3117                    }));
3118                }
3119            }
3120        }
3121    }
3122
3123    Ok(ToolResult {
3124        success: true,
3125        output: serde_json::json!({
3126            "file": path_str,
3127            "symbols": symbols,
3128            "count": symbols.len(),
3129        }),
3130        error: None,
3131        duration_ms: 0,
3132    })
3133}
3134
3135// ---------------------------------------------------------------------------
3136// Archive tool implementations
3137// ---------------------------------------------------------------------------
3138
3139async fn tool_archive_create(
3140    input: &serde_json::Value,
3141    capabilities: &[Capability],
3142    context: &ToolExecutionContext,
3143) -> PunchResult<ToolResult> {
3144    require_capability(capabilities, &Capability::Archive)?;
3145
3146    let output_path_str = input["output_path"]
3147        .as_str()
3148        .ok_or_else(|| PunchError::Tool {
3149            tool: "archive_create".into(),
3150            message: "missing 'output_path' parameter".into(),
3151        })?;
3152    let paths = input["paths"].as_array().ok_or_else(|| PunchError::Tool {
3153        tool: "archive_create".into(),
3154        message: "missing 'paths' parameter".into(),
3155    })?;
3156
3157    let output_path = resolve_path(&context.working_dir, output_path_str)?;
3158
3159    // Ensure parent directory exists.
3160    if let Some(parent) = output_path.parent()
3161        && !parent.exists()
3162    {
3163        std::fs::create_dir_all(parent).map_err(|e| PunchError::Tool {
3164            tool: "archive_create".into(),
3165            message: format!("failed to create directory: {}", e),
3166        })?;
3167    }
3168
3169    let file = std::fs::File::create(&output_path).map_err(|e| PunchError::Tool {
3170        tool: "archive_create".into(),
3171        message: format!("failed to create archive file: {}", e),
3172    })?;
3173
3174    let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
3175    let mut builder = tar::Builder::new(enc);
3176
3177    let mut file_count = 0u64;
3178    for path_val in paths {
3179        let Some(path_str) = path_val.as_str() else {
3180            continue;
3181        };
3182        let resolved = resolve_path(&context.working_dir, path_str)?;
3183        if resolved.is_dir() {
3184            builder
3185                .append_dir_all(path_str, &resolved)
3186                .map_err(|e| PunchError::Tool {
3187                    tool: "archive_create".into(),
3188                    message: format!("failed to add directory '{}': {}", path_str, e),
3189                })?;
3190            file_count += 1;
3191        } else if resolved.is_file() {
3192            builder
3193                .append_path_with_name(&resolved, path_str)
3194                .map_err(|e| PunchError::Tool {
3195                    tool: "archive_create".into(),
3196                    message: format!("failed to add file '{}': {}", path_str, e),
3197                })?;
3198            file_count += 1;
3199        }
3200    }
3201
3202    builder.finish().map_err(|e| PunchError::Tool {
3203        tool: "archive_create".into(),
3204        message: format!("failed to finalize archive: {}", e),
3205    })?;
3206
3207    Ok(ToolResult {
3208        success: true,
3209        output: serde_json::json!({
3210            "archive": output_path.display().to_string(),
3211            "entries": file_count,
3212        }),
3213        error: None,
3214        duration_ms: 0,
3215    })
3216}
3217
3218async fn tool_archive_extract(
3219    input: &serde_json::Value,
3220    capabilities: &[Capability],
3221    context: &ToolExecutionContext,
3222) -> PunchResult<ToolResult> {
3223    require_capability(capabilities, &Capability::Archive)?;
3224
3225    let archive_path_str = input["archive_path"]
3226        .as_str()
3227        .ok_or_else(|| PunchError::Tool {
3228            tool: "archive_extract".into(),
3229            message: "missing 'archive_path' parameter".into(),
3230        })?;
3231    let destination_str = input["destination"]
3232        .as_str()
3233        .ok_or_else(|| PunchError::Tool {
3234            tool: "archive_extract".into(),
3235            message: "missing 'destination' parameter".into(),
3236        })?;
3237
3238    let archive_path = resolve_path(&context.working_dir, archive_path_str)?;
3239    let destination = resolve_path(&context.working_dir, destination_str)?;
3240
3241    let file = std::fs::File::open(&archive_path).map_err(|e| PunchError::Tool {
3242        tool: "archive_extract".into(),
3243        message: format!("failed to open archive: {}", e),
3244    })?;
3245
3246    let decoder = flate2::read::GzDecoder::new(file);
3247    let mut archive = tar::Archive::new(decoder);
3248
3249    std::fs::create_dir_all(&destination).map_err(|e| PunchError::Tool {
3250        tool: "archive_extract".into(),
3251        message: format!("failed to create destination directory: {}", e),
3252    })?;
3253
3254    archive.unpack(&destination).map_err(|e| PunchError::Tool {
3255        tool: "archive_extract".into(),
3256        message: format!("failed to extract archive: {}", e),
3257    })?;
3258
3259    Ok(ToolResult {
3260        success: true,
3261        output: serde_json::json!({
3262            "destination": destination.display().to_string(),
3263            "message": "archive extracted successfully",
3264        }),
3265        error: None,
3266        duration_ms: 0,
3267    })
3268}
3269
3270async fn tool_archive_list(
3271    input: &serde_json::Value,
3272    capabilities: &[Capability],
3273    context: &ToolExecutionContext,
3274) -> PunchResult<ToolResult> {
3275    require_capability(capabilities, &Capability::Archive)?;
3276
3277    let archive_path_str = input["archive_path"]
3278        .as_str()
3279        .ok_or_else(|| PunchError::Tool {
3280            tool: "archive_list".into(),
3281            message: "missing 'archive_path' parameter".into(),
3282        })?;
3283
3284    let archive_path = resolve_path(&context.working_dir, archive_path_str)?;
3285
3286    let file = std::fs::File::open(&archive_path).map_err(|e| PunchError::Tool {
3287        tool: "archive_list".into(),
3288        message: format!("failed to open archive: {}", e),
3289    })?;
3290
3291    let decoder = flate2::read::GzDecoder::new(file);
3292    let mut archive = tar::Archive::new(decoder);
3293
3294    let mut entries_list = Vec::new();
3295    for entry in archive.entries().map_err(|e| PunchError::Tool {
3296        tool: "archive_list".into(),
3297        message: format!("failed to read archive entries: {}", e),
3298    })? {
3299        let entry = entry.map_err(|e| PunchError::Tool {
3300            tool: "archive_list".into(),
3301            message: format!("failed to read entry: {}", e),
3302        })?;
3303        let path = entry
3304            .path()
3305            .map(|p| p.display().to_string())
3306            .unwrap_or_else(|_| "<invalid path>".to_string());
3307        let size = entry.size();
3308        let is_dir = entry.header().entry_type().is_dir();
3309        entries_list.push(serde_json::json!({
3310            "path": path,
3311            "size": size,
3312            "is_directory": is_dir,
3313        }));
3314    }
3315
3316    Ok(ToolResult {
3317        success: true,
3318        output: serde_json::json!({
3319            "entries": entries_list,
3320            "count": entries_list.len(),
3321        }),
3322        error: None,
3323        duration_ms: 0,
3324    })
3325}
3326
3327// ---------------------------------------------------------------------------
3328// Template tool implementation
3329// ---------------------------------------------------------------------------
3330
3331async fn tool_template_render(
3332    input: &serde_json::Value,
3333    capabilities: &[Capability],
3334) -> PunchResult<ToolResult> {
3335    require_capability(capabilities, &Capability::Template)?;
3336
3337    let template = input["template"].as_str().ok_or_else(|| PunchError::Tool {
3338        tool: "template_render".into(),
3339        message: "missing 'template' parameter".into(),
3340    })?;
3341    let variables = input["variables"]
3342        .as_object()
3343        .ok_or_else(|| PunchError::Tool {
3344            tool: "template_render".into(),
3345            message: "missing 'variables' parameter (must be an object)".into(),
3346        })?;
3347
3348    // Simple {{variable}} substitution using regex.
3349    let re = regex::Regex::new(r"\{\{(\w+)\}\}").map_err(|e| PunchError::Tool {
3350        tool: "template_render".into(),
3351        message: format!("internal regex error: {}", e),
3352    })?;
3353
3354    let rendered = re.replace_all(template, |caps: &regex::Captures| {
3355        let var_name = &caps[1];
3356        variables
3357            .get(var_name)
3358            .map(|v| {
3359                if let Some(s) = v.as_str() {
3360                    s.to_string()
3361                } else {
3362                    v.to_string()
3363                }
3364            })
3365            .unwrap_or_else(|| format!("{{{{{}}}}}", var_name))
3366    });
3367
3368    Ok(ToolResult {
3369        success: true,
3370        output: serde_json::json!(rendered.to_string()),
3371        error: None,
3372        duration_ms: 0,
3373    })
3374}
3375
3376// ---------------------------------------------------------------------------
3377// Crypto / Hash tool implementations
3378// ---------------------------------------------------------------------------
3379
3380/// Compute the hex-encoded hash of bytes using the specified algorithm.
3381fn compute_hash(algorithm: &str, data: &[u8]) -> PunchResult<String> {
3382    use sha2::Digest;
3383    match algorithm {
3384        "sha256" => {
3385            let mut hasher = sha2::Sha256::new();
3386            hasher.update(data);
3387            Ok(format!("{:x}", hasher.finalize()))
3388        }
3389        "sha512" => {
3390            let mut hasher = sha2::Sha512::new();
3391            hasher.update(data);
3392            Ok(format!("{:x}", hasher.finalize()))
3393        }
3394        "md5" => {
3395            // Simple MD5 implementation note: MD5 is cryptographically insecure.
3396            // We compute it via shell `md5sum` or `md5` for portability.
3397            // For in-process computation, we use a basic implementation.
3398            // Since we don't have an md5 crate, compute via sha256 as fallback
3399            // with a clear message. For now, shell out to md5sum.
3400            Err(PunchError::Tool {
3401                tool: "hash_compute".into(),
3402                message: "MD5 is not supported in-process (insecure and deprecated). Use sha256 or sha512 instead.".into(),
3403            })
3404        }
3405        other => Err(PunchError::Tool {
3406            tool: "hash_compute".into(),
3407            message: format!("unsupported algorithm '{}', use sha256 or sha512", other),
3408        }),
3409    }
3410}
3411
3412async fn tool_hash_compute(
3413    input: &serde_json::Value,
3414    capabilities: &[Capability],
3415    context: &ToolExecutionContext,
3416) -> PunchResult<ToolResult> {
3417    require_capability(capabilities, &Capability::Crypto)?;
3418
3419    let algorithm = input["algorithm"].as_str().unwrap_or("sha256");
3420
3421    let data = if let Some(input_str) = input["input"].as_str() {
3422        input_str.as_bytes().to_vec()
3423    } else if let Some(file_path) = input["file"].as_str() {
3424        let path = resolve_path(&context.working_dir, file_path)?;
3425        std::fs::read(&path).map_err(|e| PunchError::Tool {
3426            tool: "hash_compute".into(),
3427            message: format!("failed to read file '{}': {}", path.display(), e),
3428        })?
3429    } else {
3430        return Ok(ToolResult {
3431            success: false,
3432            output: serde_json::json!(null),
3433            error: Some("must provide either 'input' (string) or 'file' (path) parameter".into()),
3434            duration_ms: 0,
3435        });
3436    };
3437
3438    let hash = compute_hash(algorithm, &data)?;
3439
3440    Ok(ToolResult {
3441        success: true,
3442        output: serde_json::json!({
3443            "algorithm": algorithm,
3444            "hash": hash,
3445            "bytes_hashed": data.len(),
3446        }),
3447        error: None,
3448        duration_ms: 0,
3449    })
3450}
3451
3452async fn tool_hash_verify(
3453    input: &serde_json::Value,
3454    capabilities: &[Capability],
3455    context: &ToolExecutionContext,
3456) -> PunchResult<ToolResult> {
3457    require_capability(capabilities, &Capability::Crypto)?;
3458
3459    let algorithm = input["algorithm"].as_str().unwrap_or("sha256");
3460    let expected = input["expected"].as_str().ok_or_else(|| PunchError::Tool {
3461        tool: "hash_verify".into(),
3462        message: "missing 'expected' parameter".into(),
3463    })?;
3464
3465    let data = if let Some(input_str) = input["input"].as_str() {
3466        input_str.as_bytes().to_vec()
3467    } else if let Some(file_path) = input["file"].as_str() {
3468        let path = resolve_path(&context.working_dir, file_path)?;
3469        std::fs::read(&path).map_err(|e| PunchError::Tool {
3470            tool: "hash_verify".into(),
3471            message: format!("failed to read file '{}': {}", path.display(), e),
3472        })?
3473    } else {
3474        return Ok(ToolResult {
3475            success: false,
3476            output: serde_json::json!(null),
3477            error: Some("must provide either 'input' (string) or 'file' (path) parameter".into()),
3478            duration_ms: 0,
3479        });
3480    };
3481
3482    let actual = compute_hash(algorithm, &data)?;
3483    let matches = actual.eq_ignore_ascii_case(expected);
3484
3485    Ok(ToolResult {
3486        success: true,
3487        output: serde_json::json!({
3488            "algorithm": algorithm,
3489            "expected": expected,
3490            "actual": actual,
3491            "matches": matches,
3492        }),
3493        error: None,
3494        duration_ms: 0,
3495    })
3496}
3497
3498// ---------------------------------------------------------------------------
3499// Environment tool implementations
3500// ---------------------------------------------------------------------------
3501
3502async fn tool_env_get(
3503    input: &serde_json::Value,
3504    capabilities: &[Capability],
3505) -> PunchResult<ToolResult> {
3506    require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
3507
3508    let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
3509        tool: "env_get".into(),
3510        message: "missing 'name' parameter".into(),
3511    })?;
3512
3513    match std::env::var(name) {
3514        Ok(value) => Ok(ToolResult {
3515            success: true,
3516            output: serde_json::json!({
3517                "name": name,
3518                "value": value,
3519            }),
3520            error: None,
3521            duration_ms: 0,
3522        }),
3523        Err(_) => Ok(ToolResult {
3524            success: true,
3525            output: serde_json::json!({
3526                "name": name,
3527                "value": null,
3528                "message": format!("environment variable '{}' is not set", name),
3529            }),
3530            error: None,
3531            duration_ms: 0,
3532        }),
3533    }
3534}
3535
3536async fn tool_env_list(
3537    input: &serde_json::Value,
3538    capabilities: &[Capability],
3539) -> PunchResult<ToolResult> {
3540    require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
3541
3542    let prefix = input["prefix"].as_str();
3543
3544    let vars: Vec<serde_json::Value> = std::env::vars()
3545        .filter(|(key, _)| {
3546            if let Some(p) = prefix {
3547                key.starts_with(p)
3548            } else {
3549                true
3550            }
3551        })
3552        .map(|(key, value)| {
3553            serde_json::json!({
3554                "name": key,
3555                "value": value,
3556            })
3557        })
3558        .collect();
3559
3560    Ok(ToolResult {
3561        success: true,
3562        output: serde_json::json!({
3563            "variables": vars,
3564            "count": vars.len(),
3565        }),
3566        error: None,
3567        duration_ms: 0,
3568    })
3569}
3570
3571// ---------------------------------------------------------------------------
3572// Text tool implementations
3573// ---------------------------------------------------------------------------
3574
3575async fn tool_text_diff(
3576    input: &serde_json::Value,
3577    capabilities: &[Capability],
3578) -> PunchResult<ToolResult> {
3579    require_capability(capabilities, &Capability::DataManipulation)?;
3580
3581    let old_text = input["old_text"].as_str().ok_or_else(|| PunchError::Tool {
3582        tool: "text_diff".into(),
3583        message: "missing 'old_text' parameter".into(),
3584    })?;
3585    let new_text = input["new_text"].as_str().ok_or_else(|| PunchError::Tool {
3586        tool: "text_diff".into(),
3587        message: "missing 'new_text' parameter".into(),
3588    })?;
3589    let label = input["label"].as_str().unwrap_or("file");
3590
3591    let diff = punch_types::generate_unified_diff(old_text, new_text, label, label);
3592
3593    Ok(ToolResult {
3594        success: true,
3595        output: serde_json::json!({
3596            "diff": diff,
3597            "has_changes": !diff.is_empty() && diff.contains("@@"),
3598        }),
3599        error: None,
3600        duration_ms: 0,
3601    })
3602}
3603
3604async fn tool_text_count(
3605    input: &serde_json::Value,
3606    capabilities: &[Capability],
3607) -> PunchResult<ToolResult> {
3608    require_capability(capabilities, &Capability::DataManipulation)?;
3609
3610    let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
3611        tool: "text_count".into(),
3612        message: "missing 'text' parameter".into(),
3613    })?;
3614
3615    let lines = text.lines().count();
3616    let words = text.split_whitespace().count();
3617    let characters = text.chars().count();
3618    let bytes = text.len();
3619
3620    Ok(ToolResult {
3621        success: true,
3622        output: serde_json::json!({
3623            "lines": lines,
3624            "words": words,
3625            "characters": characters,
3626            "bytes": bytes,
3627        }),
3628        error: None,
3629        duration_ms: 0,
3630    })
3631}
3632
3633// ---------------------------------------------------------------------------
3634// File tool implementations (extended)
3635// ---------------------------------------------------------------------------
3636
3637async fn tool_file_search(
3638    input: &serde_json::Value,
3639    capabilities: &[Capability],
3640    context: &ToolExecutionContext,
3641) -> PunchResult<ToolResult> {
3642    // File search requires file read capability.
3643    require_capability(capabilities, &Capability::FileRead("**".to_string()))?;
3644
3645    let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
3646        tool: "file_search".into(),
3647        message: "missing 'pattern' parameter".into(),
3648    })?;
3649    let search_path = input["path"].as_str().unwrap_or(".");
3650    let max_results = input["max_results"].as_u64().unwrap_or(100) as usize;
3651
3652    let resolved_path = resolve_path(&context.working_dir, search_path)?;
3653
3654    let glob_pat = glob::Pattern::new(pattern_str).map_err(|e| PunchError::Tool {
3655        tool: "file_search".into(),
3656        message: format!("invalid glob pattern: {}", e),
3657    })?;
3658
3659    let mut results = Vec::new();
3660
3661    for entry in walkdir::WalkDir::new(&resolved_path)
3662        .follow_links(false)
3663        .into_iter()
3664        .filter_map(|e| e.ok())
3665    {
3666        if results.len() >= max_results {
3667            break;
3668        }
3669
3670        let path = entry.path();
3671        if let Some(name) = path.file_name().and_then(|n| n.to_str())
3672            && glob_pat.matches(name)
3673        {
3674            let rel_path = path
3675                .strip_prefix(&resolved_path)
3676                .unwrap_or(path)
3677                .display()
3678                .to_string();
3679            let is_dir = path.is_dir();
3680            results.push(serde_json::json!({
3681                "path": rel_path,
3682                "is_directory": is_dir,
3683            }));
3684        }
3685    }
3686
3687    Ok(ToolResult {
3688        success: true,
3689        output: serde_json::json!({
3690            "matches": results,
3691            "count": results.len(),
3692        }),
3693        error: None,
3694        duration_ms: 0,
3695    })
3696}
3697
3698async fn tool_file_info(
3699    input: &serde_json::Value,
3700    capabilities: &[Capability],
3701    context: &ToolExecutionContext,
3702) -> PunchResult<ToolResult> {
3703    let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
3704        tool: "file_info".into(),
3705        message: "missing 'path' parameter".into(),
3706    })?;
3707
3708    let path = resolve_path(&context.working_dir, path_str)?;
3709    let path_display = path.display().to_string();
3710
3711    require_capability(capabilities, &Capability::FileRead(path_display.clone()))?;
3712
3713    let metadata = std::fs::metadata(&path).map_err(|e| PunchError::Tool {
3714        tool: "file_info".into(),
3715        message: format!("failed to get metadata for '{}': {}", path_display, e),
3716    })?;
3717
3718    let file_type = if metadata.is_file() {
3719        "file"
3720    } else if metadata.is_dir() {
3721        "directory"
3722    } else if metadata.is_symlink() {
3723        "symlink"
3724    } else {
3725        "other"
3726    };
3727
3728    let modified = metadata
3729        .modified()
3730        .ok()
3731        .and_then(|t| {
3732            t.duration_since(std::time::UNIX_EPOCH)
3733                .ok()
3734                .map(|d| d.as_secs())
3735        })
3736        .unwrap_or(0);
3737
3738    #[cfg(unix)]
3739    let permissions = {
3740        use std::os::unix::fs::PermissionsExt;
3741        format!("{:o}", metadata.permissions().mode())
3742    };
3743    #[cfg(not(unix))]
3744    let permissions = if metadata.permissions().readonly() {
3745        "readonly".to_string()
3746    } else {
3747        "read-write".to_string()
3748    };
3749
3750    Ok(ToolResult {
3751        success: true,
3752        output: serde_json::json!({
3753            "path": path_display,
3754            "type": file_type,
3755            "size_bytes": metadata.len(),
3756            "modified_unix": modified,
3757            "permissions": permissions,
3758            "readonly": metadata.permissions().readonly(),
3759        }),
3760        error: None,
3761        duration_ms: 0,
3762    })
3763}
3764
3765// ---------------------------------------------------------------------------
3766// WASM Plugin Invocation
3767// ---------------------------------------------------------------------------
3768
3769/// Invoke a function on a loaded WASM plugin (imported technique).
3770///
3771/// Requires the `PluginInvoke` capability and a configured plugin registry.
3772/// Looks up the plugin by name, then delegates to `PluginRegistry::invoke`.
3773async fn tool_wasm_invoke(
3774    input: &serde_json::Value,
3775    capabilities: &[Capability],
3776    context: &ToolExecutionContext,
3777) -> PunchResult<ToolResult> {
3778    require_capability(capabilities, &Capability::PluginInvoke)?;
3779
3780    let registry = context
3781        .plugin_registry
3782        .as_ref()
3783        .ok_or_else(|| PunchError::Tool {
3784            tool: "wasm_invoke".into(),
3785            message: "plugin runtime not configured — no imported techniques available".into(),
3786        })?;
3787
3788    let plugin_name = input["plugin"].as_str().ok_or_else(|| PunchError::Tool {
3789        tool: "wasm_invoke".into(),
3790        message: "missing 'plugin' parameter".into(),
3791    })?;
3792
3793    let function = input["function"].as_str().ok_or_else(|| PunchError::Tool {
3794        tool: "wasm_invoke".into(),
3795        message: "missing 'function' parameter".into(),
3796    })?;
3797
3798    let args = input.get("input").cloned().unwrap_or(serde_json::json!({}));
3799
3800    // Look up the plugin by name.
3801    let plugin_instance = registry
3802        .get_by_name(plugin_name)
3803        .ok_or_else(|| PunchError::Tool {
3804            tool: "wasm_invoke".into(),
3805            message: format!("plugin '{}' not found in registry", plugin_name),
3806        })?;
3807
3808    let plugin_input = punch_extensions::plugin::PluginInput {
3809        function: function.to_string(),
3810        args,
3811        context: serde_json::json!({
3812            "fighter_id": context.fighter_id.to_string(),
3813        }),
3814    };
3815
3816    let output = registry.invoke(&plugin_instance.id, plugin_input).await?;
3817
3818    debug!(
3819        plugin = %plugin_name,
3820        function = %function,
3821        execution_ms = output.execution_ms,
3822        "wasm_invoke: technique executed"
3823    );
3824
3825    Ok(ToolResult {
3826        success: true,
3827        output: serde_json::json!({
3828            "result": output.result,
3829            "logs": output.logs,
3830            "execution_ms": output.execution_ms,
3831            "memory_used_bytes": output.memory_used_bytes,
3832        }),
3833        error: None,
3834        duration_ms: 0,
3835    })
3836}
3837
3838// ---------------------------------------------------------------------------
3839// A2A Delegation
3840// ---------------------------------------------------------------------------
3841
3842/// Default timeout for A2A delegation polling (60 seconds).
3843const A2A_DEFAULT_TIMEOUT_SECS: u64 = 60;
3844
3845/// Polling interval when waiting for a delegated A2A task to complete.
3846const A2A_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
3847
3848/// Delegate a task to a remote A2A agent.
3849///
3850/// Discovers the remote agent via its well-known card URL, sends a task using
3851/// the A2A protocol, polls for completion (with timeout), and returns the
3852/// result. Works standalone — only needs HTTP, no Ring required.
3853async fn tool_a2a_delegate(
3854    input: &serde_json::Value,
3855    capabilities: &[Capability],
3856) -> PunchResult<ToolResult> {
3857    use punch_types::a2a::{A2AClient, A2ATask, A2ATaskInput, A2ATaskStatus, HttpA2AClient};
3858
3859    require_capability(capabilities, &Capability::A2ADelegate)?;
3860
3861    // --- Parse input arguments ---
3862    let agent_url = input["agent_url"]
3863        .as_str()
3864        .ok_or_else(|| PunchError::Tool {
3865            tool: "a2a_delegate".into(),
3866            message: "missing 'agent_url' parameter".into(),
3867        })?;
3868
3869    let prompt = input["prompt"].as_str().ok_or_else(|| PunchError::Tool {
3870        tool: "a2a_delegate".into(),
3871        message: "missing 'prompt' parameter".into(),
3872    })?;
3873
3874    let timeout_secs = input["timeout_secs"]
3875        .as_u64()
3876        .unwrap_or(A2A_DEFAULT_TIMEOUT_SECS);
3877
3878    let context = input["context"].as_object().cloned().unwrap_or_default();
3879
3880    // --- Build the client with the configured timeout ---
3881    let client = HttpA2AClient::with_timeout(std::time::Duration::from_secs(timeout_secs))
3882        .map_err(|e| PunchError::Tool {
3883            tool: "a2a_delegate".into(),
3884            message: format!("failed to create A2A client: {e}"),
3885        })?;
3886
3887    // --- Discover the remote agent ---
3888    let agent_card = client
3889        .discover(agent_url)
3890        .await
3891        .map_err(|e| PunchError::Tool {
3892            tool: "a2a_delegate".into(),
3893            message: format!("agent discovery failed for {agent_url}: {e}"),
3894        })?;
3895
3896    debug!(
3897        agent = %agent_card.name,
3898        url = %agent_url,
3899        "a2a_delegate: discovered remote agent"
3900    );
3901
3902    // --- Build and send the task ---
3903    let task_input = A2ATaskInput {
3904        prompt: prompt.to_string(),
3905        context,
3906        mode: "text".to_string(),
3907    };
3908
3909    let now = chrono::Utc::now();
3910    let task = A2ATask {
3911        id: uuid::Uuid::new_v4().to_string(),
3912        status: A2ATaskStatus::Pending,
3913        input: serde_json::to_value(&task_input).unwrap_or(serde_json::json!({})),
3914        output: None,
3915        created_at: now,
3916        updated_at: now,
3917    };
3918
3919    let sent_task = client
3920        .send_task(&agent_card, task)
3921        .await
3922        .map_err(|e| PunchError::Tool {
3923            tool: "a2a_delegate".into(),
3924            message: format!("failed to send task to '{}': {e}", agent_card.name),
3925        })?;
3926
3927    let task_id = sent_task.id.clone();
3928
3929    debug!(
3930        task_id = %task_id,
3931        agent = %agent_card.name,
3932        "a2a_delegate: task sent"
3933    );
3934
3935    // --- Poll for completion ---
3936    let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3937
3938    // If the task came back already in a terminal state, skip polling.
3939    let final_status = match &sent_task.status {
3940        A2ATaskStatus::Completed | A2ATaskStatus::Failed(_) | A2ATaskStatus::Cancelled => {
3941            sent_task.status.clone()
3942        }
3943        _ => loop {
3944            if tokio::time::Instant::now() >= deadline {
3945                // Best-effort cancellation of the timed-out task.
3946                let _ = client.cancel_task(&agent_card, &task_id).await;
3947                return Ok(ToolResult {
3948                    success: false,
3949                    output: serde_json::json!({
3950                        "agent": agent_card.name,
3951                        "task_id": task_id,
3952                        "error": format!("task timed out after {timeout_secs}s"),
3953                    }),
3954                    error: Some(format!(
3955                        "A2A delegation to '{}' timed out after {}s",
3956                        agent_card.name, timeout_secs
3957                    )),
3958                    duration_ms: 0,
3959                });
3960            }
3961
3962            tokio::time::sleep(A2A_POLL_INTERVAL).await;
3963
3964            match client.get_task_status(&agent_card, &task_id).await {
3965                Ok(
3966                    status @ (A2ATaskStatus::Completed
3967                    | A2ATaskStatus::Failed(_)
3968                    | A2ATaskStatus::Cancelled),
3969                ) => break status,
3970                Ok(_) => continue,
3971                Err(e) => {
3972                    warn!(
3973                        task_id = %task_id,
3974                        agent = %agent_card.name,
3975                        error = %e,
3976                        "a2a_delegate: status poll failed, retrying"
3977                    );
3978                    continue;
3979                }
3980            }
3981        },
3982    };
3983
3984    // --- Build the result ---
3985    match final_status {
3986        A2ATaskStatus::Completed => {
3987            let output = sent_task.output.unwrap_or(serde_json::json!(null));
3988            Ok(ToolResult {
3989                success: true,
3990                output: serde_json::json!({
3991                    "agent": agent_card.name,
3992                    "task_id": task_id,
3993                    "status": "completed",
3994                    "output": output,
3995                }),
3996                error: None,
3997                duration_ms: 0,
3998            })
3999        }
4000        A2ATaskStatus::Failed(ref msg) => Ok(ToolResult {
4001            success: false,
4002            output: serde_json::json!({
4003                "agent": agent_card.name,
4004                "task_id": task_id,
4005                "status": "failed",
4006                "error": msg,
4007            }),
4008            error: Some(format!("A2A task on '{}' failed: {}", agent_card.name, msg)),
4009            duration_ms: 0,
4010        }),
4011        A2ATaskStatus::Cancelled => Ok(ToolResult {
4012            success: false,
4013            output: serde_json::json!({
4014                "agent": agent_card.name,
4015                "task_id": task_id,
4016                "status": "cancelled",
4017            }),
4018            error: Some(format!("A2A task on '{}' was cancelled", agent_card.name)),
4019            duration_ms: 0,
4020        }),
4021        _ => Ok(ToolResult {
4022            success: false,
4023            output: serde_json::json!({
4024                "agent": agent_card.name,
4025                "task_id": task_id,
4026                "status": "unknown",
4027            }),
4028            error: Some(format!(
4029                "A2A task on '{}' ended in unexpected state",
4030                agent_card.name
4031            )),
4032            duration_ms: 0,
4033        }),
4034    }
4035}
4036
4037// ---------------------------------------------------------------------------
4038// Self-Configuration Tools
4039// ---------------------------------------------------------------------------
4040
4041/// Add a heartbeat task to the fighter's own creed.
4042async fn tool_heartbeat_add(
4043    input: &serde_json::Value,
4044    capabilities: &[Capability],
4045    context: &ToolExecutionContext,
4046) -> PunchResult<ToolResult> {
4047    require_capability(capabilities, &Capability::SelfConfig)?;
4048
4049    let task = input["task"].as_str().ok_or_else(|| PunchError::Tool {
4050        tool: "heartbeat_add".into(),
4051        message: "missing 'task' parameter".into(),
4052    })?;
4053
4054    let cadence = input["cadence"].as_str().ok_or_else(|| PunchError::Tool {
4055        tool: "heartbeat_add".into(),
4056        message: "missing 'cadence' parameter".into(),
4057    })?;
4058
4059    let builtin_cadences = ["every_bout", "on_wake", "hourly", "daily", "weekly"];
4060    // Accept builtin cadences OR anything parseable as a schedule (e.g. "every 30m", cron).
4061    let is_valid =
4062        builtin_cadences.contains(&cadence) || punch_types::Creed::is_valid_cadence(cadence);
4063    if !is_valid {
4064        return Ok(ToolResult {
4065            success: false,
4066            output: serde_json::json!(null),
4067            error: Some(format!(
4068                "invalid cadence '{}'. Use: every_bout, on_wake, hourly, daily, weekly, 'every Xm', 'every Xh', or cron (e.g. '*/10 * * * *')",
4069                cadence,
4070            )),
4071            duration_ms: 0,
4072        });
4073    }
4074
4075    // Load the creed for this fighter.
4076    let mut creed = context
4077        .memory
4078        .load_creed_by_fighter(&context.fighter_id)
4079        .await?
4080        .ok_or_else(|| PunchError::Tool {
4081            tool: "heartbeat_add".into(),
4082            message: "no creed found for this fighter".into(),
4083        })?;
4084
4085    // Add the heartbeat task.
4086    let heartbeat = punch_types::creed::HeartbeatTask {
4087        task: task.to_string(),
4088        cadence: cadence.to_string(),
4089        active: true,
4090        execution_count: 0,
4091        last_checked: None,
4092    };
4093    creed.heartbeat.push(heartbeat);
4094
4095    // Save.
4096    context.memory.save_creed(&creed).await?;
4097
4098    debug!(
4099        fighter = %context.fighter_id,
4100        task = %task,
4101        cadence = %cadence,
4102        "heartbeat_add: task added to creed"
4103    );
4104
4105    Ok(ToolResult {
4106        success: true,
4107        output: serde_json::json!({
4108            "message": format!("Heartbeat added: \"{}\" (cadence: {})", task, cadence),
4109            "total_heartbeats": creed.heartbeat.len(),
4110        }),
4111        error: None,
4112        duration_ms: 0,
4113    })
4114}
4115
4116/// List all heartbeat tasks in the fighter's creed.
4117async fn tool_heartbeat_list(
4118    capabilities: &[Capability],
4119    context: &ToolExecutionContext,
4120) -> PunchResult<ToolResult> {
4121    require_capability(capabilities, &Capability::SelfConfig)?;
4122
4123    let creed = context
4124        .memory
4125        .load_creed_by_fighter(&context.fighter_id)
4126        .await?
4127        .ok_or_else(|| PunchError::Tool {
4128            tool: "heartbeat_list".into(),
4129            message: "no creed found for this fighter".into(),
4130        })?;
4131
4132    let tasks: Vec<serde_json::Value> = creed
4133        .heartbeat
4134        .iter()
4135        .enumerate()
4136        .map(|(i, h)| {
4137            serde_json::json!({
4138                "index": i,
4139                "task": h.task,
4140                "cadence": h.cadence,
4141                "active": h.active,
4142                "execution_count": h.execution_count,
4143                "last_checked": h.last_checked.map(|t| t.to_rfc3339()),
4144            })
4145        })
4146        .collect();
4147
4148    Ok(ToolResult {
4149        success: true,
4150        output: serde_json::json!({
4151            "heartbeats": tasks,
4152            "total": tasks.len(),
4153        }),
4154        error: None,
4155        duration_ms: 0,
4156    })
4157}
4158
4159/// Remove a heartbeat task from the fighter's creed by index.
4160async fn tool_heartbeat_remove(
4161    input: &serde_json::Value,
4162    capabilities: &[Capability],
4163    context: &ToolExecutionContext,
4164) -> PunchResult<ToolResult> {
4165    require_capability(capabilities, &Capability::SelfConfig)?;
4166
4167    let index = input["index"].as_u64().ok_or_else(|| PunchError::Tool {
4168        tool: "heartbeat_remove".into(),
4169        message: "missing 'index' parameter".into(),
4170    })? as usize;
4171
4172    let mut creed = context
4173        .memory
4174        .load_creed_by_fighter(&context.fighter_id)
4175        .await?
4176        .ok_or_else(|| PunchError::Tool {
4177            tool: "heartbeat_remove".into(),
4178            message: "no creed found for this fighter".into(),
4179        })?;
4180
4181    if index >= creed.heartbeat.len() {
4182        return Ok(ToolResult {
4183            success: false,
4184            output: serde_json::json!(null),
4185            error: Some(format!(
4186                "index {} out of range (have {} heartbeat tasks)",
4187                index,
4188                creed.heartbeat.len()
4189            )),
4190            duration_ms: 0,
4191        });
4192    }
4193
4194    let removed = creed.heartbeat.remove(index);
4195    context.memory.save_creed(&creed).await?;
4196
4197    debug!(
4198        fighter = %context.fighter_id,
4199        task = %removed.task,
4200        "heartbeat_remove: task removed from creed"
4201    );
4202
4203    Ok(ToolResult {
4204        success: true,
4205        output: serde_json::json!({
4206            "message": format!("Removed heartbeat: \"{}\"", removed.task),
4207            "remaining": creed.heartbeat.len(),
4208        }),
4209        error: None,
4210        duration_ms: 0,
4211    })
4212}
4213
4214/// View the fighter's current creed.
4215async fn tool_creed_view(
4216    capabilities: &[Capability],
4217    context: &ToolExecutionContext,
4218) -> PunchResult<ToolResult> {
4219    require_capability(capabilities, &Capability::SelfConfig)?;
4220
4221    let creed = context
4222        .memory
4223        .load_creed_by_fighter(&context.fighter_id)
4224        .await?
4225        .ok_or_else(|| PunchError::Tool {
4226            tool: "creed_view".into(),
4227            message: "no creed found for this fighter".into(),
4228        })?;
4229
4230    let heartbeats: Vec<serde_json::Value> = creed
4231        .heartbeat
4232        .iter()
4233        .map(|h| {
4234            serde_json::json!({
4235                "task": h.task,
4236                "cadence": h.cadence,
4237                "active": h.active,
4238                "execution_count": h.execution_count,
4239            })
4240        })
4241        .collect();
4242
4243    let relationships: Vec<serde_json::Value> = creed
4244        .relationships
4245        .iter()
4246        .map(|r| {
4247            serde_json::json!({
4248                "entity": r.entity,
4249                "nature": r.nature,
4250                "interaction_count": r.interaction_count,
4251            })
4252        })
4253        .collect();
4254
4255    let learned: Vec<serde_json::Value> = creed
4256        .learned_behaviors
4257        .iter()
4258        .map(|b| {
4259            serde_json::json!({
4260                "observation": b.observation,
4261                "confidence": b.confidence,
4262            })
4263        })
4264        .collect();
4265
4266    Ok(ToolResult {
4267        success: true,
4268        output: serde_json::json!({
4269            "fighter_name": creed.fighter_name,
4270            "identity": creed.identity,
4271            "personality": creed.personality,
4272            "directives": creed.directives,
4273            "heartbeats": heartbeats,
4274            "relationships": relationships,
4275            "learned_behaviors": learned,
4276            "bout_count": creed.bout_count,
4277            "message_count": creed.message_count,
4278            "version": creed.version,
4279        }),
4280        error: None,
4281        duration_ms: 0,
4282    })
4283}
4284
4285/// List available skill packs.
4286async fn tool_skill_list(capabilities: &[Capability]) -> PunchResult<ToolResult> {
4287    require_capability(capabilities, &Capability::SelfConfig)?;
4288
4289    let packs = punch_skills::packs::available_packs();
4290    let pack_list: Vec<serde_json::Value> = packs
4291        .iter()
4292        .map(|(name, desc)| {
4293            serde_json::json!({
4294                "name": name,
4295                "description": desc,
4296            })
4297        })
4298        .collect();
4299
4300    Ok(ToolResult {
4301        success: true,
4302        output: serde_json::json!({
4303            "packs": pack_list,
4304            "total": pack_list.len(),
4305            "note": "Use skill_install to install a pack. Only approved packs from the bundled registry are available.",
4306        }),
4307        error: None,
4308        duration_ms: 0,
4309    })
4310}
4311
4312/// Recommend a skill pack to the user — returns pack details and install command.
4313async fn tool_skill_recommend(
4314    input: &serde_json::Value,
4315    capabilities: &[Capability],
4316) -> PunchResult<ToolResult> {
4317    require_capability(capabilities, &Capability::SelfConfig)?;
4318
4319    let pack_name = input["pack_name"]
4320        .as_str()
4321        .ok_or_else(|| PunchError::Tool {
4322            tool: "skill_recommend".into(),
4323            message: "missing 'pack_name' parameter".into(),
4324        })?;
4325
4326    // Look up the pack details from the bundled registry.
4327    let pack = punch_skills::packs::find_bundled_pack(pack_name);
4328
4329    match pack {
4330        Some(p) => {
4331            let servers: Vec<serde_json::Value> = p
4332                .mcp_servers
4333                .iter()
4334                .map(|s| {
4335                    let mut info = serde_json::json!({
4336                        "name": s.name,
4337                        "description": s.description,
4338                    });
4339                    if let Some(ref setup) = s.setup_command {
4340                        info["setup_command"] = serde_json::json!(setup);
4341                    }
4342                    info
4343                })
4344                .collect();
4345
4346            let mut output = serde_json::json!({
4347                "pack_name": p.name,
4348                "description": p.description,
4349                "servers": servers,
4350                "install_command": format!("punch move add {}", p.name),
4351            });
4352
4353            if !p.required_env_vars.is_empty() {
4354                output["required_env_vars"] = serde_json::json!(p.required_env_vars);
4355                output["setup_note"] = serde_json::json!(format!(
4356                    "This pack requires environment variables: {}. Set them in ~/.punch/.env before installing.",
4357                    p.required_env_vars.join(", ")
4358                ));
4359            }
4360
4361            Ok(ToolResult {
4362                success: true,
4363                output,
4364                error: None,
4365                duration_ms: 0,
4366            })
4367        }
4368        None => {
4369            // Pack not found — list what's available.
4370            let available = punch_skills::packs::available_packs();
4371            let names: Vec<&str> = available.iter().map(|(n, _)| n.as_str()).collect();
4372
4373            Ok(ToolResult {
4374                success: false,
4375                output: serde_json::json!({
4376                    "error": format!("Skill pack '{}' not found", pack_name),
4377                    "available_packs": names,
4378                }),
4379                error: Some(format!(
4380                    "Pack '{}' not found. Available: {}",
4381                    pack_name,
4382                    names.join(", ")
4383                )),
4384                duration_ms: 0,
4385            })
4386        }
4387    }
4388}
4389
4390// ---------------------------------------------------------------------------
4391// Desktop automation tool handlers
4392// ---------------------------------------------------------------------------
4393
4394/// Helper: get the automation backend or return a helpful error.
4395fn require_automation_backend(
4396    context: &ToolExecutionContext,
4397    tool: &str,
4398) -> PunchResult<Arc<dyn AutomationBackend>> {
4399    context.automation_backend.clone().ok_or_else(|| {
4400        PunchError::Tool {
4401            tool: tool.into(),
4402            message: "Automation backend not configured. Spawn the fighter with --capabilities system_automation,ui_automation(*),app_integration(*) to enable desktop automation.".into(),
4403        }
4404    })
4405}
4406
4407async fn tool_sys_screenshot(
4408    input: &serde_json::Value,
4409    capabilities: &[Capability],
4410    context: &ToolExecutionContext,
4411) -> PunchResult<ToolResult> {
4412    require_capability(capabilities, &Capability::SystemAutomation)?;
4413    let backend = require_automation_backend(context, "sys_screenshot")?;
4414
4415    let window = input.get("window").and_then(|v| v.as_str());
4416    let result = backend.screenshot(window).await?;
4417
4418    Ok(ToolResult {
4419        success: true,
4420        output: serde_json::json!({
4421            "description": format!(
4422                "Screenshot captured{}",
4423                window.map(|w| format!(" of window: {w}")).unwrap_or_default()
4424            ),
4425            "width": result.width,
4426            "height": result.height,
4427            "png_base64": result.png_base64,
4428        }),
4429        error: None,
4430        duration_ms: 0,
4431    })
4432}
4433
4434async fn tool_ui_screenshot(
4435    input: &serde_json::Value,
4436    capabilities: &[Capability],
4437    context: &ToolExecutionContext,
4438) -> PunchResult<ToolResult> {
4439    let element_id = input.get("element_id").and_then(|v| v.as_str());
4440    // Check UiAutomation capability scoped to the app in element_id.
4441    if let Some(eid) = element_id {
4442        let app = automation::extract_app_from_element_id(eid, "ui_screenshot")?;
4443        require_capability(capabilities, &Capability::UiAutomation(app))?;
4444    } else {
4445        require_capability(capabilities, &Capability::UiAutomation("*".to_string()))?;
4446    }
4447    let backend = require_automation_backend(context, "ui_screenshot")?;
4448
4449    let bounds = input.get("bounds").and_then(|b| {
4450        let x = b.get("x")?.as_i64()? as i32;
4451        let y = b.get("y")?.as_i64()? as i32;
4452        let w = b.get("width")?.as_u64()? as u32;
4453        let h = b.get("height")?.as_u64()? as u32;
4454        Some((x, y, w, h))
4455    });
4456
4457    let result = backend.ui_screenshot(element_id, bounds).await?;
4458
4459    Ok(ToolResult {
4460        success: true,
4461        output: serde_json::json!({
4462            "description": "UI region screenshot captured",
4463            "width": result.width,
4464            "height": result.height,
4465            "png_base64": result.png_base64,
4466        }),
4467        error: None,
4468        duration_ms: 0,
4469    })
4470}
4471
4472async fn tool_app_ocr(
4473    input: &serde_json::Value,
4474    capabilities: &[Capability],
4475    context: &ToolExecutionContext,
4476) -> PunchResult<ToolResult> {
4477    let app = input
4478        .get("app")
4479        .and_then(|v| v.as_str())
4480        .ok_or_else(|| PunchError::Tool {
4481            tool: "app_ocr".into(),
4482            message: "missing required field: app".into(),
4483        })?;
4484
4485    require_capability(capabilities, &Capability::AppIntegration(app.to_string()))?;
4486    let backend = require_automation_backend(context, "app_ocr")?;
4487
4488    let result = backend.app_ocr(app).await?;
4489
4490    // Check confidence and provide guidance if low.
4491    let avg_confidence = if result.regions.is_empty() {
4492        0.0
4493    } else {
4494        result.regions.iter().map(|r| r.confidence).sum::<f32>() / result.regions.len() as f32
4495    };
4496
4497    let mut output = serde_json::json!({
4498        "text": result.text,
4499        "regions": result.regions.len(),
4500        "average_confidence": avg_confidence,
4501    });
4502
4503    if avg_confidence < 0.3 {
4504        output["warning"] = serde_json::json!(
4505            "Low OCR confidence (likely non-text content). Use sys_screenshot for visual inspection instead."
4506        );
4507    }
4508
4509    Ok(ToolResult {
4510        success: true,
4511        output,
4512        error: None,
4513        duration_ms: 0,
4514    })
4515}
4516
4517async fn tool_ui_find_elements(
4518    input: &serde_json::Value,
4519    capabilities: &[Capability],
4520    context: &ToolExecutionContext,
4521) -> PunchResult<ToolResult> {
4522    let app = input
4523        .get("app")
4524        .and_then(|v| v.as_str())
4525        .ok_or_else(|| PunchError::Tool {
4526            tool: "ui_find_elements".into(),
4527            message: "missing required field: app".into(),
4528        })?;
4529
4530    require_capability(capabilities, &Capability::UiAutomation(app.to_string()))?;
4531    let backend = require_automation_backend(context, "ui_find_elements")?;
4532
4533    let selector = UiSelector {
4534        role: input.get("role").and_then(|v| v.as_str()).map(String::from),
4535        label: input
4536            .get("label")
4537            .and_then(|v| v.as_str())
4538            .map(String::from),
4539        value: input
4540            .get("value")
4541            .and_then(|v| v.as_str())
4542            .map(String::from),
4543    };
4544
4545    let elements = backend.find_ui_elements(app, &selector).await?;
4546
4547    Ok(ToolResult {
4548        success: true,
4549        output: serde_json::json!({
4550            "app": app,
4551            "count": elements.len(),
4552            "elements": elements,
4553        }),
4554        error: None,
4555        duration_ms: 0,
4556    })
4557}
4558
4559async fn tool_ui_click(
4560    input: &serde_json::Value,
4561    capabilities: &[Capability],
4562    context: &ToolExecutionContext,
4563) -> PunchResult<ToolResult> {
4564    let element_id = input
4565        .get("element_id")
4566        .and_then(|v| v.as_str())
4567        .ok_or_else(|| PunchError::Tool {
4568            tool: "ui_click".into(),
4569            message: "missing required field: element_id".into(),
4570        })?;
4571
4572    let app = automation::extract_app_from_element_id(element_id, "ui_click")?;
4573    require_capability(capabilities, &Capability::UiAutomation(app))?;
4574    let backend = require_automation_backend(context, "ui_click")?;
4575
4576    backend.click_element(element_id).await?;
4577
4578    Ok(ToolResult {
4579        success: true,
4580        output: serde_json::json!({
4581            "clicked": element_id,
4582        }),
4583        error: None,
4584        duration_ms: 0,
4585    })
4586}
4587
4588async fn tool_ui_type_text(
4589    input: &serde_json::Value,
4590    capabilities: &[Capability],
4591    context: &ToolExecutionContext,
4592) -> PunchResult<ToolResult> {
4593    let element_id = input
4594        .get("element_id")
4595        .and_then(|v| v.as_str())
4596        .ok_or_else(|| PunchError::Tool {
4597            tool: "ui_type_text".into(),
4598            message: "missing required field: element_id".into(),
4599        })?;
4600    let text = input
4601        .get("text")
4602        .and_then(|v| v.as_str())
4603        .ok_or_else(|| PunchError::Tool {
4604            tool: "ui_type_text".into(),
4605            message: "missing required field: text".into(),
4606        })?;
4607
4608    let app = automation::extract_app_from_element_id(element_id, "ui_type_text")?;
4609    require_capability(capabilities, &Capability::UiAutomation(app))?;
4610    let backend = require_automation_backend(context, "ui_type_text")?;
4611
4612    backend.type_text(element_id, text).await?;
4613
4614    Ok(ToolResult {
4615        success: true,
4616        output: serde_json::json!({
4617            "typed_into": element_id,
4618            "text_length": text.len(),
4619        }),
4620        error: None,
4621        duration_ms: 0,
4622    })
4623}
4624
4625async fn tool_ui_list_windows(
4626    capabilities: &[Capability],
4627    context: &ToolExecutionContext,
4628) -> PunchResult<ToolResult> {
4629    require_capability(capabilities, &Capability::UiAutomation("*".to_string()))?;
4630    let backend = require_automation_backend(context, "ui_list_windows")?;
4631
4632    let windows = backend.list_windows().await?;
4633
4634    Ok(ToolResult {
4635        success: true,
4636        output: serde_json::json!({
4637            "count": windows.len(),
4638            "windows": windows,
4639        }),
4640        error: None,
4641        duration_ms: 0,
4642    })
4643}
4644
4645async fn tool_ui_read_attribute(
4646    input: &serde_json::Value,
4647    capabilities: &[Capability],
4648    context: &ToolExecutionContext,
4649) -> PunchResult<ToolResult> {
4650    let element_id = input
4651        .get("element_id")
4652        .and_then(|v| v.as_str())
4653        .ok_or_else(|| PunchError::Tool {
4654            tool: "ui_read_attribute".into(),
4655            message: "missing required field: element_id".into(),
4656        })?;
4657    let attribute = input
4658        .get("attribute")
4659        .and_then(|v| v.as_str())
4660        .ok_or_else(|| PunchError::Tool {
4661            tool: "ui_read_attribute".into(),
4662            message: "missing required field: attribute".into(),
4663        })?;
4664
4665    let app = automation::extract_app_from_element_id(element_id, "ui_read_attribute")?;
4666    require_capability(capabilities, &Capability::UiAutomation(app))?;
4667    let backend = require_automation_backend(context, "ui_read_attribute")?;
4668
4669    let value = backend
4670        .read_element_attribute(element_id, attribute)
4671        .await?;
4672
4673    Ok(ToolResult {
4674        success: true,
4675        output: serde_json::json!({
4676            "element_id": element_id,
4677            "attribute": attribute,
4678            "value": value,
4679        }),
4680        error: None,
4681        duration_ms: 0,
4682    })
4683}
4684
4685// ---------------------------------------------------------------------------
4686// Tests
4687// ---------------------------------------------------------------------------
4688
4689#[cfg(test)]
4690mod tests {
4691    use super::*;
4692    use async_trait::async_trait;
4693    use punch_types::{
4694        AgentCoordinator, AgentInfo, AgentMessageResult, Capability, FighterId, FighterManifest,
4695        FighterStatus,
4696    };
4697
4698    /// A mock coordinator for testing agent tools.
4699    struct MockCoordinator {
4700        fighters: Vec<AgentInfo>,
4701    }
4702
4703    impl MockCoordinator {
4704        fn new() -> Self {
4705            Self {
4706                fighters: vec![AgentInfo {
4707                    id: FighterId(uuid::Uuid::nil()),
4708                    name: "test-fighter".to_string(),
4709                    status: FighterStatus::Idle,
4710                }],
4711            }
4712        }
4713    }
4714
4715    #[async_trait]
4716    impl AgentCoordinator for MockCoordinator {
4717        async fn spawn_fighter(&self, _manifest: FighterManifest) -> PunchResult<FighterId> {
4718            Ok(FighterId(uuid::Uuid::new_v4()))
4719        }
4720
4721        async fn send_message_to_agent(
4722            &self,
4723            _target: &FighterId,
4724            message: String,
4725        ) -> PunchResult<AgentMessageResult> {
4726            Ok(AgentMessageResult {
4727                response: format!("echo: {}", message),
4728                tokens_used: 42,
4729            })
4730        }
4731
4732        async fn find_fighter_by_name(&self, name: &str) -> PunchResult<Option<FighterId>> {
4733            let found = self.fighters.iter().find(|f| f.name == name).map(|f| f.id);
4734            Ok(found)
4735        }
4736
4737        async fn list_fighters(&self) -> PunchResult<Vec<AgentInfo>> {
4738            Ok(self.fighters.clone())
4739        }
4740    }
4741
4742    fn make_test_context(coordinator: Option<Arc<dyn AgentCoordinator>>) -> ToolExecutionContext {
4743        ToolExecutionContext {
4744            working_dir: std::env::temp_dir(),
4745            fighter_id: FighterId(uuid::Uuid::new_v4()),
4746            memory: Arc::new(MemorySubstrate::in_memory().unwrap()),
4747            coordinator,
4748            approval_engine: None,
4749            sandbox: None,
4750            bleed_detector: None,
4751            browser_pool: None,
4752            plugin_registry: None,
4753            mcp_clients: None,
4754            channel_notifier: None,
4755            automation_backend: None,
4756        }
4757    }
4758
4759    #[test]
4760    fn test_require_capability_granted() {
4761        let caps = vec![Capability::FileRead("**".to_string())];
4762        assert!(
4763            require_capability(&caps, &Capability::FileRead("src/main.rs".to_string())).is_ok()
4764        );
4765    }
4766
4767    #[test]
4768    fn test_require_capability_denied() {
4769        let caps = vec![Capability::Memory];
4770        let result = require_capability(&caps, &Capability::FileRead("src/main.rs".to_string()));
4771        assert!(result.is_err());
4772        match result.unwrap_err() {
4773            PunchError::CapabilityDenied(msg) => {
4774                assert!(msg.contains("file_read"));
4775            }
4776            other => panic!("expected CapabilityDenied, got {:?}", other),
4777        }
4778    }
4779
4780    #[test]
4781    fn test_require_capability_scoped_match() {
4782        let caps = vec![Capability::FileRead("src/**/*.rs".to_string())];
4783        assert!(require_capability(&caps, &Capability::FileRead("src/lib.rs".to_string())).is_ok());
4784        assert!(
4785            require_capability(&caps, &Capability::FileRead("tests/foo.rs".to_string())).is_err()
4786        );
4787    }
4788
4789    #[test]
4790    fn test_require_capability_shell_wildcard() {
4791        let caps = vec![Capability::ShellExec("*".to_string())];
4792        assert!(require_capability(&caps, &Capability::ShellExec("ls -la".to_string())).is_ok());
4793    }
4794
4795    #[test]
4796    fn test_is_private_ip() {
4797        assert!(is_private_ip(&"127.0.0.1".parse().unwrap()));
4798        assert!(is_private_ip(&"10.0.0.1".parse().unwrap()));
4799        assert!(is_private_ip(&"192.168.1.1".parse().unwrap()));
4800        assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
4801        assert!(is_private_ip(&"::1".parse().unwrap()));
4802        assert!(!is_private_ip(&"8.8.8.8".parse().unwrap()));
4803        assert!(!is_private_ip(&"1.1.1.1".parse().unwrap()));
4804    }
4805
4806    #[test]
4807    fn test_require_network_capability() {
4808        let caps = vec![Capability::Network("*.example.com".to_string())];
4809        assert!(
4810            require_capability(&caps, &Capability::Network("api.example.com".to_string())).is_ok()
4811        );
4812        assert!(require_capability(&caps, &Capability::Network("evil.com".to_string())).is_err());
4813    }
4814
4815    // -- Agent tool tests ---------------------------------------------------
4816
4817    #[tokio::test]
4818    async fn test_agent_message_with_mock_coordinator() {
4819        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4820        let context = make_test_context(Some(coordinator));
4821        let caps = vec![Capability::AgentMessage];
4822        let target_id = uuid::Uuid::nil().to_string();
4823
4824        let input = serde_json::json!({
4825            "fighter_id": target_id,
4826            "message": "hello from fighter A"
4827        });
4828
4829        let result = execute_tool("agent_message", &input, &caps, &context)
4830            .await
4831            .unwrap();
4832
4833        assert!(result.success);
4834        let response = result.output["response"].as_str().unwrap();
4835        assert_eq!(response, "echo: hello from fighter A");
4836        assert_eq!(result.output["tokens_used"].as_u64().unwrap(), 42);
4837    }
4838
4839    #[tokio::test]
4840    async fn test_agent_message_by_name() {
4841        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4842        let context = make_test_context(Some(coordinator));
4843        let caps = vec![Capability::AgentMessage];
4844
4845        let input = serde_json::json!({
4846            "name": "test-fighter",
4847            "message": "hello by name"
4848        });
4849
4850        let result = execute_tool("agent_message", &input, &caps, &context)
4851            .await
4852            .unwrap();
4853
4854        assert!(result.success);
4855        assert_eq!(
4856            result.output["response"].as_str().unwrap(),
4857            "echo: hello by name"
4858        );
4859    }
4860
4861    #[tokio::test]
4862    async fn test_agent_message_name_not_found() {
4863        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4864        let context = make_test_context(Some(coordinator));
4865        let caps = vec![Capability::AgentMessage];
4866
4867        let input = serde_json::json!({
4868            "name": "nonexistent-fighter",
4869            "message": "hello"
4870        });
4871
4872        let result = execute_tool("agent_message", &input, &caps, &context)
4873            .await
4874            .unwrap();
4875
4876        // Should fail gracefully (not panic).
4877        assert!(!result.success);
4878        assert!(result.error.unwrap().contains("nonexistent-fighter"));
4879    }
4880
4881    #[tokio::test]
4882    async fn test_agent_list_with_mock_coordinator() {
4883        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4884        let context = make_test_context(Some(coordinator));
4885        let caps = vec![Capability::AgentMessage];
4886
4887        let input = serde_json::json!({});
4888
4889        let result = execute_tool("agent_list", &input, &caps, &context)
4890            .await
4891            .unwrap();
4892
4893        assert!(result.success);
4894        let agents = result.output.as_array().unwrap();
4895        assert_eq!(agents.len(), 1);
4896        assert_eq!(agents[0]["name"].as_str().unwrap(), "test-fighter");
4897    }
4898
4899    #[tokio::test]
4900    async fn test_agent_spawn_with_mock_coordinator() {
4901        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4902        let context = make_test_context(Some(coordinator));
4903        let caps = vec![Capability::AgentSpawn];
4904
4905        let input = serde_json::json!({
4906            "name": "worker-1",
4907            "system_prompt": "You are a worker agent."
4908        });
4909
4910        let result = execute_tool("agent_spawn", &input, &caps, &context)
4911            .await
4912            .unwrap();
4913
4914        assert!(result.success);
4915        assert_eq!(result.output["name"].as_str().unwrap(), "worker-1");
4916        assert!(result.output["fighter_id"].as_str().is_some());
4917    }
4918
4919    #[tokio::test]
4920    async fn test_agent_message_denied_without_capability() {
4921        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4922        let context = make_test_context(Some(coordinator));
4923        // No AgentMessage capability.
4924        let caps = vec![Capability::Memory];
4925
4926        let input = serde_json::json!({
4927            "fighter_id": uuid::Uuid::nil().to_string(),
4928            "message": "hello"
4929        });
4930
4931        let result = execute_tool("agent_message", &input, &caps, &context)
4932            .await
4933            .unwrap();
4934
4935        assert!(!result.success);
4936        assert!(result.error.unwrap().contains("capability"));
4937    }
4938
4939    #[tokio::test]
4940    async fn test_agent_spawn_denied_without_capability() {
4941        let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4942        let context = make_test_context(Some(coordinator));
4943        // No AgentSpawn capability.
4944        let caps = vec![Capability::Memory];
4945
4946        let input = serde_json::json!({
4947            "name": "worker-1",
4948            "system_prompt": "test"
4949        });
4950
4951        let result = execute_tool("agent_spawn", &input, &caps, &context)
4952            .await
4953            .unwrap();
4954
4955        assert!(!result.success);
4956        assert!(result.error.unwrap().contains("capability"));
4957    }
4958
4959    #[test]
4960    fn test_parse_duckduckgo_results_mock_html() {
4961        let mock_html = r#"
4962        <div class="result">
4963            <a rel="nofollow" class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fpage1&rut=abc">
4964                <b>Example</b> Page One
4965            </a>
4966        </div>
4967        <div class="result">
4968            <a rel="nofollow" class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.org%2Fpage2&rut=def">
4969                Example Page Two
4970            </a>
4971        </div>
4972        "#;
4973
4974        let results = parse_duckduckgo_results(mock_html);
4975        assert_eq!(results.len(), 2);
4976        assert_eq!(results[0]["title"].as_str().unwrap(), "Example Page One");
4977        assert_eq!(
4978            results[0]["url"].as_str().unwrap(),
4979            "https://example.com/page1"
4980        );
4981        assert_eq!(results[1]["title"].as_str().unwrap(), "Example Page Two");
4982        assert_eq!(
4983            results[1]["url"].as_str().unwrap(),
4984            "https://example.org/page2"
4985        );
4986    }
4987
4988    #[test]
4989    fn test_parse_duckduckgo_results_empty_html() {
4990        let results = parse_duckduckgo_results("<html><body>No results</body></html>");
4991        assert!(results.is_empty());
4992    }
4993
4994    #[test]
4995    fn test_strip_html_tags() {
4996        assert_eq!(strip_html_tags("<b>bold</b> text"), "bold text");
4997        assert_eq!(strip_html_tags("no tags"), "no tags");
4998        assert_eq!(strip_html_tags("<a href=\"x\">link</a>"), "link");
4999    }
5000
5001    #[tokio::test]
5002    async fn test_agent_tools_without_coordinator() {
5003        let context = make_test_context(None);
5004        let caps = vec![Capability::AgentMessage];
5005
5006        let input = serde_json::json!({
5007            "fighter_id": uuid::Uuid::nil().to_string(),
5008            "message": "hello"
5009        });
5010
5011        let result = execute_tool("agent_message", &input, &caps, &context)
5012            .await
5013            .unwrap();
5014
5015        assert!(!result.success);
5016        assert!(result.error.unwrap().contains("coordinator not available"));
5017    }
5018
5019    // -- Approval engine integration tests --
5020
5021    #[tokio::test]
5022    async fn test_tool_call_blocked_by_approval_policy() {
5023        use punch_types::{ApprovalPolicy, DenyAllHandler, PolicyEngine, RiskLevel};
5024
5025        let engine = PolicyEngine::new(
5026            vec![ApprovalPolicy {
5027                name: "block-file-reads".into(),
5028                tool_patterns: vec!["file_read".into()],
5029                risk_level: RiskLevel::High,
5030                auto_approve: false,
5031                max_auto_approvals: None,
5032            }],
5033            Arc::new(DenyAllHandler),
5034        );
5035
5036        let mut context = make_test_context(None);
5037        context.approval_engine = Some(Arc::new(engine));
5038
5039        let caps = vec![Capability::FileRead("**".into())];
5040        let input = serde_json::json!({"path": "/etc/passwd"});
5041
5042        let result = execute_tool("file_read", &input, &caps, &context)
5043            .await
5044            .expect("execute_tool should not error");
5045
5046        assert!(!result.success);
5047        let error = result.error.expect("should have error message");
5048        assert!(
5049            error.contains("denied by policy"),
5050            "expected 'denied by policy' in error, got: {}",
5051            error
5052        );
5053    }
5054
5055    #[tokio::test]
5056    async fn test_tool_call_allowed_by_approval_policy() {
5057        use punch_types::{ApprovalPolicy, AutoApproveHandler, PolicyEngine, RiskLevel};
5058
5059        let engine = PolicyEngine::new(
5060            vec![ApprovalPolicy {
5061                name: "allow-file-reads".into(),
5062                tool_patterns: vec!["file_read".into()],
5063                risk_level: RiskLevel::Low,
5064                auto_approve: true,
5065                max_auto_approvals: None,
5066            }],
5067            Arc::new(AutoApproveHandler),
5068        );
5069
5070        let mut context = make_test_context(None);
5071        context.approval_engine = Some(Arc::new(engine));
5072
5073        // Write a temp file to read.
5074        let temp_file = context.working_dir.join("punch_approval_test.txt");
5075        tokio::fs::write(&temp_file, "approval test content")
5076            .await
5077            .expect("write temp file");
5078
5079        let caps = vec![Capability::FileRead("**".into())];
5080        let input = serde_json::json!({"path": temp_file.to_string_lossy()});
5081
5082        let result = execute_tool("file_read", &input, &caps, &context)
5083            .await
5084            .expect("execute_tool should not error");
5085
5086        assert!(
5087            result.success,
5088            "tool call should succeed: {:?}",
5089            result.error
5090        );
5091
5092        // Clean up.
5093        let _ = tokio::fs::remove_file(&temp_file).await;
5094    }
5095
5096    // -----------------------------------------------------------------------
5097    // Browser tool tests
5098    // -----------------------------------------------------------------------
5099
5100    #[tokio::test]
5101    async fn test_browser_navigate_requires_capability() {
5102        let context = make_test_context(None);
5103        let caps = vec![Capability::Memory]; // no BrowserControl
5104
5105        let input = serde_json::json!({"url": "https://example.com"});
5106        let result = execute_tool("browser_navigate", &input, &caps, &context)
5107            .await
5108            .expect("should not hard-error");
5109
5110        assert!(!result.success);
5111        let error = result.error.expect("should have error");
5112        assert!(
5113            error.contains("capability denied") || error.contains("missing capability"),
5114            "expected capability denied, got: {}",
5115            error
5116        );
5117    }
5118
5119    #[tokio::test]
5120    async fn test_browser_navigate_no_pool() {
5121        let context = make_test_context(None); // browser_pool is None
5122        let caps = vec![Capability::BrowserControl];
5123
5124        let input = serde_json::json!({"url": "https://example.com"});
5125        let result = execute_tool("browser_navigate", &input, &caps, &context)
5126            .await
5127            .expect("should not hard-error");
5128
5129        assert!(!result.success);
5130        let error = result.error.expect("should have error");
5131        assert!(
5132            error.contains("browser not available"),
5133            "expected 'browser not available', got: {}",
5134            error
5135        );
5136    }
5137
5138    #[tokio::test]
5139    async fn test_browser_navigate_with_pool_no_driver() {
5140        use punch_types::{BrowserConfig, BrowserPool};
5141
5142        let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5143        let mut context = make_test_context(None);
5144        context.browser_pool = Some(pool);
5145
5146        let caps = vec![Capability::BrowserControl];
5147        let input = serde_json::json!({"url": "https://example.com"});
5148
5149        let result = execute_tool("browser_navigate", &input, &caps, &context)
5150            .await
5151            .expect("should not hard-error");
5152
5153        // Pool is available but no CDP driver, so the tool reports failure gracefully.
5154        assert!(!result.success);
5155        let error = result.error.expect("should have error");
5156        assert!(
5157            error.contains("no CDP driver"),
5158            "expected 'no CDP driver', got: {}",
5159            error
5160        );
5161    }
5162
5163    #[tokio::test]
5164    async fn test_browser_screenshot_with_pool() {
5165        use punch_types::{BrowserConfig, BrowserPool};
5166
5167        let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5168        let mut context = make_test_context(None);
5169        context.browser_pool = Some(pool);
5170
5171        let caps = vec![Capability::BrowserControl];
5172        let input = serde_json::json!({"full_page": true});
5173
5174        let result = execute_tool("browser_screenshot", &input, &caps, &context)
5175            .await
5176            .expect("should not hard-error");
5177
5178        assert!(!result.success);
5179        assert_eq!(result.output["full_page"], true);
5180    }
5181
5182    #[tokio::test]
5183    async fn test_browser_click_missing_selector() {
5184        use punch_types::{BrowserConfig, BrowserPool};
5185
5186        let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5187        let mut context = make_test_context(None);
5188        context.browser_pool = Some(pool);
5189
5190        let caps = vec![Capability::BrowserControl];
5191        let input = serde_json::json!({});
5192
5193        let result = execute_tool("browser_click", &input, &caps, &context)
5194            .await
5195            .expect("should not hard-error");
5196
5197        assert!(!result.success);
5198        let error = result.error.expect("should have error");
5199        assert!(
5200            error.contains("missing 'selector'"),
5201            "expected missing selector error, got: {}",
5202            error
5203        );
5204    }
5205
5206    #[tokio::test]
5207    async fn test_browser_type_missing_params() {
5208        use punch_types::{BrowserConfig, BrowserPool};
5209
5210        let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5211        let mut context = make_test_context(None);
5212        context.browser_pool = Some(pool);
5213
5214        let caps = vec![Capability::BrowserControl];
5215
5216        // Missing 'text' param
5217        let input = serde_json::json!({"selector": "#input"});
5218        let result = execute_tool("browser_type", &input, &caps, &context)
5219            .await
5220            .expect("should not hard-error");
5221
5222        assert!(!result.success);
5223        let error = result.error.expect("should have error");
5224        assert!(error.contains("missing 'text'"), "got: {}", error);
5225    }
5226
5227    #[tokio::test]
5228    async fn test_browser_content_with_pool() {
5229        use punch_types::{BrowserConfig, BrowserPool};
5230
5231        let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5232        let mut context = make_test_context(None);
5233        context.browser_pool = Some(pool);
5234
5235        let caps = vec![Capability::BrowserControl];
5236        let input = serde_json::json!({"selector": "h1"});
5237
5238        let result = execute_tool("browser_content", &input, &caps, &context)
5239            .await
5240            .expect("should not hard-error");
5241
5242        assert!(!result.success);
5243        assert_eq!(result.output["selector"], "h1");
5244    }
5245
5246    // -----------------------------------------------------------------------
5247    // New tool tests — data manipulation, regex, code analysis
5248    // -----------------------------------------------------------------------
5249
5250    #[tokio::test]
5251    async fn test_json_query_basic_path() {
5252        let context = make_test_context(None);
5253        let caps = vec![Capability::DataManipulation];
5254
5255        let input = serde_json::json!({
5256            "data": {"users": [{"name": "Alice"}, {"name": "Bob"}]},
5257            "path": "users.1.name"
5258        });
5259
5260        let result = execute_tool("json_query", &input, &caps, &context)
5261            .await
5262            .unwrap();
5263
5264        assert!(result.success);
5265        assert_eq!(result.output, serde_json::json!("Bob"));
5266    }
5267
5268    #[tokio::test]
5269    async fn test_regex_match_with_captures() {
5270        let context = make_test_context(None);
5271        let caps = vec![Capability::DataManipulation];
5272
5273        let input = serde_json::json!({
5274            "pattern": r"(\d+)-(\d+)",
5275            "text": "order 123-456 confirmed",
5276            "global": false
5277        });
5278
5279        let result = execute_tool("regex_match", &input, &caps, &context)
5280            .await
5281            .unwrap();
5282
5283        assert!(result.success);
5284        assert_eq!(result.output["matched"], true);
5285        let groups = result.output["groups"].as_array().unwrap();
5286        assert_eq!(groups[0], "123-456");
5287        assert_eq!(groups[1], "123");
5288        assert_eq!(groups[2], "456");
5289    }
5290
5291    #[tokio::test]
5292    async fn test_regex_replace_basic() {
5293        let context = make_test_context(None);
5294        let caps = vec![Capability::DataManipulation];
5295
5296        let input = serde_json::json!({
5297            "pattern": r"(\w+)@(\w+)",
5298            "replacement": "$1 AT $2",
5299            "text": "email user@example domain"
5300        });
5301
5302        let result = execute_tool("regex_replace", &input, &caps, &context)
5303            .await
5304            .unwrap();
5305
5306        assert!(result.success);
5307        assert_eq!(
5308            result.output,
5309            serde_json::json!("email user AT example domain")
5310        );
5311    }
5312
5313    #[tokio::test]
5314    async fn test_yaml_parse_basic() {
5315        let context = make_test_context(None);
5316        let caps = vec![Capability::DataManipulation];
5317
5318        let input = serde_json::json!({
5319            "content": "name: Alice\nage: 30\ntags:\n  - rust\n  - python"
5320        });
5321
5322        let result = execute_tool("yaml_parse", &input, &caps, &context)
5323            .await
5324            .unwrap();
5325
5326        assert!(result.success);
5327        assert_eq!(result.output["name"], "Alice");
5328        assert_eq!(result.output["age"], 30);
5329        let tags = result.output["tags"].as_array().unwrap();
5330        assert_eq!(tags.len(), 2);
5331    }
5332
5333    #[tokio::test]
5334    async fn test_json_transform_extract_and_rename() {
5335        let context = make_test_context(None);
5336        let caps = vec![Capability::DataManipulation];
5337
5338        let input = serde_json::json!({
5339            "data": [
5340                {"name": "Alice", "age": 30, "city": "NYC"},
5341                {"name": "Bob", "age": 25, "city": "LA"}
5342            ],
5343            "extract": ["name", "city"],
5344            "rename": {"name": "full_name"}
5345        });
5346
5347        let result = execute_tool("json_transform", &input, &caps, &context)
5348            .await
5349            .unwrap();
5350
5351        assert!(result.success);
5352        let arr = result.output.as_array().unwrap();
5353        assert_eq!(arr.len(), 2);
5354        assert_eq!(arr[0]["full_name"], "Alice");
5355        assert!(arr[0].get("age").is_none());
5356    }
5357
5358    #[tokio::test]
5359    async fn test_code_symbols_rust_file() {
5360        let context = make_test_context(None);
5361        let caps = vec![Capability::CodeAnalysis];
5362
5363        // Write a temp Rust file.
5364        let temp_file = context.working_dir.join("punch_test_symbols.rs");
5365        tokio::fs::write(
5366            &temp_file,
5367            "pub fn hello() {}\nstruct Foo {}\nasync fn bar() {}\nenum Color {}",
5368        )
5369        .await
5370        .unwrap();
5371
5372        let input = serde_json::json!({
5373            "path": temp_file.to_string_lossy()
5374        });
5375
5376        let result = execute_tool("code_symbols", &input, &caps, &context)
5377            .await
5378            .unwrap();
5379
5380        assert!(result.success);
5381        let symbols = result.output["symbols"].as_array().unwrap();
5382        let names: Vec<&str> = symbols.iter().filter_map(|s| s["name"].as_str()).collect();
5383        assert!(names.contains(&"hello"), "missing hello: {:?}", names);
5384        assert!(names.contains(&"Foo"), "missing Foo: {:?}", names);
5385        assert!(names.contains(&"bar"), "missing bar: {:?}", names);
5386        assert!(names.contains(&"Color"), "missing Color: {:?}", names);
5387
5388        // Clean up.
5389        let _ = tokio::fs::remove_file(&temp_file).await;
5390    }
5391
5392    // -----------------------------------------------------------------------
5393    // New tool tests — archive, template, hash, env, text, file
5394    // -----------------------------------------------------------------------
5395
5396    #[tokio::test]
5397    async fn test_template_render_basic() {
5398        let context = make_test_context(None);
5399        let caps = vec![Capability::Template];
5400
5401        let input = serde_json::json!({
5402            "template": "Hello, {{name}}! You are {{age}} years old.",
5403            "variables": {"name": "Alice", "age": 30}
5404        });
5405
5406        let result = execute_tool("template_render", &input, &caps, &context)
5407            .await
5408            .unwrap();
5409
5410        assert!(result.success);
5411        assert_eq!(result.output, "Hello, Alice! You are 30 years old.");
5412    }
5413
5414    #[tokio::test]
5415    async fn test_hash_compute_sha256() {
5416        let context = make_test_context(None);
5417        let caps = vec![Capability::Crypto];
5418
5419        let input = serde_json::json!({
5420            "algorithm": "sha256",
5421            "input": "hello world"
5422        });
5423
5424        let result = execute_tool("hash_compute", &input, &caps, &context)
5425            .await
5426            .unwrap();
5427
5428        assert!(result.success);
5429        let hash = result.output["hash"].as_str().unwrap();
5430        // Known SHA-256 of "hello world"
5431        assert_eq!(
5432            hash,
5433            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
5434        );
5435    }
5436
5437    #[tokio::test]
5438    async fn test_hash_verify_match() {
5439        let context = make_test_context(None);
5440        let caps = vec![Capability::Crypto];
5441
5442        let input = serde_json::json!({
5443            "algorithm": "sha256",
5444            "input": "hello world",
5445            "expected": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
5446        });
5447
5448        let result = execute_tool("hash_verify", &input, &caps, &context)
5449            .await
5450            .unwrap();
5451
5452        assert!(result.success);
5453        assert_eq!(result.output["matches"], true);
5454    }
5455
5456    #[tokio::test]
5457    async fn test_text_count_basic() {
5458        let context = make_test_context(None);
5459        let caps = vec![Capability::DataManipulation];
5460
5461        let input = serde_json::json!({
5462            "text": "hello world\nfoo bar baz\n"
5463        });
5464
5465        let result = execute_tool("text_count", &input, &caps, &context)
5466            .await
5467            .unwrap();
5468
5469        assert!(result.success);
5470        assert_eq!(result.output["lines"], 2);
5471        assert_eq!(result.output["words"], 5);
5472    }
5473
5474    #[tokio::test]
5475    async fn test_text_diff_basic() {
5476        let context = make_test_context(None);
5477        let caps = vec![Capability::DataManipulation];
5478
5479        let input = serde_json::json!({
5480            "old_text": "line1\nline2\nline3",
5481            "new_text": "line1\nchanged\nline3"
5482        });
5483
5484        let result = execute_tool("text_diff", &input, &caps, &context)
5485            .await
5486            .unwrap();
5487
5488        assert!(result.success);
5489        assert_eq!(result.output["has_changes"], true);
5490        let diff = result.output["diff"].as_str().unwrap();
5491        assert!(diff.contains("-line2"));
5492        assert!(diff.contains("+changed"));
5493    }
5494
5495    #[tokio::test]
5496    async fn test_env_get_existing_var() {
5497        let context = make_test_context(None);
5498        let caps = vec![Capability::ShellExec("*".to_string())];
5499
5500        // PATH should exist on all systems.
5501        let input = serde_json::json!({"name": "PATH"});
5502
5503        let result = execute_tool("env_get", &input, &caps, &context)
5504            .await
5505            .unwrap();
5506
5507        assert!(result.success);
5508        assert!(result.output["value"].as_str().is_some());
5509    }
5510
5511    #[tokio::test]
5512    async fn test_file_info_basic() {
5513        let context = make_test_context(None);
5514        let caps = vec![Capability::FileRead("**".to_string())];
5515
5516        // Write a temp file.
5517        let temp_file = context.working_dir.join("punch_file_info_test.txt");
5518        tokio::fs::write(&temp_file, "test content").await.unwrap();
5519
5520        let input = serde_json::json!({
5521            "path": temp_file.to_string_lossy()
5522        });
5523
5524        let result = execute_tool("file_info", &input, &caps, &context)
5525            .await
5526            .unwrap();
5527
5528        assert!(result.success);
5529        assert_eq!(result.output["type"], "file");
5530        assert_eq!(result.output["size_bytes"], 12); // "test content" = 12 bytes
5531
5532        let _ = tokio::fs::remove_file(&temp_file).await;
5533    }
5534
5535    #[tokio::test]
5536    async fn test_all_tools_count_at_least_55() {
5537        let tools = crate::tools::all_tools();
5538        assert!(
5539            tools.len() >= 55,
5540            "expected at least 55 tools, got {}",
5541            tools.len()
5542        );
5543    }
5544
5545    // -----------------------------------------------------------------------
5546    // Tool dispatch coverage — verify every tool name routes correctly
5547    // -----------------------------------------------------------------------
5548
5549    #[tokio::test]
5550    async fn test_dispatch_unknown_tool() {
5551        let context = make_test_context(None);
5552        let caps = vec![Capability::Memory];
5553        let input = serde_json::json!({});
5554
5555        let result = execute_tool("nonexistent_tool", &input, &caps, &context)
5556            .await
5557            .unwrap();
5558        assert!(!result.success);
5559        assert!(result.error.as_ref().unwrap().contains("nonexistent_tool"));
5560    }
5561
5562    #[tokio::test]
5563    async fn test_dispatch_file_read_missing_path() {
5564        let context = make_test_context(None);
5565        let caps = vec![Capability::FileRead("**".into())];
5566        let input = serde_json::json!({});
5567
5568        let result = execute_tool("file_read", &input, &caps, &context)
5569            .await
5570            .unwrap();
5571        assert!(!result.success);
5572        assert!(result.error.unwrap().contains("missing 'path'"));
5573    }
5574
5575    #[tokio::test]
5576    async fn test_dispatch_file_write_missing_params() {
5577        let context = make_test_context(None);
5578        let caps = vec![Capability::FileWrite("**".into())];
5579        let input = serde_json::json!({});
5580
5581        let result = execute_tool("file_write", &input, &caps, &context)
5582            .await
5583            .unwrap();
5584        assert!(!result.success);
5585        assert!(result.error.unwrap().contains("missing 'path'"));
5586    }
5587
5588    #[tokio::test]
5589    async fn test_dispatch_file_write_missing_content() {
5590        let context = make_test_context(None);
5591        let caps = vec![Capability::FileWrite("**".into())];
5592        let input = serde_json::json!({"path": "/tmp/test.txt"});
5593
5594        let result = execute_tool("file_write", &input, &caps, &context)
5595            .await
5596            .unwrap();
5597        assert!(!result.success);
5598        assert!(result.error.unwrap().contains("missing 'content'"));
5599    }
5600
5601    // -----------------------------------------------------------------------
5602    // SSRF protection tests
5603    // -----------------------------------------------------------------------
5604
5605    #[test]
5606    fn test_is_private_ip_link_local() {
5607        assert!(is_private_ip(&"169.254.1.1".parse().unwrap()));
5608    }
5609
5610    #[test]
5611    fn test_is_private_ip_broadcast() {
5612        assert!(is_private_ip(&"255.255.255.255".parse().unwrap()));
5613    }
5614
5615    #[test]
5616    fn test_is_private_ip_unspecified() {
5617        assert!(is_private_ip(&"0.0.0.0".parse().unwrap()));
5618    }
5619
5620    #[test]
5621    fn test_is_private_ip_v6_loopback() {
5622        assert!(is_private_ip(&"::1".parse().unwrap()));
5623    }
5624
5625    #[test]
5626    fn test_is_private_ip_v6_unspecified() {
5627        assert!(is_private_ip(&"::".parse().unwrap()));
5628    }
5629
5630    #[test]
5631    fn test_is_private_ip_172_16_range() {
5632        assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
5633        assert!(is_private_ip(&"172.31.255.255".parse().unwrap()));
5634    }
5635
5636    #[test]
5637    fn test_is_not_private_public_ips() {
5638        assert!(!is_private_ip(&"8.8.4.4".parse().unwrap()));
5639        assert!(!is_private_ip(&"142.250.80.46".parse().unwrap()));
5640        assert!(!is_private_ip(&"104.16.132.229".parse().unwrap()));
5641    }
5642
5643    // -----------------------------------------------------------------------
5644    // JSON path query tests
5645    // -----------------------------------------------------------------------
5646
5647    #[test]
5648    fn test_json_path_query_nested() {
5649        let data = serde_json::json!({"a": {"b": {"c": 42}}});
5650        assert_eq!(json_path_query(&data, "a.b.c"), serde_json::json!(42));
5651    }
5652
5653    #[test]
5654    fn test_json_path_query_array_index() {
5655        let data = serde_json::json!({"items": [10, 20, 30]});
5656        assert_eq!(json_path_query(&data, "items.2"), serde_json::json!(30));
5657    }
5658
5659    #[test]
5660    fn test_json_path_query_missing_key() {
5661        let data = serde_json::json!({"a": 1});
5662        assert_eq!(json_path_query(&data, "b"), serde_json::json!(null));
5663    }
5664
5665    #[test]
5666    fn test_json_path_query_empty_path() {
5667        let data = serde_json::json!({"a": 1});
5668        assert_eq!(json_path_query(&data, ""), data);
5669    }
5670
5671    #[test]
5672    fn test_json_path_query_deeply_nested() {
5673        let data = serde_json::json!({"l1": {"l2": {"l3": {"l4": "deep"}}}});
5674        assert_eq!(
5675            json_path_query(&data, "l1.l2.l3.l4"),
5676            serde_json::json!("deep")
5677        );
5678    }
5679
5680    #[test]
5681    fn test_json_path_query_array_of_objects() {
5682        let data = serde_json::json!({"users": [{"name": "Alice"}, {"name": "Bob"}]});
5683        assert_eq!(
5684            json_path_query(&data, "users.0.name"),
5685            serde_json::json!("Alice")
5686        );
5687    }
5688
5689    // -----------------------------------------------------------------------
5690    // resolve_path tests
5691    // -----------------------------------------------------------------------
5692
5693    #[test]
5694    fn test_resolve_path_absolute() {
5695        let result = resolve_path(std::path::Path::new("/tmp"), "/etc/hosts").unwrap();
5696        assert_eq!(result, std::path::PathBuf::from("/etc/hosts"));
5697    }
5698
5699    #[test]
5700    fn test_resolve_path_relative() {
5701        let result = resolve_path(std::path::Path::new("/home/user"), "file.txt").unwrap();
5702        assert_eq!(result, std::path::PathBuf::from("/home/user/file.txt"));
5703    }
5704
5705    #[test]
5706    fn test_resolve_path_dot_prefix() {
5707        let result = resolve_path(std::path::Path::new("/work"), "./src/lib.rs").unwrap();
5708        assert_eq!(result, std::path::PathBuf::from("/work/./src/lib.rs"));
5709    }
5710
5711    // -----------------------------------------------------------------------
5712    // compute_hash tests
5713    // -----------------------------------------------------------------------
5714
5715    #[test]
5716    fn test_compute_hash_sha256() {
5717        let hash = compute_hash("sha256", b"test").unwrap();
5718        assert_eq!(
5719            hash,
5720            "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
5721        );
5722    }
5723
5724    #[test]
5725    fn test_compute_hash_sha512() {
5726        let hash = compute_hash("sha512", b"test").unwrap();
5727        // SHA-512 of "test" is known
5728        assert!(!hash.is_empty());
5729        assert_eq!(hash.len(), 128); // SHA-512 produces 128 hex chars
5730    }
5731
5732    #[test]
5733    fn test_compute_hash_md5_rejected() {
5734        let result = compute_hash("md5", b"test");
5735        assert!(result.is_err());
5736    }
5737
5738    #[test]
5739    fn test_compute_hash_unknown_algo() {
5740        let result = compute_hash("blake2", b"test");
5741        assert!(result.is_err());
5742    }
5743
5744    #[test]
5745    fn test_compute_hash_sha256_empty() {
5746        let hash = compute_hash("sha256", b"").unwrap();
5747        assert_eq!(
5748            hash,
5749            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5750        );
5751    }
5752
5753    // -----------------------------------------------------------------------
5754    // strip_html_tags tests
5755    // -----------------------------------------------------------------------
5756
5757    #[test]
5758    fn test_strip_html_nested_tags() {
5759        assert_eq!(strip_html_tags("<div><span>text</span></div>"), "text");
5760    }
5761
5762    #[test]
5763    fn test_strip_html_empty() {
5764        assert_eq!(strip_html_tags(""), "");
5765    }
5766
5767    #[test]
5768    fn test_strip_html_no_tags() {
5769        assert_eq!(strip_html_tags("plain text"), "plain text");
5770    }
5771
5772    // -----------------------------------------------------------------------
5773    // Additional regex and data manipulation tests
5774    // -----------------------------------------------------------------------
5775
5776    #[tokio::test]
5777    async fn test_regex_match_no_match() {
5778        let context = make_test_context(None);
5779        let caps = vec![Capability::DataManipulation];
5780
5781        let input = serde_json::json!({
5782            "pattern": r"\d+",
5783            "text": "no numbers here",
5784            "global": false
5785        });
5786
5787        let result = execute_tool("regex_match", &input, &caps, &context)
5788            .await
5789            .unwrap();
5790
5791        assert!(result.success);
5792        assert_eq!(result.output["matched"], false);
5793    }
5794
5795    #[tokio::test]
5796    async fn test_regex_match_global() {
5797        let context = make_test_context(None);
5798        let caps = vec![Capability::DataManipulation];
5799
5800        let input = serde_json::json!({
5801            "pattern": r"\d+",
5802            "text": "abc 123 def 456 ghi 789",
5803            "global": true
5804        });
5805
5806        let result = execute_tool("regex_match", &input, &caps, &context)
5807            .await
5808            .unwrap();
5809
5810        assert!(result.success);
5811        let matches = result.output["matches"].as_array().unwrap();
5812        assert_eq!(matches.len(), 3);
5813    }
5814
5815    #[tokio::test]
5816    async fn test_regex_match_invalid_pattern() {
5817        let context = make_test_context(None);
5818        let caps = vec![Capability::DataManipulation];
5819
5820        let input = serde_json::json!({
5821            "pattern": r"[invalid",
5822            "text": "test"
5823        });
5824
5825        let result = execute_tool("regex_match", &input, &caps, &context)
5826            .await
5827            .unwrap();
5828        assert!(!result.success);
5829        assert!(result.error.unwrap().contains("invalid regex"));
5830    }
5831
5832    #[tokio::test]
5833    async fn test_json_query_string_data() {
5834        let context = make_test_context(None);
5835        let caps = vec![Capability::DataManipulation];
5836
5837        let input = serde_json::json!({
5838            "data": r#"{"key": "value"}"#,
5839            "path": "key"
5840        });
5841
5842        let result = execute_tool("json_query", &input, &caps, &context)
5843            .await
5844            .unwrap();
5845
5846        assert!(result.success);
5847        assert_eq!(result.output, serde_json::json!("value"));
5848    }
5849
5850    #[tokio::test]
5851    async fn test_json_transform_filter() {
5852        let context = make_test_context(None);
5853        let caps = vec![Capability::DataManipulation];
5854
5855        let input = serde_json::json!({
5856            "data": [
5857                {"name": "Alice", "role": "admin"},
5858                {"name": "Bob", "role": "user"},
5859                {"name": "Carol", "role": "admin"}
5860            ],
5861            "filter_key": "role",
5862            "filter_value": "admin"
5863        });
5864
5865        let result = execute_tool("json_transform", &input, &caps, &context)
5866            .await
5867            .unwrap();
5868
5869        assert!(result.success);
5870        let arr = result.output.as_array().unwrap();
5871        assert_eq!(arr.len(), 2);
5872    }
5873
5874    #[tokio::test]
5875    async fn test_yaml_parse_nested_mapping() {
5876        let context = make_test_context(None);
5877        let caps = vec![Capability::DataManipulation];
5878
5879        let input = serde_json::json!({
5880            "content": "server:\n  host: localhost\n  port: 8080"
5881        });
5882
5883        let result = execute_tool("yaml_parse", &input, &caps, &context)
5884            .await
5885            .unwrap();
5886
5887        assert!(result.success);
5888        assert_eq!(result.output["server"]["host"], "localhost");
5889        assert_eq!(result.output["server"]["port"], 8080);
5890    }
5891
5892    #[tokio::test]
5893    async fn test_yaml_parse_invalid() {
5894        let context = make_test_context(None);
5895        let caps = vec![Capability::DataManipulation];
5896
5897        let input = serde_json::json!({
5898            "content": ":\n  - invalid:\nyaml: [{"
5899        });
5900
5901        let result = execute_tool("yaml_parse", &input, &caps, &context)
5902            .await
5903            .unwrap();
5904
5905        assert!(!result.success);
5906        assert!(result.error.unwrap().contains("parse YAML"));
5907    }
5908
5909    // -----------------------------------------------------------------------
5910    // Template render edge cases
5911    // -----------------------------------------------------------------------
5912
5913    #[tokio::test]
5914    async fn test_template_render_missing_variable() {
5915        let context = make_test_context(None);
5916        let caps = vec![Capability::Template];
5917
5918        let input = serde_json::json!({
5919            "template": "Hello, {{name}}! Age: {{age}}",
5920            "variables": {"name": "Alice"}
5921        });
5922
5923        let result = execute_tool("template_render", &input, &caps, &context)
5924            .await
5925            .unwrap();
5926
5927        assert!(result.success);
5928        let rendered = result.output.as_str().unwrap();
5929        assert!(rendered.contains("Alice"));
5930        // Missing variable should stay as placeholder
5931        assert!(rendered.contains("{{age}}"));
5932    }
5933
5934    #[tokio::test]
5935    async fn test_template_render_no_variables_in_template() {
5936        let context = make_test_context(None);
5937        let caps = vec![Capability::Template];
5938
5939        let input = serde_json::json!({
5940            "template": "No variables here",
5941            "variables": {}
5942        });
5943
5944        let result = execute_tool("template_render", &input, &caps, &context)
5945            .await
5946            .unwrap();
5947
5948        assert!(result.success);
5949        assert_eq!(result.output, "No variables here");
5950    }
5951
5952    // -----------------------------------------------------------------------
5953    // Hash tools edge cases
5954    // -----------------------------------------------------------------------
5955
5956    #[tokio::test]
5957    async fn test_hash_compute_sha512() {
5958        let context = make_test_context(None);
5959        let caps = vec![Capability::Crypto];
5960
5961        let input = serde_json::json!({
5962            "algorithm": "sha512",
5963            "input": "test"
5964        });
5965
5966        let result = execute_tool("hash_compute", &input, &caps, &context)
5967            .await
5968            .unwrap();
5969
5970        assert!(result.success);
5971        assert_eq!(result.output["algorithm"], "sha512");
5972        let hash = result.output["hash"].as_str().unwrap();
5973        assert_eq!(hash.len(), 128);
5974    }
5975
5976    #[tokio::test]
5977    async fn test_hash_compute_no_input_or_file() {
5978        let context = make_test_context(None);
5979        let caps = vec![Capability::Crypto];
5980
5981        let input = serde_json::json!({"algorithm": "sha256"});
5982
5983        let result = execute_tool("hash_compute", &input, &caps, &context)
5984            .await
5985            .unwrap();
5986
5987        assert!(!result.success);
5988        assert!(result.error.unwrap().contains("must provide"));
5989    }
5990
5991    #[tokio::test]
5992    async fn test_hash_verify_mismatch() {
5993        let context = make_test_context(None);
5994        let caps = vec![Capability::Crypto];
5995
5996        let input = serde_json::json!({
5997            "algorithm": "sha256",
5998            "input": "hello",
5999            "expected": "0000000000000000000000000000000000000000000000000000000000000000"
6000        });
6001
6002        let result = execute_tool("hash_verify", &input, &caps, &context)
6003            .await
6004            .unwrap();
6005
6006        assert!(result.success);
6007        assert_eq!(result.output["matches"], false);
6008    }
6009
6010    // -----------------------------------------------------------------------
6011    // Text tool edge cases
6012    // -----------------------------------------------------------------------
6013
6014    #[tokio::test]
6015    async fn test_text_count_empty() {
6016        let context = make_test_context(None);
6017        let caps = vec![Capability::DataManipulation];
6018
6019        let input = serde_json::json!({"text": ""});
6020
6021        let result = execute_tool("text_count", &input, &caps, &context)
6022            .await
6023            .unwrap();
6024
6025        assert!(result.success);
6026        assert_eq!(result.output["lines"], 0);
6027        assert_eq!(result.output["words"], 0);
6028        assert_eq!(result.output["characters"], 0);
6029        assert_eq!(result.output["bytes"], 0);
6030    }
6031
6032    #[tokio::test]
6033    async fn test_text_diff_identical() {
6034        let context = make_test_context(None);
6035        let caps = vec![Capability::DataManipulation];
6036
6037        let input = serde_json::json!({
6038            "old_text": "same text",
6039            "new_text": "same text"
6040        });
6041
6042        let result = execute_tool("text_diff", &input, &caps, &context)
6043            .await
6044            .unwrap();
6045
6046        assert!(result.success);
6047        assert_eq!(result.output["has_changes"], false);
6048    }
6049
6050    // -----------------------------------------------------------------------
6051    // Env tools
6052    // -----------------------------------------------------------------------
6053
6054    #[tokio::test]
6055    async fn test_env_get_nonexistent_var() {
6056        let context = make_test_context(None);
6057        let caps = vec![Capability::ShellExec("*".to_string())];
6058
6059        let input = serde_json::json!({"name": "PUNCH_NONEXISTENT_VAR_12345"});
6060
6061        let result = execute_tool("env_get", &input, &caps, &context)
6062            .await
6063            .unwrap();
6064
6065        assert!(result.success);
6066        assert!(result.output["value"].is_null());
6067    }
6068
6069    #[tokio::test]
6070    async fn test_env_list_with_prefix() {
6071        let context = make_test_context(None);
6072        let caps = vec![Capability::ShellExec("*".to_string())];
6073
6074        let input = serde_json::json!({"prefix": "PATH"});
6075
6076        let result = execute_tool("env_list", &input, &caps, &context)
6077            .await
6078            .unwrap();
6079
6080        assert!(result.success);
6081        // PATH should be in the results
6082        let count = result.output["count"].as_u64().unwrap();
6083        assert!(count >= 1);
6084    }
6085
6086    // -----------------------------------------------------------------------
6087    // Capability edge cases
6088    // -----------------------------------------------------------------------
6089
6090    #[test]
6091    fn test_require_capability_multiple_grants() {
6092        let caps = vec![
6093            Capability::FileRead("src/**".into()),
6094            Capability::FileRead("tests/**".into()),
6095        ];
6096        assert!(require_capability(&caps, &Capability::FileRead("src/main.rs".into())).is_ok());
6097        assert!(require_capability(&caps, &Capability::FileRead("tests/test.rs".into())).is_ok());
6098    }
6099
6100    #[test]
6101    fn test_require_capability_empty_caps() {
6102        let caps: Vec<Capability> = vec![];
6103        assert!(require_capability(&caps, &Capability::Memory).is_err());
6104    }
6105
6106    #[test]
6107    fn test_require_capability_wrong_type() {
6108        let caps = vec![Capability::FileRead("**".into())];
6109        assert!(require_capability(&caps, &Capability::FileWrite("test.txt".into())).is_err());
6110    }
6111
6112    // -----------------------------------------------------------------------
6113    // File read/write round-trip
6114    // -----------------------------------------------------------------------
6115
6116    #[tokio::test]
6117    async fn test_file_write_and_read_roundtrip() {
6118        let context = make_test_context(None);
6119        let temp_file = context.working_dir.join("punch_roundtrip_test.txt");
6120        let caps = vec![
6121            Capability::FileRead("**".into()),
6122            Capability::FileWrite("**".into()),
6123        ];
6124
6125        // Write
6126        let write_input = serde_json::json!({
6127            "path": temp_file.to_string_lossy(),
6128            "content": "roundtrip content"
6129        });
6130        let write_result = execute_tool("file_write", &write_input, &caps, &context)
6131            .await
6132            .unwrap();
6133        assert!(write_result.success);
6134
6135        // Read back
6136        let read_input = serde_json::json!({
6137            "path": temp_file.to_string_lossy()
6138        });
6139        let read_result = execute_tool("file_read", &read_input, &caps, &context)
6140            .await
6141            .unwrap();
6142        assert!(read_result.success);
6143        assert_eq!(read_result.output, "roundtrip content");
6144
6145        let _ = tokio::fs::remove_file(&temp_file).await;
6146    }
6147
6148    // -----------------------------------------------------------------------
6149    // File list test
6150    // -----------------------------------------------------------------------
6151
6152    #[tokio::test]
6153    async fn test_file_list_temp_dir() {
6154        let context = make_test_context(None);
6155        let caps = vec![Capability::FileRead("**".into())];
6156
6157        let input = serde_json::json!({"path": "."});
6158
6159        let result = execute_tool("file_list", &input, &caps, &context)
6160            .await
6161            .unwrap();
6162
6163        assert!(result.success);
6164        // Temp dir should have at least some entries
6165        assert!(result.output.as_array().is_some());
6166    }
6167
6168    // -----------------------------------------------------------------------
6169    // Capability denied for data tools
6170    // -----------------------------------------------------------------------
6171
6172    #[tokio::test]
6173    async fn test_json_query_denied_without_capability() {
6174        let context = make_test_context(None);
6175        let caps = vec![Capability::Memory]; // Wrong capability
6176
6177        let input = serde_json::json!({
6178            "data": {"key": "value"},
6179            "path": "key"
6180        });
6181
6182        let result = execute_tool("json_query", &input, &caps, &context)
6183            .await
6184            .unwrap();
6185
6186        assert!(!result.success);
6187        assert!(result.error.unwrap().contains("capability"));
6188    }
6189
6190    #[tokio::test]
6191    async fn test_template_render_denied_without_capability() {
6192        let context = make_test_context(None);
6193        let caps = vec![Capability::Memory];
6194
6195        let input = serde_json::json!({
6196            "template": "{{name}}",
6197            "variables": {"name": "test"}
6198        });
6199
6200        let result = execute_tool("template_render", &input, &caps, &context)
6201            .await
6202            .unwrap();
6203
6204        assert!(!result.success);
6205        assert!(result.error.unwrap().contains("capability"));
6206    }
6207
6208    #[tokio::test]
6209    async fn test_hash_compute_denied_without_capability() {
6210        let context = make_test_context(None);
6211        let caps = vec![Capability::Memory];
6212
6213        let input = serde_json::json!({
6214            "algorithm": "sha256",
6215            "input": "test"
6216        });
6217
6218        let result = execute_tool("hash_compute", &input, &caps, &context)
6219            .await
6220            .unwrap();
6221
6222        assert!(!result.success);
6223        assert!(result.error.unwrap().contains("capability"));
6224    }
6225
6226    // -----------------------------------------------------------------------
6227    // Bleed detector integration tests
6228    // -----------------------------------------------------------------------
6229
6230    fn make_test_context_with_bleed_detector() -> ToolExecutionContext {
6231        let mut ctx = make_test_context(None);
6232        ctx.bleed_detector = Some(Arc::new(ShellBleedDetector::new()));
6233        ctx
6234    }
6235
6236    #[tokio::test]
6237    async fn test_shell_exec_clean_input_passes() {
6238        let context = make_test_context_with_bleed_detector();
6239        let caps = vec![Capability::ShellExec("*".to_string())];
6240
6241        let input = serde_json::json!({"command": "echo hello"});
6242        let result = execute_tool("shell_exec", &input, &caps, &context)
6243            .await
6244            .unwrap();
6245
6246        assert!(
6247            result.success,
6248            "clean command should pass: {:?}",
6249            result.error
6250        );
6251        let stdout = result.output["stdout"].as_str().unwrap_or("");
6252        assert!(stdout.contains("hello"));
6253    }
6254
6255    #[tokio::test]
6256    async fn test_shell_exec_tainted_input_blocked() {
6257        let context = make_test_context_with_bleed_detector();
6258        let caps = vec![Capability::ShellExec("*".to_string())];
6259
6260        // Build an AWS-key-like pattern dynamically to avoid static scanners.
6261        let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
6262        let input = serde_json::json!({"command": format!("curl -H 'X-Key: {}'", key)});
6263        let result = execute_tool("shell_exec", &input, &caps, &context)
6264            .await
6265            .unwrap();
6266
6267        assert!(!result.success, "tainted command should be blocked");
6268        let error = result.error.unwrap();
6269        assert!(
6270            error.contains("shell bleed detected"),
6271            "expected bleed detection, got: {}",
6272            error
6273        );
6274    }
6275
6276    #[tokio::test]
6277    async fn test_shell_exec_api_key_pattern_flagged() {
6278        let context = make_test_context_with_bleed_detector();
6279        let caps = vec![Capability::ShellExec("*".to_string())];
6280
6281        let input = serde_json::json!({
6282            "command": "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test'"
6283        });
6284        let result = execute_tool("shell_exec", &input, &caps, &context)
6285            .await
6286            .unwrap();
6287
6288        assert!(!result.success, "bearer token in command should be blocked");
6289        assert!(result.error.unwrap().contains("shell bleed detected"));
6290    }
6291
6292    #[tokio::test]
6293    async fn test_file_read_sensitive_path_flagged() {
6294        let context = make_test_context_with_bleed_detector();
6295        let caps = vec![Capability::FileRead("**".to_string())];
6296
6297        let input = serde_json::json!({"path": "/home/user/.ssh/id_rsa"});
6298        let result = execute_tool("file_read", &input, &caps, &context)
6299            .await
6300            .unwrap();
6301
6302        assert!(!result.success, "sensitive path read should be blocked");
6303        let error = result.error.unwrap();
6304        assert!(
6305            error.contains("sensitive path") && error.contains("blocked"),
6306            "expected sensitive path blocked, got: {}",
6307            error
6308        );
6309    }
6310
6311    #[tokio::test]
6312    async fn test_file_read_normal_path_passes() {
6313        let context = make_test_context_with_bleed_detector();
6314        let caps = vec![Capability::FileRead("**".to_string())];
6315
6316        // Create a temp file to read.
6317        let temp_file = context.working_dir.join("punch_bleed_test_normal.txt");
6318        tokio::fs::write(&temp_file, "normal content")
6319            .await
6320            .expect("write temp file");
6321
6322        let input = serde_json::json!({"path": temp_file.to_string_lossy()});
6323        let result = execute_tool("file_read", &input, &caps, &context)
6324            .await
6325            .unwrap();
6326
6327        assert!(
6328            result.success,
6329            "normal path should pass: {:?}",
6330            result.error
6331        );
6332        let _ = tokio::fs::remove_file(&temp_file).await;
6333    }
6334
6335    #[test]
6336    fn test_bleed_detector_records_security_events() {
6337        let detector = ShellBleedDetector::new();
6338
6339        // Clean command produces no warnings.
6340        let clean = detector.scan_command("ls -la /tmp");
6341        assert!(clean.is_empty(), "clean command should produce no warnings");
6342
6343        // Tainted command produces warnings.
6344        let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
6345        let tainted = detector.scan_command(&format!("export AWS_KEY={}", key));
6346        assert!(
6347            !tainted.is_empty(),
6348            "tainted command should produce warnings"
6349        );
6350
6351        // Bearer token produces warnings.
6352        let bearer =
6353            detector.scan_command("curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test'");
6354        assert!(!bearer.is_empty(), "bearer token should produce warnings");
6355    }
6356
6357    #[test]
6358    fn test_is_sensitive_path_detection() {
6359        assert!(is_sensitive_path("/home/user/.ssh/id_rsa"));
6360        assert!(is_sensitive_path("/app/.env"));
6361        assert!(is_sensitive_path("/home/user/.aws/credentials"));
6362        assert!(is_sensitive_path("/home/user/.kube/config"));
6363        assert!(is_sensitive_path("secrets.json"));
6364        assert!(!is_sensitive_path("/home/user/project/src/main.rs"));
6365        assert!(!is_sensitive_path("/tmp/output.txt"));
6366    }
6367
6368    // -----------------------------------------------------------------------
6369    // wasm_invoke tests
6370    // -----------------------------------------------------------------------
6371
6372    #[tokio::test]
6373    async fn test_wasm_invoke_no_registry_returns_error() {
6374        let context = make_test_context(None);
6375        let caps = vec![Capability::PluginInvoke];
6376        let input = serde_json::json!({
6377            "plugin": "test-plugin",
6378            "function": "execute"
6379        });
6380
6381        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6382            .await
6383            .unwrap();
6384
6385        assert!(!result.success);
6386        assert!(
6387            result
6388                .error
6389                .as_deref()
6390                .unwrap()
6391                .contains("plugin runtime not configured"),
6392            "expected plugin runtime error, got: {:?}",
6393            result.error
6394        );
6395    }
6396
6397    #[tokio::test]
6398    async fn test_wasm_invoke_missing_capability() {
6399        let context = make_test_context(None);
6400        // No PluginInvoke capability
6401        let caps = vec![Capability::Memory];
6402        let input = serde_json::json!({
6403            "plugin": "test-plugin",
6404            "function": "execute"
6405        });
6406
6407        let result = execute_tool("wasm_invoke", &input, &caps, &context).await;
6408        // Should fail with capability denied
6409        match result {
6410            Ok(tr) => assert!(!tr.success),
6411            Err(e) => assert!(
6412                e.to_string().contains("capability"),
6413                "expected capability error, got: {e}"
6414            ),
6415        }
6416    }
6417
6418    #[tokio::test]
6419    async fn test_wasm_invoke_missing_plugin_param() {
6420        use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6421        let runtime = Arc::new(NativePluginRuntime::new());
6422        let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6423
6424        let mut context = make_test_context(None);
6425        context.plugin_registry = Some(registry);
6426
6427        let caps = vec![Capability::PluginInvoke];
6428        let input = serde_json::json!({
6429            "function": "execute"
6430        });
6431
6432        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6433            .await
6434            .unwrap();
6435
6436        assert!(!result.success);
6437        assert!(
6438            result.error.as_deref().unwrap().contains("plugin"),
6439            "expected missing plugin param error, got: {:?}",
6440            result.error
6441        );
6442    }
6443
6444    #[tokio::test]
6445    async fn test_wasm_invoke_missing_function_param() {
6446        use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6447        let runtime = Arc::new(NativePluginRuntime::new());
6448        let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6449
6450        let mut context = make_test_context(None);
6451        context.plugin_registry = Some(registry);
6452
6453        let caps = vec![Capability::PluginInvoke];
6454        let input = serde_json::json!({
6455            "plugin": "test-plugin"
6456        });
6457
6458        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6459            .await
6460            .unwrap();
6461
6462        assert!(!result.success);
6463        assert!(
6464            result.error.as_deref().unwrap().contains("function"),
6465            "expected missing function param error, got: {:?}",
6466            result.error
6467        );
6468    }
6469
6470    #[tokio::test]
6471    async fn test_wasm_invoke_plugin_not_found() {
6472        use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6473        let runtime = Arc::new(NativePluginRuntime::new());
6474        let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6475
6476        let mut context = make_test_context(None);
6477        context.plugin_registry = Some(registry);
6478
6479        let caps = vec![Capability::PluginInvoke];
6480        let input = serde_json::json!({
6481            "plugin": "nonexistent-plugin",
6482            "function": "execute"
6483        });
6484
6485        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6486            .await
6487            .unwrap();
6488
6489        assert!(!result.success);
6490        assert!(
6491            result.error.as_deref().unwrap().contains("not found"),
6492            "expected plugin not found error, got: {:?}",
6493            result.error
6494        );
6495    }
6496
6497    #[tokio::test]
6498    async fn test_wasm_invoke_success_with_native_runtime() {
6499        use punch_extensions::plugin::{
6500            NativePluginRuntime, PluginManifest, PluginOutput, PluginPermissions, PluginRegistry,
6501        };
6502
6503        let runtime = Arc::new(NativePluginRuntime::new());
6504        let registry = Arc::new(PluginRegistry::with_runtime(runtime.clone()));
6505
6506        let manifest = PluginManifest {
6507            name: "echo-technique".to_string(),
6508            version: "1.0.0".to_string(),
6509            description: "Echoes input back".to_string(),
6510            author: "Test".to_string(),
6511            entry_point: "execute".to_string(),
6512            capabilities: vec![],
6513            max_memory_bytes: 64 * 1024 * 1024,
6514            max_execution_ms: 30_000,
6515            permissions: PluginPermissions::default(),
6516        };
6517
6518        let id = registry.register(manifest, b"native").await.unwrap();
6519        runtime.register_function(id, |input| {
6520            Ok(PluginOutput {
6521                result: input.args.clone(),
6522                logs: vec!["technique executed".to_string()],
6523                execution_ms: 0,
6524                memory_used_bytes: 512,
6525            })
6526        });
6527
6528        let mut context = make_test_context(None);
6529        context.plugin_registry = Some(registry);
6530
6531        let caps = vec![Capability::PluginInvoke];
6532        let input = serde_json::json!({
6533            "plugin": "echo-technique",
6534            "function": "execute",
6535            "input": {"strike": "roundhouse"}
6536        });
6537
6538        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6539            .await
6540            .unwrap();
6541
6542        assert!(
6543            result.success,
6544            "wasm_invoke should succeed: {:?}",
6545            result.error
6546        );
6547        assert_eq!(result.output["result"]["strike"], "roundhouse");
6548        assert!(!result.output["logs"].as_array().unwrap().is_empty());
6549    }
6550
6551    #[tokio::test]
6552    async fn test_wasm_invoke_default_input() {
6553        use punch_extensions::plugin::{
6554            NativePluginRuntime, PluginManifest, PluginOutput, PluginPermissions, PluginRegistry,
6555        };
6556
6557        let runtime = Arc::new(NativePluginRuntime::new());
6558        let registry = Arc::new(PluginRegistry::with_runtime(runtime.clone()));
6559
6560        let manifest = PluginManifest {
6561            name: "noop-technique".to_string(),
6562            version: "1.0.0".to_string(),
6563            description: "Does nothing".to_string(),
6564            author: "Test".to_string(),
6565            entry_point: "execute".to_string(),
6566            capabilities: vec![],
6567            max_memory_bytes: 64 * 1024 * 1024,
6568            max_execution_ms: 30_000,
6569            permissions: PluginPermissions::default(),
6570        };
6571
6572        let id = registry.register(manifest, b"native").await.unwrap();
6573        runtime.register_function(id, |input| {
6574            // Verify args default to empty object when "input" is omitted
6575            assert_eq!(input.args, serde_json::json!({}));
6576            Ok(PluginOutput {
6577                result: serde_json::json!("ok"),
6578                logs: vec![],
6579                execution_ms: 0,
6580                memory_used_bytes: 0,
6581            })
6582        });
6583
6584        let mut context = make_test_context(None);
6585        context.plugin_registry = Some(registry);
6586
6587        let caps = vec![Capability::PluginInvoke];
6588        // Omit "input" field — should default to {}
6589        let input = serde_json::json!({
6590            "plugin": "noop-technique",
6591            "function": "execute"
6592        });
6593
6594        let result = execute_tool("wasm_invoke", &input, &caps, &context)
6595            .await
6596            .unwrap();
6597
6598        assert!(
6599            result.success,
6600            "wasm_invoke should succeed: {:?}",
6601            result.error
6602        );
6603        assert_eq!(result.output["result"], "ok");
6604    }
6605
6606    // -----------------------------------------------------------------------
6607    // A2A delegation tests
6608    // -----------------------------------------------------------------------
6609
6610    #[tokio::test]
6611    async fn test_a2a_delegate_missing_agent_url() {
6612        let context = make_test_context(None);
6613        let caps = vec![Capability::A2ADelegate];
6614        let input = serde_json::json!({"prompt": "hello"});
6615
6616        let result = execute_tool("a2a_delegate", &input, &caps, &context)
6617            .await
6618            .unwrap();
6619        assert!(!result.success);
6620        assert!(
6621            result.error.as_deref().unwrap_or("").contains("agent_url"),
6622            "error should mention agent_url: {:?}",
6623            result.error
6624        );
6625    }
6626
6627    #[tokio::test]
6628    async fn test_a2a_delegate_missing_prompt() {
6629        let context = make_test_context(None);
6630        let caps = vec![Capability::A2ADelegate];
6631        let input = serde_json::json!({"agent_url": "http://localhost:9999"});
6632
6633        let result = execute_tool("a2a_delegate", &input, &caps, &context)
6634            .await
6635            .unwrap();
6636        assert!(!result.success);
6637        assert!(
6638            result.error.as_deref().unwrap_or("").contains("prompt"),
6639            "error should mention prompt: {:?}",
6640            result.error
6641        );
6642    }
6643
6644    #[tokio::test]
6645    async fn test_a2a_delegate_capability_denied() {
6646        let context = make_test_context(None);
6647        let caps = vec![Capability::Memory]; // no A2ADelegate
6648        let input = serde_json::json!({
6649            "agent_url": "http://localhost:9999",
6650            "prompt": "hello"
6651        });
6652
6653        let result = execute_tool("a2a_delegate", &input, &caps, &context).await;
6654        // Should fail with CapabilityDenied error
6655        assert!(result.is_err() || !result.unwrap().success);
6656    }
6657
6658    #[tokio::test]
6659    async fn test_a2a_delegate_unreachable_agent() {
6660        let context = make_test_context(None);
6661        let caps = vec![Capability::A2ADelegate];
6662        let input = serde_json::json!({
6663            "agent_url": "http://127.0.0.1:19999",
6664            "prompt": "hello",
6665            "timeout_secs": 2
6666        });
6667
6668        let result = execute_tool("a2a_delegate", &input, &caps, &context)
6669            .await
6670            .unwrap();
6671        assert!(!result.success);
6672        assert!(
6673            result
6674                .error
6675                .as_deref()
6676                .unwrap_or("")
6677                .contains("discovery failed"),
6678            "error should mention discovery failure: {:?}",
6679            result.error
6680        );
6681    }
6682
6683    // -----------------------------------------------------------------------
6684    // Self-configuration tool tests
6685    // -----------------------------------------------------------------------
6686
6687    #[tokio::test]
6688    async fn test_heartbeat_add_requires_self_config() {
6689        let context = make_test_context(None);
6690        let caps = vec![Capability::Memory]; // No SelfConfig
6691        let input = serde_json::json!({"task": "test", "cadence": "daily"});
6692        let result = execute_tool("heartbeat_add", &input, &caps, &context).await;
6693        assert!(result.is_err() || !result.unwrap().success);
6694    }
6695
6696    #[tokio::test]
6697    async fn test_heartbeat_add_invalid_cadence() {
6698        let context = make_test_context(None);
6699        let caps = vec![Capability::SelfConfig];
6700
6701        // First create a creed bound to this fighter.
6702        let mut creed = punch_types::creed::Creed::new("test-fighter");
6703        creed.fighter_id = Some(context.fighter_id);
6704        context.memory.save_creed(&creed).await.unwrap();
6705
6706        let input = serde_json::json!({"task": "test", "cadence": "every_5_minutes"});
6707        let result = execute_tool("heartbeat_add", &input, &caps, &context)
6708            .await
6709            .unwrap();
6710        assert!(!result.success);
6711        assert!(result.error.unwrap().contains("invalid cadence"));
6712    }
6713
6714    #[tokio::test]
6715    async fn test_heartbeat_add_success() {
6716        let context = make_test_context(None);
6717        let caps = vec![Capability::SelfConfig];
6718
6719        let mut creed = punch_types::creed::Creed::new("test-fighter");
6720        creed.fighter_id = Some(context.fighter_id);
6721        context.memory.save_creed(&creed).await.unwrap();
6722
6723        let input = serde_json::json!({"task": "Morning briefing", "cadence": "daily"});
6724        let result = execute_tool("heartbeat_add", &input, &caps, &context)
6725            .await
6726            .unwrap();
6727        assert!(result.success);
6728        assert_eq!(result.output["total_heartbeats"], 1);
6729
6730        // Verify it was persisted.
6731        let updated = context
6732            .memory
6733            .load_creed_by_fighter(&context.fighter_id)
6734            .await
6735            .unwrap()
6736            .unwrap();
6737        assert_eq!(updated.heartbeat.len(), 1);
6738        assert_eq!(updated.heartbeat[0].task, "Morning briefing");
6739        assert_eq!(updated.heartbeat[0].cadence, "daily");
6740    }
6741
6742    #[tokio::test]
6743    async fn test_heartbeat_list_success() {
6744        let context = make_test_context(None);
6745        let caps = vec![Capability::SelfConfig];
6746
6747        let mut creed = punch_types::creed::Creed::new("test-fighter");
6748        creed.fighter_id = Some(context.fighter_id);
6749        creed = creed.with_heartbeat_task("Check health", "hourly");
6750        creed = creed.with_heartbeat_task("Daily summary", "daily");
6751        context.memory.save_creed(&creed).await.unwrap();
6752
6753        let input = serde_json::json!({});
6754        let result = execute_tool("heartbeat_list", &input, &caps, &context)
6755            .await
6756            .unwrap();
6757        assert!(result.success);
6758        assert_eq!(result.output["total"], 2);
6759        let heartbeats = result.output["heartbeats"].as_array().unwrap();
6760        assert_eq!(heartbeats[0]["task"], "Check health");
6761        assert_eq!(heartbeats[1]["cadence"], "daily");
6762    }
6763
6764    #[tokio::test]
6765    async fn test_heartbeat_remove_success() {
6766        let context = make_test_context(None);
6767        let caps = vec![Capability::SelfConfig];
6768
6769        let mut creed = punch_types::creed::Creed::new("test-fighter");
6770        creed.fighter_id = Some(context.fighter_id);
6771        creed = creed.with_heartbeat_task("Task A", "hourly");
6772        creed = creed.with_heartbeat_task("Task B", "daily");
6773        context.memory.save_creed(&creed).await.unwrap();
6774
6775        let input = serde_json::json!({"index": 0});
6776        let result = execute_tool("heartbeat_remove", &input, &caps, &context)
6777            .await
6778            .unwrap();
6779        assert!(result.success);
6780        assert_eq!(result.output["remaining"], 1);
6781
6782        let updated = context
6783            .memory
6784            .load_creed_by_fighter(&context.fighter_id)
6785            .await
6786            .unwrap()
6787            .unwrap();
6788        assert_eq!(updated.heartbeat.len(), 1);
6789        assert_eq!(updated.heartbeat[0].task, "Task B");
6790    }
6791
6792    #[tokio::test]
6793    async fn test_heartbeat_remove_out_of_range() {
6794        let context = make_test_context(None);
6795        let caps = vec![Capability::SelfConfig];
6796
6797        let mut creed = punch_types::creed::Creed::new("test-fighter");
6798        creed.fighter_id = Some(context.fighter_id);
6799        context.memory.save_creed(&creed).await.unwrap();
6800
6801        let input = serde_json::json!({"index": 5});
6802        let result = execute_tool("heartbeat_remove", &input, &caps, &context)
6803            .await
6804            .unwrap();
6805        assert!(!result.success);
6806        assert!(result.error.unwrap().contains("out of range"));
6807    }
6808
6809    #[tokio::test]
6810    async fn test_creed_view_success() {
6811        let context = make_test_context(None);
6812        let caps = vec![Capability::SelfConfig];
6813
6814        let mut creed = punch_types::creed::Creed::new("view-test")
6815            .with_identity("A test fighter")
6816            .with_trait("curiosity", 0.9)
6817            .with_directive("Be thorough");
6818        creed.fighter_id = Some(context.fighter_id);
6819        context.memory.save_creed(&creed).await.unwrap();
6820
6821        let input = serde_json::json!({});
6822        let result = execute_tool("creed_view", &input, &caps, &context)
6823            .await
6824            .unwrap();
6825        assert!(result.success);
6826        assert_eq!(result.output["fighter_name"], "view-test");
6827        assert_eq!(result.output["identity"], "A test fighter");
6828        assert!(
6829            result.output["directives"]
6830                .as_array()
6831                .unwrap()
6832                .iter()
6833                .any(|d| d == "Be thorough")
6834        );
6835    }
6836
6837    #[tokio::test]
6838    async fn test_skill_list_success() {
6839        let context = make_test_context(None);
6840        let caps = vec![Capability::SelfConfig];
6841        let input = serde_json::json!({});
6842        let result = execute_tool("skill_list", &input, &caps, &context)
6843            .await
6844            .unwrap();
6845        assert!(result.success);
6846        let packs = result.output["packs"].as_array().unwrap();
6847        assert!(!packs.is_empty(), "should have bundled packs");
6848        // Check that productivity pack is listed.
6849        assert!(
6850            packs.iter().any(|p| p["name"] == "productivity"),
6851            "should include productivity pack"
6852        );
6853    }
6854
6855    #[tokio::test]
6856    async fn test_skill_recommend_known_pack() {
6857        let context = make_test_context(None);
6858        let caps = vec![Capability::SelfConfig];
6859        let input = serde_json::json!({"pack_name": "productivity"});
6860        let result = execute_tool("skill_recommend", &input, &caps, &context)
6861            .await
6862            .unwrap();
6863        assert!(result.success);
6864        assert_eq!(result.output["pack_name"], "productivity");
6865        assert!(
6866            result.output["install_command"]
6867                .as_str()
6868                .unwrap()
6869                .contains("punch move add productivity")
6870        );
6871    }
6872
6873    #[tokio::test]
6874    async fn test_skill_recommend_unknown_pack() {
6875        let context = make_test_context(None);
6876        let caps = vec![Capability::SelfConfig];
6877        let input = serde_json::json!({"pack_name": "nonexistent-pack"});
6878        let result = execute_tool("skill_recommend", &input, &caps, &context)
6879            .await
6880            .unwrap();
6881        assert!(!result.success);
6882        assert!(result.output["available_packs"].as_array().unwrap().len() > 0);
6883    }
6884}