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