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