1pub mod auto_fix;
2pub mod bash;
3pub mod blast_radius;
4pub mod cd;
5pub mod diagnostics;
6pub mod edit;
7pub mod file_deps;
8pub mod file_history;
9pub mod find_references;
10pub mod glob;
11pub mod grep;
12pub mod list_dir;
13pub mod list_symbols;
14pub mod open_file;
15pub mod parallel_edit;
16pub mod read;
17pub mod read_symbol;
18pub mod result_store;
19pub mod search_replace;
20pub mod todo;
21pub mod trace_callees;
22pub mod trace_callers;
23pub mod trace_chain;
24pub mod use_skill;
25pub mod web_fetch;
26pub mod web_search;
27pub mod write;
28
29use std::collections::{BTreeMap, HashMap, HashSet};
30use std::ffi::{OsStr, OsString};
31use std::path::{Component, Path, PathBuf};
32use std::sync::Arc;
33
34pub const SKIP_DIRS: &[&str] = &[
37 "node_modules",
38 ".git",
39 "target",
40 "__pycache__",
41 ".next",
42 "dist",
43 "build",
44 ".cache",
45 "vendor",
46 ".venv",
47 "venv",
48 ".idea",
49 ".vscode",
50 ".DS_Store",
51 ".env",
52 "datalog",
53 "logs",
54 "log",
55 ".atomcode",
56 ".claude",
57 "runs",
58];
59
60pub const SKIP_DIR_PREFIXES: &[&str] = &[".venv-"];
63
64pub fn should_skip_dir(name: &str) -> bool {
67 SKIP_DIRS.contains(&name) || SKIP_DIR_PREFIXES.iter().any(|p| name.starts_with(p))
68}
69
70pub fn diagnose_args(
92 tool: &str,
93 args: &str,
94 required_modes: &[&[&str]],
95 example: &str,
96) -> std::result::Result<serde_json::Value, String> {
97 let trimmed = args.trim();
98 if trimmed.is_empty() || trimmed == "{}" {
99 return Err(format!(
100 "{tool} called with empty arguments — likely max_tokens cutoff. \
101 Re-issue: {example}"
102 ));
103 }
104 let value: serde_json::Value = serde_json::from_str(args).map_err(|_| {
105 format!(
106 "{tool} arguments are not valid JSON. Re-issue: {example}"
107 )
108 })?;
109 let obj = match value.as_object() {
110 Some(o) => o,
111 None => {
112 let kind = match &value {
113 serde_json::Value::Null => "null",
114 serde_json::Value::Bool(_) => "boolean",
115 serde_json::Value::Number(_) => "number",
116 serde_json::Value::String(_) => "string",
117 serde_json::Value::Array(_) => "array",
118 serde_json::Value::Object(_) => unreachable!(),
119 };
120 return Err(format!(
121 "{tool} expected a JSON object, got {kind}. Re-issue: {example}"
122 ));
123 }
124 };
125 if required_modes
126 .iter()
127 .any(|m| m.iter().all(|k| obj.contains_key(*k)))
128 {
129 return Ok(value);
130 }
131 let provided: Vec<&str> = obj.keys().map(String::as_str).collect();
132 let (closest, missing) = required_modes
135 .iter()
136 .map(|m| {
137 let miss: Vec<&str> = m
138 .iter()
139 .filter(|k| !obj.contains_key(**k))
140 .copied()
141 .collect();
142 (*m, miss)
143 })
144 .min_by_key(|(_, miss)| miss.len())
145 .expect("required_modes must be non-empty");
146 Err(format!(
147 "{tool}: provided keys [{}], missing required [{}] for mode [{}]. \
148 Re-issue: {}",
149 provided.join(", "),
150 missing.join(", "),
151 closest.join("+"),
152 example,
153 ))
154}
155
156pub(crate) fn is_sensitive_input_path(path: &str) -> bool {
159 let base_dir = std::env::current_dir().ok();
160 let home_dir = dirs::home_dir();
161 is_sensitive_input_path_with_context(path, base_dir.as_deref(), home_dir.as_deref())
162}
163
164fn is_sensitive_input_path_with_context(
165 path: &str,
166 base_dir: Option<&Path>,
167 home_dir: Option<&Path>,
168) -> bool {
169 if is_windows_sensitive_path(path) {
170 return true;
171 }
172
173 let mut expanded = expand_home_path(path, home_dir);
174 if !expanded.is_absolute() {
175 if let Some(base_dir) = base_dir {
176 expanded = base_dir.join(expanded);
177 }
178 }
179
180 let normalized = lexical_normalize(&expanded);
181 if is_windows_sensitive_path(&normalized.to_string_lossy()) {
182 return true;
183 }
184
185 is_sensitive_path(&normalized)
186}
187
188fn expand_home_path(path: &str, home_dir: Option<&Path>) -> PathBuf {
189 if let Some(stripped) = path.strip_prefix("~/") {
190 if let Some(home_dir) = home_dir {
191 return home_dir.join(stripped);
192 }
193 }
194
195 if path == "~" {
196 if let Some(home_dir) = home_dir {
197 return home_dir.to_path_buf();
198 }
199 }
200
201 PathBuf::from(path)
202}
203
204fn lexical_normalize(path: &Path) -> PathBuf {
205 let mut prefix: Option<OsString> = None;
206 let mut has_root = false;
207 let mut parts: Vec<OsString> = Vec::new();
208
209 for component in path.components() {
210 match component {
211 Component::Prefix(prefix_component) => {
212 prefix = Some(prefix_component.as_os_str().to_os_string());
213 parts.clear();
214 }
215 Component::RootDir => {
216 has_root = true;
217 parts.clear();
218 }
219 Component::CurDir => {}
220 Component::ParentDir => {
221 if parts.last().is_some_and(|part| part != OsStr::new("..")) {
222 parts.pop();
223 } else if !has_root {
224 parts.push(OsString::from(".."));
225 }
226 }
227 Component::Normal(part) => parts.push(part.to_os_string()),
228 }
229 }
230
231 let mut normalized = PathBuf::new();
232 if let Some(prefix) = prefix {
233 normalized.push(prefix);
234 }
235 if has_root {
236 normalized.push(std::path::MAIN_SEPARATOR.to_string());
237 }
238 for part in parts {
239 normalized.push(part);
240 }
241 normalized
242}
243
244fn is_windows_sensitive_path(path: &str) -> bool {
245 let normalized = path.replace('/', "\\");
246 let normalized = normalized.strip_prefix(r"\\?\").unwrap_or(&normalized);
247 let lowercase = normalized.to_ascii_lowercase();
248 let sensitive_roots = [
249 r"\windows",
250 r"\program files",
251 r"\program files (x86)",
252 r"\programdata",
253 ];
254 let Some(path_root) = windows_rooted_path(&lowercase) else {
255 return false;
256 };
257
258 sensitive_roots
259 .iter()
260 .any(|root| windows_path_starts_with(path_root, root))
261}
262
263fn windows_path_starts_with(path: &str, root: &str) -> bool {
264 path == root
265 || path
266 .strip_prefix(root)
267 .is_some_and(|rest| rest.starts_with('\\'))
268}
269
270fn windows_rooted_path(path: &str) -> Option<&str> {
271 if let Some(path_without_drive) = strip_windows_drive_prefix(path) {
272 return Some(path_without_drive);
273 }
274
275 if path.starts_with('\\') && !path.starts_with(r"\\") {
276 return Some(path);
277 }
278
279 None
280}
281
282fn strip_windows_drive_prefix(path: &str) -> Option<&str> {
283 let bytes = path.as_bytes();
284 if bytes.len() < 3
285 || !bytes[0].is_ascii_alphabetic()
286 || bytes[1] != b':'
287 || bytes[2] != b'\\'
288 {
289 return None;
290 }
291
292 Some(&path[2..])
293}
294
295pub fn shared_prefix_len(a: &str, b: &str) -> usize {
298 a.chars().zip(b.chars()).take_while(|(x, y)| x == y).count()
299}
300
301use anyhow::{bail, Context, Result};
302use async_trait::async_trait;
303use tokio::sync::{Mutex, RwLock};
304
305pub fn real_home_dir() -> Option<PathBuf> {
315 if let Ok(sudo_user) = std::env::var("SUDO_USER") {
317 if let Some(home) = get_user_home(&sudo_user) {
319 return Some(home);
320 }
321 }
322
323 dirs::home_dir()
325}
326
327#[cfg(unix)]
330fn get_user_home(username: &str) -> Option<PathBuf> {
331 use std::ffi::CString;
332 use std::ptr;
333
334 let username_c = CString::new(username).ok()?;
337
338 unsafe {
339 let mut pwd: libc::passwd = std::mem::zeroed();
340 let mut buf = vec![0u8; 4096]; let mut result: *mut libc::passwd = ptr::null_mut();
342
343 let ret = libc::getpwnam_r(
344 username_c.as_ptr(),
345 &mut pwd,
346 buf.as_mut_ptr() as *mut libc::c_char,
347 buf.len(),
348 &mut result,
349 );
350
351 if ret == 0 && !result.is_null() {
352 let home = std::ffi::CStr::from_ptr(pwd.pw_dir)
353 .to_string_lossy()
354 .into_owned();
355 return Some(PathBuf::from(home));
356 }
357 }
358
359 None
360}
361
362#[cfg(not(unix))]
363fn get_user_home(_username: &str) -> Option<PathBuf> {
364 None
367}
368
369fn expand_user_path(path: &str) -> PathBuf {
370 if path == "~" {
371 return real_home_dir().unwrap_or_else(|| PathBuf::from(path));
372 }
373
374 if let Some(rest) = path.strip_prefix("~/") {
375 return real_home_dir()
376 .map(|home| home.join(rest))
377 .unwrap_or_else(|| PathBuf::from(path));
378 }
379
380 PathBuf::from(path)
381}
382fn normalize_path(path: &Path) -> PathBuf {
383 let mut normalized = PathBuf::new();
384
385 for component in path.components() {
386 match component {
387 Component::CurDir => {}
388 Component::ParentDir => {
389 let can_pop = normalized
390 .components()
391 .next_back()
392 .is_some_and(|last| matches!(last, Component::Normal(_)));
393 if can_pop {
394 normalized.pop();
395 } else if normalized.as_os_str().is_empty() {
396 normalized.push(component.as_os_str());
397 }
398 }
399 Component::RootDir | Component::Prefix(_) | Component::Normal(_) => {
400 normalized.push(component.as_os_str());
401 }
402 }
403 }
404
405 normalized
406}
407
408fn canonicalize_candidate_path(path: &Path) -> Result<PathBuf> {
409 if path.exists() {
410 return std::fs::canonicalize(path)
411 .with_context(|| format!("Failed to resolve path {}", path.display()));
412 }
413
414 let mut missing_parts = Vec::new();
415 let mut current = path;
416
417 loop {
418 if current.exists() {
419 let mut resolved = std::fs::canonicalize(current)
420 .with_context(|| format!("Failed to resolve parent path {}", current.display()))?;
421 for part in missing_parts.iter().rev() {
422 resolved.push(part);
423 }
424 return Ok(resolved);
425 }
426
427 let name = current.file_name().ok_or_else(|| {
428 anyhow::anyhow!("Path {} has no existing parent directory", path.display())
429 })?;
430 missing_parts.push(name.to_os_string());
431 current = current.parent().ok_or_else(|| {
432 anyhow::anyhow!("Path {} has no existing parent directory", path.display())
433 })?;
434 }
435}
436
437pub struct ResolvedPath {
438 pub path: PathBuf,
439 pub workspace_root: PathBuf,
440 pub within_workspace: bool,
441}
442
443#[derive(Clone, Copy, Debug, Eq, PartialEq)]
444pub enum ExternalPathAction {
445 Enumerate,
446 Read,
447 Write,
448}
449
450pub fn inspect_path_access(raw_path: &str, working_dir: &Path) -> Result<ResolvedPath> {
451 let workspace_root = std::fs::canonicalize(working_dir).with_context(|| {
452 format!(
453 "Failed to resolve working directory {}",
454 working_dir.display()
455 )
456 })?;
457 let expanded = expand_user_path(raw_path);
458 let candidate = if expanded.is_absolute() {
459 expanded
460 } else {
461 working_dir.join(expanded)
462 };
463 let candidate = normalize_path(&candidate);
464 let resolved = canonicalize_candidate_path(&candidate)?;
465
466 Ok(ResolvedPath {
467 within_workspace: resolved.starts_with(&workspace_root),
468 path: resolved,
469 workspace_root,
470 })
471}
472
473pub fn resolve_workspace_path(raw_path: &str, working_dir: &Path) -> Result<PathBuf> {
474 let resolved = inspect_path_access(raw_path, working_dir)?;
475 if resolved.within_workspace {
476 Ok(resolved.path)
477 } else {
478 bail!(
479 "Access denied: {} resolves outside working directory {}",
480 raw_path,
481 resolved.workspace_root.display()
482 );
483 }
484}
485
486fn is_sensitive_path(path: &Path) -> bool {
487 const SYSTEM_PROTECTED_PREFIXES: &[&str] = &[
488 "/System",
489 "/bin",
490 "/sbin",
491 "/usr",
492 "/var",
493 "/private/etc",
494 "/private/var",
495 "/etc",
496 "/root",
497 "/var/root",
498 "/private/var/root",
499 ];
500 const SYSTEM_PROTECTED_EXCEPTIONS: &[&str] = &[
501 "/usr/local",
502 "/private/usr/local",
503 "/Applications",
504 "/Library",
505 "/var/folders",
506 "/private/var/folders",
507 "/var/tmp",
508 "/private/var/tmp",
509 ];
510 const SECRET_HOME_DIRS: &[&str] = &[".ssh", ".aws", ".gnupg", ".config"];
511 const SECRET_FILE_NAMES: &[&str] = &[
512 ".bashrc",
513 ".bash_profile",
514 ".zshrc",
515 ".zprofile",
516 ".zshenv",
517 ".npmrc",
518 ".pypirc",
519 ".env",
520 ".env.local",
521 "credentials",
522 "config",
523 "id_rsa",
524 "id_dsa",
525 "id_ecdsa",
526 "id_ed25519",
527 ];
528 const SECRET_EXTS: &[&str] = &["pem", "key", "p12", "pfx", "der", "crt", "cer"];
529
530 let has_protected_prefix = SYSTEM_PROTECTED_PREFIXES
531 .iter()
532 .any(|prefix| path == Path::new(prefix) || path.starts_with(prefix));
533 let has_exception_prefix = SYSTEM_PROTECTED_EXCEPTIONS
534 .iter()
535 .any(|prefix| path == Path::new(prefix) || path.starts_with(prefix));
536
537 if has_protected_prefix && !has_exception_prefix {
538 return true;
539 }
540
541 if let Some(home) = real_home_dir() {
542 for dir in SECRET_HOME_DIRS {
543 if path.starts_with(home.join(dir)) {
544 return true;
545 }
546 }
547
548 for file in SECRET_FILE_NAMES {
549 if path == home.join(file) {
550 return true;
551 }
552 }
553 }
554
555 if path
556 .file_name()
557 .and_then(|n| n.to_str())
558 .is_some_and(|name| SECRET_FILE_NAMES.contains(&name))
559 {
560 return true;
561 }
562 path.extension()
563 .and_then(|ext| ext.to_str())
564 .is_some_and(|ext| {
565 SECRET_EXTS
566 .iter()
567 .any(|candidate| ext.eq_ignore_ascii_case(candidate))
568 })
569}
570
571pub fn approval_for_path(
572 raw_path: &str,
573 working_dir: &Path,
574 action: ExternalPathAction,
575) -> Result<ApprovalRequirement> {
576 let access = inspect_path_access(raw_path, working_dir)?;
577 if access.within_workspace {
578 return Ok(ApprovalRequirement::AutoApprove);
579 }
580
581 let sensitive = is_sensitive_path(&access.path);
582 let action_label = match action {
583 ExternalPathAction::Enumerate => "Accessing",
584 ExternalPathAction::Read => "Reading",
585 ExternalPathAction::Write => "Writing",
586 };
587 let base_reason = format!(
588 "{} path outside working directory: {} (working dir: {})",
589 action_label,
590 raw_path,
591 access.workspace_root.display()
592 );
593
594 Ok(match action {
595 ExternalPathAction::Enumerate => {
596 if sensitive {
597 ApprovalRequirement::RequireApprovalAlways(format!(
598 "{}. This path looks sensitive and always requires confirmation.",
599 base_reason
600 ))
601 } else {
602 ApprovalRequirement::AutoApprove
603 }
604 }
605 ExternalPathAction::Read => {
606 if sensitive {
607 ApprovalRequirement::RequireApprovalAlways(format!(
608 "{}. This path looks sensitive and always requires confirmation.",
609 base_reason
610 ))
611 } else {
612 ApprovalRequirement::RequireApproval(format!("{base_reason}."))
613 }
614 }
615 ExternalPathAction::Write => ApprovalRequirement::RequireApprovalAlways(format!(
616 "{}. Writing outside the workspace always requires confirmation.",
617 base_reason
618 )),
619 })
620}
621
622#[derive(Debug, Clone)]
623pub struct ToolDef {
624 pub name: &'static str,
625 pub description: String,
626 pub parameters: serde_json::Value,
627}
628
629#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
630pub struct ToolCall {
631 pub id: String,
632 pub name: String,
633 pub arguments: String,
634}
635
636#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
637pub struct ToolResult {
638 pub call_id: String,
639 pub output: String,
640 pub success: bool,
641}
642
643#[derive(Debug, Clone)]
644pub struct ToolCallBuffer {
645 pub id: String,
646 pub name: String,
647 pub arguments: String,
648 pub hint_sent: bool,
650}
651
652pub enum ApprovalRequirement {
653 AutoApprove,
654 RequireApproval(String),
655 RequireApprovalAlways(String),
656}
657
658#[derive(Debug, Clone, PartialEq)]
660pub enum PermissionLevel {
661 AlwaysAllow,
663 Ask,
665 SessionAllow,
667 AlwaysDeny,
669}
670
671#[derive(Debug, Clone)]
673pub enum PermissionDecision {
674 Allow,
675 Ask(String),
677 Deny,
678}
679
680pub struct PermissionStore {
682 overrides: HashMap<String, PermissionLevel>,
684 session_grants: HashSet<String>,
686}
687
688impl PermissionStore {
689 pub fn new() -> Self {
690 Self {
691 overrides: HashMap::new(),
692 session_grants: HashSet::new(),
693 }
694 }
695
696 pub fn check(&self, tool_name: &str, approval: &ApprovalRequirement) -> PermissionDecision {
698 if let ApprovalRequirement::RequireApprovalAlways(reason) = approval {
699 return PermissionDecision::Ask(reason.clone());
700 }
701
702 if self.session_grants.contains(tool_name) {
707 return PermissionDecision::Allow;
708 }
709
710 if let ApprovalRequirement::RequireApproval(reason) = approval {
712 return PermissionDecision::Ask(reason.clone());
713 }
714 if let Some(level) = self.overrides.get(tool_name) {
716 match level {
717 PermissionLevel::AlwaysAllow | PermissionLevel::SessionAllow => {
718 return PermissionDecision::Allow;
719 }
720 PermissionLevel::AlwaysDeny => return PermissionDecision::Deny,
721 PermissionLevel::Ask => {} }
723 }
724
725 PermissionDecision::Allow
727 }
728
729 pub fn grant_session(&mut self, tool_name: &str) {
731 self.session_grants.insert(tool_name.to_string());
732 }
733
734 pub fn set_override(&mut self, tool_name: &str, level: PermissionLevel) {
736 self.overrides.insert(tool_name.to_string(), level);
737 }
738}
739
740pub type ReadCacheKey = (PathBuf, Option<usize>, Option<usize>);
744
745pub type ReadCacheEntry = (std::time::SystemTime, String, usize);
757
758#[derive(Clone)]
760pub struct ToolContext {
761 pub working_dir: Arc<RwLock<PathBuf>>,
762 pub semantic: Arc<Mutex<crate::semantic::SemanticSearcher>>,
763 pub file_history: Arc<Mutex<file_history::FileHistory>>,
764 pub graph: Arc<RwLock<crate::graph::CodeGraph>>,
765 pub ctx_budget_hint: Arc<std::sync::atomic::AtomicUsize>,
768 pub read_budget_tokens: Arc<std::sync::atomic::AtomicUsize>,
772 pub read_cache: Arc<RwLock<std::collections::HashMap<ReadCacheKey, ReadCacheEntry>>>,
776 pub first_error_signatures: Arc<RwLock<Vec<String>>>,
791 pub telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
793 pub lsp: Option<std::sync::Arc<crate::lsp::manager::LspManager>>,
795 pub event_tx: Option<Arc<tokio::sync::mpsc::UnboundedSender<crate::turn::event::TurnEvent>>>,
798 pub current_call_id: Option<String>,
800 pub tool_registry: Option<Arc<ToolRegistry>>,
808 pub file_store: Arc<RwLock<crate::ctx::file_store::FileStore>>,
816}
817
818impl ToolContext {
819 pub fn new(working_dir: PathBuf) -> Self {
822 let telemetry = disabled_telemetry();
823 Self::with_telemetry(working_dir, "default", telemetry)
824 }
825
826 pub fn with_session(working_dir: PathBuf, session_id: &str) -> Self {
827 let telemetry = disabled_telemetry();
828 Self::with_telemetry(working_dir, session_id, telemetry)
829 }
830
831 pub fn with_telemetry(
832 working_dir: PathBuf,
833 session_id: &str,
834 telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
835 ) -> Self {
836 Self {
837 working_dir: Arc::new(RwLock::new(working_dir)),
838 semantic: Arc::new(Mutex::new(crate::semantic::SemanticSearcher::new())),
839 file_history: Arc::new(Mutex::new(file_history::FileHistory::new(session_id))),
840 ctx_budget_hint: Arc::new(std::sync::atomic::AtomicUsize::new(usize::MAX)),
841 read_budget_tokens: Arc::new(std::sync::atomic::AtomicUsize::new(usize::MAX)),
842 graph: Arc::new(RwLock::new(crate::graph::CodeGraph::new())),
843 read_cache: Arc::new(RwLock::new(std::collections::HashMap::new())),
844 first_error_signatures: Arc::new(RwLock::new(Vec::new())),
845 telemetry,
846 lsp: None,
847 event_tx: None,
848 current_call_id: None,
849 tool_registry: None,
850 file_store: Arc::new(RwLock::new(crate::ctx::file_store::FileStore::new())),
851 }
852 }
853
854 pub async fn isolate(&self) -> Self {
857 let wd = self.working_dir.read().await.clone();
858 let mut ctx = Self::new(wd);
859 ctx.graph = self.graph.clone();
860 ctx.telemetry = self.telemetry.clone();
861 ctx.lsp = self.lsp.clone();
862 ctx.file_store = self.file_store.clone();
866 ctx
867 }
868
869 pub async fn notify_lsp_file_changed(&self, path: &Path, content: &str) {
872 if let Some(ref lsp) = self.lsp {
873 if let Err(e) = lsp.notify_file_changed(path, content).await {
874 eprintln!(
875 "[lsp] Failed to refresh diagnostics for {}: {}",
876 path.display(),
877 e
878 );
879 }
880 }
881 }
882}
883
884fn disabled_telemetry() -> std::sync::Arc<atomcode_telemetry::Telemetry> {
887 let cfg = atomcode_telemetry::ResolvedConfig {
888 state: atomcode_telemetry::TelemetryState::Disabled("default"),
889 endpoint: "http://localhost/v1/events".into(),
890 atomcode_dir: std::path::PathBuf::from("/tmp"),
891 };
892 atomcode_telemetry::Telemetry::init(cfg, env!("CARGO_PKG_VERSION").into())
893}
894
895pub fn extract_error_signatures(output: &str) -> Vec<String> {
909 let mut lines: Vec<String> = Vec::new();
910 for line in output.lines() {
911 let trimmed = line.trim();
912 if trimmed.is_empty() {
913 continue;
914 }
915 if trimmed.starts_with('[') {
918 continue;
919 }
920 if trimmed == "STDERR:" {
921 continue;
922 }
923 if trimmed.len() < 15 {
924 continue;
925 }
926 let s: String = trimmed.chars().take(120).collect();
927 if !lines.contains(&s) {
928 lines.push(s);
929 }
930 }
931 lines.sort_by_key(|s| std::cmp::Reverse(s.len()));
934 lines.into_iter().take(5).collect()
935}
936
937#[async_trait]
938pub trait Tool: Send + Sync {
939 fn definition(&self) -> ToolDef;
940 fn approval(&self, args: &str) -> ApprovalRequirement;
941 fn approval_with_context(&self, args: &str, _ctx: &ToolContext) -> ApprovalRequirement {
942 self.approval(args)
943 }
944 async fn execute(&self, args: &str, ctx: &ToolContext) -> Result<ToolResult>;
945
946 fn validate_args(&self, _args: &str) -> std::result::Result<(), String> {
966 Ok(())
967 }
968}
969
970pub struct ToolRegistry {
971 tools: tokio::sync::RwLock<BTreeMap<String, Arc<dyn Tool>>>,
976}
977
978impl ToolRegistry {
979 pub fn new() -> Self {
980 Self {
981 tools: tokio::sync::RwLock::new(BTreeMap::new()),
982 }
983 }
984
985 pub async fn register(&self, tool: Box<dyn Tool>) {
987 let name = tool.definition().name.to_string();
988 let mut tools = self.tools.write().await;
989 tools.insert(name, Arc::from(tool));
990 }
991
992 pub fn register_sync(&mut self, tool: Box<dyn Tool>) {
995 let name = tool.definition().name.to_string();
996 self.tools.get_mut().insert(name, Arc::from(tool));
997 }
998
999 pub async fn get_definitions(&self) -> Vec<ToolDef> {
1001 let tools = self.tools.read().await;
1002 tools.values().map(|t| t.definition()).collect()
1003 }
1004
1005 pub async fn get(&self, name: &str) -> Option<Arc<dyn Tool>> {
1007 let tools = self.tools.read().await;
1008 tools.get(name).cloned()
1009 }
1010
1011 pub async fn iter(&self) -> impl Iterator<Item = (String, Arc<dyn Tool>)> {
1013 let tools = self.tools.read().await;
1014 tools.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>().into_iter()
1015 }
1016
1017 pub async fn register_arc(&self, name: String, tool: Arc<dyn Tool>) {
1019 let mut tools = self.tools.write().await;
1020 tools.insert(name, tool);
1021 }
1022
1023 pub async fn expected_top_keys(&self, name: &str) -> Vec<String> {
1029 let tools = self.tools.read().await;
1030 let Some(tool) = tools.get(name) else { return Vec::new() };
1031 let def = tool.definition();
1032 def.parameters
1033 .get("properties")
1034 .and_then(|p| p.as_object())
1035 .map(|o| o.keys().cloned().collect())
1036 .unwrap_or_default()
1037 }
1038
1039 pub async fn unregister_prefix(&self, prefix: &str) -> usize {
1044 let mut tools = self.tools.write().await;
1045 let to_remove: Vec<String> = tools
1046 .keys()
1047 .filter(|k| k.starts_with(prefix))
1048 .cloned()
1049 .collect();
1050 let n = to_remove.len();
1051 for k in to_remove {
1052 tools.remove(&k);
1053 }
1054 n
1055 }
1056
1057}
1058
1059const ARGS_WRAPPER_KEYS: &[&str] = &["arguments", "input", "content"];
1064
1065pub fn recover_tool_args(raw: &str, expected_top_keys: &[String]) -> Option<String> {
1099 let mut value: serde_json::Value = serde_json::from_str(raw).ok()?;
1100 if !value.is_object() {
1101 return None;
1102 }
1103
1104 if !expected_top_keys.is_empty() && all_keys_in_expected(&value, expected_top_keys) {
1118 return None;
1119 }
1120
1121 let mut progressed = false;
1123 for _ in 0..5 {
1124 match try_unwrap_once(value, expected_top_keys) {
1125 UnwrapStep::Stable(v) => {
1126 value = v;
1127 break;
1128 }
1129 UnwrapStep::Progressed(v) => {
1130 value = v;
1131 progressed = true;
1132 }
1133 }
1134 }
1135
1136 if !progressed {
1139 return None;
1140 }
1141
1142 if !expected_top_keys.is_empty() && !has_expected_key(&value, expected_top_keys) {
1147 return None;
1148 }
1149 if has_wrapper_shape(&value) {
1150 return None;
1152 }
1153 serde_json::to_string(&value).ok()
1154}
1155
1156fn has_expected_key(v: &serde_json::Value, expected: &[String]) -> bool {
1157 let Some(map) = v.as_object() else { return false };
1158 expected.iter().any(|k| map.contains_key(k.as_str()))
1159}
1160
1161fn all_keys_in_expected(v: &serde_json::Value, expected: &[String]) -> bool {
1167 let Some(map) = v.as_object() else { return false };
1168 if map.is_empty() {
1169 return false;
1170 }
1171 map.keys().all(|k| expected.iter().any(|e| e == k))
1172}
1173
1174fn has_wrapper_shape(v: &serde_json::Value) -> bool {
1175 let Some(map) = v.as_object() else { return false };
1176 ARGS_WRAPPER_KEYS.iter().any(|k| {
1177 map.get(*k).is_some_and(|inner| {
1178 if inner.is_object() {
1181 return true;
1182 }
1183 if let Some(s) = inner.as_str() {
1184 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
1185 return parsed.is_object();
1186 }
1187 }
1188 false
1189 })
1190 })
1191}
1192
1193enum UnwrapStep {
1194 Progressed(serde_json::Value),
1195 Stable(serde_json::Value),
1196}
1197
1198fn try_unwrap_once(value: serde_json::Value, expected: &[String]) -> UnwrapStep {
1199 let Some(map) = value.as_object() else {
1200 return UnwrapStep::Stable(value);
1201 };
1202
1203 let mut wrapper_key: Option<&str> = None;
1205 let mut inner_obj: Option<serde_json::Value> = None;
1206 for &k in ARGS_WRAPPER_KEYS {
1207 let Some(v) = map.get(k) else { continue };
1208 if let Some(obj) = v.as_object() {
1209 wrapper_key = Some(k);
1210 inner_obj = Some(serde_json::Value::Object(obj.clone()));
1211 break;
1212 }
1213 if let Some(s) = v.as_str() {
1214 if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(s) {
1215 if parsed.is_object() {
1216 wrapper_key = Some(k);
1217 inner_obj = Some(parsed);
1218 break;
1219 }
1220 }
1221 }
1222 }
1223
1224 let (Some(wk), Some(mut inner)) = (wrapper_key, inner_obj) else {
1225 return UnwrapStep::Stable(value);
1226 };
1227
1228 if let Some(inner_map) = inner.as_object_mut() {
1233 for (k, v) in map.iter() {
1234 if k == wk {
1235 continue;
1236 }
1237 if expected.iter().any(|e| e == k) && !inner_map.contains_key(k) {
1238 inner_map.insert(k.clone(), v.clone());
1239 }
1240 }
1241 }
1242
1243 UnwrapStep::Progressed(inner)
1244}
1245
1246#[cfg(test)]
1247mod tests {
1248 use super::*;
1249 use tempfile::TempDir;
1250
1251 struct DummyTool;
1252
1253 #[async_trait::async_trait]
1254 impl Tool for DummyTool {
1255 fn definition(&self) -> ToolDef {
1256 ToolDef {
1257 name: "dummy",
1258 description: "A dummy tool".to_string(),
1259 parameters: serde_json::json!({
1260 "type": "object",
1261 "properties": {},
1262 }),
1263 }
1264 }
1265
1266 fn approval(&self, _args: &str) -> ApprovalRequirement {
1267 ApprovalRequirement::AutoApprove
1268 }
1269
1270 async fn execute(&self, _args: &str, _ctx: &ToolContext) -> anyhow::Result<ToolResult> {
1271 Ok(ToolResult {
1272 call_id: "test".to_string(),
1273 output: "ok".to_string(),
1274 success: true,
1275 })
1276 }
1277 }
1278
1279 #[tokio::test]
1280 async fn test_registry_register_and_get() {
1281 let reg = ToolRegistry::new();
1282 reg.register(Box::new(DummyTool)).await;
1283 assert!(reg.get("dummy").await.is_some());
1284 assert!(reg.get("nonexistent").await.is_none());
1285 }
1286
1287 #[tokio::test]
1288 async fn test_registry_definitions() {
1289 let reg = ToolRegistry::new();
1290 reg.register(Box::new(DummyTool)).await;
1291 let defs = reg.get_definitions().await;
1292 assert_eq!(defs.len(), 1);
1293 assert_eq!(defs[0].name, "dummy");
1294 }
1295
1296 #[test]
1297 fn sensitive_path_detects_relative_traversal_to_unix_root() {
1298 assert!(is_sensitive_input_path_with_context(
1299 "../../../etc/passwd",
1300 Some(Path::new("/home/alice/project")),
1301 Some(Path::new("/home/alice")),
1302 ));
1303 }
1304
1305 #[test]
1306 fn sensitive_path_detects_windows_system_roots() {
1307 assert!(is_sensitive_input_path_with_context(
1308 r"C:\Windows\System32\drivers\etc\hosts",
1309 None,
1310 None,
1311 ));
1312 assert!(is_sensitive_input_path_with_context(
1313 r"D:\Windows\System32\drivers\etc\hosts",
1314 None,
1315 None,
1316 ));
1317 assert!(is_sensitive_input_path_with_context(
1318 r"\Windows\System32\drivers\etc\hosts",
1319 None,
1320 None,
1321 ));
1322 assert!(is_sensitive_input_path_with_context(
1323 r"C:\Program Files\AtomCode\config.toml",
1324 None,
1325 None,
1326 ));
1327 assert!(is_sensitive_input_path_with_context(
1328 r"C:\ProgramData\AtomCode\config.toml",
1329 None,
1330 None,
1331 ));
1332 }
1333
1334 #[test]
1335 fn sensitive_path_uses_path_boundaries() {
1336 assert!(!is_sensitive_input_path_with_context(
1337 "/etc-old/passwd",
1338 None,
1339 None,
1340 ));
1341 assert!(!is_sensitive_input_path_with_context(
1342 r"C:\Windows.old\system.ini",
1343 None,
1344 None,
1345 ));
1346 assert!(!is_sensitive_input_path_with_context(
1347 r"D:\Windows.old\system.ini",
1348 None,
1349 None,
1350 ));
1351 assert!(!is_sensitive_input_path_with_context(
1352 r"\Windows.old\system.ini",
1353 None,
1354 None,
1355 ));
1356 assert!(!is_sensitive_input_path_with_context(
1357 r"\\server\share\Windows\system.ini",
1358 None,
1359 None,
1360 ));
1361 }
1362
1363 #[tokio::test]
1364 async fn test_tool_execute() {
1365 let tool = DummyTool;
1366 let ctx = ToolContext::new(std::env::current_dir().unwrap());
1367 let result = tool.execute("{}", &ctx).await.unwrap();
1368 assert!(result.success);
1369 assert_eq!(result.output, "ok");
1370 }
1371
1372 #[test]
1373 fn resolve_workspace_path_rejects_parent_escape() {
1374 let workspace = TempDir::new().unwrap();
1375 let outside = TempDir::new().unwrap();
1376 let path = format!("{}/secret.txt", outside.path().display());
1377 std::fs::write(outside.path().join("secret.txt"), "top-secret").unwrap();
1378
1379 let err = resolve_workspace_path(&path, workspace.path()).unwrap_err();
1380 assert!(err.to_string().contains("outside working directory"));
1381 }
1382
1383 #[cfg(unix)]
1384 #[test]
1385 fn resolve_workspace_path_rejects_symlink_escape() {
1386 let workspace = TempDir::new().unwrap();
1387 let outside = TempDir::new().unwrap();
1388 let target = outside.path().join("secret.txt");
1389 std::fs::write(&target, "top-secret").unwrap();
1390 let link = workspace.path().join("secret-link");
1391 std::os::unix::fs::symlink(&target, &link).unwrap();
1392
1393 let err =
1394 resolve_workspace_path(link.to_string_lossy().as_ref(), workspace.path()).unwrap_err();
1395 assert!(err.to_string().contains("outside working directory"));
1396 }
1397
1398 #[test]
1399 fn inspect_path_access_marks_workspace_escape() {
1400 let workspace = TempDir::new().unwrap();
1401 let outside = TempDir::new().unwrap();
1402 let target = outside.path().join("secret.txt");
1403 std::fs::write(&target, "top-secret").unwrap();
1404
1405 let access = inspect_path_access(&target.to_string_lossy(), workspace.path()).unwrap();
1406 assert!(!access.within_workspace);
1407 assert_eq!(access.path, target.canonicalize().unwrap());
1411 }
1412
1413 #[test]
1414 fn approval_for_non_sensitive_enumeration_outside_workspace_is_auto() {
1415 let workspace = TempDir::new().unwrap();
1416 let outside = TempDir::new().unwrap();
1417
1418 let approval = approval_for_path(
1419 &outside.path().to_string_lossy(),
1420 workspace.path(),
1421 ExternalPathAction::Enumerate,
1422 )
1423 .unwrap();
1424 assert!(matches!(approval, ApprovalRequirement::AutoApprove));
1425 }
1426
1427 #[test]
1428 fn approval_for_non_sensitive_read_outside_workspace_requires_confirmation() {
1429 let workspace = TempDir::new().unwrap();
1430 let outside = TempDir::new().unwrap();
1431 let target = outside.path().join("notes.txt");
1432 std::fs::write(&target, "hello").unwrap();
1433
1434 let approval = approval_for_path(
1435 &target.to_string_lossy(),
1436 workspace.path(),
1437 ExternalPathAction::Read,
1438 )
1439 .unwrap();
1440 assert!(matches!(approval, ApprovalRequirement::RequireApproval(_)));
1441 }
1442
1443 #[test]
1444 fn approval_for_sensitive_read_outside_workspace_requires_always() {
1445 let workspace = TempDir::new().unwrap();
1446 let outside = TempDir::new().unwrap();
1447 let target = outside.path().join("id_rsa");
1448 std::fs::write(&target, "private-key").unwrap();
1449
1450 let approval = approval_for_path(
1451 &target.to_string_lossy(),
1452 workspace.path(),
1453 ExternalPathAction::Read,
1454 )
1455 .unwrap();
1456 assert!(matches!(
1457 approval,
1458 ApprovalRequirement::RequireApprovalAlways(_)
1459 ));
1460 }
1461
1462 #[test]
1463 fn approval_for_system_protected_prefix_requires_always() {
1464 assert!(is_sensitive_path(Path::new(
1465 "/System/Library/CoreServices/boot.efi"
1466 )));
1467 }
1468
1469 #[test]
1470 fn approval_for_usr_local_exception_is_not_sensitive() {
1471 assert!(!is_sensitive_path(Path::new("/usr/local/bin/tool")));
1472 }
1473
1474 #[test]
1475 fn approval_for_private_var_prefix_requires_always() {
1476 assert!(is_sensitive_path(Path::new("/private/var/db/config")));
1477 }
1478
1479 #[test]
1480 fn approval_for_private_var_folders_exception_is_not_sensitive() {
1481 assert!(!is_sensitive_path(Path::new(
1482 "/private/var/folders/xx/yy/T/file.txt"
1483 )));
1484 }
1485
1486 #[test]
1487 fn approval_for_write_outside_workspace_requires_always() {
1488 let workspace = TempDir::new().unwrap();
1489 let outside = TempDir::new().unwrap();
1490 let target = outside.path().join("notes.txt");
1491
1492 let approval = approval_for_path(
1493 &target.to_string_lossy(),
1494 workspace.path(),
1495 ExternalPathAction::Write,
1496 )
1497 .unwrap();
1498 assert!(matches!(
1499 approval,
1500 ApprovalRequirement::RequireApprovalAlways(_)
1501 ));
1502 }
1503
1504 #[tokio::test]
1505 async fn read_file_requests_approval_for_workspace_escape() {
1506 let workspace = TempDir::new().unwrap();
1507 let outside = TempDir::new().unwrap();
1508 let target = outside.path().join("secret.txt");
1509 std::fs::write(&target, "top-secret").unwrap();
1510
1511 let tool = crate::tool::read::ReadFileTool;
1512 let ctx = ToolContext::new(workspace.path().to_path_buf());
1513 let args = format!(r#"{{"file_path":"{}"}}"#, target.display());
1514
1515 assert!(matches!(
1516 tool.approval_with_context(&args, &ctx),
1517 ApprovalRequirement::RequireApproval(_)
1518 ));
1519 }
1520
1521 #[tokio::test]
1522 async fn edit_file_requests_approval_for_workspace_escape() {
1523 let workspace = TempDir::new().unwrap();
1524 let outside = TempDir::new().unwrap();
1525 let target = outside.path().join("secret.txt");
1526 std::fs::write(&target, "top-secret").unwrap();
1527
1528 let tool = crate::tool::edit::EditFileTool;
1529 let ctx = ToolContext::new(workspace.path().to_path_buf());
1530 let args = format!(
1531 r#"{{"file_path":"{}","old_string":"top-secret","new_string":"changed"}}"#,
1532 target.display()
1533 );
1534
1535 assert!(matches!(
1536 tool.approval_with_context(&args, &ctx),
1537 ApprovalRequirement::RequireApprovalAlways(_)
1538 ));
1539 }
1540
1541 #[test]
1544 fn test_permission_store_auto_approve() {
1545 let store = PermissionStore::new();
1546 let decision = store.check("bash", &ApprovalRequirement::AutoApprove);
1547 assert!(matches!(decision, PermissionDecision::Allow));
1548 }
1549
1550 #[test]
1551 fn test_permission_store_require_approval() {
1552 let store = PermissionStore::new();
1553 let decision = store.check(
1554 "bash",
1555 &ApprovalRequirement::RequireApproval("Destructive".into()),
1556 );
1557 assert!(matches!(decision, PermissionDecision::Ask(_)));
1558 }
1559
1560 #[test]
1561 fn test_permission_store_session_grant_bypasses_destructive() {
1562 let mut store = PermissionStore::new();
1566 store.grant_session("bash");
1567 let decision = store.check(
1568 "bash",
1569 &ApprovalRequirement::RequireApproval("Destructive".into()),
1570 );
1571 assert!(matches!(decision, PermissionDecision::Allow));
1572 }
1573
1574 #[test]
1575 fn test_permission_store_session_grant_does_not_bypass_require_approval_always() {
1576 let mut store = PermissionStore::new();
1577 store.grant_session("bash");
1578 let decision = store.check(
1579 "bash",
1580 &ApprovalRequirement::RequireApprovalAlways("Sensitive".into()),
1581 );
1582 assert!(matches!(decision, PermissionDecision::Ask(_)));
1583 }
1584
1585 #[test]
1586 fn test_permission_store_session_grant_allows_auto_approve() {
1587 let mut store = PermissionStore::new();
1589 store.grant_session("bash");
1590 let decision = store.check("bash", &ApprovalRequirement::AutoApprove);
1591 assert!(matches!(decision, PermissionDecision::Allow));
1592 }
1593
1594 #[test]
1595 fn test_permission_store_always_deny_override() {
1596 let mut store = PermissionStore::new();
1597 store.set_override("bash", PermissionLevel::AlwaysDeny);
1598 let decision = store.check("bash", &ApprovalRequirement::AutoApprove);
1600 assert!(matches!(decision, PermissionDecision::Deny));
1601 }
1602
1603 #[test]
1604 fn test_permission_store_always_allow_cannot_bypass_destructive() {
1605 let mut store = PermissionStore::new();
1607 store.set_override("bash", PermissionLevel::AlwaysAllow);
1608 let decision = store.check(
1609 "bash",
1610 &ApprovalRequirement::RequireApproval("Destructive".into()),
1611 );
1612 assert!(matches!(decision, PermissionDecision::Ask(_)));
1613 }
1614
1615 #[tokio::test]
1616 async fn test_tool_context_isolate() {
1617 let ctx = ToolContext::new(PathBuf::from("/original"));
1618 let isolated = ctx.isolate().await;
1619 *isolated.working_dir.write().await = PathBuf::from("/changed");
1621 let original_wd = ctx.working_dir.read().await.clone();
1622 assert_eq!(original_wd, PathBuf::from("/original"));
1623 }
1624
1625 #[tokio::test]
1626 async fn test_registry_iter() {
1627 let reg = ToolRegistry::new();
1628 reg.register(Box::new(DummyTool)).await;
1629 let items: Vec<_> = reg.iter().await.collect();
1630 assert_eq!(items.len(), 1);
1631 assert_eq!(items[0].0, "dummy");
1632 }
1633
1634 #[tokio::test]
1635 async fn test_registry_register_arc() {
1636 let reg1 = ToolRegistry::new();
1637 reg1.register(Box::new(DummyTool)).await;
1638 let reg2 = ToolRegistry::new();
1639 for (name, arc) in reg1.iter().await {
1640 reg2.register_arc(name, arc).await;
1641 }
1642 assert!(reg2.get("dummy").await.is_some());
1643 }
1644
1645 #[test]
1646 fn test_permission_store_session_grant_only_affects_named_tool() {
1647 let mut store = PermissionStore::new();
1648 store.grant_session("bash");
1649 let decision = store.check(
1651 "create_file",
1652 &ApprovalRequirement::RequireApproval("write".into()),
1653 );
1654 assert!(matches!(decision, PermissionDecision::Ask(_)));
1655 }
1656
1657 fn cmd_keys() -> Vec<String> {
1658 vec!["command".into(), "timeout".into()]
1659 }
1660 fn read_keys() -> Vec<String> {
1661 vec!["file_path".into(), "offset".into(), "limit".into()]
1662 }
1663 fn grep_keys() -> Vec<String> {
1664 vec!["pattern".into(), "path".into(), "max_results".into(), "context".into()]
1665 }
1666 fn write_keys() -> Vec<String> {
1667 vec!["file_path".into(), "content".into()]
1668 }
1669 fn todo_keys() -> Vec<String> {
1670 vec!["action".into(), "content".into(), "id".into()]
1671 }
1672
1673 fn parse(s: &str) -> serde_json::Value {
1674 serde_json::from_str(s).unwrap()
1675 }
1676
1677 #[test]
1678 fn recover_flat_passes_through() {
1679 let raw = r#"{"command":"ls -la"}"#;
1681 assert!(recover_tool_args(raw, &cmd_keys()).is_none());
1682 }
1683
1684 #[test]
1685 fn recover_variant_a1_string_inner() {
1686 let raw = r#"{"arguments":"{\"command\":\"ls\"}"}"#;
1688 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1689 assert_eq!(parse(&recovered)["command"], "ls");
1690 }
1691
1692 #[test]
1693 fn recover_variant_a2_object_inner() {
1694 let raw = r#"{"arguments":{"command":"ls","timeout":30}}"#;
1696 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1697 let v = parse(&recovered);
1698 assert_eq!(v["command"], "ls");
1699 assert_eq!(v["timeout"], 30);
1700 }
1701
1702 #[test]
1703 fn recover_variant_b_double_string() {
1704 let raw = r#"{"arguments":"{\"arguments\":\"{\\\"command\\\":\\\"ls\\\"}\"}"}"#;
1706 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1707 assert_eq!(parse(&recovered)["command"], "ls");
1708 }
1709
1710 #[test]
1711 fn recover_variant_b_triple_object() {
1712 let raw = r#"{"arguments":{"arguments":{"command":"ls"}}}"#;
1714 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1715 assert_eq!(parse(&recovered)["command"], "ls");
1716 }
1717
1718 #[test]
1719 fn recover_variant_c_multi_key_merges_siblings() {
1720 let raw = r#"{"arguments":"{\"command\":\"ls\"}","timeout":120}"#;
1724 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1725 let v = parse(&recovered);
1726 assert_eq!(v["command"], "ls");
1727 assert_eq!(v["timeout"], 120);
1728 }
1729
1730 #[test]
1731 fn recover_variant_d_content_wrapper() {
1732 let raw = r#"{"content":"{\"pattern\":\"foo\",\"path\":\"/x\"}"}"#;
1734 let recovered = recover_tool_args(raw, &grep_keys()).unwrap();
1735 let v = parse(&recovered);
1736 assert_eq!(v["pattern"], "foo");
1737 assert_eq!(v["path"], "/x");
1738 }
1739
1740 #[test]
1741 fn recover_variant_d_input_wrapper() {
1742 let raw = r#"{"input":{"file_path":"/tmp/a.rs"}}"#;
1745 let recovered = recover_tool_args(raw, &read_keys()).unwrap();
1746 assert_eq!(parse(&recovered)["file_path"], "/tmp/a.rs");
1747 }
1748
1749 #[test]
1750 fn recover_unrecoverable_returns_none() {
1751 let raw = r#"{"arguments":{"random":"junk"}}"#;
1753 assert!(recover_tool_args(raw, &cmd_keys()).is_none());
1754 }
1755
1756 #[test]
1757 fn recover_iteration_bound_pathological_input() {
1758 let mut deep = String::from(r#"{"command":"ls"}"#);
1760 for _ in 0..100 {
1761 deep = format!(r#"{{"arguments":{}}}"#, deep);
1762 }
1763 let result = recover_tool_args(&deep, &cmd_keys());
1766 assert!(result.is_none() || result.is_some());
1769 }
1770
1771 #[test]
1772 fn recover_no_expected_keys_falls_back_permissive() {
1773 let wrapped = r#"{"arguments":{"x":1}}"#;
1776 let recovered = recover_tool_args(wrapped, &[]).unwrap();
1777 assert_eq!(parse(&recovered)["x"], 1);
1778
1779 let flat = r#"{"x":1}"#;
1780 assert!(recover_tool_args(flat, &[]).is_none());
1781 }
1782
1783 #[test]
1784 fn recover_real_datalog_payload() {
1785 let raw = r#"{"arguments": "{\"command\": \"cd /Users/lichao/project/gitcode/ai/atomcode && cargo check 2>&1 | grep -iE 'warning.*(dead_code|unused)' | head -20\"}"}"#;
1787 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1788 let v = parse(&recovered);
1789 assert!(v["command"].as_str().unwrap().contains("cargo check"));
1790 }
1791
1792 #[test]
1793 fn recover_real_bruno_object_payload() {
1794 let raw = r#"{"arguments": {"command": "grep -rn '#\\[allow(dead_code)\\]' /Users/lichao/project/gitcode/ai/atomcode/crates/ --include='*.rs' | head -50", "timeout": 10}}"#;
1796 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1797 let v = parse(&recovered);
1798 assert_eq!(v["timeout"], 10);
1799 assert!(v["command"].as_str().unwrap().contains("dead_code"));
1800 }
1801
1802 #[test]
1803 fn recover_malformed_json_returns_none() {
1804 assert!(recover_tool_args("not json", &cmd_keys()).is_none());
1805 assert!(recover_tool_args("", &cmd_keys()).is_none());
1806 assert!(recover_tool_args("[]", &cmd_keys()).is_none());
1807 }
1808
1809 #[test]
1822 fn recover_write_with_json_object_content_passthrough() {
1823 let raw = r#"{"file_path":"/tmp/x.json","content":"{\"foo\":1}"}"#;
1828 assert!(recover_tool_args(raw, &write_keys()).is_none());
1829 }
1830
1831 #[test]
1832 fn recover_write_with_nested_json_content_passthrough() {
1833 let raw = r#"{"file_path":"/tmp/cfg.json","content":"{\"a\":{\"b\":{\"c\":1}}}"}"#;
1836 assert!(recover_tool_args(raw, &write_keys()).is_none());
1837 }
1838
1839 #[test]
1840 fn recover_todo_with_json_content_passthrough() {
1841 let raw = r#"{"action":"add","content":"{\"task\":\"refactor\"}"}"#;
1844 assert!(recover_tool_args(raw, &todo_keys()).is_none());
1845 }
1846
1847 #[test]
1848 fn recover_write_genuine_wrap_still_recovered() {
1849 let raw = r#"{"arguments":{"file_path":"/tmp/x","content":"hello"}}"#;
1854 let recovered = recover_tool_args(raw, &write_keys()).unwrap();
1855 let v = parse(&recovered);
1856 assert_eq!(v["file_path"], "/tmp/x");
1857 assert_eq!(v["content"], "hello");
1858 }
1859
1860 #[test]
1861 fn recover_partial_keys_still_recoverable_via_wrapper() {
1862 let raw = r#"{"arguments":"{\"command\":\"ls\"}","foo":1}"#;
1866 let recovered = recover_tool_args(raw, &cmd_keys()).unwrap();
1867 assert_eq!(parse(&recovered)["command"], "ls");
1868 }
1869
1870 #[test]
1871 fn test_real_home_dir_returns_something() {
1872 let home = real_home_dir();
1874 assert!(home.is_some(), "real_home_dir should return Some in normal conditions");
1875 let path = home.unwrap();
1876 assert!(path.is_absolute(), "Home directory should be an absolute path");
1877 }
1878
1879 #[test]
1880 fn test_real_home_dir_with_simulated_sudo() {
1881 let original_sudo_user = std::env::var("SUDO_USER").ok();
1883 let original_home = std::env::var("HOME").ok();
1884
1885 #[cfg(unix)]
1888 {
1889 let normal_home = dirs::home_dir();
1891
1892 if let Some(ref home) = normal_home {
1895 assert!(home.is_absolute());
1897 }
1898 }
1899
1900 if let Some(orig) = original_sudo_user {
1902 std::env::set_var("SUDO_USER", orig);
1903 } else {
1904 std::env::remove_var("SUDO_USER");
1905 }
1906
1907 if let Some(orig) = original_home {
1908 std::env::set_var("HOME", orig);
1909 }
1910 }
1911
1912 #[test]
1913 fn test_expand_user_path_with_tilde() {
1914 let home = real_home_dir().unwrap();
1916 let expanded = expand_user_path("~/test");
1917 assert_eq!(expanded, home.join("test"));
1918
1919 let expanded = expand_user_path("~");
1921 assert_eq!(expanded, home);
1922
1923 let expanded = expand_user_path("/absolute/path");
1925 assert_eq!(expanded, PathBuf::from("/absolute/path"));
1926 }
1927}