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