1use 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
23pub struct ToolExecutionContext {
25 pub working_dir: PathBuf,
27 pub fighter_id: FighterId,
29 pub memory: Arc<MemorySubstrate>,
31 pub coordinator: Option<Arc<dyn AgentCoordinator>>,
34 pub approval_engine: Option<Arc<PolicyEngine>>,
38 pub sandbox: Option<Arc<SandboxEnforcer>>,
41 pub bleed_detector: Option<Arc<ShellBleedDetector>>,
45 pub browser_pool: Option<Arc<BrowserPool>>,
50}
51
52const DEFAULT_TIMEOUT_SECS: u64 = 120;
54
55#[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
93async fn execute_tool_inner(
95 name: &str,
96 input: &serde_json::Value,
97 capabilities: &[Capability],
98 context: &ToolExecutionContext,
99) -> PunchResult<ToolResult> {
100 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 }
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_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 "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_request" => tool_http_request(input, capabilities).await,
162 "http_post" => tool_http_post(input, capabilities).await,
163 "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_list" => tool_process_list(input, capabilities, context).await,
171 "process_kill" => tool_process_kill(input, capabilities).await,
172 "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_search" => tool_code_search(input, capabilities, context).await,
178 "code_symbols" => tool_code_symbols(input, capabilities, context).await,
179 "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_render" => tool_template_render(input, capabilities).await,
185 "hash_compute" => tool_hash_compute(input, capabilities, context).await,
187 "hash_verify" => tool_hash_verify(input, capabilities, context).await,
188 "env_get" => tool_env_get(input, capabilities).await,
190 "env_list" => tool_env_list(input, capabilities).await,
191 "text_diff" => tool_text_diff(input, capabilities).await,
193 "text_count" => tool_text_count(input, capabilities).await,
194 "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
201fn 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
220fn 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
231static 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
263fn 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
271async 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 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 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 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 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 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 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
417async 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 require_capability(capabilities, &Capability::FileWrite(path_display.clone()))?;
440
441 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 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 let file_patch = &patch_set.patches[0];
466
467 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 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 match punch_types::apply_patch_fuzzy(&original, file_patch, 3) {
498 Ok(patched) => {
499 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 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 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 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 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 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 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
803fn parse_duckduckgo_results(html: &str) -> Vec<serde_json::Value> {
805 let mut results = Vec::new();
806 let mut remaining = html;
807
808 while results.len() < 5 {
812 let marker = "class=\"result__a\"";
814 let Some(pos) = remaining.find(marker) else {
815 break;
816 };
817 remaining = &remaining[pos + marker.len()..];
818
819 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 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 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 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_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
868fn 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 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 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 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 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
1175fn 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 use punch_types::{FighterManifest, ModelConfig, Provider, WeightClass};
1218
1219 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 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
1348fn is_private_ip(ip: &IpAddr) -> bool {
1354 match ip {
1355 IpAddr::V4(v4) => {
1356 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() || v4.is_unspecified() }
1362 IpAddr::V6(v6) => {
1363 v6.is_loopback() || v6.is_unspecified() }
1366 }
1367}
1368
1369fn 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
1517async 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 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
1767async 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 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 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
1999async 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
2167fn 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 if let Ok(idx) = segment.parse::<usize>()
2180 && let Some(val) = current.get(idx)
2181 {
2182 current = val;
2183 continue;
2184 }
2185 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 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 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 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 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 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
2450async 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 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) .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#[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
2568static SCHEDULED_TASKS: LazyLock<DashMap<String, ScheduledTask>> = LazyLock::new(DashMap::new);
2570
2571static 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 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 let _output = Command::new("sh")
2628 .arg("-c")
2629 .arg(&command_owned)
2630 .current_dir(&working_dir)
2631 .output()
2632 .await;
2633
2634 if let Some(mut entry) = SCHEDULED_TASKS.get_mut(&task_id_clone) {
2636 entry.status = "executed".to_string();
2637 }
2638
2639 let Some(interval) = interval_secs else {
2641 break;
2642 };
2643
2644 tokio::select! {
2646 _ = tokio::time::sleep(std::time::Duration::from_secs(interval)) => {}
2647 _ = cancel_rx.changed() => {
2648 break;
2649 }
2650 }
2651 }
2652
2653 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 if let Some(sender) = TASK_CANCELLERS.get(task_id) {
2712 let _ = sender.send(true);
2713 }
2714
2715 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
2734async 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 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 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 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 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
2923async 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 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
3117async 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 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: ®ex::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
3166fn 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 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
3288async 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
3361async 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
3423async fn tool_file_search(
3428 input: &serde_json::Value,
3429 capabilities: &[Capability],
3430 context: &ToolExecutionContext,
3431) -> PunchResult<ToolResult> {
3432 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#[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 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 #[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 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 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 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 #[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 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 let _ = tokio::fs::remove_file(&temp_file).await;
3960 }
3961
3962 #[tokio::test]
3967 async fn test_browser_navigate_requires_capability() {
3968 let context = make_test_context(None);
3969 let caps = vec![Capability::Memory]; 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); 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 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 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 #[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 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 let _ = tokio::fs::remove_file(&temp_file).await;
4256 }
4257
4258 #[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 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 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 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); 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 #[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 #[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 #[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 #[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 #[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 assert!(!hash.is_empty());
4591 assert_eq!(hash.len(), 128); }
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 #[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 #[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 #[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 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 #[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 #[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 #[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 let count = result.output["count"].as_u64().unwrap();
4945 assert!(count >= 1);
4946 }
4947
4948 #[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 #[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 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 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 #[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 assert!(result.output.as_array().is_some());
5028 }
5029
5030 #[tokio::test]
5035 async fn test_json_query_denied_without_capability() {
5036 let context = make_test_context(None);
5037 let caps = vec![Capability::Memory]; 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 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 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 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 let clean = detector.scan_command("ls -la /tmp");
5195 assert!(clean.is_empty(), "clean command should produce no warnings");
5196
5197 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 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}