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