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