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, ChannelNotifier, FighterId,
20 PolicyEngine, PunchError, PunchResult, SandboxEnforcer, Sensitivity, ShellBleedDetector,
21 ToolResult, capability::capability_matches,
22};
23
24use crate::automation::{self, AutomationBackend, UiSelector};
25use crate::mcp::McpClient;
26
27pub struct ToolExecutionContext {
29 pub working_dir: PathBuf,
31 pub fighter_id: FighterId,
33 pub memory: Arc<MemorySubstrate>,
35 pub coordinator: Option<Arc<dyn AgentCoordinator>>,
38 pub approval_engine: Option<Arc<PolicyEngine>>,
42 pub sandbox: Option<Arc<SandboxEnforcer>>,
45 pub bleed_detector: Option<Arc<ShellBleedDetector>>,
49 pub browser_pool: Option<Arc<BrowserPool>>,
54 pub plugin_registry: Option<Arc<PluginRegistry>>,
59 pub mcp_clients: Option<Arc<DashMap<String, Arc<McpClient>>>>,
63 pub channel_notifier: Option<Arc<dyn ChannelNotifier>>,
67 pub automation_backend: Option<Arc<dyn AutomationBackend>>,
72}
73
74const DEFAULT_TIMEOUT_SECS: u64 = 120;
76
77#[instrument(skip(input, capabilities, context), fields(tool = %name, fighter = %context.fighter_id))]
81pub async fn execute_tool(
82 name: &str,
83 input: &serde_json::Value,
84 capabilities: &[Capability],
85 context: &ToolExecutionContext,
86) -> PunchResult<ToolResult> {
87 let start = Instant::now();
88
89 let result = tokio::time::timeout(
90 std::time::Duration::from_secs(DEFAULT_TIMEOUT_SECS),
91 execute_tool_inner(name, input, capabilities, context),
92 )
93 .await;
94
95 let duration_ms = start.elapsed().as_millis() as u64;
96
97 match result {
98 Ok(Ok(mut tool_result)) => {
99 tool_result.duration_ms = duration_ms;
100 Ok(tool_result)
101 }
102 Ok(Err(e)) => Ok(ToolResult {
103 success: false,
104 output: serde_json::json!(null),
105 error: Some(e.to_string()),
106 duration_ms,
107 }),
108 Err(_) => Err(PunchError::ToolTimeout {
109 tool: name.to_string(),
110 timeout_ms: DEFAULT_TIMEOUT_SECS * 1000,
111 }),
112 }
113}
114
115async fn execute_tool_inner(
117 name: &str,
118 input: &serde_json::Value,
119 capabilities: &[Capability],
120 context: &ToolExecutionContext,
121) -> PunchResult<ToolResult> {
122 if let Some(ref engine) = context.approval_engine {
124 let decision = engine.evaluate(name, input, &context.fighter_id).await?;
125 match decision {
126 ApprovalDecision::Allow => {
127 }
129 ApprovalDecision::Deny(reason) => {
130 debug!(tool = %name, reason = %reason, "tool call denied by approval policy");
131 return Ok(ToolResult {
132 success: false,
133 output: serde_json::json!(null),
134 error: Some(format!("denied by policy: {}", reason)),
135 duration_ms: 0,
136 });
137 }
138 ApprovalDecision::NeedsApproval(reason) => {
139 debug!(tool = %name, reason = %reason, "tool call needs approval");
140 return Ok(ToolResult {
141 success: false,
142 output: serde_json::json!(null),
143 error: Some(format!("approval required: {}", reason)),
144 duration_ms: 0,
145 });
146 }
147 }
148 }
149
150 match name {
151 "file_read" => tool_file_read(input, capabilities, context).await,
152 "file_write" => tool_file_write(input, capabilities, context).await,
153 "file_list" => tool_file_list(input, capabilities, context).await,
154 "shell_exec" => tool_shell_exec(input, capabilities, context).await,
155 "web_search" => tool_web_search(input).await,
156 "web_fetch" => tool_web_fetch(input, capabilities).await,
157 "memory_store" => tool_memory_store(input, capabilities, context).await,
158 "memory_recall" => tool_memory_recall(input, capabilities, context).await,
159 "knowledge_add_entity" => tool_knowledge_add_entity(input, capabilities, context).await,
160 "knowledge_add_relation" => tool_knowledge_add_relation(input, capabilities, context).await,
161 "knowledge_query" => tool_knowledge_query(input, capabilities, context).await,
162 "agent_spawn" => tool_agent_spawn(input, capabilities, context).await,
163 "agent_message" => tool_agent_message(input, capabilities, context).await,
164 "agent_list" => tool_agent_list(capabilities, context).await,
165 "patch_apply" => tool_patch_apply(input, capabilities, context).await,
166 "browser_navigate" => tool_browser_navigate(input, capabilities, context).await,
167 "browser_screenshot" => tool_browser_screenshot(input, capabilities, context).await,
168 "browser_click" => tool_browser_click(input, capabilities, context).await,
169 "browser_type" => tool_browser_type(input, capabilities, context).await,
170 "browser_content" => tool_browser_content(input, capabilities, context).await,
171 "git_status" => tool_git_status(input, capabilities, context).await,
173 "git_diff" => tool_git_diff(input, capabilities, context).await,
174 "git_log" => tool_git_log(input, capabilities, context).await,
175 "git_commit" => tool_git_commit(input, capabilities, context).await,
176 "git_branch" => tool_git_branch(input, capabilities, context).await,
177 "docker_ps" => tool_docker_ps(input, capabilities).await,
179 "docker_run" => tool_docker_run(input, capabilities).await,
180 "docker_build" => tool_docker_build(input, capabilities, context).await,
181 "docker_logs" => tool_docker_logs(input, capabilities).await,
182 "http_request" => tool_http_request(input, capabilities).await,
184 "http_post" => tool_http_post(input, capabilities).await,
185 "json_query" => tool_json_query(input, capabilities).await,
187 "json_transform" => tool_json_transform(input, capabilities).await,
188 "yaml_parse" => tool_yaml_parse(input, capabilities).await,
189 "regex_match" => tool_regex_match(input, capabilities).await,
190 "regex_replace" => tool_regex_replace(input, capabilities).await,
191 "process_list" => tool_process_list(input, capabilities, context).await,
193 "process_kill" => tool_process_kill(input, capabilities).await,
194 "schedule_task" => tool_schedule_task(input, capabilities, context).await,
196 "schedule_list" => tool_schedule_list(capabilities).await,
197 "schedule_cancel" => tool_schedule_cancel(input, capabilities).await,
198 "code_search" => tool_code_search(input, capabilities, context).await,
200 "code_symbols" => tool_code_symbols(input, capabilities, context).await,
201 "archive_create" => tool_archive_create(input, capabilities, context).await,
203 "archive_extract" => tool_archive_extract(input, capabilities, context).await,
204 "archive_list" => tool_archive_list(input, capabilities, context).await,
205 "template_render" => tool_template_render(input, capabilities).await,
207 "hash_compute" => tool_hash_compute(input, capabilities, context).await,
209 "hash_verify" => tool_hash_verify(input, capabilities, context).await,
210 "env_get" => tool_env_get(input, capabilities).await,
212 "env_list" => tool_env_list(input, capabilities).await,
213 "text_diff" => tool_text_diff(input, capabilities).await,
215 "text_count" => tool_text_count(input, capabilities).await,
216 "file_search" => tool_file_search(input, capabilities, context).await,
218 "file_info" => tool_file_info(input, capabilities, context).await,
219 "wasm_invoke" => tool_wasm_invoke(input, capabilities, context).await,
221 "a2a_delegate" => tool_a2a_delegate(input, capabilities).await,
223 "channel_notify" => tool_channel_notify(input, capabilities, context).await,
225 "heartbeat_add" => tool_heartbeat_add(input, capabilities, context).await,
227 "heartbeat_list" => tool_heartbeat_list(capabilities, context).await,
228 "heartbeat_remove" => tool_heartbeat_remove(input, capabilities, context).await,
229 "creed_view" => tool_creed_view(capabilities, context).await,
230 "skill_list" => tool_skill_list(capabilities).await,
231 "skill_recommend" => tool_skill_recommend(input, capabilities).await,
232 "sys_screenshot" => tool_sys_screenshot(input, capabilities, context).await,
234 "ui_screenshot" => tool_ui_screenshot(input, capabilities, context).await,
235 "app_ocr" => tool_app_ocr(input, capabilities, context).await,
236 "ui_find_elements" => tool_ui_find_elements(input, capabilities, context).await,
237 "ui_click" => tool_ui_click(input, capabilities, context).await,
238 "ui_type_text" => tool_ui_type_text(input, capabilities, context).await,
239 "ui_list_windows" => tool_ui_list_windows(capabilities, context).await,
240 "ui_read_attribute" => tool_ui_read_attribute(input, capabilities, context).await,
241 _ if name.starts_with("mcp_") => tool_mcp_call(name, input, capabilities, context).await,
243 _ => Err(PunchError::ToolNotFound(name.to_string())),
244 }
245}
246
247fn require_capability(capabilities: &[Capability], required: &Capability) -> PunchResult<()> {
253 if capabilities
254 .iter()
255 .any(|granted| capability_matches(granted, required))
256 {
257 Ok(())
258 } else {
259 Err(PunchError::CapabilityDenied(format!(
260 "missing capability: {}",
261 required
262 )))
263 }
264}
265
266fn resolve_path(working_dir: &Path, requested: &str) -> PunchResult<PathBuf> {
268 let path = if Path::new(requested).is_absolute() {
269 PathBuf::from(requested)
270 } else {
271 working_dir.join(requested)
272 };
273
274 Ok(path)
275}
276
277static SENSITIVE_PATH_PATTERNS: LazyLock<Vec<&'static str>> = LazyLock::new(|| {
285 vec![
286 ".env",
287 ".ssh/",
288 ".gnupg/",
289 ".aws/credentials",
290 ".aws/config",
291 ".npmrc",
292 ".pypirc",
293 ".docker/config.json",
294 ".kube/config",
295 ".netrc",
296 "id_rsa",
297 "id_ed25519",
298 "id_ecdsa",
299 "credentials.json",
300 "service_account.json",
301 "secrets.yaml",
302 "secrets.yml",
303 "secrets.json",
304 "/etc/shadow",
305 "/etc/passwd",
306 ]
307});
308
309fn is_sensitive_path(path: &str) -> bool {
311 let normalized = path.replace('\\', "/");
312 SENSITIVE_PATH_PATTERNS
313 .iter()
314 .any(|pattern| normalized.contains(pattern))
315}
316
317async fn tool_channel_notify(
323 input: &serde_json::Value,
324 capabilities: &[Capability],
325 context: &ToolExecutionContext,
326) -> PunchResult<ToolResult> {
327 require_capability(capabilities, &Capability::ChannelNotify)?;
328
329 let notifier = context
330 .channel_notifier
331 .as_ref()
332 .ok_or_else(|| PunchError::Tool {
333 tool: "channel_notify".into(),
334 message: "channel notifier not configured — no channel adapters are available".into(),
335 })?;
336
337 let channel = input["channel"].as_str().ok_or_else(|| PunchError::Tool {
338 tool: "channel_notify".into(),
339 message: "missing 'channel' parameter (e.g., \"telegram\", \"discord\", \"slack\")".into(),
340 })?;
341
342 let chat_id = input["chat_id"].as_str().ok_or_else(|| PunchError::Tool {
343 tool: "channel_notify".into(),
344 message: "missing 'chat_id' parameter (the channel/conversation ID to send to)".into(),
345 })?;
346
347 let message = input["message"].as_str().ok_or_else(|| PunchError::Tool {
348 tool: "channel_notify".into(),
349 message: "missing 'message' parameter (the text to send)".into(),
350 })?;
351
352 debug!(
353 channel = %channel,
354 chat_id = %chat_id,
355 message_len = message.len(),
356 "channel_notify: sending proactive message"
357 );
358
359 notifier.notify(channel, chat_id, message).await?;
360
361 Ok(ToolResult {
362 success: true,
363 output: serde_json::json!({
364 "sent": true,
365 "channel": channel,
366 "chat_id": chat_id,
367 "message_length": message.len(),
368 }),
369 error: None,
370 duration_ms: 0,
371 })
372}
373
374async fn tool_mcp_call(
384 name: &str,
385 input: &serde_json::Value,
386 capabilities: &[Capability],
387 context: &ToolExecutionContext,
388) -> PunchResult<ToolResult> {
389 let clients = context.mcp_clients.as_ref().ok_or_else(|| {
390 PunchError::ToolNotFound(format!(
391 "MCP tool '{}' requested but no MCP servers are configured",
392 name
393 ))
394 })?;
395
396 let mut matched_client: Option<Arc<McpClient>> = None;
398 let mut raw_tool_name: Option<String> = None;
399
400 for entry in clients.iter() {
401 if let Some(stripped) = entry.value().strip_namespace(name) {
402 require_capability(capabilities, &Capability::McpAccess(entry.key().clone()))?;
404 matched_client = Some(Arc::clone(entry.value()));
405 raw_tool_name = Some(stripped.to_string());
406 break;
407 }
408 }
409
410 let client = matched_client.ok_or_else(|| {
411 PunchError::ToolNotFound(format!("no MCP server matches tool '{}'", name))
412 })?;
413 let raw_name = raw_tool_name.unwrap();
414
415 debug!(
416 server = %client.server_name(),
417 tool = %raw_name,
418 "dispatching MCP tool call"
419 );
420
421 match client.call_tool(&raw_name, input.clone()).await {
422 Ok(result) => {
423 let output = if let Some(content) = result.get("content") {
425 if let Some(arr) = content.as_array() {
426 arr.iter()
427 .filter_map(|item| item.get("text").and_then(|t| t.as_str()))
428 .collect::<Vec<_>>()
429 .join("\n")
430 } else {
431 serde_json::to_string_pretty(&result).unwrap_or_default()
432 }
433 } else {
434 serde_json::to_string_pretty(&result).unwrap_or_default()
435 };
436
437 let is_error = result
438 .get("isError")
439 .and_then(|v| v.as_bool())
440 .unwrap_or(false);
441
442 Ok(ToolResult {
443 success: !is_error,
444 output: serde_json::Value::String(output),
445 error: if is_error {
446 Some("MCP tool returned error".to_string())
447 } else {
448 None
449 },
450 duration_ms: 0,
451 })
452 }
453 Err(e) => Ok(ToolResult {
454 success: false,
455 output: serde_json::json!(null),
456 error: Some(format!("MCP call failed: {}", e)),
457 duration_ms: 0,
458 }),
459 }
460}
461
462async fn tool_file_read(
467 input: &serde_json::Value,
468 capabilities: &[Capability],
469 context: &ToolExecutionContext,
470) -> PunchResult<ToolResult> {
471 let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
472 tool: "file_read".into(),
473 message: "missing 'path' parameter".into(),
474 })?;
475
476 let path = resolve_path(&context.working_dir, path_str)?;
477 let path_display = path.display().to_string();
478
479 require_capability(capabilities, &Capability::FileRead(path_display.clone()))?;
480
481 if let Some(ref sandbox) = context.sandbox {
483 sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
484 tool: "file_read".into(),
485 message: v.to_string(),
486 })?;
487 }
488
489 if is_sensitive_path(&path_display) {
491 warn!(
492 path = %path_display,
493 fighter = %context.fighter_id,
494 "sensitive path access detected during file_read"
495 );
496
497 if context.bleed_detector.is_some() {
499 return Ok(ToolResult {
500 success: false,
501 output: serde_json::json!(null),
502 error: Some(format!(
503 "security: read of sensitive path '{}' blocked by bleed detector",
504 path_display
505 )),
506 duration_ms: 0,
507 });
508 }
509 }
510
511 const MAX_FILE_CHARS: usize = 100_000;
513
514 match tokio::fs::read_to_string(&path).await {
515 Ok(content) => {
516 if let Some(ref detector) = context.bleed_detector {
518 let warnings = detector.scan_command(&content);
519 let secret_warnings: Vec<_> = warnings
520 .iter()
521 .filter(|w| w.severity >= Sensitivity::Confidential)
522 .collect();
523 if !secret_warnings.is_empty() {
524 warn!(
525 path = %path_display,
526 warning_count = secret_warnings.len(),
527 "file content contains potential secrets"
528 );
529 }
530 }
531
532 let total_bytes = content.len();
533 let (output_content, was_truncated) = if content.len() > MAX_FILE_CHARS {
534 let truncated: String = content.chars().take(MAX_FILE_CHARS).collect();
535 (truncated, true)
536 } else {
537 (content, false)
538 };
539
540 debug!(path = %path_display, bytes = total_bytes, truncated = was_truncated, "file read");
541
542 let output = if was_truncated {
543 serde_json::json!(format!(
544 "{}\n\n--- TRUNCATED: showing first {} of {} bytes. Use shell_exec with head/tail/grep to inspect specific sections. ---",
545 output_content, MAX_FILE_CHARS, total_bytes
546 ))
547 } else {
548 serde_json::json!(output_content)
549 };
550
551 Ok(ToolResult {
552 success: true,
553 output,
554 error: None,
555 duration_ms: 0,
556 })
557 }
558 Err(e) => Ok(ToolResult {
559 success: false,
560 output: serde_json::json!(null),
561 error: Some(format!("failed to read '{}': {}", path_display, e)),
562 duration_ms: 0,
563 }),
564 }
565}
566
567async fn tool_file_write(
568 input: &serde_json::Value,
569 capabilities: &[Capability],
570 context: &ToolExecutionContext,
571) -> PunchResult<ToolResult> {
572 let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
573 tool: "file_write".into(),
574 message: "missing 'path' parameter".into(),
575 })?;
576 let content = input["content"].as_str().ok_or_else(|| PunchError::Tool {
577 tool: "file_write".into(),
578 message: "missing 'content' parameter".into(),
579 })?;
580
581 let path = resolve_path(&context.working_dir, path_str)?;
582 let path_display = path.display().to_string();
583
584 require_capability(capabilities, &Capability::FileWrite(path_display.clone()))?;
585
586 if let Some(ref sandbox) = context.sandbox {
588 sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
589 tool: "file_write".into(),
590 message: v.to_string(),
591 })?;
592 }
593
594 if let Some(parent) = path.parent()
596 && !parent.exists()
597 {
598 tokio::fs::create_dir_all(parent)
599 .await
600 .map_err(|e| PunchError::Tool {
601 tool: "file_write".into(),
602 message: format!("failed to create directory '{}': {}", parent.display(), e),
603 })?;
604 }
605
606 match tokio::fs::write(&path, content).await {
607 Ok(()) => {
608 debug!(path = %path_display, bytes = content.len(), "file written");
609 Ok(ToolResult {
610 success: true,
611 output: serde_json::json!(format!(
612 "wrote {} bytes to {}",
613 content.len(),
614 path_display
615 )),
616 error: None,
617 duration_ms: 0,
618 })
619 }
620 Err(e) => Ok(ToolResult {
621 success: false,
622 output: serde_json::json!(null),
623 error: Some(format!("failed to write '{}': {}", path_display, e)),
624 duration_ms: 0,
625 }),
626 }
627}
628
629async fn tool_patch_apply(
634 input: &serde_json::Value,
635 capabilities: &[Capability],
636 context: &ToolExecutionContext,
637) -> PunchResult<ToolResult> {
638 let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
639 tool: "patch_apply".into(),
640 message: "missing 'path' parameter".into(),
641 })?;
642 let diff_text = input["diff"].as_str().ok_or_else(|| PunchError::Tool {
643 tool: "patch_apply".into(),
644 message: "missing 'diff' parameter".into(),
645 })?;
646
647 let path = resolve_path(&context.working_dir, path_str)?;
648 let path_display = path.display().to_string();
649
650 require_capability(capabilities, &Capability::FileWrite(path_display.clone()))?;
652
653 if let Some(ref sandbox) = context.sandbox {
655 sandbox.validate_path(&path).map_err(|v| PunchError::Tool {
656 tool: "patch_apply".into(),
657 message: v.to_string(),
658 })?;
659 }
660
661 let patch_set = punch_types::parse_unified_diff(diff_text).map_err(|e| PunchError::Tool {
663 tool: "patch_apply".into(),
664 message: format!("failed to parse diff: {}", e),
665 })?;
666
667 if patch_set.patches.is_empty() {
668 return Ok(ToolResult {
669 success: false,
670 output: serde_json::json!(null),
671 error: Some("diff contains no file patches".into()),
672 duration_ms: 0,
673 });
674 }
675
676 let file_patch = &patch_set.patches[0];
678
679 let original = if file_patch.is_new_file {
681 String::new()
682 } else {
683 tokio::fs::read_to_string(&path)
684 .await
685 .map_err(|e| PunchError::Tool {
686 tool: "patch_apply".into(),
687 message: format!("failed to read '{}': {}", path_display, e),
688 })?
689 };
690
691 let conflicts = punch_types::validate_patch(&original, file_patch);
693 if !conflicts.is_empty() {
694 let conflict_desc: Vec<String> = conflicts
695 .iter()
696 .map(|c| {
697 format!(
698 "hunk {}: line {} — expected {:?}, found {:?} ({:?})",
699 c.hunk_index + 1,
700 c.line_number,
701 c.expected_line,
702 c.actual_line,
703 c.conflict_type
704 )
705 })
706 .collect();
707
708 match punch_types::apply_patch_fuzzy(&original, file_patch, 3) {
710 Ok(patched) => {
711 if let Some(parent) = path.parent()
713 && !parent.exists()
714 {
715 tokio::fs::create_dir_all(parent)
716 .await
717 .map_err(|e| PunchError::Tool {
718 tool: "patch_apply".into(),
719 message: format!(
720 "failed to create directory '{}': {}",
721 parent.display(),
722 e
723 ),
724 })?;
725 }
726 tokio::fs::write(&path, &patched)
727 .await
728 .map_err(|e| PunchError::Tool {
729 tool: "patch_apply".into(),
730 message: format!("failed to write '{}': {}", path_display, e),
731 })?;
732 debug!(path = %path_display, "patch applied with fuzzy matching");
733 return Ok(ToolResult {
734 success: true,
735 output: serde_json::json!(format!(
736 "patch applied to {} with fuzzy matching (offset adjustments needed). Warnings: {}",
737 path_display,
738 conflict_desc.join("; ")
739 )),
740 error: None,
741 duration_ms: 0,
742 });
743 }
744 Err(_) => {
745 return Ok(ToolResult {
746 success: false,
747 output: serde_json::json!(null),
748 error: Some(format!(
749 "patch conflicts detected: {}",
750 conflict_desc.join("; ")
751 )),
752 duration_ms: 0,
753 });
754 }
755 }
756 }
757
758 let patched =
760 punch_types::apply_patch(&original, file_patch).map_err(|e| PunchError::Tool {
761 tool: "patch_apply".into(),
762 message: format!("failed to apply patch: {}", e),
763 })?;
764
765 if let Some(parent) = path.parent()
767 && !parent.exists()
768 {
769 tokio::fs::create_dir_all(parent)
770 .await
771 .map_err(|e| PunchError::Tool {
772 tool: "patch_apply".into(),
773 message: format!("failed to create directory '{}': {}", parent.display(), e),
774 })?;
775 }
776
777 tokio::fs::write(&path, &patched)
778 .await
779 .map_err(|e| PunchError::Tool {
780 tool: "patch_apply".into(),
781 message: format!("failed to write '{}': {}", path_display, e),
782 })?;
783
784 debug!(path = %path_display, "patch applied cleanly");
785 Ok(ToolResult {
786 success: true,
787 output: serde_json::json!(format!("patch applied cleanly to {}", path_display)),
788 error: None,
789 duration_ms: 0,
790 })
791}
792
793async fn tool_file_list(
794 input: &serde_json::Value,
795 _capabilities: &[Capability],
796 context: &ToolExecutionContext,
797) -> PunchResult<ToolResult> {
798 let path_str = input["path"].as_str().unwrap_or(".");
799 let path = resolve_path(&context.working_dir, path_str)?;
800
801 let mut entries = Vec::new();
802 let mut dir = tokio::fs::read_dir(&path)
803 .await
804 .map_err(|e| PunchError::Tool {
805 tool: "file_list".into(),
806 message: format!("failed to list '{}': {}", path.display(), e),
807 })?;
808
809 while let Some(entry) = dir.next_entry().await.map_err(|e| PunchError::Tool {
810 tool: "file_list".into(),
811 message: format!("failed to read entry: {}", e),
812 })? {
813 let file_type = entry.file_type().await.ok();
814 let is_dir = file_type.as_ref().map(|ft| ft.is_dir()).unwrap_or(false);
815 let name = entry.file_name().to_string_lossy().to_string();
816 entries.push(serde_json::json!({
817 "name": name,
818 "is_directory": is_dir,
819 }));
820 }
821
822 Ok(ToolResult {
823 success: true,
824 output: serde_json::json!(entries),
825 error: None,
826 duration_ms: 0,
827 })
828}
829
830async fn tool_shell_exec(
831 input: &serde_json::Value,
832 capabilities: &[Capability],
833 context: &ToolExecutionContext,
834) -> PunchResult<ToolResult> {
835 let command_str = input["command"].as_str().ok_or_else(|| PunchError::Tool {
836 tool: "shell_exec".into(),
837 message: "missing 'command' parameter".into(),
838 })?;
839
840 require_capability(
841 capabilities,
842 &Capability::ShellExec(command_str.to_string()),
843 )?;
844
845 if let Some(ref detector) = context.bleed_detector {
848 let warnings = detector.scan_command(command_str);
849 let blocked: Vec<_> = warnings
850 .iter()
851 .filter(|w| w.severity >= Sensitivity::Confidential)
852 .collect();
853
854 if !blocked.is_empty() {
855 let details: Vec<String> = blocked
856 .iter()
857 .map(|w| {
858 format!(
859 "[{}] {} (severity: {})",
860 w.pattern_name, w.location, w.severity
861 )
862 })
863 .collect();
864 return Ok(ToolResult {
865 success: false,
866 output: serde_json::json!(null),
867 error: Some(format!(
868 "shell bleed detected — command blocked: {}",
869 details.join("; ")
870 )),
871 duration_ms: 0,
872 });
873 }
874
875 for w in &warnings {
877 if w.severity == Sensitivity::Internal {
878 tracing::warn!(
879 pattern = %w.pattern_name,
880 location = %w.location,
881 "shell bleed warning (internal severity) — allowing execution"
882 );
883 }
884 }
885 }
886
887 let output = if let Some(ref sandbox) = context.sandbox {
895 let mut cmd = sandbox
896 .build_command(command_str)
897 .map_err(|v| PunchError::Tool {
898 tool: "shell_exec".into(),
899 message: v.to_string(),
900 })?;
901 cmd.current_dir(&context.working_dir);
902 cmd.output().await.map_err(|e| PunchError::Tool {
903 tool: "shell_exec".into(),
904 message: format!("failed to execute command: {}", e),
905 })?
906 } else {
907 Command::new("sh")
908 .arg("-c")
909 .arg(command_str)
910 .current_dir(&context.working_dir)
911 .output()
912 .await
913 .map_err(|e| PunchError::Tool {
914 tool: "shell_exec".into(),
915 message: format!("failed to execute command: {}", e),
916 })?
917 };
918
919 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
920 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
921 let exit_code = output.status.code().unwrap_or(-1);
922
923 debug!(exit_code = exit_code, "shell exec complete");
924
925 if let Some(ref detector) = context.bleed_detector {
927 let stdout_warnings = detector.scan_command(&stdout);
928 let stderr_warnings = detector.scan_command(&stderr);
929
930 let all_warnings: Vec<_> = stdout_warnings
931 .iter()
932 .chain(stderr_warnings.iter())
933 .filter(|w| w.severity >= Sensitivity::Confidential)
934 .collect();
935
936 if !all_warnings.is_empty() {
937 let details: Vec<String> = all_warnings
938 .iter()
939 .map(|w| {
940 format!(
941 "[{}] {} (severity: {})",
942 w.pattern_name, w.location, w.severity
943 )
944 })
945 .collect();
946 warn!(
947 warning_count = all_warnings.len(),
948 details = %details.join("; "),
949 "shell output contains potential secrets — flagging security event"
950 );
951 }
952 }
953
954 Ok(ToolResult {
955 success: output.status.success(),
956 output: serde_json::json!({
957 "stdout": stdout,
958 "stderr": stderr,
959 "exit_code": exit_code,
960 }),
961 error: if output.status.success() {
962 None
963 } else {
964 Some(format!("command exited with code {}", exit_code))
965 },
966 duration_ms: 0,
967 })
968}
969
970async fn tool_web_search(input: &serde_json::Value) -> PunchResult<ToolResult> {
971 let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
972 tool: "web_search".into(),
973 message: "missing 'query' parameter".into(),
974 })?;
975
976 let client = reqwest::Client::builder()
977 .timeout(std::time::Duration::from_secs(15))
978 .redirect(reqwest::redirect::Policy::limited(5))
979 .build()
980 .map_err(|e| PunchError::Tool {
981 tool: "web_search".into(),
982 message: format!("failed to create HTTP client: {}", e),
983 })?;
984
985 let url = format!(
986 "https://html.duckduckgo.com/html/?q={}",
987 urlencoding::encode(query)
988 );
989
990 let response = client
991 .get(&url)
992 .header("User-Agent", "Mozilla/5.0 (compatible; PunchAgent/1.0)")
993 .send()
994 .await
995 .map_err(|e| PunchError::Tool {
996 tool: "web_search".into(),
997 message: format!("search request failed: {}", e),
998 })?;
999
1000 let body = response.text().await.map_err(|e| PunchError::Tool {
1001 tool: "web_search".into(),
1002 message: format!("failed to read search response: {}", e),
1003 })?;
1004
1005 let results = parse_duckduckgo_results(&body);
1006
1007 Ok(ToolResult {
1008 success: true,
1009 output: serde_json::json!(results),
1010 error: None,
1011 duration_ms: 0,
1012 })
1013}
1014
1015fn parse_duckduckgo_results(html: &str) -> Vec<serde_json::Value> {
1017 let mut results = Vec::new();
1018 let mut remaining = html;
1019
1020 while results.len() < 5 {
1024 let marker = "class=\"result__a\"";
1026 let Some(pos) = remaining.find(marker) else {
1027 break;
1028 };
1029 remaining = &remaining[pos + marker.len()..];
1030
1031 let href = if let Some(href_pos) = remaining.find("href=\"") {
1033 let start = href_pos + 6;
1034 let href_rest = &remaining[start..];
1035 if let Some(end) = href_rest.find('"') {
1036 let raw_href = &href_rest[..end];
1037 if let Some(uddg_pos) = raw_href.find("uddg=") {
1039 let encoded = &raw_href[uddg_pos + 5..];
1040 let decoded = urlencoding::decode(encoded)
1041 .unwrap_or_else(|_| encoded.into())
1042 .to_string();
1043 decoded.split('&').next().unwrap_or(&decoded).to_string()
1045 } else {
1046 raw_href.to_string()
1047 }
1048 } else {
1049 continue;
1050 }
1051 } else {
1052 continue;
1053 };
1054
1055 let title = if let Some(gt_pos) = remaining.find('>') {
1057 let after_gt = &remaining[gt_pos + 1..];
1058 if let Some(end_tag) = after_gt.find("</a>") {
1059 let raw_title = &after_gt[..end_tag];
1060 strip_html_tags(raw_title).trim().to_string()
1062 } else {
1063 "Untitled".to_string()
1064 }
1065 } else {
1066 "Untitled".to_string()
1067 };
1068
1069 if !title.is_empty() && !href.is_empty() {
1070 results.push(serde_json::json!({
1071 "title": title,
1072 "url": href,
1073 }));
1074 }
1075 }
1076
1077 results
1078}
1079
1080fn strip_html_tags(s: &str) -> String {
1082 let mut result = String::with_capacity(s.len());
1083 let mut in_tag = false;
1084 for c in s.chars() {
1085 match c {
1086 '<' => in_tag = true,
1087 '>' => in_tag = false,
1088 _ if !in_tag => result.push(c),
1089 _ => {}
1090 }
1091 }
1092 result
1093}
1094
1095async fn tool_web_fetch(
1096 input: &serde_json::Value,
1097 capabilities: &[Capability],
1098) -> PunchResult<ToolResult> {
1099 let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
1100 tool: "web_fetch".into(),
1101 message: "missing 'url' parameter".into(),
1102 })?;
1103
1104 let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
1105 tool: "web_fetch".into(),
1106 message: format!("invalid URL: {}", e),
1107 })?;
1108
1109 if let Some(host) = parsed_url.host_str() {
1111 require_capability(capabilities, &Capability::Network(host.to_string()))?;
1112
1113 if let Ok(ip) = host.parse::<IpAddr>()
1114 && is_private_ip(&ip)
1115 {
1116 return Ok(ToolResult {
1117 success: false,
1118 output: serde_json::json!(null),
1119 error: Some(format!(
1120 "SSRF protection: requests to private IP {} are blocked",
1121 ip
1122 )),
1123 duration_ms: 0,
1124 });
1125 }
1126
1127 if let Ok(addrs) = tokio::net::lookup_host(format!("{}:80", host)).await {
1129 for addr in addrs {
1130 if is_private_ip(&addr.ip()) {
1131 return Ok(ToolResult {
1132 success: false,
1133 output: serde_json::json!(null),
1134 error: Some(format!(
1135 "SSRF protection: hostname '{}' resolves to private IP {}",
1136 host,
1137 addr.ip()
1138 )),
1139 duration_ms: 0,
1140 });
1141 }
1142 }
1143 }
1144 }
1145
1146 let client = reqwest::Client::builder()
1147 .timeout(std::time::Duration::from_secs(30))
1148 .redirect(reqwest::redirect::Policy::limited(5))
1149 .build()
1150 .map_err(|e| PunchError::Tool {
1151 tool: "web_fetch".into(),
1152 message: format!("failed to create HTTP client: {}", e),
1153 })?;
1154
1155 let response = client
1156 .get(url_str)
1157 .send()
1158 .await
1159 .map_err(|e| PunchError::Tool {
1160 tool: "web_fetch".into(),
1161 message: format!("request failed: {}", e),
1162 })?;
1163
1164 let status = response.status().as_u16();
1165 let body = response.text().await.map_err(|e| PunchError::Tool {
1166 tool: "web_fetch".into(),
1167 message: format!("failed to read response body: {}", e),
1168 })?;
1169
1170 let truncated = if body.len() > 100_000 {
1172 format!(
1173 "{}... [truncated, {} total bytes]",
1174 &body[..100_000],
1175 body.len()
1176 )
1177 } else {
1178 body
1179 };
1180
1181 Ok(ToolResult {
1182 success: (200..300).contains(&(status as usize)),
1183 output: serde_json::json!({
1184 "status": status,
1185 "body": truncated,
1186 }),
1187 error: None,
1188 duration_ms: 0,
1189 })
1190}
1191
1192async fn tool_memory_store(
1193 input: &serde_json::Value,
1194 capabilities: &[Capability],
1195 context: &ToolExecutionContext,
1196) -> PunchResult<ToolResult> {
1197 require_capability(capabilities, &Capability::Memory)?;
1198
1199 let key = input["key"].as_str().ok_or_else(|| PunchError::Tool {
1200 tool: "memory_store".into(),
1201 message: "missing 'key' parameter".into(),
1202 })?;
1203 let value = input["value"].as_str().ok_or_else(|| PunchError::Tool {
1204 tool: "memory_store".into(),
1205 message: "missing 'value' parameter".into(),
1206 })?;
1207 let confidence = input["confidence"].as_f64().unwrap_or(0.9);
1208
1209 context
1210 .memory
1211 .store_memory(&context.fighter_id, key, value, confidence)
1212 .await?;
1213
1214 Ok(ToolResult {
1215 success: true,
1216 output: serde_json::json!(format!("stored memory '{}'", key)),
1217 error: None,
1218 duration_ms: 0,
1219 })
1220}
1221
1222async fn tool_memory_recall(
1223 input: &serde_json::Value,
1224 capabilities: &[Capability],
1225 context: &ToolExecutionContext,
1226) -> PunchResult<ToolResult> {
1227 require_capability(capabilities, &Capability::Memory)?;
1228
1229 let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
1230 tool: "memory_recall".into(),
1231 message: "missing 'query' parameter".into(),
1232 })?;
1233 let limit = input["limit"].as_u64().unwrap_or(10) as u32;
1234
1235 let memories = context
1236 .memory
1237 .recall_memories(&context.fighter_id, query, limit)
1238 .await?;
1239
1240 let entries: Vec<serde_json::Value> = memories
1241 .iter()
1242 .map(|m| {
1243 serde_json::json!({
1244 "key": m.key,
1245 "value": m.value,
1246 "confidence": m.confidence,
1247 })
1248 })
1249 .collect();
1250
1251 Ok(ToolResult {
1252 success: true,
1253 output: serde_json::json!(entries),
1254 error: None,
1255 duration_ms: 0,
1256 })
1257}
1258
1259async fn tool_knowledge_add_entity(
1260 input: &serde_json::Value,
1261 capabilities: &[Capability],
1262 context: &ToolExecutionContext,
1263) -> PunchResult<ToolResult> {
1264 require_capability(capabilities, &Capability::KnowledgeGraph)?;
1265
1266 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1267 tool: "knowledge_add_entity".into(),
1268 message: "missing 'name' parameter".into(),
1269 })?;
1270 let entity_type = input["entity_type"]
1271 .as_str()
1272 .ok_or_else(|| PunchError::Tool {
1273 tool: "knowledge_add_entity".into(),
1274 message: "missing 'entity_type' parameter".into(),
1275 })?;
1276 let properties = input
1277 .get("properties")
1278 .cloned()
1279 .unwrap_or(serde_json::json!({}));
1280
1281 context
1282 .memory
1283 .add_entity(&context.fighter_id, name, entity_type, &properties)
1284 .await?;
1285
1286 Ok(ToolResult {
1287 success: true,
1288 output: serde_json::json!(format!("added entity '{}' ({})", name, entity_type)),
1289 error: None,
1290 duration_ms: 0,
1291 })
1292}
1293
1294async fn tool_knowledge_add_relation(
1295 input: &serde_json::Value,
1296 capabilities: &[Capability],
1297 context: &ToolExecutionContext,
1298) -> PunchResult<ToolResult> {
1299 require_capability(capabilities, &Capability::KnowledgeGraph)?;
1300
1301 let from = input["from"].as_str().ok_or_else(|| PunchError::Tool {
1302 tool: "knowledge_add_relation".into(),
1303 message: "missing 'from' parameter".into(),
1304 })?;
1305 let relation = input["relation"].as_str().ok_or_else(|| PunchError::Tool {
1306 tool: "knowledge_add_relation".into(),
1307 message: "missing 'relation' parameter".into(),
1308 })?;
1309 let to = input["to"].as_str().ok_or_else(|| PunchError::Tool {
1310 tool: "knowledge_add_relation".into(),
1311 message: "missing 'to' parameter".into(),
1312 })?;
1313 let properties = input
1314 .get("properties")
1315 .cloned()
1316 .unwrap_or(serde_json::json!({}));
1317
1318 context
1319 .memory
1320 .add_relation(&context.fighter_id, from, relation, to, &properties)
1321 .await?;
1322
1323 Ok(ToolResult {
1324 success: true,
1325 output: serde_json::json!(format!("{} --[{}]--> {}", from, relation, to)),
1326 error: None,
1327 duration_ms: 0,
1328 })
1329}
1330
1331async fn tool_knowledge_query(
1332 input: &serde_json::Value,
1333 capabilities: &[Capability],
1334 context: &ToolExecutionContext,
1335) -> PunchResult<ToolResult> {
1336 require_capability(capabilities, &Capability::KnowledgeGraph)?;
1337
1338 let query = input["query"].as_str().ok_or_else(|| PunchError::Tool {
1339 tool: "knowledge_query".into(),
1340 message: "missing 'query' parameter".into(),
1341 })?;
1342
1343 let entities = context
1344 .memory
1345 .query_entities(&context.fighter_id, query)
1346 .await?;
1347
1348 let entity_results: Vec<serde_json::Value> = entities
1349 .iter()
1350 .map(|e| {
1351 serde_json::json!({
1352 "name": e.name,
1353 "type": e.entity_type,
1354 "properties": e.properties,
1355 })
1356 })
1357 .collect();
1358
1359 let mut all_relations = Vec::new();
1361 for entity in &entities {
1362 let relations = context
1363 .memory
1364 .query_relations(&context.fighter_id, &entity.name)
1365 .await?;
1366 for r in relations {
1367 all_relations.push(serde_json::json!({
1368 "from": r.from_entity,
1369 "relation": r.relation,
1370 "to": r.to_entity,
1371 "properties": r.properties,
1372 }));
1373 }
1374 }
1375
1376 Ok(ToolResult {
1377 success: true,
1378 output: serde_json::json!({
1379 "entities": entity_results,
1380 "relations": all_relations,
1381 }),
1382 error: None,
1383 duration_ms: 0,
1384 })
1385}
1386
1387fn get_coordinator(context: &ToolExecutionContext) -> PunchResult<&dyn AgentCoordinator> {
1393 context
1394 .coordinator
1395 .as_deref()
1396 .ok_or_else(|| PunchError::Tool {
1397 tool: "agent".into(),
1398 message: "agent coordinator not available in this context".into(),
1399 })
1400}
1401
1402async fn tool_agent_spawn(
1403 input: &serde_json::Value,
1404 capabilities: &[Capability],
1405 context: &ToolExecutionContext,
1406) -> PunchResult<ToolResult> {
1407 require_capability(capabilities, &Capability::AgentSpawn)?;
1408
1409 let coordinator = get_coordinator(context)?;
1410
1411 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1412 tool: "agent_spawn".into(),
1413 message: "missing 'name' parameter".into(),
1414 })?;
1415
1416 let system_prompt = input["system_prompt"]
1417 .as_str()
1418 .ok_or_else(|| PunchError::Tool {
1419 tool: "agent_spawn".into(),
1420 message: "missing 'system_prompt' parameter".into(),
1421 })?;
1422
1423 let description = input["description"]
1424 .as_str()
1425 .unwrap_or("Spawned by another agent");
1426
1427 use punch_types::{FighterManifest, ModelConfig, Provider, WeightClass};
1430
1431 let child_capabilities: Vec<punch_types::Capability> =
1433 if let Some(caps) = input.get("capabilities") {
1434 serde_json::from_value(caps.clone()).unwrap_or_default()
1435 } else {
1436 Vec::new()
1437 };
1438
1439 let manifest = FighterManifest {
1440 name: name.to_string(),
1441 description: description.to_string(),
1442 model: ModelConfig {
1443 provider: Provider::Ollama,
1444 model: "gpt-oss:20b".to_string(),
1445 api_key_env: None,
1446 base_url: Some("http://localhost:11434".to_string()),
1447 max_tokens: Some(4096),
1448 temperature: Some(0.7),
1449 },
1450 system_prompt: system_prompt.to_string(),
1451 capabilities: child_capabilities,
1452 weight_class: WeightClass::Featherweight,
1453 tenant_id: None,
1454 };
1455
1456 let fighter_id = coordinator.spawn_fighter(manifest).await?;
1457
1458 debug!(fighter_id = %fighter_id, name = %name, "agent_spawn: fighter spawned");
1459
1460 Ok(ToolResult {
1461 success: true,
1462 output: serde_json::json!({
1463 "fighter_id": fighter_id.0.to_string(),
1464 "name": name,
1465 }),
1466 error: None,
1467 duration_ms: 0,
1468 })
1469}
1470
1471async fn tool_agent_message(
1472 input: &serde_json::Value,
1473 capabilities: &[Capability],
1474 context: &ToolExecutionContext,
1475) -> PunchResult<ToolResult> {
1476 require_capability(capabilities, &Capability::AgentMessage)?;
1477
1478 let coordinator = get_coordinator(context)?;
1479
1480 let target_id = if let Some(id_str) = input["fighter_id"].as_str() {
1482 let uuid = uuid::Uuid::parse_str(id_str).map_err(|e| PunchError::Tool {
1483 tool: "agent_message".into(),
1484 message: format!("invalid fighter_id '{}': {}", id_str, e),
1485 })?;
1486 punch_types::FighterId(uuid)
1487 } else if let Some(name) = input["name"].as_str() {
1488 coordinator
1489 .find_fighter_by_name(name)
1490 .await?
1491 .ok_or_else(|| PunchError::Tool {
1492 tool: "agent_message".into(),
1493 message: format!("no fighter found with name '{}'", name),
1494 })?
1495 } else {
1496 return Err(PunchError::Tool {
1497 tool: "agent_message".into(),
1498 message: "must provide either 'fighter_id' or 'name' parameter".into(),
1499 });
1500 };
1501
1502 let message = input["message"]
1503 .as_str()
1504 .ok_or_else(|| PunchError::Tool {
1505 tool: "agent_message".into(),
1506 message: "missing 'message' parameter".into(),
1507 })?
1508 .to_string();
1509
1510 debug!(
1511 target = %target_id,
1512 from = %context.fighter_id,
1513 "agent_message: sending inter-agent message"
1514 );
1515
1516 let result = coordinator
1517 .send_message_to_agent(&target_id, message)
1518 .await?;
1519
1520 Ok(ToolResult {
1521 success: true,
1522 output: serde_json::json!({
1523 "response": result.response,
1524 "tokens_used": result.tokens_used,
1525 }),
1526 error: None,
1527 duration_ms: 0,
1528 })
1529}
1530
1531async fn tool_agent_list(
1532 capabilities: &[Capability],
1533 context: &ToolExecutionContext,
1534) -> PunchResult<ToolResult> {
1535 require_capability(capabilities, &Capability::AgentMessage)?;
1536
1537 let coordinator = get_coordinator(context)?;
1538
1539 let agents = coordinator.list_fighters().await?;
1540
1541 let agent_list: Vec<serde_json::Value> = agents
1542 .iter()
1543 .map(|a| {
1544 serde_json::json!({
1545 "id": a.id.0.to_string(),
1546 "name": a.name,
1547 "status": format!("{}", a.status),
1548 })
1549 })
1550 .collect();
1551
1552 Ok(ToolResult {
1553 success: true,
1554 output: serde_json::json!(agent_list),
1555 error: None,
1556 duration_ms: 0,
1557 })
1558}
1559
1560fn is_private_ip(ip: &IpAddr) -> bool {
1566 match ip {
1567 IpAddr::V4(v4) => {
1568 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() || v4.is_unspecified() }
1574 IpAddr::V6(v6) => {
1575 v6.is_loopback() || v6.is_unspecified() }
1578 }
1579}
1580
1581fn require_browser_pool<'a>(
1587 capabilities: &[Capability],
1588 context: &'a ToolExecutionContext,
1589) -> PunchResult<&'a Arc<BrowserPool>> {
1590 require_capability(capabilities, &Capability::BrowserControl)?;
1591 context
1592 .browser_pool
1593 .as_ref()
1594 .ok_or_else(|| PunchError::Tool {
1595 tool: "browser".into(),
1596 message: "browser not available — no CDP driver configured".into(),
1597 })
1598}
1599
1600async fn tool_browser_navigate(
1601 input: &serde_json::Value,
1602 capabilities: &[Capability],
1603 context: &ToolExecutionContext,
1604) -> PunchResult<ToolResult> {
1605 let _pool = require_browser_pool(capabilities, context)?;
1606
1607 let url = input["url"].as_str().ok_or_else(|| PunchError::Tool {
1608 tool: "browser_navigate".into(),
1609 message: "missing 'url' parameter".into(),
1610 })?;
1611
1612 debug!(url = %url, "browser_navigate requested (no CDP driver)");
1613
1614 Ok(ToolResult {
1615 success: false,
1616 output: serde_json::json!({
1617 "action": "navigate",
1618 "url": url,
1619 "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable navigation"
1620 }),
1621 error: Some("no CDP driver configured".into()),
1622 duration_ms: 0,
1623 })
1624}
1625
1626async fn tool_browser_screenshot(
1627 input: &serde_json::Value,
1628 capabilities: &[Capability],
1629 context: &ToolExecutionContext,
1630) -> PunchResult<ToolResult> {
1631 let _pool = require_browser_pool(capabilities, context)?;
1632
1633 let full_page = input["full_page"].as_bool().unwrap_or(false);
1634
1635 debug!(full_page = %full_page, "browser_screenshot requested (no CDP driver)");
1636
1637 Ok(ToolResult {
1638 success: false,
1639 output: serde_json::json!({
1640 "action": "screenshot",
1641 "full_page": full_page,
1642 "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable screenshots"
1643 }),
1644 error: Some("no CDP driver configured".into()),
1645 duration_ms: 0,
1646 })
1647}
1648
1649async fn tool_browser_click(
1650 input: &serde_json::Value,
1651 capabilities: &[Capability],
1652 context: &ToolExecutionContext,
1653) -> PunchResult<ToolResult> {
1654 let _pool = require_browser_pool(capabilities, context)?;
1655
1656 let selector = input["selector"].as_str().ok_or_else(|| PunchError::Tool {
1657 tool: "browser_click".into(),
1658 message: "missing 'selector' parameter".into(),
1659 })?;
1660
1661 debug!(selector = %selector, "browser_click requested (no CDP driver)");
1662
1663 Ok(ToolResult {
1664 success: false,
1665 output: serde_json::json!({
1666 "action": "click",
1667 "selector": selector,
1668 "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable clicking"
1669 }),
1670 error: Some("no CDP driver configured".into()),
1671 duration_ms: 0,
1672 })
1673}
1674
1675async fn tool_browser_type(
1676 input: &serde_json::Value,
1677 capabilities: &[Capability],
1678 context: &ToolExecutionContext,
1679) -> PunchResult<ToolResult> {
1680 let _pool = require_browser_pool(capabilities, context)?;
1681
1682 let selector = input["selector"].as_str().ok_or_else(|| PunchError::Tool {
1683 tool: "browser_type".into(),
1684 message: "missing 'selector' parameter".into(),
1685 })?;
1686 let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
1687 tool: "browser_type".into(),
1688 message: "missing 'text' parameter".into(),
1689 })?;
1690
1691 debug!(selector = %selector, text_len = text.len(), "browser_type requested (no CDP driver)");
1692
1693 Ok(ToolResult {
1694 success: false,
1695 output: serde_json::json!({
1696 "action": "type",
1697 "selector": selector,
1698 "text_length": text.len(),
1699 "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable typing"
1700 }),
1701 error: Some("no CDP driver configured".into()),
1702 duration_ms: 0,
1703 })
1704}
1705
1706async fn tool_browser_content(
1707 input: &serde_json::Value,
1708 capabilities: &[Capability],
1709 context: &ToolExecutionContext,
1710) -> PunchResult<ToolResult> {
1711 let _pool = require_browser_pool(capabilities, context)?;
1712
1713 let selector = input["selector"].as_str();
1714
1715 debug!(selector = ?selector, "browser_content requested (no CDP driver)");
1716
1717 Ok(ToolResult {
1718 success: false,
1719 output: serde_json::json!({
1720 "action": "get_content",
1721 "selector": selector,
1722 "message": "browser pool is available but no CDP driver is configured — install a BrowserDriver to enable content extraction"
1723 }),
1724 error: Some("no CDP driver configured".into()),
1725 duration_ms: 0,
1726 })
1727}
1728
1729async fn tool_git_status(
1734 _input: &serde_json::Value,
1735 capabilities: &[Capability],
1736 context: &ToolExecutionContext,
1737) -> PunchResult<ToolResult> {
1738 require_capability(capabilities, &Capability::SourceControl)?;
1739
1740 let output = Command::new("git")
1741 .args(["status", "--porcelain"])
1742 .current_dir(&context.working_dir)
1743 .output()
1744 .await
1745 .map_err(|e| PunchError::Tool {
1746 tool: "git_status".into(),
1747 message: format!("failed to run git status: {}", e),
1748 })?;
1749
1750 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1751 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1752
1753 Ok(ToolResult {
1754 success: output.status.success(),
1755 output: serde_json::json!({
1756 "status": stdout,
1757 "stderr": stderr,
1758 }),
1759 error: if output.status.success() {
1760 None
1761 } else {
1762 Some(stderr)
1763 },
1764 duration_ms: 0,
1765 })
1766}
1767
1768async fn tool_git_diff(
1769 input: &serde_json::Value,
1770 capabilities: &[Capability],
1771 context: &ToolExecutionContext,
1772) -> PunchResult<ToolResult> {
1773 require_capability(capabilities, &Capability::SourceControl)?;
1774
1775 let staged = input["staged"].as_bool().unwrap_or(false);
1776 let mut args = vec!["diff".to_string()];
1777 if staged {
1778 args.push("--staged".to_string());
1779 }
1780 if let Some(path) = input["path"].as_str() {
1781 args.push("--".to_string());
1782 args.push(path.to_string());
1783 }
1784
1785 let output = Command::new("git")
1786 .args(&args)
1787 .current_dir(&context.working_dir)
1788 .output()
1789 .await
1790 .map_err(|e| PunchError::Tool {
1791 tool: "git_diff".into(),
1792 message: format!("failed to run git diff: {}", 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_log(
1811 input: &serde_json::Value,
1812 capabilities: &[Capability],
1813 context: &ToolExecutionContext,
1814) -> PunchResult<ToolResult> {
1815 require_capability(capabilities, &Capability::SourceControl)?;
1816
1817 let count = input["count"].as_u64().unwrap_or(10);
1818 let output = Command::new("git")
1819 .args(["log", "--oneline", "-n", &count.to_string()])
1820 .current_dir(&context.working_dir)
1821 .output()
1822 .await
1823 .map_err(|e| PunchError::Tool {
1824 tool: "git_log".into(),
1825 message: format!("failed to run git log: {}", e),
1826 })?;
1827
1828 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1829 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1830
1831 Ok(ToolResult {
1832 success: output.status.success(),
1833 output: serde_json::json!(stdout),
1834 error: if output.status.success() {
1835 None
1836 } else {
1837 Some(stderr)
1838 },
1839 duration_ms: 0,
1840 })
1841}
1842
1843async fn tool_git_commit(
1844 input: &serde_json::Value,
1845 capabilities: &[Capability],
1846 context: &ToolExecutionContext,
1847) -> PunchResult<ToolResult> {
1848 require_capability(capabilities, &Capability::SourceControl)?;
1849
1850 let message = input["message"].as_str().ok_or_else(|| PunchError::Tool {
1851 tool: "git_commit".into(),
1852 message: "missing 'message' parameter".into(),
1853 })?;
1854
1855 if let Some(files) = input["files"].as_array() {
1857 let file_args: Vec<&str> = files.iter().filter_map(|f| f.as_str()).collect();
1858 if !file_args.is_empty() {
1859 let mut add_args = vec!["add"];
1860 add_args.extend(file_args);
1861 let add_output = Command::new("git")
1862 .args(&add_args)
1863 .current_dir(&context.working_dir)
1864 .output()
1865 .await
1866 .map_err(|e| PunchError::Tool {
1867 tool: "git_commit".into(),
1868 message: format!("failed to stage files: {}", e),
1869 })?;
1870
1871 if !add_output.status.success() {
1872 let stderr = String::from_utf8_lossy(&add_output.stderr);
1873 return Ok(ToolResult {
1874 success: false,
1875 output: serde_json::json!(null),
1876 error: Some(format!("git add failed: {}", stderr)),
1877 duration_ms: 0,
1878 });
1879 }
1880 }
1881 }
1882
1883 let output = Command::new("git")
1884 .args(["commit", "-m", message])
1885 .current_dir(&context.working_dir)
1886 .output()
1887 .await
1888 .map_err(|e| PunchError::Tool {
1889 tool: "git_commit".into(),
1890 message: format!("failed to run git commit: {}", e),
1891 })?;
1892
1893 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1894 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1895
1896 Ok(ToolResult {
1897 success: output.status.success(),
1898 output: serde_json::json!(stdout),
1899 error: if output.status.success() {
1900 None
1901 } else {
1902 Some(stderr)
1903 },
1904 duration_ms: 0,
1905 })
1906}
1907
1908async fn tool_git_branch(
1909 input: &serde_json::Value,
1910 capabilities: &[Capability],
1911 context: &ToolExecutionContext,
1912) -> PunchResult<ToolResult> {
1913 require_capability(capabilities, &Capability::SourceControl)?;
1914
1915 let action = input["action"].as_str().unwrap_or("list");
1916
1917 let output = match action {
1918 "list" => {
1919 Command::new("git")
1920 .args(["branch", "--list"])
1921 .current_dir(&context.working_dir)
1922 .output()
1923 .await
1924 }
1925 "create" => {
1926 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1927 tool: "git_branch".into(),
1928 message: "missing 'name' parameter for create".into(),
1929 })?;
1930 Command::new("git")
1931 .args(["branch", name])
1932 .current_dir(&context.working_dir)
1933 .output()
1934 .await
1935 }
1936 "switch" => {
1937 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
1938 tool: "git_branch".into(),
1939 message: "missing 'name' parameter for switch".into(),
1940 })?;
1941 Command::new("git")
1942 .args(["checkout", name])
1943 .current_dir(&context.working_dir)
1944 .output()
1945 .await
1946 }
1947 other => {
1948 return Ok(ToolResult {
1949 success: false,
1950 output: serde_json::json!(null),
1951 error: Some(format!(
1952 "unknown action '{}', expected list/create/switch",
1953 other
1954 )),
1955 duration_ms: 0,
1956 });
1957 }
1958 }
1959 .map_err(|e| PunchError::Tool {
1960 tool: "git_branch".into(),
1961 message: format!("failed to run git branch: {}", e),
1962 })?;
1963
1964 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
1965 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1966
1967 Ok(ToolResult {
1968 success: output.status.success(),
1969 output: serde_json::json!(stdout),
1970 error: if output.status.success() {
1971 None
1972 } else {
1973 Some(stderr)
1974 },
1975 duration_ms: 0,
1976 })
1977}
1978
1979async fn tool_docker_ps(
1984 input: &serde_json::Value,
1985 capabilities: &[Capability],
1986) -> PunchResult<ToolResult> {
1987 require_capability(capabilities, &Capability::Container)?;
1988
1989 let show_all = input["all"].as_bool().unwrap_or(false);
1990 let mut args = vec![
1991 "ps",
1992 "--format",
1993 "{{.ID}}\t{{.Image}}\t{{.Status}}\t{{.Names}}",
1994 ];
1995 if show_all {
1996 args.push("-a");
1997 }
1998
1999 let output = Command::new("docker")
2000 .args(&args)
2001 .output()
2002 .await
2003 .map_err(|e| PunchError::Tool {
2004 tool: "docker_ps".into(),
2005 message: format!("failed to run docker ps: {}", e),
2006 })?;
2007
2008 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2009 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2010
2011 let containers: Vec<serde_json::Value> = stdout
2012 .lines()
2013 .filter(|l| !l.is_empty())
2014 .map(|line| {
2015 let parts: Vec<&str> = line.splitn(4, '\t').collect();
2016 serde_json::json!({
2017 "id": parts.first().unwrap_or(&""),
2018 "image": parts.get(1).unwrap_or(&""),
2019 "status": parts.get(2).unwrap_or(&""),
2020 "name": parts.get(3).unwrap_or(&""),
2021 })
2022 })
2023 .collect();
2024
2025 Ok(ToolResult {
2026 success: output.status.success(),
2027 output: serde_json::json!(containers),
2028 error: if output.status.success() {
2029 None
2030 } else {
2031 Some(stderr)
2032 },
2033 duration_ms: 0,
2034 })
2035}
2036
2037async fn tool_docker_run(
2038 input: &serde_json::Value,
2039 capabilities: &[Capability],
2040) -> PunchResult<ToolResult> {
2041 require_capability(capabilities, &Capability::Container)?;
2042
2043 let image = input["image"].as_str().ok_or_else(|| PunchError::Tool {
2044 tool: "docker_run".into(),
2045 message: "missing 'image' parameter".into(),
2046 })?;
2047
2048 let detach = input["detach"].as_bool().unwrap_or(false);
2049 let mut args = vec!["run".to_string()];
2050
2051 if detach {
2052 args.push("-d".to_string());
2053 }
2054
2055 if let Some(name) = input["name"].as_str() {
2056 args.push("--name".to_string());
2057 args.push(name.to_string());
2058 }
2059
2060 if let Some(env) = input["env"].as_object() {
2061 for (key, val) in env {
2062 args.push("-e".to_string());
2063 args.push(format!("{}={}", key, val.as_str().unwrap_or_default()));
2064 }
2065 }
2066
2067 if let Some(ports) = input["ports"].as_array() {
2068 for port in ports {
2069 if let Some(p) = port.as_str() {
2070 args.push("-p".to_string());
2071 args.push(p.to_string());
2072 }
2073 }
2074 }
2075
2076 args.push(image.to_string());
2077
2078 if let Some(cmd) = input["command"].as_str() {
2079 for part in cmd.split_whitespace() {
2081 args.push(part.to_string());
2082 }
2083 }
2084
2085 let output = Command::new("docker")
2086 .args(&args)
2087 .output()
2088 .await
2089 .map_err(|e| PunchError::Tool {
2090 tool: "docker_run".into(),
2091 message: format!("failed to run docker run: {}", e),
2092 })?;
2093
2094 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2095 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2096
2097 Ok(ToolResult {
2098 success: output.status.success(),
2099 output: serde_json::json!({
2100 "stdout": stdout.trim(),
2101 "stderr": stderr.trim(),
2102 }),
2103 error: if output.status.success() {
2104 None
2105 } else {
2106 Some(stderr)
2107 },
2108 duration_ms: 0,
2109 })
2110}
2111
2112async fn tool_docker_build(
2113 input: &serde_json::Value,
2114 capabilities: &[Capability],
2115 context: &ToolExecutionContext,
2116) -> PunchResult<ToolResult> {
2117 require_capability(capabilities, &Capability::Container)?;
2118
2119 let build_path = input["path"].as_str().unwrap_or(".");
2120 let resolved_path = resolve_path(&context.working_dir, build_path)?;
2121
2122 let mut args = vec!["build".to_string()];
2123
2124 if let Some(tag) = input["tag"].as_str() {
2125 args.push("-t".to_string());
2126 args.push(tag.to_string());
2127 }
2128
2129 if let Some(dockerfile) = input["dockerfile"].as_str() {
2130 args.push("-f".to_string());
2131 args.push(dockerfile.to_string());
2132 }
2133
2134 args.push(resolved_path.display().to_string());
2135
2136 let output = Command::new("docker")
2137 .args(&args)
2138 .output()
2139 .await
2140 .map_err(|e| PunchError::Tool {
2141 tool: "docker_build".into(),
2142 message: format!("failed to run docker build: {}", e),
2143 })?;
2144
2145 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2146 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2147
2148 let truncated_stdout = if stdout.len() > 10_000 {
2150 format!("{}... [truncated]", &stdout[..10_000])
2151 } else {
2152 stdout
2153 };
2154
2155 Ok(ToolResult {
2156 success: output.status.success(),
2157 output: serde_json::json!({
2158 "stdout": truncated_stdout,
2159 "stderr": stderr,
2160 }),
2161 error: if output.status.success() {
2162 None
2163 } else {
2164 Some(stderr)
2165 },
2166 duration_ms: 0,
2167 })
2168}
2169
2170async fn tool_docker_logs(
2171 input: &serde_json::Value,
2172 capabilities: &[Capability],
2173) -> PunchResult<ToolResult> {
2174 require_capability(capabilities, &Capability::Container)?;
2175
2176 let container = input["container"]
2177 .as_str()
2178 .ok_or_else(|| PunchError::Tool {
2179 tool: "docker_logs".into(),
2180 message: "missing 'container' parameter".into(),
2181 })?;
2182
2183 let tail = input["tail"].as_u64().unwrap_or(100);
2184
2185 let output = Command::new("docker")
2186 .args(["logs", "--tail", &tail.to_string(), container])
2187 .output()
2188 .await
2189 .map_err(|e| PunchError::Tool {
2190 tool: "docker_logs".into(),
2191 message: format!("failed to run docker logs: {}", e),
2192 })?;
2193
2194 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2195 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2196
2197 Ok(ToolResult {
2198 success: output.status.success(),
2199 output: serde_json::json!({
2200 "logs": format!("{}{}", stdout, stderr),
2201 }),
2202 error: if output.status.success() {
2203 None
2204 } else {
2205 Some(format!("docker logs failed: {}", stderr))
2206 },
2207 duration_ms: 0,
2208 })
2209}
2210
2211async fn tool_http_request(
2216 input: &serde_json::Value,
2217 capabilities: &[Capability],
2218) -> PunchResult<ToolResult> {
2219 let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
2220 tool: "http_request".into(),
2221 message: "missing 'url' parameter".into(),
2222 })?;
2223
2224 let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
2225 tool: "http_request".into(),
2226 message: format!("invalid URL: {}", e),
2227 })?;
2228
2229 if let Some(host) = parsed_url.host_str() {
2230 require_capability(capabilities, &Capability::Network(host.to_string()))?;
2231 }
2232
2233 let method_str = input["method"].as_str().unwrap_or("GET");
2234 let timeout_secs = input["timeout_secs"].as_u64().unwrap_or(30);
2235
2236 let client = reqwest::Client::builder()
2237 .timeout(std::time::Duration::from_secs(timeout_secs))
2238 .redirect(reqwest::redirect::Policy::limited(5))
2239 .build()
2240 .map_err(|e| PunchError::Tool {
2241 tool: "http_request".into(),
2242 message: format!("failed to create HTTP client: {}", e),
2243 })?;
2244
2245 let method = method_str
2246 .parse::<reqwest::Method>()
2247 .map_err(|e| PunchError::Tool {
2248 tool: "http_request".into(),
2249 message: format!("invalid HTTP method '{}': {}", method_str, e),
2250 })?;
2251
2252 let mut req = client.request(method, url_str);
2253
2254 if let Some(headers) = input["headers"].as_object() {
2255 for (key, val) in headers {
2256 if let Some(v) = val.as_str() {
2257 req = req.header(key.as_str(), v);
2258 }
2259 }
2260 }
2261
2262 if let Some(body) = input["body"].as_str() {
2263 req = req.body(body.to_string());
2264 }
2265
2266 let response = req.send().await.map_err(|e| PunchError::Tool {
2267 tool: "http_request".into(),
2268 message: format!("request failed: {}", e),
2269 })?;
2270
2271 let status = response.status().as_u16();
2272 let resp_headers: HashMap<String, String> = response
2273 .headers()
2274 .iter()
2275 .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string()))
2276 .collect();
2277
2278 let body = response.text().await.map_err(|e| PunchError::Tool {
2279 tool: "http_request".into(),
2280 message: format!("failed to read response body: {}", e),
2281 })?;
2282
2283 let truncated = if body.len() > 100_000 {
2284 format!(
2285 "{}... [truncated, {} total bytes]",
2286 &body[..100_000],
2287 body.len()
2288 )
2289 } else {
2290 body
2291 };
2292
2293 Ok(ToolResult {
2294 success: (200..300).contains(&(status as usize)),
2295 output: serde_json::json!({
2296 "status": status,
2297 "headers": resp_headers,
2298 "body": truncated,
2299 }),
2300 error: None,
2301 duration_ms: 0,
2302 })
2303}
2304
2305async fn tool_http_post(
2306 input: &serde_json::Value,
2307 capabilities: &[Capability],
2308) -> PunchResult<ToolResult> {
2309 let url_str = input["url"].as_str().ok_or_else(|| PunchError::Tool {
2310 tool: "http_post".into(),
2311 message: "missing 'url' parameter".into(),
2312 })?;
2313
2314 let json_body = input.get("json").ok_or_else(|| PunchError::Tool {
2315 tool: "http_post".into(),
2316 message: "missing 'json' parameter".into(),
2317 })?;
2318
2319 let parsed_url = url::Url::parse(url_str).map_err(|e| PunchError::Tool {
2320 tool: "http_post".into(),
2321 message: format!("invalid URL: {}", e),
2322 })?;
2323
2324 if let Some(host) = parsed_url.host_str() {
2325 require_capability(capabilities, &Capability::Network(host.to_string()))?;
2326 }
2327
2328 let client = reqwest::Client::builder()
2329 .timeout(std::time::Duration::from_secs(30))
2330 .redirect(reqwest::redirect::Policy::limited(5))
2331 .build()
2332 .map_err(|e| PunchError::Tool {
2333 tool: "http_post".into(),
2334 message: format!("failed to create HTTP client: {}", e),
2335 })?;
2336
2337 let mut req = client.post(url_str).json(json_body);
2338
2339 if let Some(headers) = input["headers"].as_object() {
2340 for (key, val) in headers {
2341 if let Some(v) = val.as_str() {
2342 req = req.header(key.as_str(), v);
2343 }
2344 }
2345 }
2346
2347 let response = req.send().await.map_err(|e| PunchError::Tool {
2348 tool: "http_post".into(),
2349 message: format!("request failed: {}", e),
2350 })?;
2351
2352 let status = response.status().as_u16();
2353 let body = response.text().await.map_err(|e| PunchError::Tool {
2354 tool: "http_post".into(),
2355 message: format!("failed to read response body: {}", e),
2356 })?;
2357
2358 let truncated = if body.len() > 100_000 {
2359 format!(
2360 "{}... [truncated, {} total bytes]",
2361 &body[..100_000],
2362 body.len()
2363 )
2364 } else {
2365 body
2366 };
2367
2368 Ok(ToolResult {
2369 success: (200..300).contains(&(status as usize)),
2370 output: serde_json::json!({
2371 "status": status,
2372 "body": truncated,
2373 }),
2374 error: None,
2375 duration_ms: 0,
2376 })
2377}
2378
2379fn json_path_query(data: &serde_json::Value, path: &str) -> serde_json::Value {
2385 let mut current = data;
2386 for segment in path.split('.') {
2387 if segment.is_empty() {
2388 continue;
2389 }
2390 if let Ok(idx) = segment.parse::<usize>()
2392 && let Some(val) = current.get(idx)
2393 {
2394 current = val;
2395 continue;
2396 }
2397 if let Some(val) = current.get(segment) {
2399 current = val;
2400 } else {
2401 return serde_json::json!(null);
2402 }
2403 }
2404 current.clone()
2405}
2406
2407async fn tool_json_query(
2408 input: &serde_json::Value,
2409 capabilities: &[Capability],
2410) -> PunchResult<ToolResult> {
2411 require_capability(capabilities, &Capability::DataManipulation)?;
2412
2413 let path = input["path"].as_str().ok_or_else(|| PunchError::Tool {
2414 tool: "json_query".into(),
2415 message: "missing 'path' parameter".into(),
2416 })?;
2417
2418 let data = input.get("data").ok_or_else(|| PunchError::Tool {
2419 tool: "json_query".into(),
2420 message: "missing 'data' parameter".into(),
2421 })?;
2422
2423 let parsed_data = if let Some(s) = data.as_str() {
2425 serde_json::from_str(s).unwrap_or_else(|_| serde_json::json!(s))
2426 } else {
2427 data.clone()
2428 };
2429
2430 let result = json_path_query(&parsed_data, path);
2431
2432 Ok(ToolResult {
2433 success: true,
2434 output: result,
2435 error: None,
2436 duration_ms: 0,
2437 })
2438}
2439
2440async fn tool_json_transform(
2441 input: &serde_json::Value,
2442 capabilities: &[Capability],
2443) -> PunchResult<ToolResult> {
2444 require_capability(capabilities, &Capability::DataManipulation)?;
2445
2446 let data = input.get("data").ok_or_else(|| PunchError::Tool {
2447 tool: "json_transform".into(),
2448 message: "missing 'data' parameter".into(),
2449 })?;
2450
2451 let mut parsed_data = if let Some(s) = data.as_str() {
2453 serde_json::from_str(s).unwrap_or_else(|_| serde_json::json!(s))
2454 } else {
2455 data.clone()
2456 };
2457
2458 if let Some(extract_keys) = input["extract"].as_array() {
2460 let keys: Vec<&str> = extract_keys.iter().filter_map(|k| k.as_str()).collect();
2461 if let Some(arr) = parsed_data.as_array() {
2462 let filtered: Vec<serde_json::Value> = arr
2463 .iter()
2464 .map(|item| {
2465 let mut obj = serde_json::Map::new();
2466 for key in &keys {
2467 if let Some(val) = item.get(*key) {
2468 obj.insert(key.to_string(), val.clone());
2469 }
2470 }
2471 serde_json::Value::Object(obj)
2472 })
2473 .collect();
2474 parsed_data = serde_json::json!(filtered);
2475 } else if let Some(obj) = parsed_data.as_object() {
2476 let mut result = serde_json::Map::new();
2477 for key in &keys {
2478 if let Some(val) = obj.get(*key) {
2479 result.insert(key.to_string(), val.clone());
2480 }
2481 }
2482 parsed_data = serde_json::Value::Object(result);
2483 }
2484 }
2485
2486 if let Some(rename_map) = input["rename"].as_object() {
2488 if let Some(arr) = parsed_data.as_array() {
2489 let renamed: Vec<serde_json::Value> = arr
2490 .iter()
2491 .map(|item| {
2492 if let Some(obj) = item.as_object() {
2493 let mut new_obj = serde_json::Map::new();
2494 for (k, v) in obj {
2495 let new_key = rename_map.get(k).and_then(|r| r.as_str()).unwrap_or(k);
2496 new_obj.insert(new_key.to_string(), v.clone());
2497 }
2498 serde_json::Value::Object(new_obj)
2499 } else {
2500 item.clone()
2501 }
2502 })
2503 .collect();
2504 parsed_data = serde_json::json!(renamed);
2505 } else if let Some(obj) = parsed_data.as_object() {
2506 let mut new_obj = serde_json::Map::new();
2507 for (k, v) in obj {
2508 let new_key = rename_map.get(k).and_then(|r| r.as_str()).unwrap_or(k);
2509 new_obj.insert(new_key.to_string(), v.clone());
2510 }
2511 parsed_data = serde_json::Value::Object(new_obj);
2512 }
2513 }
2514
2515 if let Some(filter_key) = input["filter_key"].as_str()
2517 && let Some(filter_value) = input["filter_value"].as_str()
2518 && let Some(arr) = parsed_data.as_array()
2519 {
2520 let filtered: Vec<serde_json::Value> = arr
2521 .iter()
2522 .filter(|item| {
2523 item.get(filter_key)
2524 .and_then(|v| v.as_str())
2525 .is_some_and(|s| s == filter_value)
2526 })
2527 .cloned()
2528 .collect();
2529 parsed_data = serde_json::json!(filtered);
2530 }
2531
2532 Ok(ToolResult {
2533 success: true,
2534 output: parsed_data,
2535 error: None,
2536 duration_ms: 0,
2537 })
2538}
2539
2540async fn tool_yaml_parse(
2541 input: &serde_json::Value,
2542 capabilities: &[Capability],
2543) -> PunchResult<ToolResult> {
2544 require_capability(capabilities, &Capability::DataManipulation)?;
2545
2546 let content = input["content"].as_str().ok_or_else(|| PunchError::Tool {
2547 tool: "yaml_parse".into(),
2548 message: "missing 'content' parameter".into(),
2549 })?;
2550
2551 let parsed: serde_json::Value =
2552 serde_yaml::from_str(content).map_err(|e| PunchError::Tool {
2553 tool: "yaml_parse".into(),
2554 message: format!("failed to parse YAML: {}", e),
2555 })?;
2556
2557 Ok(ToolResult {
2558 success: true,
2559 output: parsed,
2560 error: None,
2561 duration_ms: 0,
2562 })
2563}
2564
2565async fn tool_regex_match(
2566 input: &serde_json::Value,
2567 capabilities: &[Capability],
2568) -> PunchResult<ToolResult> {
2569 require_capability(capabilities, &Capability::DataManipulation)?;
2570
2571 let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2572 tool: "regex_match".into(),
2573 message: "missing 'pattern' parameter".into(),
2574 })?;
2575 let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
2576 tool: "regex_match".into(),
2577 message: "missing 'text' parameter".into(),
2578 })?;
2579 let global = input["global"].as_bool().unwrap_or(false);
2580
2581 let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2582 tool: "regex_match".into(),
2583 message: format!("invalid regex: {}", e),
2584 })?;
2585
2586 if global {
2587 let matches: Vec<serde_json::Value> = re
2588 .captures_iter(text)
2589 .map(|cap| {
2590 let groups: Vec<serde_json::Value> = cap
2591 .iter()
2592 .map(|m| m.map_or(serde_json::json!(null), |m| serde_json::json!(m.as_str())))
2593 .collect();
2594 serde_json::json!(groups)
2595 })
2596 .collect();
2597
2598 Ok(ToolResult {
2599 success: true,
2600 output: serde_json::json!({ "matches": matches }),
2601 error: None,
2602 duration_ms: 0,
2603 })
2604 } else if let Some(cap) = re.captures(text) {
2605 let groups: Vec<serde_json::Value> = cap
2606 .iter()
2607 .map(|m| m.map_or(serde_json::json!(null), |m| serde_json::json!(m.as_str())))
2608 .collect();
2609
2610 Ok(ToolResult {
2611 success: true,
2612 output: serde_json::json!({ "matched": true, "groups": groups }),
2613 error: None,
2614 duration_ms: 0,
2615 })
2616 } else {
2617 Ok(ToolResult {
2618 success: true,
2619 output: serde_json::json!({ "matched": false, "groups": [] }),
2620 error: None,
2621 duration_ms: 0,
2622 })
2623 }
2624}
2625
2626async fn tool_regex_replace(
2627 input: &serde_json::Value,
2628 capabilities: &[Capability],
2629) -> PunchResult<ToolResult> {
2630 require_capability(capabilities, &Capability::DataManipulation)?;
2631
2632 let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2633 tool: "regex_replace".into(),
2634 message: "missing 'pattern' parameter".into(),
2635 })?;
2636 let replacement = input["replacement"]
2637 .as_str()
2638 .ok_or_else(|| PunchError::Tool {
2639 tool: "regex_replace".into(),
2640 message: "missing 'replacement' parameter".into(),
2641 })?;
2642 let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
2643 tool: "regex_replace".into(),
2644 message: "missing 'text' parameter".into(),
2645 })?;
2646
2647 let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2648 tool: "regex_replace".into(),
2649 message: format!("invalid regex: {}", e),
2650 })?;
2651
2652 let result = re.replace_all(text, replacement).to_string();
2653
2654 Ok(ToolResult {
2655 success: true,
2656 output: serde_json::json!(result),
2657 error: None,
2658 duration_ms: 0,
2659 })
2660}
2661
2662async fn tool_process_list(
2667 input: &serde_json::Value,
2668 capabilities: &[Capability],
2669 context: &ToolExecutionContext,
2670) -> PunchResult<ToolResult> {
2671 require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
2672
2673 let filter = input["filter"].as_str();
2674
2675 let output = Command::new("ps")
2677 .args(["aux"])
2678 .current_dir(&context.working_dir)
2679 .output()
2680 .await
2681 .map_err(|e| PunchError::Tool {
2682 tool: "process_list".into(),
2683 message: format!("failed to run ps: {}", e),
2684 })?;
2685
2686 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
2687 let lines: Vec<&str> = stdout.lines().collect();
2688
2689 let header = lines.first().copied().unwrap_or("");
2690 let processes: Vec<serde_json::Value> = lines
2691 .iter()
2692 .skip(1)
2693 .filter(|line| {
2694 if let Some(f) = filter {
2695 line.contains(f)
2696 } else {
2697 true
2698 }
2699 })
2700 .take(100) .map(|line| {
2702 let parts: Vec<&str> = line.split_whitespace().collect();
2703 serde_json::json!({
2704 "user": parts.first().unwrap_or(&""),
2705 "pid": parts.get(1).unwrap_or(&""),
2706 "cpu": parts.get(2).unwrap_or(&""),
2707 "mem": parts.get(3).unwrap_or(&""),
2708 "command": parts.get(10..).map(|s| s.join(" ")).unwrap_or_default(),
2709 })
2710 })
2711 .collect();
2712
2713 Ok(ToolResult {
2714 success: output.status.success(),
2715 output: serde_json::json!({
2716 "header": header,
2717 "processes": processes,
2718 "count": processes.len(),
2719 }),
2720 error: None,
2721 duration_ms: 0,
2722 })
2723}
2724
2725async fn tool_process_kill(
2726 input: &serde_json::Value,
2727 capabilities: &[Capability],
2728) -> PunchResult<ToolResult> {
2729 require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
2730
2731 let pid = input["pid"].as_u64().ok_or_else(|| PunchError::Tool {
2732 tool: "process_kill".into(),
2733 message: "missing 'pid' parameter".into(),
2734 })?;
2735
2736 let signal = input["signal"].as_str().unwrap_or("TERM");
2737
2738 let output = Command::new("kill")
2739 .args([&format!("-{}", signal), &pid.to_string()])
2740 .output()
2741 .await
2742 .map_err(|e| PunchError::Tool {
2743 tool: "process_kill".into(),
2744 message: format!("failed to run kill: {}", e),
2745 })?;
2746
2747 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
2748
2749 Ok(ToolResult {
2750 success: output.status.success(),
2751 output: serde_json::json!({
2752 "pid": pid,
2753 "signal": signal,
2754 "killed": output.status.success(),
2755 }),
2756 error: if output.status.success() {
2757 None
2758 } else {
2759 Some(format!("kill failed: {}", stderr))
2760 },
2761 duration_ms: 0,
2762 })
2763}
2764
2765#[derive(Clone, Debug, serde::Serialize)]
2771struct ScheduledTask {
2772 id: String,
2773 name: String,
2774 command: String,
2775 delay_secs: u64,
2776 interval_secs: Option<u64>,
2777 status: String,
2778}
2779
2780static SCHEDULED_TASKS: LazyLock<DashMap<String, ScheduledTask>> = LazyLock::new(DashMap::new);
2782
2783static TASK_CANCELLERS: LazyLock<DashMap<String, tokio::sync::watch::Sender<bool>>> =
2785 LazyLock::new(DashMap::new);
2786
2787async fn tool_schedule_task(
2788 input: &serde_json::Value,
2789 capabilities: &[Capability],
2790 context: &ToolExecutionContext,
2791) -> PunchResult<ToolResult> {
2792 require_capability(capabilities, &Capability::Schedule)?;
2793
2794 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
2795 tool: "schedule_task".into(),
2796 message: "missing 'name' parameter".into(),
2797 })?;
2798 let command = input["command"].as_str().ok_or_else(|| PunchError::Tool {
2799 tool: "schedule_task".into(),
2800 message: "missing 'command' parameter".into(),
2801 })?;
2802 let delay_secs = input["delay_secs"]
2803 .as_u64()
2804 .ok_or_else(|| PunchError::Tool {
2805 tool: "schedule_task".into(),
2806 message: "missing 'delay_secs' parameter".into(),
2807 })?;
2808 let interval_secs = input["interval_secs"].as_u64();
2809
2810 let task_id = uuid::Uuid::new_v4().to_string();
2811 let task = ScheduledTask {
2812 id: task_id.clone(),
2813 name: name.to_string(),
2814 command: command.to_string(),
2815 delay_secs,
2816 interval_secs,
2817 status: "scheduled".to_string(),
2818 };
2819
2820 SCHEDULED_TASKS.insert(task_id.clone(), task);
2821
2822 let (cancel_tx, mut cancel_rx) = tokio::sync::watch::channel(false);
2823 TASK_CANCELLERS.insert(task_id.clone(), cancel_tx);
2824
2825 let task_id_clone = task_id.clone();
2827 let command_owned = command.to_string();
2828 let working_dir = context.working_dir.clone();
2829
2830 tokio::spawn(async move {
2831 tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
2832
2833 loop {
2834 if *cancel_rx.borrow() {
2835 break;
2836 }
2837
2838 let _output = Command::new("sh")
2840 .arg("-c")
2841 .arg(&command_owned)
2842 .current_dir(&working_dir)
2843 .output()
2844 .await;
2845
2846 if let Some(mut entry) = SCHEDULED_TASKS.get_mut(&task_id_clone) {
2848 entry.status = "executed".to_string();
2849 }
2850
2851 let Some(interval) = interval_secs else {
2853 break;
2854 };
2855
2856 tokio::select! {
2858 _ = tokio::time::sleep(std::time::Duration::from_secs(interval)) => {}
2859 _ = cancel_rx.changed() => {
2860 break;
2861 }
2862 }
2863 }
2864
2865 if interval_secs.is_none() {
2867 SCHEDULED_TASKS.remove(&task_id_clone);
2868 TASK_CANCELLERS.remove(&task_id_clone);
2869 }
2870 });
2871
2872 Ok(ToolResult {
2873 success: true,
2874 output: serde_json::json!({
2875 "task_id": task_id,
2876 "name": name,
2877 "delay_secs": delay_secs,
2878 "interval_secs": interval_secs,
2879 }),
2880 error: None,
2881 duration_ms: 0,
2882 })
2883}
2884
2885async fn tool_schedule_list(capabilities: &[Capability]) -> PunchResult<ToolResult> {
2886 require_capability(capabilities, &Capability::Schedule)?;
2887
2888 let tasks: Vec<serde_json::Value> = SCHEDULED_TASKS
2889 .iter()
2890 .map(|entry| {
2891 let task = entry.value();
2892 serde_json::json!({
2893 "id": task.id,
2894 "name": task.name,
2895 "command": task.command,
2896 "delay_secs": task.delay_secs,
2897 "interval_secs": task.interval_secs,
2898 "status": task.status,
2899 })
2900 })
2901 .collect();
2902
2903 Ok(ToolResult {
2904 success: true,
2905 output: serde_json::json!(tasks),
2906 error: None,
2907 duration_ms: 0,
2908 })
2909}
2910
2911async fn tool_schedule_cancel(
2912 input: &serde_json::Value,
2913 capabilities: &[Capability],
2914) -> PunchResult<ToolResult> {
2915 require_capability(capabilities, &Capability::Schedule)?;
2916
2917 let task_id = input["task_id"].as_str().ok_or_else(|| PunchError::Tool {
2918 tool: "schedule_cancel".into(),
2919 message: "missing 'task_id' parameter".into(),
2920 })?;
2921
2922 if let Some(sender) = TASK_CANCELLERS.get(task_id) {
2924 let _ = sender.send(true);
2925 }
2926
2927 let removed = SCHEDULED_TASKS.remove(task_id).is_some();
2929 TASK_CANCELLERS.remove(task_id);
2930
2931 Ok(ToolResult {
2932 success: removed,
2933 output: serde_json::json!({
2934 "task_id": task_id,
2935 "cancelled": removed,
2936 }),
2937 error: if removed {
2938 None
2939 } else {
2940 Some(format!("task '{}' not found", task_id))
2941 },
2942 duration_ms: 0,
2943 })
2944}
2945
2946async fn tool_code_search(
2951 input: &serde_json::Value,
2952 capabilities: &[Capability],
2953 context: &ToolExecutionContext,
2954) -> PunchResult<ToolResult> {
2955 require_capability(capabilities, &Capability::CodeAnalysis)?;
2956
2957 let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
2958 tool: "code_search".into(),
2959 message: "missing 'pattern' parameter".into(),
2960 })?;
2961 let search_path = input["path"].as_str().unwrap_or(".");
2962 let file_pattern = input["file_pattern"].as_str();
2963 let max_results = input["max_results"].as_u64().unwrap_or(50) as usize;
2964
2965 let resolved_path = resolve_path(&context.working_dir, search_path)?;
2966
2967 let re = regex::Regex::new(pattern_str).map_err(|e| PunchError::Tool {
2968 tool: "code_search".into(),
2969 message: format!("invalid regex: {}", e),
2970 })?;
2971
2972 let file_glob = file_pattern.and_then(|p| glob::Pattern::new(p).ok());
2973
2974 let mut results = Vec::new();
2975
2976 for entry in walkdir::WalkDir::new(&resolved_path)
2977 .follow_links(false)
2978 .into_iter()
2979 .filter_map(|e| e.ok())
2980 {
2981 if results.len() >= max_results {
2982 break;
2983 }
2984
2985 let path = entry.path();
2986 if !path.is_file() {
2987 continue;
2988 }
2989
2990 if let Some(ref glob_pat) = file_glob
2992 && let Some(name) = path.file_name().and_then(|n| n.to_str())
2993 && !glob_pat.matches(name)
2994 {
2995 continue;
2996 }
2997
2998 let Ok(content) = std::fs::read_to_string(path) else {
3000 continue;
3001 };
3002
3003 for (line_num, line) in content.lines().enumerate() {
3004 if results.len() >= max_results {
3005 break;
3006 }
3007 if re.is_match(line) {
3008 let rel_path = path
3009 .strip_prefix(&resolved_path)
3010 .unwrap_or(path)
3011 .display()
3012 .to_string();
3013 results.push(serde_json::json!({
3014 "file": rel_path,
3015 "line": line_num + 1,
3016 "text": line.chars().take(200).collect::<String>(),
3017 }));
3018 }
3019 }
3020 }
3021
3022 Ok(ToolResult {
3023 success: true,
3024 output: serde_json::json!({
3025 "matches": results,
3026 "count": results.len(),
3027 }),
3028 error: None,
3029 duration_ms: 0,
3030 })
3031}
3032
3033async fn tool_code_symbols(
3034 input: &serde_json::Value,
3035 capabilities: &[Capability],
3036 context: &ToolExecutionContext,
3037) -> PunchResult<ToolResult> {
3038 require_capability(capabilities, &Capability::CodeAnalysis)?;
3039
3040 let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
3041 tool: "code_symbols".into(),
3042 message: "missing 'path' parameter".into(),
3043 })?;
3044
3045 let path = resolve_path(&context.working_dir, path_str)?;
3046
3047 let content = tokio::fs::read_to_string(&path)
3048 .await
3049 .map_err(|e| PunchError::Tool {
3050 tool: "code_symbols".into(),
3051 message: format!("failed to read '{}': {}", path.display(), e),
3052 })?;
3053
3054 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
3055
3056 let patterns: Vec<(&str, &str)> = match ext {
3058 "rs" => vec![
3059 ("function", r"(?m)^\s*(?:pub\s+)?(?:async\s+)?fn\s+(\w+)"),
3060 ("struct", r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)"),
3061 ("enum", r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)"),
3062 ("trait", r"(?m)^\s*(?:pub\s+)?trait\s+(\w+)"),
3063 ("impl", r"(?m)^\s*impl(?:<[^>]*>)?\s+(\w+)"),
3064 ],
3065 "py" => vec![
3066 ("function", r"(?m)^\s*def\s+(\w+)"),
3067 ("class", r"(?m)^\s*class\s+(\w+)"),
3068 ],
3069 "js" | "ts" | "jsx" | "tsx" => vec![
3070 (
3071 "function",
3072 r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)",
3073 ),
3074 ("class", r"(?m)^\s*(?:export\s+)?class\s+(\w+)"),
3075 (
3076 "const_fn",
3077 r"(?m)^\s*(?:export\s+)?const\s+(\w+)\s*=\s*(?:async\s+)?(?:\([^)]*\)|[^=])\s*=>",
3078 ),
3079 ],
3080 "go" => vec![
3081 ("function", r"(?m)^func\s+(?:\([^)]*\)\s+)?(\w+)"),
3082 ("type", r"(?m)^type\s+(\w+)\s+struct"),
3083 ("interface", r"(?m)^type\s+(\w+)\s+interface"),
3084 ],
3085 "java" | "kt" => vec![
3086 (
3087 "class",
3088 r"(?m)^\s*(?:public|private|protected)?\s*(?:static\s+)?class\s+(\w+)",
3089 ),
3090 (
3091 "method",
3092 r"(?m)^\s*(?:public|private|protected)?\s*(?:static\s+)?\w+\s+(\w+)\s*\(",
3093 ),
3094 ],
3095 _ => vec![
3096 (
3097 "function",
3098 r"(?m)^\s*(?:pub\s+)?(?:async\s+)?(?:fn|function|def)\s+(\w+)",
3099 ),
3100 ("class", r"(?m)^\s*(?:pub\s+)?(?:class|struct|enum)\s+(\w+)"),
3101 ],
3102 };
3103
3104 let mut symbols = Vec::new();
3105
3106 for (kind, pattern) in patterns {
3107 if let Ok(re) = regex::Regex::new(pattern) {
3108 for cap in re.captures_iter(&content) {
3109 if let Some(name_match) = cap.get(1) {
3110 let byte_offset = name_match.start();
3112 let line_num = content[..byte_offset].matches('\n').count() + 1;
3113 symbols.push(serde_json::json!({
3114 "kind": kind,
3115 "name": name_match.as_str(),
3116 "line": line_num,
3117 }));
3118 }
3119 }
3120 }
3121 }
3122
3123 Ok(ToolResult {
3124 success: true,
3125 output: serde_json::json!({
3126 "file": path_str,
3127 "symbols": symbols,
3128 "count": symbols.len(),
3129 }),
3130 error: None,
3131 duration_ms: 0,
3132 })
3133}
3134
3135async fn tool_archive_create(
3140 input: &serde_json::Value,
3141 capabilities: &[Capability],
3142 context: &ToolExecutionContext,
3143) -> PunchResult<ToolResult> {
3144 require_capability(capabilities, &Capability::Archive)?;
3145
3146 let output_path_str = input["output_path"]
3147 .as_str()
3148 .ok_or_else(|| PunchError::Tool {
3149 tool: "archive_create".into(),
3150 message: "missing 'output_path' parameter".into(),
3151 })?;
3152 let paths = input["paths"].as_array().ok_or_else(|| PunchError::Tool {
3153 tool: "archive_create".into(),
3154 message: "missing 'paths' parameter".into(),
3155 })?;
3156
3157 let output_path = resolve_path(&context.working_dir, output_path_str)?;
3158
3159 if let Some(parent) = output_path.parent()
3161 && !parent.exists()
3162 {
3163 std::fs::create_dir_all(parent).map_err(|e| PunchError::Tool {
3164 tool: "archive_create".into(),
3165 message: format!("failed to create directory: {}", e),
3166 })?;
3167 }
3168
3169 let file = std::fs::File::create(&output_path).map_err(|e| PunchError::Tool {
3170 tool: "archive_create".into(),
3171 message: format!("failed to create archive file: {}", e),
3172 })?;
3173
3174 let enc = flate2::write::GzEncoder::new(file, flate2::Compression::default());
3175 let mut builder = tar::Builder::new(enc);
3176
3177 let mut file_count = 0u64;
3178 for path_val in paths {
3179 let Some(path_str) = path_val.as_str() else {
3180 continue;
3181 };
3182 let resolved = resolve_path(&context.working_dir, path_str)?;
3183 if resolved.is_dir() {
3184 builder
3185 .append_dir_all(path_str, &resolved)
3186 .map_err(|e| PunchError::Tool {
3187 tool: "archive_create".into(),
3188 message: format!("failed to add directory '{}': {}", path_str, e),
3189 })?;
3190 file_count += 1;
3191 } else if resolved.is_file() {
3192 builder
3193 .append_path_with_name(&resolved, path_str)
3194 .map_err(|e| PunchError::Tool {
3195 tool: "archive_create".into(),
3196 message: format!("failed to add file '{}': {}", path_str, e),
3197 })?;
3198 file_count += 1;
3199 }
3200 }
3201
3202 builder.finish().map_err(|e| PunchError::Tool {
3203 tool: "archive_create".into(),
3204 message: format!("failed to finalize archive: {}", e),
3205 })?;
3206
3207 Ok(ToolResult {
3208 success: true,
3209 output: serde_json::json!({
3210 "archive": output_path.display().to_string(),
3211 "entries": file_count,
3212 }),
3213 error: None,
3214 duration_ms: 0,
3215 })
3216}
3217
3218async fn tool_archive_extract(
3219 input: &serde_json::Value,
3220 capabilities: &[Capability],
3221 context: &ToolExecutionContext,
3222) -> PunchResult<ToolResult> {
3223 require_capability(capabilities, &Capability::Archive)?;
3224
3225 let archive_path_str = input["archive_path"]
3226 .as_str()
3227 .ok_or_else(|| PunchError::Tool {
3228 tool: "archive_extract".into(),
3229 message: "missing 'archive_path' parameter".into(),
3230 })?;
3231 let destination_str = input["destination"]
3232 .as_str()
3233 .ok_or_else(|| PunchError::Tool {
3234 tool: "archive_extract".into(),
3235 message: "missing 'destination' parameter".into(),
3236 })?;
3237
3238 let archive_path = resolve_path(&context.working_dir, archive_path_str)?;
3239 let destination = resolve_path(&context.working_dir, destination_str)?;
3240
3241 let file = std::fs::File::open(&archive_path).map_err(|e| PunchError::Tool {
3242 tool: "archive_extract".into(),
3243 message: format!("failed to open archive: {}", e),
3244 })?;
3245
3246 let decoder = flate2::read::GzDecoder::new(file);
3247 let mut archive = tar::Archive::new(decoder);
3248
3249 std::fs::create_dir_all(&destination).map_err(|e| PunchError::Tool {
3250 tool: "archive_extract".into(),
3251 message: format!("failed to create destination directory: {}", e),
3252 })?;
3253
3254 archive.unpack(&destination).map_err(|e| PunchError::Tool {
3255 tool: "archive_extract".into(),
3256 message: format!("failed to extract archive: {}", e),
3257 })?;
3258
3259 Ok(ToolResult {
3260 success: true,
3261 output: serde_json::json!({
3262 "destination": destination.display().to_string(),
3263 "message": "archive extracted successfully",
3264 }),
3265 error: None,
3266 duration_ms: 0,
3267 })
3268}
3269
3270async fn tool_archive_list(
3271 input: &serde_json::Value,
3272 capabilities: &[Capability],
3273 context: &ToolExecutionContext,
3274) -> PunchResult<ToolResult> {
3275 require_capability(capabilities, &Capability::Archive)?;
3276
3277 let archive_path_str = input["archive_path"]
3278 .as_str()
3279 .ok_or_else(|| PunchError::Tool {
3280 tool: "archive_list".into(),
3281 message: "missing 'archive_path' parameter".into(),
3282 })?;
3283
3284 let archive_path = resolve_path(&context.working_dir, archive_path_str)?;
3285
3286 let file = std::fs::File::open(&archive_path).map_err(|e| PunchError::Tool {
3287 tool: "archive_list".into(),
3288 message: format!("failed to open archive: {}", e),
3289 })?;
3290
3291 let decoder = flate2::read::GzDecoder::new(file);
3292 let mut archive = tar::Archive::new(decoder);
3293
3294 let mut entries_list = Vec::new();
3295 for entry in archive.entries().map_err(|e| PunchError::Tool {
3296 tool: "archive_list".into(),
3297 message: format!("failed to read archive entries: {}", e),
3298 })? {
3299 let entry = entry.map_err(|e| PunchError::Tool {
3300 tool: "archive_list".into(),
3301 message: format!("failed to read entry: {}", e),
3302 })?;
3303 let path = entry
3304 .path()
3305 .map(|p| p.display().to_string())
3306 .unwrap_or_else(|_| "<invalid path>".to_string());
3307 let size = entry.size();
3308 let is_dir = entry.header().entry_type().is_dir();
3309 entries_list.push(serde_json::json!({
3310 "path": path,
3311 "size": size,
3312 "is_directory": is_dir,
3313 }));
3314 }
3315
3316 Ok(ToolResult {
3317 success: true,
3318 output: serde_json::json!({
3319 "entries": entries_list,
3320 "count": entries_list.len(),
3321 }),
3322 error: None,
3323 duration_ms: 0,
3324 })
3325}
3326
3327async fn tool_template_render(
3332 input: &serde_json::Value,
3333 capabilities: &[Capability],
3334) -> PunchResult<ToolResult> {
3335 require_capability(capabilities, &Capability::Template)?;
3336
3337 let template = input["template"].as_str().ok_or_else(|| PunchError::Tool {
3338 tool: "template_render".into(),
3339 message: "missing 'template' parameter".into(),
3340 })?;
3341 let variables = input["variables"]
3342 .as_object()
3343 .ok_or_else(|| PunchError::Tool {
3344 tool: "template_render".into(),
3345 message: "missing 'variables' parameter (must be an object)".into(),
3346 })?;
3347
3348 let re = regex::Regex::new(r"\{\{(\w+)\}\}").map_err(|e| PunchError::Tool {
3350 tool: "template_render".into(),
3351 message: format!("internal regex error: {}", e),
3352 })?;
3353
3354 let rendered = re.replace_all(template, |caps: ®ex::Captures| {
3355 let var_name = &caps[1];
3356 variables
3357 .get(var_name)
3358 .map(|v| {
3359 if let Some(s) = v.as_str() {
3360 s.to_string()
3361 } else {
3362 v.to_string()
3363 }
3364 })
3365 .unwrap_or_else(|| format!("{{{{{}}}}}", var_name))
3366 });
3367
3368 Ok(ToolResult {
3369 success: true,
3370 output: serde_json::json!(rendered.to_string()),
3371 error: None,
3372 duration_ms: 0,
3373 })
3374}
3375
3376fn compute_hash(algorithm: &str, data: &[u8]) -> PunchResult<String> {
3382 use sha2::Digest;
3383 match algorithm {
3384 "sha256" => {
3385 let mut hasher = sha2::Sha256::new();
3386 hasher.update(data);
3387 Ok(format!("{:x}", hasher.finalize()))
3388 }
3389 "sha512" => {
3390 let mut hasher = sha2::Sha512::new();
3391 hasher.update(data);
3392 Ok(format!("{:x}", hasher.finalize()))
3393 }
3394 "md5" => {
3395 Err(PunchError::Tool {
3401 tool: "hash_compute".into(),
3402 message: "MD5 is not supported in-process (insecure and deprecated). Use sha256 or sha512 instead.".into(),
3403 })
3404 }
3405 other => Err(PunchError::Tool {
3406 tool: "hash_compute".into(),
3407 message: format!("unsupported algorithm '{}', use sha256 or sha512", other),
3408 }),
3409 }
3410}
3411
3412async fn tool_hash_compute(
3413 input: &serde_json::Value,
3414 capabilities: &[Capability],
3415 context: &ToolExecutionContext,
3416) -> PunchResult<ToolResult> {
3417 require_capability(capabilities, &Capability::Crypto)?;
3418
3419 let algorithm = input["algorithm"].as_str().unwrap_or("sha256");
3420
3421 let data = if let Some(input_str) = input["input"].as_str() {
3422 input_str.as_bytes().to_vec()
3423 } else if let Some(file_path) = input["file"].as_str() {
3424 let path = resolve_path(&context.working_dir, file_path)?;
3425 std::fs::read(&path).map_err(|e| PunchError::Tool {
3426 tool: "hash_compute".into(),
3427 message: format!("failed to read file '{}': {}", path.display(), e),
3428 })?
3429 } else {
3430 return Ok(ToolResult {
3431 success: false,
3432 output: serde_json::json!(null),
3433 error: Some("must provide either 'input' (string) or 'file' (path) parameter".into()),
3434 duration_ms: 0,
3435 });
3436 };
3437
3438 let hash = compute_hash(algorithm, &data)?;
3439
3440 Ok(ToolResult {
3441 success: true,
3442 output: serde_json::json!({
3443 "algorithm": algorithm,
3444 "hash": hash,
3445 "bytes_hashed": data.len(),
3446 }),
3447 error: None,
3448 duration_ms: 0,
3449 })
3450}
3451
3452async fn tool_hash_verify(
3453 input: &serde_json::Value,
3454 capabilities: &[Capability],
3455 context: &ToolExecutionContext,
3456) -> PunchResult<ToolResult> {
3457 require_capability(capabilities, &Capability::Crypto)?;
3458
3459 let algorithm = input["algorithm"].as_str().unwrap_or("sha256");
3460 let expected = input["expected"].as_str().ok_or_else(|| PunchError::Tool {
3461 tool: "hash_verify".into(),
3462 message: "missing 'expected' parameter".into(),
3463 })?;
3464
3465 let data = if let Some(input_str) = input["input"].as_str() {
3466 input_str.as_bytes().to_vec()
3467 } else if let Some(file_path) = input["file"].as_str() {
3468 let path = resolve_path(&context.working_dir, file_path)?;
3469 std::fs::read(&path).map_err(|e| PunchError::Tool {
3470 tool: "hash_verify".into(),
3471 message: format!("failed to read file '{}': {}", path.display(), e),
3472 })?
3473 } else {
3474 return Ok(ToolResult {
3475 success: false,
3476 output: serde_json::json!(null),
3477 error: Some("must provide either 'input' (string) or 'file' (path) parameter".into()),
3478 duration_ms: 0,
3479 });
3480 };
3481
3482 let actual = compute_hash(algorithm, &data)?;
3483 let matches = actual.eq_ignore_ascii_case(expected);
3484
3485 Ok(ToolResult {
3486 success: true,
3487 output: serde_json::json!({
3488 "algorithm": algorithm,
3489 "expected": expected,
3490 "actual": actual,
3491 "matches": matches,
3492 }),
3493 error: None,
3494 duration_ms: 0,
3495 })
3496}
3497
3498async fn tool_env_get(
3503 input: &serde_json::Value,
3504 capabilities: &[Capability],
3505) -> PunchResult<ToolResult> {
3506 require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
3507
3508 let name = input["name"].as_str().ok_or_else(|| PunchError::Tool {
3509 tool: "env_get".into(),
3510 message: "missing 'name' parameter".into(),
3511 })?;
3512
3513 match std::env::var(name) {
3514 Ok(value) => Ok(ToolResult {
3515 success: true,
3516 output: serde_json::json!({
3517 "name": name,
3518 "value": value,
3519 }),
3520 error: None,
3521 duration_ms: 0,
3522 }),
3523 Err(_) => Ok(ToolResult {
3524 success: true,
3525 output: serde_json::json!({
3526 "name": name,
3527 "value": null,
3528 "message": format!("environment variable '{}' is not set", name),
3529 }),
3530 error: None,
3531 duration_ms: 0,
3532 }),
3533 }
3534}
3535
3536async fn tool_env_list(
3537 input: &serde_json::Value,
3538 capabilities: &[Capability],
3539) -> PunchResult<ToolResult> {
3540 require_capability(capabilities, &Capability::ShellExec("*".to_string()))?;
3541
3542 let prefix = input["prefix"].as_str();
3543
3544 let vars: Vec<serde_json::Value> = std::env::vars()
3545 .filter(|(key, _)| {
3546 if let Some(p) = prefix {
3547 key.starts_with(p)
3548 } else {
3549 true
3550 }
3551 })
3552 .map(|(key, value)| {
3553 serde_json::json!({
3554 "name": key,
3555 "value": value,
3556 })
3557 })
3558 .collect();
3559
3560 Ok(ToolResult {
3561 success: true,
3562 output: serde_json::json!({
3563 "variables": vars,
3564 "count": vars.len(),
3565 }),
3566 error: None,
3567 duration_ms: 0,
3568 })
3569}
3570
3571async fn tool_text_diff(
3576 input: &serde_json::Value,
3577 capabilities: &[Capability],
3578) -> PunchResult<ToolResult> {
3579 require_capability(capabilities, &Capability::DataManipulation)?;
3580
3581 let old_text = input["old_text"].as_str().ok_or_else(|| PunchError::Tool {
3582 tool: "text_diff".into(),
3583 message: "missing 'old_text' parameter".into(),
3584 })?;
3585 let new_text = input["new_text"].as_str().ok_or_else(|| PunchError::Tool {
3586 tool: "text_diff".into(),
3587 message: "missing 'new_text' parameter".into(),
3588 })?;
3589 let label = input["label"].as_str().unwrap_or("file");
3590
3591 let diff = punch_types::generate_unified_diff(old_text, new_text, label, label);
3592
3593 Ok(ToolResult {
3594 success: true,
3595 output: serde_json::json!({
3596 "diff": diff,
3597 "has_changes": !diff.is_empty() && diff.contains("@@"),
3598 }),
3599 error: None,
3600 duration_ms: 0,
3601 })
3602}
3603
3604async fn tool_text_count(
3605 input: &serde_json::Value,
3606 capabilities: &[Capability],
3607) -> PunchResult<ToolResult> {
3608 require_capability(capabilities, &Capability::DataManipulation)?;
3609
3610 let text = input["text"].as_str().ok_or_else(|| PunchError::Tool {
3611 tool: "text_count".into(),
3612 message: "missing 'text' parameter".into(),
3613 })?;
3614
3615 let lines = text.lines().count();
3616 let words = text.split_whitespace().count();
3617 let characters = text.chars().count();
3618 let bytes = text.len();
3619
3620 Ok(ToolResult {
3621 success: true,
3622 output: serde_json::json!({
3623 "lines": lines,
3624 "words": words,
3625 "characters": characters,
3626 "bytes": bytes,
3627 }),
3628 error: None,
3629 duration_ms: 0,
3630 })
3631}
3632
3633async fn tool_file_search(
3638 input: &serde_json::Value,
3639 capabilities: &[Capability],
3640 context: &ToolExecutionContext,
3641) -> PunchResult<ToolResult> {
3642 require_capability(capabilities, &Capability::FileRead("**".to_string()))?;
3644
3645 let pattern_str = input["pattern"].as_str().ok_or_else(|| PunchError::Tool {
3646 tool: "file_search".into(),
3647 message: "missing 'pattern' parameter".into(),
3648 })?;
3649 let search_path = input["path"].as_str().unwrap_or(".");
3650 let max_results = input["max_results"].as_u64().unwrap_or(100) as usize;
3651
3652 let resolved_path = resolve_path(&context.working_dir, search_path)?;
3653
3654 let glob_pat = glob::Pattern::new(pattern_str).map_err(|e| PunchError::Tool {
3655 tool: "file_search".into(),
3656 message: format!("invalid glob pattern: {}", e),
3657 })?;
3658
3659 let mut results = Vec::new();
3660
3661 for entry in walkdir::WalkDir::new(&resolved_path)
3662 .follow_links(false)
3663 .into_iter()
3664 .filter_map(|e| e.ok())
3665 {
3666 if results.len() >= max_results {
3667 break;
3668 }
3669
3670 let path = entry.path();
3671 if let Some(name) = path.file_name().and_then(|n| n.to_str())
3672 && glob_pat.matches(name)
3673 {
3674 let rel_path = path
3675 .strip_prefix(&resolved_path)
3676 .unwrap_or(path)
3677 .display()
3678 .to_string();
3679 let is_dir = path.is_dir();
3680 results.push(serde_json::json!({
3681 "path": rel_path,
3682 "is_directory": is_dir,
3683 }));
3684 }
3685 }
3686
3687 Ok(ToolResult {
3688 success: true,
3689 output: serde_json::json!({
3690 "matches": results,
3691 "count": results.len(),
3692 }),
3693 error: None,
3694 duration_ms: 0,
3695 })
3696}
3697
3698async fn tool_file_info(
3699 input: &serde_json::Value,
3700 capabilities: &[Capability],
3701 context: &ToolExecutionContext,
3702) -> PunchResult<ToolResult> {
3703 let path_str = input["path"].as_str().ok_or_else(|| PunchError::Tool {
3704 tool: "file_info".into(),
3705 message: "missing 'path' parameter".into(),
3706 })?;
3707
3708 let path = resolve_path(&context.working_dir, path_str)?;
3709 let path_display = path.display().to_string();
3710
3711 require_capability(capabilities, &Capability::FileRead(path_display.clone()))?;
3712
3713 let metadata = std::fs::metadata(&path).map_err(|e| PunchError::Tool {
3714 tool: "file_info".into(),
3715 message: format!("failed to get metadata for '{}': {}", path_display, e),
3716 })?;
3717
3718 let file_type = if metadata.is_file() {
3719 "file"
3720 } else if metadata.is_dir() {
3721 "directory"
3722 } else if metadata.is_symlink() {
3723 "symlink"
3724 } else {
3725 "other"
3726 };
3727
3728 let modified = metadata
3729 .modified()
3730 .ok()
3731 .and_then(|t| {
3732 t.duration_since(std::time::UNIX_EPOCH)
3733 .ok()
3734 .map(|d| d.as_secs())
3735 })
3736 .unwrap_or(0);
3737
3738 #[cfg(unix)]
3739 let permissions = {
3740 use std::os::unix::fs::PermissionsExt;
3741 format!("{:o}", metadata.permissions().mode())
3742 };
3743 #[cfg(not(unix))]
3744 let permissions = if metadata.permissions().readonly() {
3745 "readonly".to_string()
3746 } else {
3747 "read-write".to_string()
3748 };
3749
3750 Ok(ToolResult {
3751 success: true,
3752 output: serde_json::json!({
3753 "path": path_display,
3754 "type": file_type,
3755 "size_bytes": metadata.len(),
3756 "modified_unix": modified,
3757 "permissions": permissions,
3758 "readonly": metadata.permissions().readonly(),
3759 }),
3760 error: None,
3761 duration_ms: 0,
3762 })
3763}
3764
3765async fn tool_wasm_invoke(
3774 input: &serde_json::Value,
3775 capabilities: &[Capability],
3776 context: &ToolExecutionContext,
3777) -> PunchResult<ToolResult> {
3778 require_capability(capabilities, &Capability::PluginInvoke)?;
3779
3780 let registry = context
3781 .plugin_registry
3782 .as_ref()
3783 .ok_or_else(|| PunchError::Tool {
3784 tool: "wasm_invoke".into(),
3785 message: "plugin runtime not configured — no imported techniques available".into(),
3786 })?;
3787
3788 let plugin_name = input["plugin"].as_str().ok_or_else(|| PunchError::Tool {
3789 tool: "wasm_invoke".into(),
3790 message: "missing 'plugin' parameter".into(),
3791 })?;
3792
3793 let function = input["function"].as_str().ok_or_else(|| PunchError::Tool {
3794 tool: "wasm_invoke".into(),
3795 message: "missing 'function' parameter".into(),
3796 })?;
3797
3798 let args = input.get("input").cloned().unwrap_or(serde_json::json!({}));
3799
3800 let plugin_instance = registry
3802 .get_by_name(plugin_name)
3803 .ok_or_else(|| PunchError::Tool {
3804 tool: "wasm_invoke".into(),
3805 message: format!("plugin '{}' not found in registry", plugin_name),
3806 })?;
3807
3808 let plugin_input = punch_extensions::plugin::PluginInput {
3809 function: function.to_string(),
3810 args,
3811 context: serde_json::json!({
3812 "fighter_id": context.fighter_id.to_string(),
3813 }),
3814 };
3815
3816 let output = registry.invoke(&plugin_instance.id, plugin_input).await?;
3817
3818 debug!(
3819 plugin = %plugin_name,
3820 function = %function,
3821 execution_ms = output.execution_ms,
3822 "wasm_invoke: technique executed"
3823 );
3824
3825 Ok(ToolResult {
3826 success: true,
3827 output: serde_json::json!({
3828 "result": output.result,
3829 "logs": output.logs,
3830 "execution_ms": output.execution_ms,
3831 "memory_used_bytes": output.memory_used_bytes,
3832 }),
3833 error: None,
3834 duration_ms: 0,
3835 })
3836}
3837
3838const A2A_DEFAULT_TIMEOUT_SECS: u64 = 60;
3844
3845const A2A_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(500);
3847
3848async fn tool_a2a_delegate(
3854 input: &serde_json::Value,
3855 capabilities: &[Capability],
3856) -> PunchResult<ToolResult> {
3857 use punch_types::a2a::{A2AClient, A2ATask, A2ATaskInput, A2ATaskStatus, HttpA2AClient};
3858
3859 require_capability(capabilities, &Capability::A2ADelegate)?;
3860
3861 let agent_url = input["agent_url"]
3863 .as_str()
3864 .ok_or_else(|| PunchError::Tool {
3865 tool: "a2a_delegate".into(),
3866 message: "missing 'agent_url' parameter".into(),
3867 })?;
3868
3869 let prompt = input["prompt"].as_str().ok_or_else(|| PunchError::Tool {
3870 tool: "a2a_delegate".into(),
3871 message: "missing 'prompt' parameter".into(),
3872 })?;
3873
3874 let timeout_secs = input["timeout_secs"]
3875 .as_u64()
3876 .unwrap_or(A2A_DEFAULT_TIMEOUT_SECS);
3877
3878 let context = input["context"].as_object().cloned().unwrap_or_default();
3879
3880 let client = HttpA2AClient::with_timeout(std::time::Duration::from_secs(timeout_secs))
3882 .map_err(|e| PunchError::Tool {
3883 tool: "a2a_delegate".into(),
3884 message: format!("failed to create A2A client: {e}"),
3885 })?;
3886
3887 let agent_card = client
3889 .discover(agent_url)
3890 .await
3891 .map_err(|e| PunchError::Tool {
3892 tool: "a2a_delegate".into(),
3893 message: format!("agent discovery failed for {agent_url}: {e}"),
3894 })?;
3895
3896 debug!(
3897 agent = %agent_card.name,
3898 url = %agent_url,
3899 "a2a_delegate: discovered remote agent"
3900 );
3901
3902 let task_input = A2ATaskInput {
3904 prompt: prompt.to_string(),
3905 context,
3906 mode: "text".to_string(),
3907 };
3908
3909 let now = chrono::Utc::now();
3910 let task = A2ATask {
3911 id: uuid::Uuid::new_v4().to_string(),
3912 status: A2ATaskStatus::Pending,
3913 input: serde_json::to_value(&task_input).unwrap_or(serde_json::json!({})),
3914 output: None,
3915 created_at: now,
3916 updated_at: now,
3917 };
3918
3919 let sent_task = client
3920 .send_task(&agent_card, task)
3921 .await
3922 .map_err(|e| PunchError::Tool {
3923 tool: "a2a_delegate".into(),
3924 message: format!("failed to send task to '{}': {e}", agent_card.name),
3925 })?;
3926
3927 let task_id = sent_task.id.clone();
3928
3929 debug!(
3930 task_id = %task_id,
3931 agent = %agent_card.name,
3932 "a2a_delegate: task sent"
3933 );
3934
3935 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(timeout_secs);
3937
3938 let final_status = match &sent_task.status {
3940 A2ATaskStatus::Completed | A2ATaskStatus::Failed(_) | A2ATaskStatus::Cancelled => {
3941 sent_task.status.clone()
3942 }
3943 _ => loop {
3944 if tokio::time::Instant::now() >= deadline {
3945 let _ = client.cancel_task(&agent_card, &task_id).await;
3947 return Ok(ToolResult {
3948 success: false,
3949 output: serde_json::json!({
3950 "agent": agent_card.name,
3951 "task_id": task_id,
3952 "error": format!("task timed out after {timeout_secs}s"),
3953 }),
3954 error: Some(format!(
3955 "A2A delegation to '{}' timed out after {}s",
3956 agent_card.name, timeout_secs
3957 )),
3958 duration_ms: 0,
3959 });
3960 }
3961
3962 tokio::time::sleep(A2A_POLL_INTERVAL).await;
3963
3964 match client.get_task_status(&agent_card, &task_id).await {
3965 Ok(
3966 status @ (A2ATaskStatus::Completed
3967 | A2ATaskStatus::Failed(_)
3968 | A2ATaskStatus::Cancelled),
3969 ) => break status,
3970 Ok(_) => continue,
3971 Err(e) => {
3972 warn!(
3973 task_id = %task_id,
3974 agent = %agent_card.name,
3975 error = %e,
3976 "a2a_delegate: status poll failed, retrying"
3977 );
3978 continue;
3979 }
3980 }
3981 },
3982 };
3983
3984 match final_status {
3986 A2ATaskStatus::Completed => {
3987 let output = sent_task.output.unwrap_or(serde_json::json!(null));
3988 Ok(ToolResult {
3989 success: true,
3990 output: serde_json::json!({
3991 "agent": agent_card.name,
3992 "task_id": task_id,
3993 "status": "completed",
3994 "output": output,
3995 }),
3996 error: None,
3997 duration_ms: 0,
3998 })
3999 }
4000 A2ATaskStatus::Failed(ref msg) => Ok(ToolResult {
4001 success: false,
4002 output: serde_json::json!({
4003 "agent": agent_card.name,
4004 "task_id": task_id,
4005 "status": "failed",
4006 "error": msg,
4007 }),
4008 error: Some(format!("A2A task on '{}' failed: {}", agent_card.name, msg)),
4009 duration_ms: 0,
4010 }),
4011 A2ATaskStatus::Cancelled => Ok(ToolResult {
4012 success: false,
4013 output: serde_json::json!({
4014 "agent": agent_card.name,
4015 "task_id": task_id,
4016 "status": "cancelled",
4017 }),
4018 error: Some(format!("A2A task on '{}' was cancelled", agent_card.name)),
4019 duration_ms: 0,
4020 }),
4021 _ => Ok(ToolResult {
4022 success: false,
4023 output: serde_json::json!({
4024 "agent": agent_card.name,
4025 "task_id": task_id,
4026 "status": "unknown",
4027 }),
4028 error: Some(format!(
4029 "A2A task on '{}' ended in unexpected state",
4030 agent_card.name
4031 )),
4032 duration_ms: 0,
4033 }),
4034 }
4035}
4036
4037async fn tool_heartbeat_add(
4043 input: &serde_json::Value,
4044 capabilities: &[Capability],
4045 context: &ToolExecutionContext,
4046) -> PunchResult<ToolResult> {
4047 require_capability(capabilities, &Capability::SelfConfig)?;
4048
4049 let task = input["task"].as_str().ok_or_else(|| PunchError::Tool {
4050 tool: "heartbeat_add".into(),
4051 message: "missing 'task' parameter".into(),
4052 })?;
4053
4054 let cadence = input["cadence"].as_str().ok_or_else(|| PunchError::Tool {
4055 tool: "heartbeat_add".into(),
4056 message: "missing 'cadence' parameter".into(),
4057 })?;
4058
4059 let builtin_cadences = ["every_bout", "on_wake", "hourly", "daily", "weekly"];
4060 let is_valid =
4062 builtin_cadences.contains(&cadence) || punch_types::Creed::is_valid_cadence(cadence);
4063 if !is_valid {
4064 return Ok(ToolResult {
4065 success: false,
4066 output: serde_json::json!(null),
4067 error: Some(format!(
4068 "invalid cadence '{}'. Use: every_bout, on_wake, hourly, daily, weekly, 'every Xm', 'every Xh', or cron (e.g. '*/10 * * * *')",
4069 cadence,
4070 )),
4071 duration_ms: 0,
4072 });
4073 }
4074
4075 let mut creed = context
4077 .memory
4078 .load_creed_by_fighter(&context.fighter_id)
4079 .await?
4080 .ok_or_else(|| PunchError::Tool {
4081 tool: "heartbeat_add".into(),
4082 message: "no creed found for this fighter".into(),
4083 })?;
4084
4085 let heartbeat = punch_types::creed::HeartbeatTask {
4087 task: task.to_string(),
4088 cadence: cadence.to_string(),
4089 active: true,
4090 execution_count: 0,
4091 last_checked: None,
4092 };
4093 creed.heartbeat.push(heartbeat);
4094
4095 context.memory.save_creed(&creed).await?;
4097
4098 debug!(
4099 fighter = %context.fighter_id,
4100 task = %task,
4101 cadence = %cadence,
4102 "heartbeat_add: task added to creed"
4103 );
4104
4105 Ok(ToolResult {
4106 success: true,
4107 output: serde_json::json!({
4108 "message": format!("Heartbeat added: \"{}\" (cadence: {})", task, cadence),
4109 "total_heartbeats": creed.heartbeat.len(),
4110 }),
4111 error: None,
4112 duration_ms: 0,
4113 })
4114}
4115
4116async fn tool_heartbeat_list(
4118 capabilities: &[Capability],
4119 context: &ToolExecutionContext,
4120) -> PunchResult<ToolResult> {
4121 require_capability(capabilities, &Capability::SelfConfig)?;
4122
4123 let creed = context
4124 .memory
4125 .load_creed_by_fighter(&context.fighter_id)
4126 .await?
4127 .ok_or_else(|| PunchError::Tool {
4128 tool: "heartbeat_list".into(),
4129 message: "no creed found for this fighter".into(),
4130 })?;
4131
4132 let tasks: Vec<serde_json::Value> = creed
4133 .heartbeat
4134 .iter()
4135 .enumerate()
4136 .map(|(i, h)| {
4137 serde_json::json!({
4138 "index": i,
4139 "task": h.task,
4140 "cadence": h.cadence,
4141 "active": h.active,
4142 "execution_count": h.execution_count,
4143 "last_checked": h.last_checked.map(|t| t.to_rfc3339()),
4144 })
4145 })
4146 .collect();
4147
4148 Ok(ToolResult {
4149 success: true,
4150 output: serde_json::json!({
4151 "heartbeats": tasks,
4152 "total": tasks.len(),
4153 }),
4154 error: None,
4155 duration_ms: 0,
4156 })
4157}
4158
4159async fn tool_heartbeat_remove(
4161 input: &serde_json::Value,
4162 capabilities: &[Capability],
4163 context: &ToolExecutionContext,
4164) -> PunchResult<ToolResult> {
4165 require_capability(capabilities, &Capability::SelfConfig)?;
4166
4167 let index = input["index"].as_u64().ok_or_else(|| PunchError::Tool {
4168 tool: "heartbeat_remove".into(),
4169 message: "missing 'index' parameter".into(),
4170 })? as usize;
4171
4172 let mut creed = context
4173 .memory
4174 .load_creed_by_fighter(&context.fighter_id)
4175 .await?
4176 .ok_or_else(|| PunchError::Tool {
4177 tool: "heartbeat_remove".into(),
4178 message: "no creed found for this fighter".into(),
4179 })?;
4180
4181 if index >= creed.heartbeat.len() {
4182 return Ok(ToolResult {
4183 success: false,
4184 output: serde_json::json!(null),
4185 error: Some(format!(
4186 "index {} out of range (have {} heartbeat tasks)",
4187 index,
4188 creed.heartbeat.len()
4189 )),
4190 duration_ms: 0,
4191 });
4192 }
4193
4194 let removed = creed.heartbeat.remove(index);
4195 context.memory.save_creed(&creed).await?;
4196
4197 debug!(
4198 fighter = %context.fighter_id,
4199 task = %removed.task,
4200 "heartbeat_remove: task removed from creed"
4201 );
4202
4203 Ok(ToolResult {
4204 success: true,
4205 output: serde_json::json!({
4206 "message": format!("Removed heartbeat: \"{}\"", removed.task),
4207 "remaining": creed.heartbeat.len(),
4208 }),
4209 error: None,
4210 duration_ms: 0,
4211 })
4212}
4213
4214async fn tool_creed_view(
4216 capabilities: &[Capability],
4217 context: &ToolExecutionContext,
4218) -> PunchResult<ToolResult> {
4219 require_capability(capabilities, &Capability::SelfConfig)?;
4220
4221 let creed = context
4222 .memory
4223 .load_creed_by_fighter(&context.fighter_id)
4224 .await?
4225 .ok_or_else(|| PunchError::Tool {
4226 tool: "creed_view".into(),
4227 message: "no creed found for this fighter".into(),
4228 })?;
4229
4230 let heartbeats: Vec<serde_json::Value> = creed
4231 .heartbeat
4232 .iter()
4233 .map(|h| {
4234 serde_json::json!({
4235 "task": h.task,
4236 "cadence": h.cadence,
4237 "active": h.active,
4238 "execution_count": h.execution_count,
4239 })
4240 })
4241 .collect();
4242
4243 let relationships: Vec<serde_json::Value> = creed
4244 .relationships
4245 .iter()
4246 .map(|r| {
4247 serde_json::json!({
4248 "entity": r.entity,
4249 "nature": r.nature,
4250 "interaction_count": r.interaction_count,
4251 })
4252 })
4253 .collect();
4254
4255 let learned: Vec<serde_json::Value> = creed
4256 .learned_behaviors
4257 .iter()
4258 .map(|b| {
4259 serde_json::json!({
4260 "observation": b.observation,
4261 "confidence": b.confidence,
4262 })
4263 })
4264 .collect();
4265
4266 Ok(ToolResult {
4267 success: true,
4268 output: serde_json::json!({
4269 "fighter_name": creed.fighter_name,
4270 "identity": creed.identity,
4271 "personality": creed.personality,
4272 "directives": creed.directives,
4273 "heartbeats": heartbeats,
4274 "relationships": relationships,
4275 "learned_behaviors": learned,
4276 "bout_count": creed.bout_count,
4277 "message_count": creed.message_count,
4278 "version": creed.version,
4279 }),
4280 error: None,
4281 duration_ms: 0,
4282 })
4283}
4284
4285async fn tool_skill_list(capabilities: &[Capability]) -> PunchResult<ToolResult> {
4287 require_capability(capabilities, &Capability::SelfConfig)?;
4288
4289 let packs = punch_skills::packs::available_packs();
4290 let pack_list: Vec<serde_json::Value> = packs
4291 .iter()
4292 .map(|(name, desc)| {
4293 serde_json::json!({
4294 "name": name,
4295 "description": desc,
4296 })
4297 })
4298 .collect();
4299
4300 Ok(ToolResult {
4301 success: true,
4302 output: serde_json::json!({
4303 "packs": pack_list,
4304 "total": pack_list.len(),
4305 "note": "Use skill_install to install a pack. Only approved packs from the bundled registry are available.",
4306 }),
4307 error: None,
4308 duration_ms: 0,
4309 })
4310}
4311
4312async fn tool_skill_recommend(
4314 input: &serde_json::Value,
4315 capabilities: &[Capability],
4316) -> PunchResult<ToolResult> {
4317 require_capability(capabilities, &Capability::SelfConfig)?;
4318
4319 let pack_name = input["pack_name"]
4320 .as_str()
4321 .ok_or_else(|| PunchError::Tool {
4322 tool: "skill_recommend".into(),
4323 message: "missing 'pack_name' parameter".into(),
4324 })?;
4325
4326 let pack = punch_skills::packs::find_bundled_pack(pack_name);
4328
4329 match pack {
4330 Some(p) => {
4331 let servers: Vec<serde_json::Value> = p
4332 .mcp_servers
4333 .iter()
4334 .map(|s| {
4335 let mut info = serde_json::json!({
4336 "name": s.name,
4337 "description": s.description,
4338 });
4339 if let Some(ref setup) = s.setup_command {
4340 info["setup_command"] = serde_json::json!(setup);
4341 }
4342 info
4343 })
4344 .collect();
4345
4346 let mut output = serde_json::json!({
4347 "pack_name": p.name,
4348 "description": p.description,
4349 "servers": servers,
4350 "install_command": format!("punch move add {}", p.name),
4351 });
4352
4353 if !p.required_env_vars.is_empty() {
4354 output["required_env_vars"] = serde_json::json!(p.required_env_vars);
4355 output["setup_note"] = serde_json::json!(format!(
4356 "This pack requires environment variables: {}. Set them in ~/.punch/.env before installing.",
4357 p.required_env_vars.join(", ")
4358 ));
4359 }
4360
4361 Ok(ToolResult {
4362 success: true,
4363 output,
4364 error: None,
4365 duration_ms: 0,
4366 })
4367 }
4368 None => {
4369 let available = punch_skills::packs::available_packs();
4371 let names: Vec<&str> = available.iter().map(|(n, _)| n.as_str()).collect();
4372
4373 Ok(ToolResult {
4374 success: false,
4375 output: serde_json::json!({
4376 "error": format!("Skill pack '{}' not found", pack_name),
4377 "available_packs": names,
4378 }),
4379 error: Some(format!(
4380 "Pack '{}' not found. Available: {}",
4381 pack_name,
4382 names.join(", ")
4383 )),
4384 duration_ms: 0,
4385 })
4386 }
4387 }
4388}
4389
4390fn require_automation_backend(
4396 context: &ToolExecutionContext,
4397 tool: &str,
4398) -> PunchResult<Arc<dyn AutomationBackend>> {
4399 context.automation_backend.clone().ok_or_else(|| {
4400 PunchError::Tool {
4401 tool: tool.into(),
4402 message: "Automation backend not configured. Spawn the fighter with --capabilities system_automation,ui_automation(*),app_integration(*) to enable desktop automation.".into(),
4403 }
4404 })
4405}
4406
4407async fn tool_sys_screenshot(
4408 input: &serde_json::Value,
4409 capabilities: &[Capability],
4410 context: &ToolExecutionContext,
4411) -> PunchResult<ToolResult> {
4412 require_capability(capabilities, &Capability::SystemAutomation)?;
4413 let backend = require_automation_backend(context, "sys_screenshot")?;
4414
4415 let window = input.get("window").and_then(|v| v.as_str());
4416 let result = backend.screenshot(window).await?;
4417
4418 Ok(ToolResult {
4419 success: true,
4420 output: serde_json::json!({
4421 "description": format!(
4422 "Screenshot captured{}",
4423 window.map(|w| format!(" of window: {w}")).unwrap_or_default()
4424 ),
4425 "width": result.width,
4426 "height": result.height,
4427 "png_base64": result.png_base64,
4428 }),
4429 error: None,
4430 duration_ms: 0,
4431 })
4432}
4433
4434async fn tool_ui_screenshot(
4435 input: &serde_json::Value,
4436 capabilities: &[Capability],
4437 context: &ToolExecutionContext,
4438) -> PunchResult<ToolResult> {
4439 let element_id = input.get("element_id").and_then(|v| v.as_str());
4440 if let Some(eid) = element_id {
4442 let app = automation::extract_app_from_element_id(eid, "ui_screenshot")?;
4443 require_capability(capabilities, &Capability::UiAutomation(app))?;
4444 } else {
4445 require_capability(capabilities, &Capability::UiAutomation("*".to_string()))?;
4446 }
4447 let backend = require_automation_backend(context, "ui_screenshot")?;
4448
4449 let bounds = input.get("bounds").and_then(|b| {
4450 let x = b.get("x")?.as_i64()? as i32;
4451 let y = b.get("y")?.as_i64()? as i32;
4452 let w = b.get("width")?.as_u64()? as u32;
4453 let h = b.get("height")?.as_u64()? as u32;
4454 Some((x, y, w, h))
4455 });
4456
4457 let result = backend.ui_screenshot(element_id, bounds).await?;
4458
4459 Ok(ToolResult {
4460 success: true,
4461 output: serde_json::json!({
4462 "description": "UI region screenshot captured",
4463 "width": result.width,
4464 "height": result.height,
4465 "png_base64": result.png_base64,
4466 }),
4467 error: None,
4468 duration_ms: 0,
4469 })
4470}
4471
4472async fn tool_app_ocr(
4473 input: &serde_json::Value,
4474 capabilities: &[Capability],
4475 context: &ToolExecutionContext,
4476) -> PunchResult<ToolResult> {
4477 let app = input
4478 .get("app")
4479 .and_then(|v| v.as_str())
4480 .ok_or_else(|| PunchError::Tool {
4481 tool: "app_ocr".into(),
4482 message: "missing required field: app".into(),
4483 })?;
4484
4485 require_capability(capabilities, &Capability::AppIntegration(app.to_string()))?;
4486 let backend = require_automation_backend(context, "app_ocr")?;
4487
4488 let result = backend.app_ocr(app).await?;
4489
4490 let avg_confidence = if result.regions.is_empty() {
4492 0.0
4493 } else {
4494 result.regions.iter().map(|r| r.confidence).sum::<f32>() / result.regions.len() as f32
4495 };
4496
4497 let mut output = serde_json::json!({
4498 "text": result.text,
4499 "regions": result.regions.len(),
4500 "average_confidence": avg_confidence,
4501 });
4502
4503 if avg_confidence < 0.3 {
4504 output["warning"] = serde_json::json!(
4505 "Low OCR confidence (likely non-text content). Use sys_screenshot for visual inspection instead."
4506 );
4507 }
4508
4509 Ok(ToolResult {
4510 success: true,
4511 output,
4512 error: None,
4513 duration_ms: 0,
4514 })
4515}
4516
4517async fn tool_ui_find_elements(
4518 input: &serde_json::Value,
4519 capabilities: &[Capability],
4520 context: &ToolExecutionContext,
4521) -> PunchResult<ToolResult> {
4522 let app = input
4523 .get("app")
4524 .and_then(|v| v.as_str())
4525 .ok_or_else(|| PunchError::Tool {
4526 tool: "ui_find_elements".into(),
4527 message: "missing required field: app".into(),
4528 })?;
4529
4530 require_capability(capabilities, &Capability::UiAutomation(app.to_string()))?;
4531 let backend = require_automation_backend(context, "ui_find_elements")?;
4532
4533 let selector = UiSelector {
4534 role: input.get("role").and_then(|v| v.as_str()).map(String::from),
4535 label: input
4536 .get("label")
4537 .and_then(|v| v.as_str())
4538 .map(String::from),
4539 value: input
4540 .get("value")
4541 .and_then(|v| v.as_str())
4542 .map(String::from),
4543 };
4544
4545 let elements = backend.find_ui_elements(app, &selector).await?;
4546
4547 Ok(ToolResult {
4548 success: true,
4549 output: serde_json::json!({
4550 "app": app,
4551 "count": elements.len(),
4552 "elements": elements,
4553 }),
4554 error: None,
4555 duration_ms: 0,
4556 })
4557}
4558
4559async fn tool_ui_click(
4560 input: &serde_json::Value,
4561 capabilities: &[Capability],
4562 context: &ToolExecutionContext,
4563) -> PunchResult<ToolResult> {
4564 let element_id = input
4565 .get("element_id")
4566 .and_then(|v| v.as_str())
4567 .ok_or_else(|| PunchError::Tool {
4568 tool: "ui_click".into(),
4569 message: "missing required field: element_id".into(),
4570 })?;
4571
4572 let app = automation::extract_app_from_element_id(element_id, "ui_click")?;
4573 require_capability(capabilities, &Capability::UiAutomation(app))?;
4574 let backend = require_automation_backend(context, "ui_click")?;
4575
4576 backend.click_element(element_id).await?;
4577
4578 Ok(ToolResult {
4579 success: true,
4580 output: serde_json::json!({
4581 "clicked": element_id,
4582 }),
4583 error: None,
4584 duration_ms: 0,
4585 })
4586}
4587
4588async fn tool_ui_type_text(
4589 input: &serde_json::Value,
4590 capabilities: &[Capability],
4591 context: &ToolExecutionContext,
4592) -> PunchResult<ToolResult> {
4593 let element_id = input
4594 .get("element_id")
4595 .and_then(|v| v.as_str())
4596 .ok_or_else(|| PunchError::Tool {
4597 tool: "ui_type_text".into(),
4598 message: "missing required field: element_id".into(),
4599 })?;
4600 let text = input
4601 .get("text")
4602 .and_then(|v| v.as_str())
4603 .ok_or_else(|| PunchError::Tool {
4604 tool: "ui_type_text".into(),
4605 message: "missing required field: text".into(),
4606 })?;
4607
4608 let app = automation::extract_app_from_element_id(element_id, "ui_type_text")?;
4609 require_capability(capabilities, &Capability::UiAutomation(app))?;
4610 let backend = require_automation_backend(context, "ui_type_text")?;
4611
4612 backend.type_text(element_id, text).await?;
4613
4614 Ok(ToolResult {
4615 success: true,
4616 output: serde_json::json!({
4617 "typed_into": element_id,
4618 "text_length": text.len(),
4619 }),
4620 error: None,
4621 duration_ms: 0,
4622 })
4623}
4624
4625async fn tool_ui_list_windows(
4626 capabilities: &[Capability],
4627 context: &ToolExecutionContext,
4628) -> PunchResult<ToolResult> {
4629 require_capability(capabilities, &Capability::UiAutomation("*".to_string()))?;
4630 let backend = require_automation_backend(context, "ui_list_windows")?;
4631
4632 let windows = backend.list_windows().await?;
4633
4634 Ok(ToolResult {
4635 success: true,
4636 output: serde_json::json!({
4637 "count": windows.len(),
4638 "windows": windows,
4639 }),
4640 error: None,
4641 duration_ms: 0,
4642 })
4643}
4644
4645async fn tool_ui_read_attribute(
4646 input: &serde_json::Value,
4647 capabilities: &[Capability],
4648 context: &ToolExecutionContext,
4649) -> PunchResult<ToolResult> {
4650 let element_id = input
4651 .get("element_id")
4652 .and_then(|v| v.as_str())
4653 .ok_or_else(|| PunchError::Tool {
4654 tool: "ui_read_attribute".into(),
4655 message: "missing required field: element_id".into(),
4656 })?;
4657 let attribute = input
4658 .get("attribute")
4659 .and_then(|v| v.as_str())
4660 .ok_or_else(|| PunchError::Tool {
4661 tool: "ui_read_attribute".into(),
4662 message: "missing required field: attribute".into(),
4663 })?;
4664
4665 let app = automation::extract_app_from_element_id(element_id, "ui_read_attribute")?;
4666 require_capability(capabilities, &Capability::UiAutomation(app))?;
4667 let backend = require_automation_backend(context, "ui_read_attribute")?;
4668
4669 let value = backend
4670 .read_element_attribute(element_id, attribute)
4671 .await?;
4672
4673 Ok(ToolResult {
4674 success: true,
4675 output: serde_json::json!({
4676 "element_id": element_id,
4677 "attribute": attribute,
4678 "value": value,
4679 }),
4680 error: None,
4681 duration_ms: 0,
4682 })
4683}
4684
4685#[cfg(test)]
4690mod tests {
4691 use super::*;
4692 use async_trait::async_trait;
4693 use punch_types::{
4694 AgentCoordinator, AgentInfo, AgentMessageResult, Capability, FighterId, FighterManifest,
4695 FighterStatus,
4696 };
4697
4698 struct MockCoordinator {
4700 fighters: Vec<AgentInfo>,
4701 }
4702
4703 impl MockCoordinator {
4704 fn new() -> Self {
4705 Self {
4706 fighters: vec![AgentInfo {
4707 id: FighterId(uuid::Uuid::nil()),
4708 name: "test-fighter".to_string(),
4709 status: FighterStatus::Idle,
4710 }],
4711 }
4712 }
4713 }
4714
4715 #[async_trait]
4716 impl AgentCoordinator for MockCoordinator {
4717 async fn spawn_fighter(&self, _manifest: FighterManifest) -> PunchResult<FighterId> {
4718 Ok(FighterId(uuid::Uuid::new_v4()))
4719 }
4720
4721 async fn send_message_to_agent(
4722 &self,
4723 _target: &FighterId,
4724 message: String,
4725 ) -> PunchResult<AgentMessageResult> {
4726 Ok(AgentMessageResult {
4727 response: format!("echo: {}", message),
4728 tokens_used: 42,
4729 })
4730 }
4731
4732 async fn find_fighter_by_name(&self, name: &str) -> PunchResult<Option<FighterId>> {
4733 let found = self.fighters.iter().find(|f| f.name == name).map(|f| f.id);
4734 Ok(found)
4735 }
4736
4737 async fn list_fighters(&self) -> PunchResult<Vec<AgentInfo>> {
4738 Ok(self.fighters.clone())
4739 }
4740 }
4741
4742 fn make_test_context(coordinator: Option<Arc<dyn AgentCoordinator>>) -> ToolExecutionContext {
4743 ToolExecutionContext {
4744 working_dir: std::env::temp_dir(),
4745 fighter_id: FighterId(uuid::Uuid::new_v4()),
4746 memory: Arc::new(MemorySubstrate::in_memory().unwrap()),
4747 coordinator,
4748 approval_engine: None,
4749 sandbox: None,
4750 bleed_detector: None,
4751 browser_pool: None,
4752 plugin_registry: None,
4753 mcp_clients: None,
4754 channel_notifier: None,
4755 automation_backend: None,
4756 }
4757 }
4758
4759 #[test]
4760 fn test_require_capability_granted() {
4761 let caps = vec![Capability::FileRead("**".to_string())];
4762 assert!(
4763 require_capability(&caps, &Capability::FileRead("src/main.rs".to_string())).is_ok()
4764 );
4765 }
4766
4767 #[test]
4768 fn test_require_capability_denied() {
4769 let caps = vec![Capability::Memory];
4770 let result = require_capability(&caps, &Capability::FileRead("src/main.rs".to_string()));
4771 assert!(result.is_err());
4772 match result.unwrap_err() {
4773 PunchError::CapabilityDenied(msg) => {
4774 assert!(msg.contains("file_read"));
4775 }
4776 other => panic!("expected CapabilityDenied, got {:?}", other),
4777 }
4778 }
4779
4780 #[test]
4781 fn test_require_capability_scoped_match() {
4782 let caps = vec![Capability::FileRead("src/**/*.rs".to_string())];
4783 assert!(require_capability(&caps, &Capability::FileRead("src/lib.rs".to_string())).is_ok());
4784 assert!(
4785 require_capability(&caps, &Capability::FileRead("tests/foo.rs".to_string())).is_err()
4786 );
4787 }
4788
4789 #[test]
4790 fn test_require_capability_shell_wildcard() {
4791 let caps = vec![Capability::ShellExec("*".to_string())];
4792 assert!(require_capability(&caps, &Capability::ShellExec("ls -la".to_string())).is_ok());
4793 }
4794
4795 #[test]
4796 fn test_is_private_ip() {
4797 assert!(is_private_ip(&"127.0.0.1".parse().unwrap()));
4798 assert!(is_private_ip(&"10.0.0.1".parse().unwrap()));
4799 assert!(is_private_ip(&"192.168.1.1".parse().unwrap()));
4800 assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
4801 assert!(is_private_ip(&"::1".parse().unwrap()));
4802 assert!(!is_private_ip(&"8.8.8.8".parse().unwrap()));
4803 assert!(!is_private_ip(&"1.1.1.1".parse().unwrap()));
4804 }
4805
4806 #[test]
4807 fn test_require_network_capability() {
4808 let caps = vec![Capability::Network("*.example.com".to_string())];
4809 assert!(
4810 require_capability(&caps, &Capability::Network("api.example.com".to_string())).is_ok()
4811 );
4812 assert!(require_capability(&caps, &Capability::Network("evil.com".to_string())).is_err());
4813 }
4814
4815 #[tokio::test]
4818 async fn test_agent_message_with_mock_coordinator() {
4819 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4820 let context = make_test_context(Some(coordinator));
4821 let caps = vec![Capability::AgentMessage];
4822 let target_id = uuid::Uuid::nil().to_string();
4823
4824 let input = serde_json::json!({
4825 "fighter_id": target_id,
4826 "message": "hello from fighter A"
4827 });
4828
4829 let result = execute_tool("agent_message", &input, &caps, &context)
4830 .await
4831 .unwrap();
4832
4833 assert!(result.success);
4834 let response = result.output["response"].as_str().unwrap();
4835 assert_eq!(response, "echo: hello from fighter A");
4836 assert_eq!(result.output["tokens_used"].as_u64().unwrap(), 42);
4837 }
4838
4839 #[tokio::test]
4840 async fn test_agent_message_by_name() {
4841 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4842 let context = make_test_context(Some(coordinator));
4843 let caps = vec![Capability::AgentMessage];
4844
4845 let input = serde_json::json!({
4846 "name": "test-fighter",
4847 "message": "hello by name"
4848 });
4849
4850 let result = execute_tool("agent_message", &input, &caps, &context)
4851 .await
4852 .unwrap();
4853
4854 assert!(result.success);
4855 assert_eq!(
4856 result.output["response"].as_str().unwrap(),
4857 "echo: hello by name"
4858 );
4859 }
4860
4861 #[tokio::test]
4862 async fn test_agent_message_name_not_found() {
4863 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4864 let context = make_test_context(Some(coordinator));
4865 let caps = vec![Capability::AgentMessage];
4866
4867 let input = serde_json::json!({
4868 "name": "nonexistent-fighter",
4869 "message": "hello"
4870 });
4871
4872 let result = execute_tool("agent_message", &input, &caps, &context)
4873 .await
4874 .unwrap();
4875
4876 assert!(!result.success);
4878 assert!(result.error.unwrap().contains("nonexistent-fighter"));
4879 }
4880
4881 #[tokio::test]
4882 async fn test_agent_list_with_mock_coordinator() {
4883 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4884 let context = make_test_context(Some(coordinator));
4885 let caps = vec![Capability::AgentMessage];
4886
4887 let input = serde_json::json!({});
4888
4889 let result = execute_tool("agent_list", &input, &caps, &context)
4890 .await
4891 .unwrap();
4892
4893 assert!(result.success);
4894 let agents = result.output.as_array().unwrap();
4895 assert_eq!(agents.len(), 1);
4896 assert_eq!(agents[0]["name"].as_str().unwrap(), "test-fighter");
4897 }
4898
4899 #[tokio::test]
4900 async fn test_agent_spawn_with_mock_coordinator() {
4901 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4902 let context = make_test_context(Some(coordinator));
4903 let caps = vec![Capability::AgentSpawn];
4904
4905 let input = serde_json::json!({
4906 "name": "worker-1",
4907 "system_prompt": "You are a worker agent."
4908 });
4909
4910 let result = execute_tool("agent_spawn", &input, &caps, &context)
4911 .await
4912 .unwrap();
4913
4914 assert!(result.success);
4915 assert_eq!(result.output["name"].as_str().unwrap(), "worker-1");
4916 assert!(result.output["fighter_id"].as_str().is_some());
4917 }
4918
4919 #[tokio::test]
4920 async fn test_agent_message_denied_without_capability() {
4921 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4922 let context = make_test_context(Some(coordinator));
4923 let caps = vec![Capability::Memory];
4925
4926 let input = serde_json::json!({
4927 "fighter_id": uuid::Uuid::nil().to_string(),
4928 "message": "hello"
4929 });
4930
4931 let result = execute_tool("agent_message", &input, &caps, &context)
4932 .await
4933 .unwrap();
4934
4935 assert!(!result.success);
4936 assert!(result.error.unwrap().contains("capability"));
4937 }
4938
4939 #[tokio::test]
4940 async fn test_agent_spawn_denied_without_capability() {
4941 let coordinator: Arc<dyn AgentCoordinator> = Arc::new(MockCoordinator::new());
4942 let context = make_test_context(Some(coordinator));
4943 let caps = vec![Capability::Memory];
4945
4946 let input = serde_json::json!({
4947 "name": "worker-1",
4948 "system_prompt": "test"
4949 });
4950
4951 let result = execute_tool("agent_spawn", &input, &caps, &context)
4952 .await
4953 .unwrap();
4954
4955 assert!(!result.success);
4956 assert!(result.error.unwrap().contains("capability"));
4957 }
4958
4959 #[test]
4960 fn test_parse_duckduckgo_results_mock_html() {
4961 let mock_html = r#"
4962 <div class="result">
4963 <a rel="nofollow" class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.com%2Fpage1&rut=abc">
4964 <b>Example</b> Page One
4965 </a>
4966 </div>
4967 <div class="result">
4968 <a rel="nofollow" class="result__a" href="/l/?uddg=https%3A%2F%2Fexample.org%2Fpage2&rut=def">
4969 Example Page Two
4970 </a>
4971 </div>
4972 "#;
4973
4974 let results = parse_duckduckgo_results(mock_html);
4975 assert_eq!(results.len(), 2);
4976 assert_eq!(results[0]["title"].as_str().unwrap(), "Example Page One");
4977 assert_eq!(
4978 results[0]["url"].as_str().unwrap(),
4979 "https://example.com/page1"
4980 );
4981 assert_eq!(results[1]["title"].as_str().unwrap(), "Example Page Two");
4982 assert_eq!(
4983 results[1]["url"].as_str().unwrap(),
4984 "https://example.org/page2"
4985 );
4986 }
4987
4988 #[test]
4989 fn test_parse_duckduckgo_results_empty_html() {
4990 let results = parse_duckduckgo_results("<html><body>No results</body></html>");
4991 assert!(results.is_empty());
4992 }
4993
4994 #[test]
4995 fn test_strip_html_tags() {
4996 assert_eq!(strip_html_tags("<b>bold</b> text"), "bold text");
4997 assert_eq!(strip_html_tags("no tags"), "no tags");
4998 assert_eq!(strip_html_tags("<a href=\"x\">link</a>"), "link");
4999 }
5000
5001 #[tokio::test]
5002 async fn test_agent_tools_without_coordinator() {
5003 let context = make_test_context(None);
5004 let caps = vec![Capability::AgentMessage];
5005
5006 let input = serde_json::json!({
5007 "fighter_id": uuid::Uuid::nil().to_string(),
5008 "message": "hello"
5009 });
5010
5011 let result = execute_tool("agent_message", &input, &caps, &context)
5012 .await
5013 .unwrap();
5014
5015 assert!(!result.success);
5016 assert!(result.error.unwrap().contains("coordinator not available"));
5017 }
5018
5019 #[tokio::test]
5022 async fn test_tool_call_blocked_by_approval_policy() {
5023 use punch_types::{ApprovalPolicy, DenyAllHandler, PolicyEngine, RiskLevel};
5024
5025 let engine = PolicyEngine::new(
5026 vec![ApprovalPolicy {
5027 name: "block-file-reads".into(),
5028 tool_patterns: vec!["file_read".into()],
5029 risk_level: RiskLevel::High,
5030 auto_approve: false,
5031 max_auto_approvals: None,
5032 }],
5033 Arc::new(DenyAllHandler),
5034 );
5035
5036 let mut context = make_test_context(None);
5037 context.approval_engine = Some(Arc::new(engine));
5038
5039 let caps = vec![Capability::FileRead("**".into())];
5040 let input = serde_json::json!({"path": "/etc/passwd"});
5041
5042 let result = execute_tool("file_read", &input, &caps, &context)
5043 .await
5044 .expect("execute_tool should not error");
5045
5046 assert!(!result.success);
5047 let error = result.error.expect("should have error message");
5048 assert!(
5049 error.contains("denied by policy"),
5050 "expected 'denied by policy' in error, got: {}",
5051 error
5052 );
5053 }
5054
5055 #[tokio::test]
5056 async fn test_tool_call_allowed_by_approval_policy() {
5057 use punch_types::{ApprovalPolicy, AutoApproveHandler, PolicyEngine, RiskLevel};
5058
5059 let engine = PolicyEngine::new(
5060 vec![ApprovalPolicy {
5061 name: "allow-file-reads".into(),
5062 tool_patterns: vec!["file_read".into()],
5063 risk_level: RiskLevel::Low,
5064 auto_approve: true,
5065 max_auto_approvals: None,
5066 }],
5067 Arc::new(AutoApproveHandler),
5068 );
5069
5070 let mut context = make_test_context(None);
5071 context.approval_engine = Some(Arc::new(engine));
5072
5073 let temp_file = context.working_dir.join("punch_approval_test.txt");
5075 tokio::fs::write(&temp_file, "approval test content")
5076 .await
5077 .expect("write temp file");
5078
5079 let caps = vec![Capability::FileRead("**".into())];
5080 let input = serde_json::json!({"path": temp_file.to_string_lossy()});
5081
5082 let result = execute_tool("file_read", &input, &caps, &context)
5083 .await
5084 .expect("execute_tool should not error");
5085
5086 assert!(
5087 result.success,
5088 "tool call should succeed: {:?}",
5089 result.error
5090 );
5091
5092 let _ = tokio::fs::remove_file(&temp_file).await;
5094 }
5095
5096 #[tokio::test]
5101 async fn test_browser_navigate_requires_capability() {
5102 let context = make_test_context(None);
5103 let caps = vec![Capability::Memory]; let input = serde_json::json!({"url": "https://example.com"});
5106 let result = execute_tool("browser_navigate", &input, &caps, &context)
5107 .await
5108 .expect("should not hard-error");
5109
5110 assert!(!result.success);
5111 let error = result.error.expect("should have error");
5112 assert!(
5113 error.contains("capability denied") || error.contains("missing capability"),
5114 "expected capability denied, got: {}",
5115 error
5116 );
5117 }
5118
5119 #[tokio::test]
5120 async fn test_browser_navigate_no_pool() {
5121 let context = make_test_context(None); let caps = vec![Capability::BrowserControl];
5123
5124 let input = serde_json::json!({"url": "https://example.com"});
5125 let result = execute_tool("browser_navigate", &input, &caps, &context)
5126 .await
5127 .expect("should not hard-error");
5128
5129 assert!(!result.success);
5130 let error = result.error.expect("should have error");
5131 assert!(
5132 error.contains("browser not available"),
5133 "expected 'browser not available', got: {}",
5134 error
5135 );
5136 }
5137
5138 #[tokio::test]
5139 async fn test_browser_navigate_with_pool_no_driver() {
5140 use punch_types::{BrowserConfig, BrowserPool};
5141
5142 let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5143 let mut context = make_test_context(None);
5144 context.browser_pool = Some(pool);
5145
5146 let caps = vec![Capability::BrowserControl];
5147 let input = serde_json::json!({"url": "https://example.com"});
5148
5149 let result = execute_tool("browser_navigate", &input, &caps, &context)
5150 .await
5151 .expect("should not hard-error");
5152
5153 assert!(!result.success);
5155 let error = result.error.expect("should have error");
5156 assert!(
5157 error.contains("no CDP driver"),
5158 "expected 'no CDP driver', got: {}",
5159 error
5160 );
5161 }
5162
5163 #[tokio::test]
5164 async fn test_browser_screenshot_with_pool() {
5165 use punch_types::{BrowserConfig, BrowserPool};
5166
5167 let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5168 let mut context = make_test_context(None);
5169 context.browser_pool = Some(pool);
5170
5171 let caps = vec![Capability::BrowserControl];
5172 let input = serde_json::json!({"full_page": true});
5173
5174 let result = execute_tool("browser_screenshot", &input, &caps, &context)
5175 .await
5176 .expect("should not hard-error");
5177
5178 assert!(!result.success);
5179 assert_eq!(result.output["full_page"], true);
5180 }
5181
5182 #[tokio::test]
5183 async fn test_browser_click_missing_selector() {
5184 use punch_types::{BrowserConfig, BrowserPool};
5185
5186 let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5187 let mut context = make_test_context(None);
5188 context.browser_pool = Some(pool);
5189
5190 let caps = vec![Capability::BrowserControl];
5191 let input = serde_json::json!({});
5192
5193 let result = execute_tool("browser_click", &input, &caps, &context)
5194 .await
5195 .expect("should not hard-error");
5196
5197 assert!(!result.success);
5198 let error = result.error.expect("should have error");
5199 assert!(
5200 error.contains("missing 'selector'"),
5201 "expected missing selector error, got: {}",
5202 error
5203 );
5204 }
5205
5206 #[tokio::test]
5207 async fn test_browser_type_missing_params() {
5208 use punch_types::{BrowserConfig, BrowserPool};
5209
5210 let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5211 let mut context = make_test_context(None);
5212 context.browser_pool = Some(pool);
5213
5214 let caps = vec![Capability::BrowserControl];
5215
5216 let input = serde_json::json!({"selector": "#input"});
5218 let result = execute_tool("browser_type", &input, &caps, &context)
5219 .await
5220 .expect("should not hard-error");
5221
5222 assert!(!result.success);
5223 let error = result.error.expect("should have error");
5224 assert!(error.contains("missing 'text'"), "got: {}", error);
5225 }
5226
5227 #[tokio::test]
5228 async fn test_browser_content_with_pool() {
5229 use punch_types::{BrowserConfig, BrowserPool};
5230
5231 let pool = Arc::new(BrowserPool::new(BrowserConfig::default(), 5));
5232 let mut context = make_test_context(None);
5233 context.browser_pool = Some(pool);
5234
5235 let caps = vec![Capability::BrowserControl];
5236 let input = serde_json::json!({"selector": "h1"});
5237
5238 let result = execute_tool("browser_content", &input, &caps, &context)
5239 .await
5240 .expect("should not hard-error");
5241
5242 assert!(!result.success);
5243 assert_eq!(result.output["selector"], "h1");
5244 }
5245
5246 #[tokio::test]
5251 async fn test_json_query_basic_path() {
5252 let context = make_test_context(None);
5253 let caps = vec![Capability::DataManipulation];
5254
5255 let input = serde_json::json!({
5256 "data": {"users": [{"name": "Alice"}, {"name": "Bob"}]},
5257 "path": "users.1.name"
5258 });
5259
5260 let result = execute_tool("json_query", &input, &caps, &context)
5261 .await
5262 .unwrap();
5263
5264 assert!(result.success);
5265 assert_eq!(result.output, serde_json::json!("Bob"));
5266 }
5267
5268 #[tokio::test]
5269 async fn test_regex_match_with_captures() {
5270 let context = make_test_context(None);
5271 let caps = vec![Capability::DataManipulation];
5272
5273 let input = serde_json::json!({
5274 "pattern": r"(\d+)-(\d+)",
5275 "text": "order 123-456 confirmed",
5276 "global": false
5277 });
5278
5279 let result = execute_tool("regex_match", &input, &caps, &context)
5280 .await
5281 .unwrap();
5282
5283 assert!(result.success);
5284 assert_eq!(result.output["matched"], true);
5285 let groups = result.output["groups"].as_array().unwrap();
5286 assert_eq!(groups[0], "123-456");
5287 assert_eq!(groups[1], "123");
5288 assert_eq!(groups[2], "456");
5289 }
5290
5291 #[tokio::test]
5292 async fn test_regex_replace_basic() {
5293 let context = make_test_context(None);
5294 let caps = vec![Capability::DataManipulation];
5295
5296 let input = serde_json::json!({
5297 "pattern": r"(\w+)@(\w+)",
5298 "replacement": "$1 AT $2",
5299 "text": "email user@example domain"
5300 });
5301
5302 let result = execute_tool("regex_replace", &input, &caps, &context)
5303 .await
5304 .unwrap();
5305
5306 assert!(result.success);
5307 assert_eq!(
5308 result.output,
5309 serde_json::json!("email user AT example domain")
5310 );
5311 }
5312
5313 #[tokio::test]
5314 async fn test_yaml_parse_basic() {
5315 let context = make_test_context(None);
5316 let caps = vec![Capability::DataManipulation];
5317
5318 let input = serde_json::json!({
5319 "content": "name: Alice\nage: 30\ntags:\n - rust\n - python"
5320 });
5321
5322 let result = execute_tool("yaml_parse", &input, &caps, &context)
5323 .await
5324 .unwrap();
5325
5326 assert!(result.success);
5327 assert_eq!(result.output["name"], "Alice");
5328 assert_eq!(result.output["age"], 30);
5329 let tags = result.output["tags"].as_array().unwrap();
5330 assert_eq!(tags.len(), 2);
5331 }
5332
5333 #[tokio::test]
5334 async fn test_json_transform_extract_and_rename() {
5335 let context = make_test_context(None);
5336 let caps = vec![Capability::DataManipulation];
5337
5338 let input = serde_json::json!({
5339 "data": [
5340 {"name": "Alice", "age": 30, "city": "NYC"},
5341 {"name": "Bob", "age": 25, "city": "LA"}
5342 ],
5343 "extract": ["name", "city"],
5344 "rename": {"name": "full_name"}
5345 });
5346
5347 let result = execute_tool("json_transform", &input, &caps, &context)
5348 .await
5349 .unwrap();
5350
5351 assert!(result.success);
5352 let arr = result.output.as_array().unwrap();
5353 assert_eq!(arr.len(), 2);
5354 assert_eq!(arr[0]["full_name"], "Alice");
5355 assert!(arr[0].get("age").is_none());
5356 }
5357
5358 #[tokio::test]
5359 async fn test_code_symbols_rust_file() {
5360 let context = make_test_context(None);
5361 let caps = vec![Capability::CodeAnalysis];
5362
5363 let temp_file = context.working_dir.join("punch_test_symbols.rs");
5365 tokio::fs::write(
5366 &temp_file,
5367 "pub fn hello() {}\nstruct Foo {}\nasync fn bar() {}\nenum Color {}",
5368 )
5369 .await
5370 .unwrap();
5371
5372 let input = serde_json::json!({
5373 "path": temp_file.to_string_lossy()
5374 });
5375
5376 let result = execute_tool("code_symbols", &input, &caps, &context)
5377 .await
5378 .unwrap();
5379
5380 assert!(result.success);
5381 let symbols = result.output["symbols"].as_array().unwrap();
5382 let names: Vec<&str> = symbols.iter().filter_map(|s| s["name"].as_str()).collect();
5383 assert!(names.contains(&"hello"), "missing hello: {:?}", names);
5384 assert!(names.contains(&"Foo"), "missing Foo: {:?}", names);
5385 assert!(names.contains(&"bar"), "missing bar: {:?}", names);
5386 assert!(names.contains(&"Color"), "missing Color: {:?}", names);
5387
5388 let _ = tokio::fs::remove_file(&temp_file).await;
5390 }
5391
5392 #[tokio::test]
5397 async fn test_template_render_basic() {
5398 let context = make_test_context(None);
5399 let caps = vec![Capability::Template];
5400
5401 let input = serde_json::json!({
5402 "template": "Hello, {{name}}! You are {{age}} years old.",
5403 "variables": {"name": "Alice", "age": 30}
5404 });
5405
5406 let result = execute_tool("template_render", &input, &caps, &context)
5407 .await
5408 .unwrap();
5409
5410 assert!(result.success);
5411 assert_eq!(result.output, "Hello, Alice! You are 30 years old.");
5412 }
5413
5414 #[tokio::test]
5415 async fn test_hash_compute_sha256() {
5416 let context = make_test_context(None);
5417 let caps = vec![Capability::Crypto];
5418
5419 let input = serde_json::json!({
5420 "algorithm": "sha256",
5421 "input": "hello world"
5422 });
5423
5424 let result = execute_tool("hash_compute", &input, &caps, &context)
5425 .await
5426 .unwrap();
5427
5428 assert!(result.success);
5429 let hash = result.output["hash"].as_str().unwrap();
5430 assert_eq!(
5432 hash,
5433 "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
5434 );
5435 }
5436
5437 #[tokio::test]
5438 async fn test_hash_verify_match() {
5439 let context = make_test_context(None);
5440 let caps = vec![Capability::Crypto];
5441
5442 let input = serde_json::json!({
5443 "algorithm": "sha256",
5444 "input": "hello world",
5445 "expected": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
5446 });
5447
5448 let result = execute_tool("hash_verify", &input, &caps, &context)
5449 .await
5450 .unwrap();
5451
5452 assert!(result.success);
5453 assert_eq!(result.output["matches"], true);
5454 }
5455
5456 #[tokio::test]
5457 async fn test_text_count_basic() {
5458 let context = make_test_context(None);
5459 let caps = vec![Capability::DataManipulation];
5460
5461 let input = serde_json::json!({
5462 "text": "hello world\nfoo bar baz\n"
5463 });
5464
5465 let result = execute_tool("text_count", &input, &caps, &context)
5466 .await
5467 .unwrap();
5468
5469 assert!(result.success);
5470 assert_eq!(result.output["lines"], 2);
5471 assert_eq!(result.output["words"], 5);
5472 }
5473
5474 #[tokio::test]
5475 async fn test_text_diff_basic() {
5476 let context = make_test_context(None);
5477 let caps = vec![Capability::DataManipulation];
5478
5479 let input = serde_json::json!({
5480 "old_text": "line1\nline2\nline3",
5481 "new_text": "line1\nchanged\nline3"
5482 });
5483
5484 let result = execute_tool("text_diff", &input, &caps, &context)
5485 .await
5486 .unwrap();
5487
5488 assert!(result.success);
5489 assert_eq!(result.output["has_changes"], true);
5490 let diff = result.output["diff"].as_str().unwrap();
5491 assert!(diff.contains("-line2"));
5492 assert!(diff.contains("+changed"));
5493 }
5494
5495 #[tokio::test]
5496 async fn test_env_get_existing_var() {
5497 let context = make_test_context(None);
5498 let caps = vec![Capability::ShellExec("*".to_string())];
5499
5500 let input = serde_json::json!({"name": "PATH"});
5502
5503 let result = execute_tool("env_get", &input, &caps, &context)
5504 .await
5505 .unwrap();
5506
5507 assert!(result.success);
5508 assert!(result.output["value"].as_str().is_some());
5509 }
5510
5511 #[tokio::test]
5512 async fn test_file_info_basic() {
5513 let context = make_test_context(None);
5514 let caps = vec![Capability::FileRead("**".to_string())];
5515
5516 let temp_file = context.working_dir.join("punch_file_info_test.txt");
5518 tokio::fs::write(&temp_file, "test content").await.unwrap();
5519
5520 let input = serde_json::json!({
5521 "path": temp_file.to_string_lossy()
5522 });
5523
5524 let result = execute_tool("file_info", &input, &caps, &context)
5525 .await
5526 .unwrap();
5527
5528 assert!(result.success);
5529 assert_eq!(result.output["type"], "file");
5530 assert_eq!(result.output["size_bytes"], 12); let _ = tokio::fs::remove_file(&temp_file).await;
5533 }
5534
5535 #[tokio::test]
5536 async fn test_all_tools_count_at_least_55() {
5537 let tools = crate::tools::all_tools();
5538 assert!(
5539 tools.len() >= 55,
5540 "expected at least 55 tools, got {}",
5541 tools.len()
5542 );
5543 }
5544
5545 #[tokio::test]
5550 async fn test_dispatch_unknown_tool() {
5551 let context = make_test_context(None);
5552 let caps = vec![Capability::Memory];
5553 let input = serde_json::json!({});
5554
5555 let result = execute_tool("nonexistent_tool", &input, &caps, &context)
5556 .await
5557 .unwrap();
5558 assert!(!result.success);
5559 assert!(result.error.as_ref().unwrap().contains("nonexistent_tool"));
5560 }
5561
5562 #[tokio::test]
5563 async fn test_dispatch_file_read_missing_path() {
5564 let context = make_test_context(None);
5565 let caps = vec![Capability::FileRead("**".into())];
5566 let input = serde_json::json!({});
5567
5568 let result = execute_tool("file_read", &input, &caps, &context)
5569 .await
5570 .unwrap();
5571 assert!(!result.success);
5572 assert!(result.error.unwrap().contains("missing 'path'"));
5573 }
5574
5575 #[tokio::test]
5576 async fn test_dispatch_file_write_missing_params() {
5577 let context = make_test_context(None);
5578 let caps = vec![Capability::FileWrite("**".into())];
5579 let input = serde_json::json!({});
5580
5581 let result = execute_tool("file_write", &input, &caps, &context)
5582 .await
5583 .unwrap();
5584 assert!(!result.success);
5585 assert!(result.error.unwrap().contains("missing 'path'"));
5586 }
5587
5588 #[tokio::test]
5589 async fn test_dispatch_file_write_missing_content() {
5590 let context = make_test_context(None);
5591 let caps = vec![Capability::FileWrite("**".into())];
5592 let input = serde_json::json!({"path": "/tmp/test.txt"});
5593
5594 let result = execute_tool("file_write", &input, &caps, &context)
5595 .await
5596 .unwrap();
5597 assert!(!result.success);
5598 assert!(result.error.unwrap().contains("missing 'content'"));
5599 }
5600
5601 #[test]
5606 fn test_is_private_ip_link_local() {
5607 assert!(is_private_ip(&"169.254.1.1".parse().unwrap()));
5608 }
5609
5610 #[test]
5611 fn test_is_private_ip_broadcast() {
5612 assert!(is_private_ip(&"255.255.255.255".parse().unwrap()));
5613 }
5614
5615 #[test]
5616 fn test_is_private_ip_unspecified() {
5617 assert!(is_private_ip(&"0.0.0.0".parse().unwrap()));
5618 }
5619
5620 #[test]
5621 fn test_is_private_ip_v6_loopback() {
5622 assert!(is_private_ip(&"::1".parse().unwrap()));
5623 }
5624
5625 #[test]
5626 fn test_is_private_ip_v6_unspecified() {
5627 assert!(is_private_ip(&"::".parse().unwrap()));
5628 }
5629
5630 #[test]
5631 fn test_is_private_ip_172_16_range() {
5632 assert!(is_private_ip(&"172.16.0.1".parse().unwrap()));
5633 assert!(is_private_ip(&"172.31.255.255".parse().unwrap()));
5634 }
5635
5636 #[test]
5637 fn test_is_not_private_public_ips() {
5638 assert!(!is_private_ip(&"8.8.4.4".parse().unwrap()));
5639 assert!(!is_private_ip(&"142.250.80.46".parse().unwrap()));
5640 assert!(!is_private_ip(&"104.16.132.229".parse().unwrap()));
5641 }
5642
5643 #[test]
5648 fn test_json_path_query_nested() {
5649 let data = serde_json::json!({"a": {"b": {"c": 42}}});
5650 assert_eq!(json_path_query(&data, "a.b.c"), serde_json::json!(42));
5651 }
5652
5653 #[test]
5654 fn test_json_path_query_array_index() {
5655 let data = serde_json::json!({"items": [10, 20, 30]});
5656 assert_eq!(json_path_query(&data, "items.2"), serde_json::json!(30));
5657 }
5658
5659 #[test]
5660 fn test_json_path_query_missing_key() {
5661 let data = serde_json::json!({"a": 1});
5662 assert_eq!(json_path_query(&data, "b"), serde_json::json!(null));
5663 }
5664
5665 #[test]
5666 fn test_json_path_query_empty_path() {
5667 let data = serde_json::json!({"a": 1});
5668 assert_eq!(json_path_query(&data, ""), data);
5669 }
5670
5671 #[test]
5672 fn test_json_path_query_deeply_nested() {
5673 let data = serde_json::json!({"l1": {"l2": {"l3": {"l4": "deep"}}}});
5674 assert_eq!(
5675 json_path_query(&data, "l1.l2.l3.l4"),
5676 serde_json::json!("deep")
5677 );
5678 }
5679
5680 #[test]
5681 fn test_json_path_query_array_of_objects() {
5682 let data = serde_json::json!({"users": [{"name": "Alice"}, {"name": "Bob"}]});
5683 assert_eq!(
5684 json_path_query(&data, "users.0.name"),
5685 serde_json::json!("Alice")
5686 );
5687 }
5688
5689 #[test]
5694 fn test_resolve_path_absolute() {
5695 let result = resolve_path(std::path::Path::new("/tmp"), "/etc/hosts").unwrap();
5696 assert_eq!(result, std::path::PathBuf::from("/etc/hosts"));
5697 }
5698
5699 #[test]
5700 fn test_resolve_path_relative() {
5701 let result = resolve_path(std::path::Path::new("/home/user"), "file.txt").unwrap();
5702 assert_eq!(result, std::path::PathBuf::from("/home/user/file.txt"));
5703 }
5704
5705 #[test]
5706 fn test_resolve_path_dot_prefix() {
5707 let result = resolve_path(std::path::Path::new("/work"), "./src/lib.rs").unwrap();
5708 assert_eq!(result, std::path::PathBuf::from("/work/./src/lib.rs"));
5709 }
5710
5711 #[test]
5716 fn test_compute_hash_sha256() {
5717 let hash = compute_hash("sha256", b"test").unwrap();
5718 assert_eq!(
5719 hash,
5720 "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
5721 );
5722 }
5723
5724 #[test]
5725 fn test_compute_hash_sha512() {
5726 let hash = compute_hash("sha512", b"test").unwrap();
5727 assert!(!hash.is_empty());
5729 assert_eq!(hash.len(), 128); }
5731
5732 #[test]
5733 fn test_compute_hash_md5_rejected() {
5734 let result = compute_hash("md5", b"test");
5735 assert!(result.is_err());
5736 }
5737
5738 #[test]
5739 fn test_compute_hash_unknown_algo() {
5740 let result = compute_hash("blake2", b"test");
5741 assert!(result.is_err());
5742 }
5743
5744 #[test]
5745 fn test_compute_hash_sha256_empty() {
5746 let hash = compute_hash("sha256", b"").unwrap();
5747 assert_eq!(
5748 hash,
5749 "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
5750 );
5751 }
5752
5753 #[test]
5758 fn test_strip_html_nested_tags() {
5759 assert_eq!(strip_html_tags("<div><span>text</span></div>"), "text");
5760 }
5761
5762 #[test]
5763 fn test_strip_html_empty() {
5764 assert_eq!(strip_html_tags(""), "");
5765 }
5766
5767 #[test]
5768 fn test_strip_html_no_tags() {
5769 assert_eq!(strip_html_tags("plain text"), "plain text");
5770 }
5771
5772 #[tokio::test]
5777 async fn test_regex_match_no_match() {
5778 let context = make_test_context(None);
5779 let caps = vec![Capability::DataManipulation];
5780
5781 let input = serde_json::json!({
5782 "pattern": r"\d+",
5783 "text": "no numbers here",
5784 "global": false
5785 });
5786
5787 let result = execute_tool("regex_match", &input, &caps, &context)
5788 .await
5789 .unwrap();
5790
5791 assert!(result.success);
5792 assert_eq!(result.output["matched"], false);
5793 }
5794
5795 #[tokio::test]
5796 async fn test_regex_match_global() {
5797 let context = make_test_context(None);
5798 let caps = vec![Capability::DataManipulation];
5799
5800 let input = serde_json::json!({
5801 "pattern": r"\d+",
5802 "text": "abc 123 def 456 ghi 789",
5803 "global": true
5804 });
5805
5806 let result = execute_tool("regex_match", &input, &caps, &context)
5807 .await
5808 .unwrap();
5809
5810 assert!(result.success);
5811 let matches = result.output["matches"].as_array().unwrap();
5812 assert_eq!(matches.len(), 3);
5813 }
5814
5815 #[tokio::test]
5816 async fn test_regex_match_invalid_pattern() {
5817 let context = make_test_context(None);
5818 let caps = vec![Capability::DataManipulation];
5819
5820 let input = serde_json::json!({
5821 "pattern": r"[invalid",
5822 "text": "test"
5823 });
5824
5825 let result = execute_tool("regex_match", &input, &caps, &context)
5826 .await
5827 .unwrap();
5828 assert!(!result.success);
5829 assert!(result.error.unwrap().contains("invalid regex"));
5830 }
5831
5832 #[tokio::test]
5833 async fn test_json_query_string_data() {
5834 let context = make_test_context(None);
5835 let caps = vec![Capability::DataManipulation];
5836
5837 let input = serde_json::json!({
5838 "data": r#"{"key": "value"}"#,
5839 "path": "key"
5840 });
5841
5842 let result = execute_tool("json_query", &input, &caps, &context)
5843 .await
5844 .unwrap();
5845
5846 assert!(result.success);
5847 assert_eq!(result.output, serde_json::json!("value"));
5848 }
5849
5850 #[tokio::test]
5851 async fn test_json_transform_filter() {
5852 let context = make_test_context(None);
5853 let caps = vec![Capability::DataManipulation];
5854
5855 let input = serde_json::json!({
5856 "data": [
5857 {"name": "Alice", "role": "admin"},
5858 {"name": "Bob", "role": "user"},
5859 {"name": "Carol", "role": "admin"}
5860 ],
5861 "filter_key": "role",
5862 "filter_value": "admin"
5863 });
5864
5865 let result = execute_tool("json_transform", &input, &caps, &context)
5866 .await
5867 .unwrap();
5868
5869 assert!(result.success);
5870 let arr = result.output.as_array().unwrap();
5871 assert_eq!(arr.len(), 2);
5872 }
5873
5874 #[tokio::test]
5875 async fn test_yaml_parse_nested_mapping() {
5876 let context = make_test_context(None);
5877 let caps = vec![Capability::DataManipulation];
5878
5879 let input = serde_json::json!({
5880 "content": "server:\n host: localhost\n port: 8080"
5881 });
5882
5883 let result = execute_tool("yaml_parse", &input, &caps, &context)
5884 .await
5885 .unwrap();
5886
5887 assert!(result.success);
5888 assert_eq!(result.output["server"]["host"], "localhost");
5889 assert_eq!(result.output["server"]["port"], 8080);
5890 }
5891
5892 #[tokio::test]
5893 async fn test_yaml_parse_invalid() {
5894 let context = make_test_context(None);
5895 let caps = vec![Capability::DataManipulation];
5896
5897 let input = serde_json::json!({
5898 "content": ":\n - invalid:\nyaml: [{"
5899 });
5900
5901 let result = execute_tool("yaml_parse", &input, &caps, &context)
5902 .await
5903 .unwrap();
5904
5905 assert!(!result.success);
5906 assert!(result.error.unwrap().contains("parse YAML"));
5907 }
5908
5909 #[tokio::test]
5914 async fn test_template_render_missing_variable() {
5915 let context = make_test_context(None);
5916 let caps = vec![Capability::Template];
5917
5918 let input = serde_json::json!({
5919 "template": "Hello, {{name}}! Age: {{age}}",
5920 "variables": {"name": "Alice"}
5921 });
5922
5923 let result = execute_tool("template_render", &input, &caps, &context)
5924 .await
5925 .unwrap();
5926
5927 assert!(result.success);
5928 let rendered = result.output.as_str().unwrap();
5929 assert!(rendered.contains("Alice"));
5930 assert!(rendered.contains("{{age}}"));
5932 }
5933
5934 #[tokio::test]
5935 async fn test_template_render_no_variables_in_template() {
5936 let context = make_test_context(None);
5937 let caps = vec![Capability::Template];
5938
5939 let input = serde_json::json!({
5940 "template": "No variables here",
5941 "variables": {}
5942 });
5943
5944 let result = execute_tool("template_render", &input, &caps, &context)
5945 .await
5946 .unwrap();
5947
5948 assert!(result.success);
5949 assert_eq!(result.output, "No variables here");
5950 }
5951
5952 #[tokio::test]
5957 async fn test_hash_compute_sha512() {
5958 let context = make_test_context(None);
5959 let caps = vec![Capability::Crypto];
5960
5961 let input = serde_json::json!({
5962 "algorithm": "sha512",
5963 "input": "test"
5964 });
5965
5966 let result = execute_tool("hash_compute", &input, &caps, &context)
5967 .await
5968 .unwrap();
5969
5970 assert!(result.success);
5971 assert_eq!(result.output["algorithm"], "sha512");
5972 let hash = result.output["hash"].as_str().unwrap();
5973 assert_eq!(hash.len(), 128);
5974 }
5975
5976 #[tokio::test]
5977 async fn test_hash_compute_no_input_or_file() {
5978 let context = make_test_context(None);
5979 let caps = vec![Capability::Crypto];
5980
5981 let input = serde_json::json!({"algorithm": "sha256"});
5982
5983 let result = execute_tool("hash_compute", &input, &caps, &context)
5984 .await
5985 .unwrap();
5986
5987 assert!(!result.success);
5988 assert!(result.error.unwrap().contains("must provide"));
5989 }
5990
5991 #[tokio::test]
5992 async fn test_hash_verify_mismatch() {
5993 let context = make_test_context(None);
5994 let caps = vec![Capability::Crypto];
5995
5996 let input = serde_json::json!({
5997 "algorithm": "sha256",
5998 "input": "hello",
5999 "expected": "0000000000000000000000000000000000000000000000000000000000000000"
6000 });
6001
6002 let result = execute_tool("hash_verify", &input, &caps, &context)
6003 .await
6004 .unwrap();
6005
6006 assert!(result.success);
6007 assert_eq!(result.output["matches"], false);
6008 }
6009
6010 #[tokio::test]
6015 async fn test_text_count_empty() {
6016 let context = make_test_context(None);
6017 let caps = vec![Capability::DataManipulation];
6018
6019 let input = serde_json::json!({"text": ""});
6020
6021 let result = execute_tool("text_count", &input, &caps, &context)
6022 .await
6023 .unwrap();
6024
6025 assert!(result.success);
6026 assert_eq!(result.output["lines"], 0);
6027 assert_eq!(result.output["words"], 0);
6028 assert_eq!(result.output["characters"], 0);
6029 assert_eq!(result.output["bytes"], 0);
6030 }
6031
6032 #[tokio::test]
6033 async fn test_text_diff_identical() {
6034 let context = make_test_context(None);
6035 let caps = vec![Capability::DataManipulation];
6036
6037 let input = serde_json::json!({
6038 "old_text": "same text",
6039 "new_text": "same text"
6040 });
6041
6042 let result = execute_tool("text_diff", &input, &caps, &context)
6043 .await
6044 .unwrap();
6045
6046 assert!(result.success);
6047 assert_eq!(result.output["has_changes"], false);
6048 }
6049
6050 #[tokio::test]
6055 async fn test_env_get_nonexistent_var() {
6056 let context = make_test_context(None);
6057 let caps = vec![Capability::ShellExec("*".to_string())];
6058
6059 let input = serde_json::json!({"name": "PUNCH_NONEXISTENT_VAR_12345"});
6060
6061 let result = execute_tool("env_get", &input, &caps, &context)
6062 .await
6063 .unwrap();
6064
6065 assert!(result.success);
6066 assert!(result.output["value"].is_null());
6067 }
6068
6069 #[tokio::test]
6070 async fn test_env_list_with_prefix() {
6071 let context = make_test_context(None);
6072 let caps = vec![Capability::ShellExec("*".to_string())];
6073
6074 let input = serde_json::json!({"prefix": "PATH"});
6075
6076 let result = execute_tool("env_list", &input, &caps, &context)
6077 .await
6078 .unwrap();
6079
6080 assert!(result.success);
6081 let count = result.output["count"].as_u64().unwrap();
6083 assert!(count >= 1);
6084 }
6085
6086 #[test]
6091 fn test_require_capability_multiple_grants() {
6092 let caps = vec![
6093 Capability::FileRead("src/**".into()),
6094 Capability::FileRead("tests/**".into()),
6095 ];
6096 assert!(require_capability(&caps, &Capability::FileRead("src/main.rs".into())).is_ok());
6097 assert!(require_capability(&caps, &Capability::FileRead("tests/test.rs".into())).is_ok());
6098 }
6099
6100 #[test]
6101 fn test_require_capability_empty_caps() {
6102 let caps: Vec<Capability> = vec![];
6103 assert!(require_capability(&caps, &Capability::Memory).is_err());
6104 }
6105
6106 #[test]
6107 fn test_require_capability_wrong_type() {
6108 let caps = vec![Capability::FileRead("**".into())];
6109 assert!(require_capability(&caps, &Capability::FileWrite("test.txt".into())).is_err());
6110 }
6111
6112 #[tokio::test]
6117 async fn test_file_write_and_read_roundtrip() {
6118 let context = make_test_context(None);
6119 let temp_file = context.working_dir.join("punch_roundtrip_test.txt");
6120 let caps = vec![
6121 Capability::FileRead("**".into()),
6122 Capability::FileWrite("**".into()),
6123 ];
6124
6125 let write_input = serde_json::json!({
6127 "path": temp_file.to_string_lossy(),
6128 "content": "roundtrip content"
6129 });
6130 let write_result = execute_tool("file_write", &write_input, &caps, &context)
6131 .await
6132 .unwrap();
6133 assert!(write_result.success);
6134
6135 let read_input = serde_json::json!({
6137 "path": temp_file.to_string_lossy()
6138 });
6139 let read_result = execute_tool("file_read", &read_input, &caps, &context)
6140 .await
6141 .unwrap();
6142 assert!(read_result.success);
6143 assert_eq!(read_result.output, "roundtrip content");
6144
6145 let _ = tokio::fs::remove_file(&temp_file).await;
6146 }
6147
6148 #[tokio::test]
6153 async fn test_file_list_temp_dir() {
6154 let context = make_test_context(None);
6155 let caps = vec![Capability::FileRead("**".into())];
6156
6157 let input = serde_json::json!({"path": "."});
6158
6159 let result = execute_tool("file_list", &input, &caps, &context)
6160 .await
6161 .unwrap();
6162
6163 assert!(result.success);
6164 assert!(result.output.as_array().is_some());
6166 }
6167
6168 #[tokio::test]
6173 async fn test_json_query_denied_without_capability() {
6174 let context = make_test_context(None);
6175 let caps = vec![Capability::Memory]; let input = serde_json::json!({
6178 "data": {"key": "value"},
6179 "path": "key"
6180 });
6181
6182 let result = execute_tool("json_query", &input, &caps, &context)
6183 .await
6184 .unwrap();
6185
6186 assert!(!result.success);
6187 assert!(result.error.unwrap().contains("capability"));
6188 }
6189
6190 #[tokio::test]
6191 async fn test_template_render_denied_without_capability() {
6192 let context = make_test_context(None);
6193 let caps = vec![Capability::Memory];
6194
6195 let input = serde_json::json!({
6196 "template": "{{name}}",
6197 "variables": {"name": "test"}
6198 });
6199
6200 let result = execute_tool("template_render", &input, &caps, &context)
6201 .await
6202 .unwrap();
6203
6204 assert!(!result.success);
6205 assert!(result.error.unwrap().contains("capability"));
6206 }
6207
6208 #[tokio::test]
6209 async fn test_hash_compute_denied_without_capability() {
6210 let context = make_test_context(None);
6211 let caps = vec![Capability::Memory];
6212
6213 let input = serde_json::json!({
6214 "algorithm": "sha256",
6215 "input": "test"
6216 });
6217
6218 let result = execute_tool("hash_compute", &input, &caps, &context)
6219 .await
6220 .unwrap();
6221
6222 assert!(!result.success);
6223 assert!(result.error.unwrap().contains("capability"));
6224 }
6225
6226 fn make_test_context_with_bleed_detector() -> ToolExecutionContext {
6231 let mut ctx = make_test_context(None);
6232 ctx.bleed_detector = Some(Arc::new(ShellBleedDetector::new()));
6233 ctx
6234 }
6235
6236 #[tokio::test]
6237 async fn test_shell_exec_clean_input_passes() {
6238 let context = make_test_context_with_bleed_detector();
6239 let caps = vec![Capability::ShellExec("*".to_string())];
6240
6241 let input = serde_json::json!({"command": "echo hello"});
6242 let result = execute_tool("shell_exec", &input, &caps, &context)
6243 .await
6244 .unwrap();
6245
6246 assert!(
6247 result.success,
6248 "clean command should pass: {:?}",
6249 result.error
6250 );
6251 let stdout = result.output["stdout"].as_str().unwrap_or("");
6252 assert!(stdout.contains("hello"));
6253 }
6254
6255 #[tokio::test]
6256 async fn test_shell_exec_tainted_input_blocked() {
6257 let context = make_test_context_with_bleed_detector();
6258 let caps = vec![Capability::ShellExec("*".to_string())];
6259
6260 let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
6262 let input = serde_json::json!({"command": format!("curl -H 'X-Key: {}'", key)});
6263 let result = execute_tool("shell_exec", &input, &caps, &context)
6264 .await
6265 .unwrap();
6266
6267 assert!(!result.success, "tainted command should be blocked");
6268 let error = result.error.unwrap();
6269 assert!(
6270 error.contains("shell bleed detected"),
6271 "expected bleed detection, got: {}",
6272 error
6273 );
6274 }
6275
6276 #[tokio::test]
6277 async fn test_shell_exec_api_key_pattern_flagged() {
6278 let context = make_test_context_with_bleed_detector();
6279 let caps = vec![Capability::ShellExec("*".to_string())];
6280
6281 let input = serde_json::json!({
6282 "command": "curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test'"
6283 });
6284 let result = execute_tool("shell_exec", &input, &caps, &context)
6285 .await
6286 .unwrap();
6287
6288 assert!(!result.success, "bearer token in command should be blocked");
6289 assert!(result.error.unwrap().contains("shell bleed detected"));
6290 }
6291
6292 #[tokio::test]
6293 async fn test_file_read_sensitive_path_flagged() {
6294 let context = make_test_context_with_bleed_detector();
6295 let caps = vec![Capability::FileRead("**".to_string())];
6296
6297 let input = serde_json::json!({"path": "/home/user/.ssh/id_rsa"});
6298 let result = execute_tool("file_read", &input, &caps, &context)
6299 .await
6300 .unwrap();
6301
6302 assert!(!result.success, "sensitive path read should be blocked");
6303 let error = result.error.unwrap();
6304 assert!(
6305 error.contains("sensitive path") && error.contains("blocked"),
6306 "expected sensitive path blocked, got: {}",
6307 error
6308 );
6309 }
6310
6311 #[tokio::test]
6312 async fn test_file_read_normal_path_passes() {
6313 let context = make_test_context_with_bleed_detector();
6314 let caps = vec![Capability::FileRead("**".to_string())];
6315
6316 let temp_file = context.working_dir.join("punch_bleed_test_normal.txt");
6318 tokio::fs::write(&temp_file, "normal content")
6319 .await
6320 .expect("write temp file");
6321
6322 let input = serde_json::json!({"path": temp_file.to_string_lossy()});
6323 let result = execute_tool("file_read", &input, &caps, &context)
6324 .await
6325 .unwrap();
6326
6327 assert!(
6328 result.success,
6329 "normal path should pass: {:?}",
6330 result.error
6331 );
6332 let _ = tokio::fs::remove_file(&temp_file).await;
6333 }
6334
6335 #[test]
6336 fn test_bleed_detector_records_security_events() {
6337 let detector = ShellBleedDetector::new();
6338
6339 let clean = detector.scan_command("ls -la /tmp");
6341 assert!(clean.is_empty(), "clean command should produce no warnings");
6342
6343 let key = format!("AKIA{}", "IOSFODNN7EXAMPLE");
6345 let tainted = detector.scan_command(&format!("export AWS_KEY={}", key));
6346 assert!(
6347 !tainted.is_empty(),
6348 "tainted command should produce warnings"
6349 );
6350
6351 let bearer =
6353 detector.scan_command("curl -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test'");
6354 assert!(!bearer.is_empty(), "bearer token should produce warnings");
6355 }
6356
6357 #[test]
6358 fn test_is_sensitive_path_detection() {
6359 assert!(is_sensitive_path("/home/user/.ssh/id_rsa"));
6360 assert!(is_sensitive_path("/app/.env"));
6361 assert!(is_sensitive_path("/home/user/.aws/credentials"));
6362 assert!(is_sensitive_path("/home/user/.kube/config"));
6363 assert!(is_sensitive_path("secrets.json"));
6364 assert!(!is_sensitive_path("/home/user/project/src/main.rs"));
6365 assert!(!is_sensitive_path("/tmp/output.txt"));
6366 }
6367
6368 #[tokio::test]
6373 async fn test_wasm_invoke_no_registry_returns_error() {
6374 let context = make_test_context(None);
6375 let caps = vec![Capability::PluginInvoke];
6376 let input = serde_json::json!({
6377 "plugin": "test-plugin",
6378 "function": "execute"
6379 });
6380
6381 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6382 .await
6383 .unwrap();
6384
6385 assert!(!result.success);
6386 assert!(
6387 result
6388 .error
6389 .as_deref()
6390 .unwrap()
6391 .contains("plugin runtime not configured"),
6392 "expected plugin runtime error, got: {:?}",
6393 result.error
6394 );
6395 }
6396
6397 #[tokio::test]
6398 async fn test_wasm_invoke_missing_capability() {
6399 let context = make_test_context(None);
6400 let caps = vec![Capability::Memory];
6402 let input = serde_json::json!({
6403 "plugin": "test-plugin",
6404 "function": "execute"
6405 });
6406
6407 let result = execute_tool("wasm_invoke", &input, &caps, &context).await;
6408 match result {
6410 Ok(tr) => assert!(!tr.success),
6411 Err(e) => assert!(
6412 e.to_string().contains("capability"),
6413 "expected capability error, got: {e}"
6414 ),
6415 }
6416 }
6417
6418 #[tokio::test]
6419 async fn test_wasm_invoke_missing_plugin_param() {
6420 use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6421 let runtime = Arc::new(NativePluginRuntime::new());
6422 let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6423
6424 let mut context = make_test_context(None);
6425 context.plugin_registry = Some(registry);
6426
6427 let caps = vec![Capability::PluginInvoke];
6428 let input = serde_json::json!({
6429 "function": "execute"
6430 });
6431
6432 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6433 .await
6434 .unwrap();
6435
6436 assert!(!result.success);
6437 assert!(
6438 result.error.as_deref().unwrap().contains("plugin"),
6439 "expected missing plugin param error, got: {:?}",
6440 result.error
6441 );
6442 }
6443
6444 #[tokio::test]
6445 async fn test_wasm_invoke_missing_function_param() {
6446 use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6447 let runtime = Arc::new(NativePluginRuntime::new());
6448 let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6449
6450 let mut context = make_test_context(None);
6451 context.plugin_registry = Some(registry);
6452
6453 let caps = vec![Capability::PluginInvoke];
6454 let input = serde_json::json!({
6455 "plugin": "test-plugin"
6456 });
6457
6458 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6459 .await
6460 .unwrap();
6461
6462 assert!(!result.success);
6463 assert!(
6464 result.error.as_deref().unwrap().contains("function"),
6465 "expected missing function param error, got: {:?}",
6466 result.error
6467 );
6468 }
6469
6470 #[tokio::test]
6471 async fn test_wasm_invoke_plugin_not_found() {
6472 use punch_extensions::plugin::{NativePluginRuntime, PluginRegistry};
6473 let runtime = Arc::new(NativePluginRuntime::new());
6474 let registry = Arc::new(PluginRegistry::with_runtime(runtime));
6475
6476 let mut context = make_test_context(None);
6477 context.plugin_registry = Some(registry);
6478
6479 let caps = vec![Capability::PluginInvoke];
6480 let input = serde_json::json!({
6481 "plugin": "nonexistent-plugin",
6482 "function": "execute"
6483 });
6484
6485 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6486 .await
6487 .unwrap();
6488
6489 assert!(!result.success);
6490 assert!(
6491 result.error.as_deref().unwrap().contains("not found"),
6492 "expected plugin not found error, got: {:?}",
6493 result.error
6494 );
6495 }
6496
6497 #[tokio::test]
6498 async fn test_wasm_invoke_success_with_native_runtime() {
6499 use punch_extensions::plugin::{
6500 NativePluginRuntime, PluginManifest, PluginOutput, PluginPermissions, PluginRegistry,
6501 };
6502
6503 let runtime = Arc::new(NativePluginRuntime::new());
6504 let registry = Arc::new(PluginRegistry::with_runtime(runtime.clone()));
6505
6506 let manifest = PluginManifest {
6507 name: "echo-technique".to_string(),
6508 version: "1.0.0".to_string(),
6509 description: "Echoes input back".to_string(),
6510 author: "Test".to_string(),
6511 entry_point: "execute".to_string(),
6512 capabilities: vec![],
6513 max_memory_bytes: 64 * 1024 * 1024,
6514 max_execution_ms: 30_000,
6515 permissions: PluginPermissions::default(),
6516 };
6517
6518 let id = registry.register(manifest, b"native").await.unwrap();
6519 runtime.register_function(id, |input| {
6520 Ok(PluginOutput {
6521 result: input.args.clone(),
6522 logs: vec!["technique executed".to_string()],
6523 execution_ms: 0,
6524 memory_used_bytes: 512,
6525 })
6526 });
6527
6528 let mut context = make_test_context(None);
6529 context.plugin_registry = Some(registry);
6530
6531 let caps = vec![Capability::PluginInvoke];
6532 let input = serde_json::json!({
6533 "plugin": "echo-technique",
6534 "function": "execute",
6535 "input": {"strike": "roundhouse"}
6536 });
6537
6538 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6539 .await
6540 .unwrap();
6541
6542 assert!(
6543 result.success,
6544 "wasm_invoke should succeed: {:?}",
6545 result.error
6546 );
6547 assert_eq!(result.output["result"]["strike"], "roundhouse");
6548 assert!(!result.output["logs"].as_array().unwrap().is_empty());
6549 }
6550
6551 #[tokio::test]
6552 async fn test_wasm_invoke_default_input() {
6553 use punch_extensions::plugin::{
6554 NativePluginRuntime, PluginManifest, PluginOutput, PluginPermissions, PluginRegistry,
6555 };
6556
6557 let runtime = Arc::new(NativePluginRuntime::new());
6558 let registry = Arc::new(PluginRegistry::with_runtime(runtime.clone()));
6559
6560 let manifest = PluginManifest {
6561 name: "noop-technique".to_string(),
6562 version: "1.0.0".to_string(),
6563 description: "Does nothing".to_string(),
6564 author: "Test".to_string(),
6565 entry_point: "execute".to_string(),
6566 capabilities: vec![],
6567 max_memory_bytes: 64 * 1024 * 1024,
6568 max_execution_ms: 30_000,
6569 permissions: PluginPermissions::default(),
6570 };
6571
6572 let id = registry.register(manifest, b"native").await.unwrap();
6573 runtime.register_function(id, |input| {
6574 assert_eq!(input.args, serde_json::json!({}));
6576 Ok(PluginOutput {
6577 result: serde_json::json!("ok"),
6578 logs: vec![],
6579 execution_ms: 0,
6580 memory_used_bytes: 0,
6581 })
6582 });
6583
6584 let mut context = make_test_context(None);
6585 context.plugin_registry = Some(registry);
6586
6587 let caps = vec![Capability::PluginInvoke];
6588 let input = serde_json::json!({
6590 "plugin": "noop-technique",
6591 "function": "execute"
6592 });
6593
6594 let result = execute_tool("wasm_invoke", &input, &caps, &context)
6595 .await
6596 .unwrap();
6597
6598 assert!(
6599 result.success,
6600 "wasm_invoke should succeed: {:?}",
6601 result.error
6602 );
6603 assert_eq!(result.output["result"], "ok");
6604 }
6605
6606 #[tokio::test]
6611 async fn test_a2a_delegate_missing_agent_url() {
6612 let context = make_test_context(None);
6613 let caps = vec![Capability::A2ADelegate];
6614 let input = serde_json::json!({"prompt": "hello"});
6615
6616 let result = execute_tool("a2a_delegate", &input, &caps, &context)
6617 .await
6618 .unwrap();
6619 assert!(!result.success);
6620 assert!(
6621 result.error.as_deref().unwrap_or("").contains("agent_url"),
6622 "error should mention agent_url: {:?}",
6623 result.error
6624 );
6625 }
6626
6627 #[tokio::test]
6628 async fn test_a2a_delegate_missing_prompt() {
6629 let context = make_test_context(None);
6630 let caps = vec![Capability::A2ADelegate];
6631 let input = serde_json::json!({"agent_url": "http://localhost:9999"});
6632
6633 let result = execute_tool("a2a_delegate", &input, &caps, &context)
6634 .await
6635 .unwrap();
6636 assert!(!result.success);
6637 assert!(
6638 result.error.as_deref().unwrap_or("").contains("prompt"),
6639 "error should mention prompt: {:?}",
6640 result.error
6641 );
6642 }
6643
6644 #[tokio::test]
6645 async fn test_a2a_delegate_capability_denied() {
6646 let context = make_test_context(None);
6647 let caps = vec![Capability::Memory]; let input = serde_json::json!({
6649 "agent_url": "http://localhost:9999",
6650 "prompt": "hello"
6651 });
6652
6653 let result = execute_tool("a2a_delegate", &input, &caps, &context).await;
6654 assert!(result.is_err() || !result.unwrap().success);
6656 }
6657
6658 #[tokio::test]
6659 async fn test_a2a_delegate_unreachable_agent() {
6660 let context = make_test_context(None);
6661 let caps = vec![Capability::A2ADelegate];
6662 let input = serde_json::json!({
6663 "agent_url": "http://127.0.0.1:19999",
6664 "prompt": "hello",
6665 "timeout_secs": 2
6666 });
6667
6668 let result = execute_tool("a2a_delegate", &input, &caps, &context)
6669 .await
6670 .unwrap();
6671 assert!(!result.success);
6672 assert!(
6673 result
6674 .error
6675 .as_deref()
6676 .unwrap_or("")
6677 .contains("discovery failed"),
6678 "error should mention discovery failure: {:?}",
6679 result.error
6680 );
6681 }
6682
6683 #[tokio::test]
6688 async fn test_heartbeat_add_requires_self_config() {
6689 let context = make_test_context(None);
6690 let caps = vec![Capability::Memory]; let input = serde_json::json!({"task": "test", "cadence": "daily"});
6692 let result = execute_tool("heartbeat_add", &input, &caps, &context).await;
6693 assert!(result.is_err() || !result.unwrap().success);
6694 }
6695
6696 #[tokio::test]
6697 async fn test_heartbeat_add_invalid_cadence() {
6698 let context = make_test_context(None);
6699 let caps = vec![Capability::SelfConfig];
6700
6701 let mut creed = punch_types::creed::Creed::new("test-fighter");
6703 creed.fighter_id = Some(context.fighter_id);
6704 context.memory.save_creed(&creed).await.unwrap();
6705
6706 let input = serde_json::json!({"task": "test", "cadence": "every_5_minutes"});
6707 let result = execute_tool("heartbeat_add", &input, &caps, &context)
6708 .await
6709 .unwrap();
6710 assert!(!result.success);
6711 assert!(result.error.unwrap().contains("invalid cadence"));
6712 }
6713
6714 #[tokio::test]
6715 async fn test_heartbeat_add_success() {
6716 let context = make_test_context(None);
6717 let caps = vec![Capability::SelfConfig];
6718
6719 let mut creed = punch_types::creed::Creed::new("test-fighter");
6720 creed.fighter_id = Some(context.fighter_id);
6721 context.memory.save_creed(&creed).await.unwrap();
6722
6723 let input = serde_json::json!({"task": "Morning briefing", "cadence": "daily"});
6724 let result = execute_tool("heartbeat_add", &input, &caps, &context)
6725 .await
6726 .unwrap();
6727 assert!(result.success);
6728 assert_eq!(result.output["total_heartbeats"], 1);
6729
6730 let updated = context
6732 .memory
6733 .load_creed_by_fighter(&context.fighter_id)
6734 .await
6735 .unwrap()
6736 .unwrap();
6737 assert_eq!(updated.heartbeat.len(), 1);
6738 assert_eq!(updated.heartbeat[0].task, "Morning briefing");
6739 assert_eq!(updated.heartbeat[0].cadence, "daily");
6740 }
6741
6742 #[tokio::test]
6743 async fn test_heartbeat_list_success() {
6744 let context = make_test_context(None);
6745 let caps = vec![Capability::SelfConfig];
6746
6747 let mut creed = punch_types::creed::Creed::new("test-fighter");
6748 creed.fighter_id = Some(context.fighter_id);
6749 creed = creed.with_heartbeat_task("Check health", "hourly");
6750 creed = creed.with_heartbeat_task("Daily summary", "daily");
6751 context.memory.save_creed(&creed).await.unwrap();
6752
6753 let input = serde_json::json!({});
6754 let result = execute_tool("heartbeat_list", &input, &caps, &context)
6755 .await
6756 .unwrap();
6757 assert!(result.success);
6758 assert_eq!(result.output["total"], 2);
6759 let heartbeats = result.output["heartbeats"].as_array().unwrap();
6760 assert_eq!(heartbeats[0]["task"], "Check health");
6761 assert_eq!(heartbeats[1]["cadence"], "daily");
6762 }
6763
6764 #[tokio::test]
6765 async fn test_heartbeat_remove_success() {
6766 let context = make_test_context(None);
6767 let caps = vec![Capability::SelfConfig];
6768
6769 let mut creed = punch_types::creed::Creed::new("test-fighter");
6770 creed.fighter_id = Some(context.fighter_id);
6771 creed = creed.with_heartbeat_task("Task A", "hourly");
6772 creed = creed.with_heartbeat_task("Task B", "daily");
6773 context.memory.save_creed(&creed).await.unwrap();
6774
6775 let input = serde_json::json!({"index": 0});
6776 let result = execute_tool("heartbeat_remove", &input, &caps, &context)
6777 .await
6778 .unwrap();
6779 assert!(result.success);
6780 assert_eq!(result.output["remaining"], 1);
6781
6782 let updated = context
6783 .memory
6784 .load_creed_by_fighter(&context.fighter_id)
6785 .await
6786 .unwrap()
6787 .unwrap();
6788 assert_eq!(updated.heartbeat.len(), 1);
6789 assert_eq!(updated.heartbeat[0].task, "Task B");
6790 }
6791
6792 #[tokio::test]
6793 async fn test_heartbeat_remove_out_of_range() {
6794 let context = make_test_context(None);
6795 let caps = vec![Capability::SelfConfig];
6796
6797 let mut creed = punch_types::creed::Creed::new("test-fighter");
6798 creed.fighter_id = Some(context.fighter_id);
6799 context.memory.save_creed(&creed).await.unwrap();
6800
6801 let input = serde_json::json!({"index": 5});
6802 let result = execute_tool("heartbeat_remove", &input, &caps, &context)
6803 .await
6804 .unwrap();
6805 assert!(!result.success);
6806 assert!(result.error.unwrap().contains("out of range"));
6807 }
6808
6809 #[tokio::test]
6810 async fn test_creed_view_success() {
6811 let context = make_test_context(None);
6812 let caps = vec![Capability::SelfConfig];
6813
6814 let mut creed = punch_types::creed::Creed::new("view-test")
6815 .with_identity("A test fighter")
6816 .with_trait("curiosity", 0.9)
6817 .with_directive("Be thorough");
6818 creed.fighter_id = Some(context.fighter_id);
6819 context.memory.save_creed(&creed).await.unwrap();
6820
6821 let input = serde_json::json!({});
6822 let result = execute_tool("creed_view", &input, &caps, &context)
6823 .await
6824 .unwrap();
6825 assert!(result.success);
6826 assert_eq!(result.output["fighter_name"], "view-test");
6827 assert_eq!(result.output["identity"], "A test fighter");
6828 assert!(
6829 result.output["directives"]
6830 .as_array()
6831 .unwrap()
6832 .iter()
6833 .any(|d| d == "Be thorough")
6834 );
6835 }
6836
6837 #[tokio::test]
6838 async fn test_skill_list_success() {
6839 let context = make_test_context(None);
6840 let caps = vec![Capability::SelfConfig];
6841 let input = serde_json::json!({});
6842 let result = execute_tool("skill_list", &input, &caps, &context)
6843 .await
6844 .unwrap();
6845 assert!(result.success);
6846 let packs = result.output["packs"].as_array().unwrap();
6847 assert!(!packs.is_empty(), "should have bundled packs");
6848 assert!(
6850 packs.iter().any(|p| p["name"] == "productivity"),
6851 "should include productivity pack"
6852 );
6853 }
6854
6855 #[tokio::test]
6856 async fn test_skill_recommend_known_pack() {
6857 let context = make_test_context(None);
6858 let caps = vec![Capability::SelfConfig];
6859 let input = serde_json::json!({"pack_name": "productivity"});
6860 let result = execute_tool("skill_recommend", &input, &caps, &context)
6861 .await
6862 .unwrap();
6863 assert!(result.success);
6864 assert_eq!(result.output["pack_name"], "productivity");
6865 assert!(
6866 result.output["install_command"]
6867 .as_str()
6868 .unwrap()
6869 .contains("punch move add productivity")
6870 );
6871 }
6872
6873 #[tokio::test]
6874 async fn test_skill_recommend_unknown_pack() {
6875 let context = make_test_context(None);
6876 let caps = vec![Capability::SelfConfig];
6877 let input = serde_json::json!({"pack_name": "nonexistent-pack"});
6878 let result = execute_tool("skill_recommend", &input, &caps, &context)
6879 .await
6880 .unwrap();
6881 assert!(!result.success);
6882 assert!(result.output["available_packs"].as_array().unwrap().len() > 0);
6883 }
6884}