Skip to main content

atomcode_core/tool/
mod.rs

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
34/// Directories to skip when scanning file trees (build artifacts, caches, VCS).
35/// Used by glob, list_dir, and collect_project_files.
36pub 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
60/// Prefixes — any directory whose name starts with one of these is skipped.
61/// Covers `.venv-*` variants (`.venv-test`, `.venv-swebench`, etc.).
62pub const SKIP_DIR_PREFIXES: &[&str] = &[".venv-"];
63
64/// Check if a directory name should be skipped (exact match OR prefix match).
65/// Use this instead of `SKIP_DIRS.contains()` for complete coverage.
66pub fn should_skip_dir(name: &str) -> bool {
67    SKIP_DIRS.contains(&name) || SKIP_DIR_PREFIXES.iter().any(|p| name.starts_with(p))
68}
69
70/// Model-friendly tool-arguments validator.
71///
72/// Why this exists: serde's "missing field `X` at line 1 column 793" error
73/// reads to weak models (GLM-5.1, Qwen) as a *parser-position* complaint and
74/// reliably triggers hallucinated "fixes" like "I should use positional
75/// arguments" — wasting a turn or six on the same tool call. See datalog
76/// `atomgr-2d99b47d/2026-05-06_08-43-12.md` Turns 64–75 for the failure
77/// mode this replaces.
78///
79/// What it returns instead, on failure:
80/// - the **keys the model actually provided**
81/// - the **keys it's missing** for the closest mode
82/// - a **one-line example** of a correct call
83///
84/// `required_modes` is a list of accepted key sets — any one fully matched
85/// passes. Single-mode tools pass `&[&[required_keys]]`. Multi-mode tools
86/// like `edit_file` pass one slice per mode; the diagnostic picks the mode
87/// with the fewest missing keys for the hint.
88///
89/// Returns the parsed `Value` on success so callers can avoid a second
90/// parse pass.
91pub 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    // Pick the mode with the fewest missing keys — that's the call shape
133    // the model was probably aiming at.
134    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
156/// Lightweight sensitive-path precheck for raw tool arguments before a
157/// workspace-aware approval pass is available.
158pub(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
295/// Count of leading characters shared between two paths. Used by read_file
296/// and glob 404 recovery to rank candidate suggestions.
297pub 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
305/// Get the real user's home directory, accounting for sudo scenarios.
306///
307/// When running under sudo, `dirs::home_dir()` returns root's home directory
308/// because $HOME is set to /root. This function checks for SUDO_USER and
309/// attempts to get the actual invoking user's home directory instead.
310///
311/// Priority:
312/// 1. If SUDO_USER is set, try to get that user's home directory
313/// 2. Fall back to dirs::home_dir() (which reads $HOME or uses system APIs)
314pub fn real_home_dir() -> Option<PathBuf> {
315    // Check if we're running under sudo
316    if let Ok(sudo_user) = std::env::var("SUDO_USER") {
317        // Try to get the home directory for the sudo user
318        if let Some(home) = get_user_home(&sudo_user) {
319            return Some(home);
320        }
321    }
322    
323    // Fall back to the standard home directory
324    dirs::home_dir()
325}
326
327/// Get the home directory for a specific user by looking up /etc/passwd (Unix)
328/// or constructing the path for the user (macOS).
329#[cfg(unix)]
330fn get_user_home(username: &str) -> Option<PathBuf> {
331    use std::ffi::CString;
332    use std::ptr;
333    
334    // SAFETY: We're calling getpwnam which is thread-safe on modern systems
335    // when using getpwnam_r
336    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]; // Buffer for string fields
341        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    // On non-Unix systems, we don't have getpwnam
365    // Fall back to trying to construct the path
366    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    /// True once we've extracted and sent a path hint — avoids resending on every delta.
649    pub hint_sent: bool,
650}
651
652pub enum ApprovalRequirement {
653    AutoApprove,
654    RequireApproval(String),
655    RequireApprovalAlways(String),
656}
657
658/// Coarse-grained permission level for a tool, stored in `PermissionStore`.
659#[derive(Debug, Clone, PartialEq)]
660pub enum PermissionLevel {
661    /// Never ask — always execute automatically.
662    AlwaysAllow,
663    /// Ask every time (default for destructive operations).
664    Ask,
665    /// Allowed for the duration of the current session.
666    SessionAllow,
667    /// Never execute.
668    AlwaysDeny,
669}
670
671/// The resolved decision returned by `PermissionStore::check`.
672#[derive(Debug, Clone)]
673pub enum PermissionDecision {
674    Allow,
675    /// Ask the user — carries the reason string from `ApprovalRequirement`.
676    Ask(String),
677    Deny,
678}
679
680/// Stores per-tool permission overrides and session-level grants.
681pub struct PermissionStore {
682    /// Per-tool level overrides: tool_name → level.
683    overrides: HashMap<String, PermissionLevel>,
684    /// Session-level grants: tool names approved with [A]lways for this session.
685    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    /// Check whether a tool call should be auto-approved, needs asking, or denied.
697    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        // 1. Session grant (user pressed [A] during this session).
703        //    This overrides RequireApproval — the user explicitly chose "Always"
704        //    for this tool, so don't prompt again. Bash still has its own
705        //    destructive-command detection as a separate safety layer.
706        if self.session_grants.contains(tool_name) {
707            return PermissionDecision::Allow;
708        }
709
710        // 2. Destructive commands (RequireApproval) prompt unless session-granted.
711        if let ApprovalRequirement::RequireApproval(reason) = approval {
712            return PermissionDecision::Ask(reason.clone());
713        }
714        // 3. Explicit per-tool override (only reached for AutoApprove tools).
715        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 => {} // fall through to normal logic
722            }
723        }
724
725        // 4. Defer to the tool's own approval requirement.
726        PermissionDecision::Allow
727    }
728
729    /// Grant session-level permission for a tool (user pressed [A]).
730    pub fn grant_session(&mut self, tool_name: &str) {
731        self.session_grants.insert(tool_name.to_string());
732    }
733
734    /// Set an explicit override level for a tool.
735    pub fn set_override(&mut self, tool_name: &str, level: PermissionLevel) {
736        self.overrides.insert(tool_name.to_string(), level);
737    }
738}
739
740/// Shared execution context passed to every tool invocation.
741/// Read cache key: (canonical path, offset, limit). offset/limit are the raw
742/// args the model sent — different slicing windows cache separately.
743pub type ReadCacheKey = (PathBuf, Option<usize>, Option<usize>);
744
745/// Read cache entry: (file mtime at cache time, rendered tool output, number of
746/// times this exact (path, offset, limit, mtime) tuple has been served).
747///
748/// The hit count drives the "you keep re-reading the same region" hint emitted
749/// by `read.rs` on cache hits — it replaced the prior `runner.rs` BLOCKED guard
750/// (deleted alongside) which was a soft-text error the model could ignore. By
751/// returning the cached content WITH a count-aware note instead of refusing the
752/// call, the framework lets the model see that the answer hasn't changed
753/// while still giving a clear "stop re-reading" signal. mtime is still the
754/// invalidation key — if disk mtime differs on next read, the entry is replaced
755/// and the count resets to 1.
756pub type ReadCacheEntry = (std::time::SystemTime, String, usize);
757
758/// Holds a shared working directory that tools can read (and `CdTool` can write).
759#[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    /// Remaining context tokens budget. Set by TurnRunner before each tool batch.
766    /// read_file uses this to decide full content vs skeleton.
767    pub ctx_budget_hint: Arc<std::sync::atomic::AtomicUsize>,
768    /// Per-file token budget for read_file. Set by runner.rs Layer B before each
769    /// tool batch: `ctx_budget / (5 * num_reads)`. read.rs compares file_tokens
770    /// against this to decide full vs skeleton. Defaults to ctx_budget/5 (single file).
771    pub read_budget_tokens: Arc<std::sync::atomic::AtomicUsize>,
772    /// Per-session read-file output cache. Hit is valid only when on-disk mtime
773    /// still matches. Avoids redoing UTF-8 parsing + semantic skeleton generation
774    /// when the model re-reads the same file — these are CPU-heavy, not just I/O.
775    pub read_cache: Arc<RwLock<std::collections::HashMap<ReadCacheKey, ReadCacheEntry>>>,
776    /// Top-5 most-distinctive lines captured from the first failed bash call
777    /// this session. Used for effect-based "error resolved" detection (P0 #5):
778    /// when a later bash succeeds and ≥3 of these 5 lines no longer appear,
779    /// the framework appends a hint nudging the model to summarize + stop.
780    ///
781    /// Why 5 lines with a majority threshold instead of 1 line (initial
782    /// design from 2026-04-22 morning): cargo / npm / pytest output
783    /// interleaves real diagnostics with ambient status (`Blocking waiting
784    /// for file lock`, `Checking crate v0.1.0`). A single-line signature
785    /// routinely caught a status line that appears on success too, so the
786    /// nudge never fired. Multi-line + majority absent is robust to noise
787    /// overlap without per-tool pattern matching.
788    ///
789    /// Stays set once captured — "original failure" anchor, not rolling.
790    pub first_error_signatures: Arc<RwLock<Vec<String>>>,
791    /// Shared telemetry handle. Always present (possibly in disabled state).
792    pub telemetry: std::sync::Arc<atomcode_telemetry::Telemetry>,
793    /// Shared LSP manager for diagnostics tool. `None` when LSP is disabled.
794    pub lsp: Option<std::sync::Arc<crate::lsp::manager::LspManager>>,
795    /// Optional event sender for real-time tool output streaming (e.g., bash stdout).
796    /// When set, tools like bash can send output chunks as they're produced.
797    pub event_tx: Option<Arc<tokio::sync::mpsc::UnboundedSender<crate::turn::event::TurnEvent>>>,
798    /// Current tool call ID for event correlation.
799    pub current_call_id: Option<String>,
800    /// Shared registry handle for tools that dispatch fork sub-agents
801    /// (currently only `parallel_edit_files`). Set by `AgentLoop::new`
802    /// after the registry is wrapped in `Arc`. Reading the registry via
803    /// `ctx` instead of holding it in the tool struct avoids creating a
804    /// `Tool ↔ Registry` `Arc` cycle that would otherwise leak memory
805    /// for the lifetime of the process. `None` in headless / test
806    /// contexts that don't need fork dispatch.
807    pub tool_registry: Option<Arc<ToolRegistry>>,
808    /// D3 file content store. read_file pushes large file content
809    /// here transparently and consults it on subsequent reads of any
810    /// range — disk hit only on first read or after edit. Conversation
811    /// messages carry only the rendered text (with line numbers) for
812    /// the requested region. edit_file / write_file invalidate
813    /// entries on success so a stale entry cannot serve outdated
814    /// bytes.
815    pub file_store: Arc<RwLock<crate::ctx::file_store::FileStore>>,
816}
817
818impl ToolContext {
819    /// Create a `ToolContext` with a disabled (no-op) telemetry handle.
820    /// Prefer `with_telemetry` in production so real events are emitted.
821    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    /// Create an isolated copy: same working directory value, independent Arc.
855    /// Shares the same graph (read-only for tools) but independent working_dir.
856    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        // Share the FileStore — sub-agents reading the same file reuse
863        // the parent's disk work and benefit from invalidation events
864        // emitted by either side.
865        ctx.file_store = self.file_store.clone();
866        ctx
867    }
868
869    /// Notify LSP that a file changed (if LSP is enabled).
870    /// This is a convenience method for write/edit/search_replace tools.
871    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
884/// Build a disabled (no-op) `Telemetry` handle — zero overhead, no I/O.
885/// Used by `ToolContext::new` and in tests that don't care about telemetry.
886fn 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
895/// Extract up to 5 distinctive diagnostic lines from a failed bash/tool
896/// output for use as a multi-signature "error anchor" (P0 #5).
897/// Selection rule: longest lines first. Rationale — status noise
898/// (`Checking v0.1.0 (/path)`, `Blocking waiting for file lock`) is almost
899/// always shorter than real diagnostic content (`error[E0425]: cannot find
900/// function \`foo\` in this scope`, full compiler traces). Sorting by length
901/// pushes ambient status to the back of the queue without hardcoding tool
902/// names.
903///
904/// Tech-neutral: no keyword matching on "error"/"failed"/"panic" etc. The
905/// caller uses majority-absent semantics (≥3 of 5 disappear on success → fire
906/// nudge) so lingering overlap on one or two status lines doesn't suppress
907/// the detection.
908pub 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        // Framework markers all start with `[` — elapsed, cwd, workspace
916        // note, blocked messages. Skip them.
917        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    // Sort by length desc — longer lines are more likely to be specific
932    // diagnostic content (includes identifiers, paths, span markers).
933    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    /// Pre-flight syntactic check on raw tool-call arguments. The runner
947    /// calls this **before** approval and before execute, so a parse
948    /// failure short-circuits to a tool-result error and the model
949    /// receives a structured retry hint without bothering the user.
950    ///
951    /// Default impl: `Ok(())`. Tools with strict required-field schemas
952    /// (write_file / edit_file / search_replace) override to surface the
953    /// serde error early. Implementations should be cheap (parse only,
954    /// no I/O) — the runner re-parses inside `execute()` for actual use.
955    ///
956    /// Trigger context (2026-05-02 datalog evidence): provider-side
957    /// stream truncation can deliver `[RAW ARGS: {]` or
958    /// `[RAW ARGS: {"file_path":"..."]` (closing-bracket wrong, content
959    /// missing). The previous flow let those reach `approval_with_context`
960    /// where the tool's own fail-closed branch returned
961    /// `RequireApproval("Could not parse … for safety check.")` and the
962    /// user saw an approval prompt for an obviously-broken call. Pressing
963    /// Allow then died on the same parse in `execute()`. Validating up
964    /// front eliminates the user-visible round-trip entirely.
965    fn validate_args(&self, _args: &str) -> std::result::Result<(), String> {
966        Ok(())
967    }
968}
969
970pub struct ToolRegistry {
971    // BTreeMap ensures stable iteration order (sorted by name),
972    // which keeps tool definitions in a consistent order across turns.
973    // This is important for OpenAI/DeepSeek auto prefix caching.
974    // RwLock allows async registration from MCP connection events.
975    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    /// Register a tool (async, acquires write lock).
986    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    /// Register a tool synchronously (for use during startup when we have exclusive access).
993    /// This bypasses the RwLock by using `get_mut()` which requires `&mut self`.
994    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    /// Get all tool definitions (async, acquires read lock).
1000    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    /// Get a tool by name (async, acquires read lock).
1006    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    /// Iterate over all registered tools (async, acquires read lock).
1012    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    /// Register a tool from an Arc (for building filtered registries from parent).
1018    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    /// Top-level property names declared in the tool's `parameters` schema.
1024    /// Used by `recover_tool_args` to decide whether a payload needs
1025    /// wrapper unwrapping. Returns empty Vec if the tool isn't registered
1026    /// or the schema doesn't expose `properties` (in which case
1027    /// `recover_tool_args` falls back to its permissive branch).
1028    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    /// Unregister all tools whose names start with `prefix`.
1040    ///
1041    /// Used by `/mcp reload` to drop all previously registered MCP tools
1042    /// (`mcp__{server}__{tool}`) before reconnecting/re-registering.
1043    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
1059/// Wrapper key names atomgit's gateway has been observed to inject around
1060/// tool_call arguments. None are used as legitimate top-level field names by
1061/// any registered tool — see `recover_tool_args` doc-comment for the safety
1062/// argument.
1063const ARGS_WRAPPER_KEYS: &[&str] = &["arguments", "input", "content"];
1064
1065/// Recover a flat schema-shaped JSON object from possibly-mangled tool args.
1066///
1067/// Background: the atomgit `api-ai.gitcode.com` gateway (and its internal
1068/// `10.205.128.41:6538` deployment) wraps tool_call `function.arguments` into
1069/// extra envelopes that violate the OpenAI tool-call protocol. Observed
1070/// shapes:
1071///
1072///   variant A1 (stream)      — `{"arguments": "<stringified-json-object>"}`
1073///   variant A2 (non-stream)  — `{"arguments": <object>}`
1074///   variant B  (double)      — `{"arguments": "{\"arguments\": ...}"}`
1075///   variant C  (multi-key)   — `{"arguments": "...", "timeout": 120}`
1076///   variant D  (alt key)     — `{"content": "<stringified-json-object>"}`
1077///
1078/// (Variant E is `function.name` field corruption — caller-side detection,
1079///  not handled here.)
1080///
1081/// This function recovers the original schema-shaped object by:
1082///   1. trying direct parse first — if the JSON already contains an
1083///      expected schema field, return None (caller uses raw),
1084///   2. otherwise iteratively unwrapping any single-key wrapper (A/A2/B),
1085///      stringified or object-valued, up to 5 levels deep,
1086///   3. on multi-key wrapper (C), unwrapping the wrapper key and merging in
1087///      the sibling keys that match `expected_top_keys`,
1088///   4. final-validating that the recovered object contains at least one
1089///      `expected_top_keys` field — otherwise returns None to signal
1090///      unrecoverable.
1091///
1092/// Safety against false positives: `ARGS_WRAPPER_KEYS` (`arguments`, `input`,
1093/// `content`) are never used as top-level field names by any tool registered
1094/// in atomcode (verified across all 22 builtin tools and MCP tool naming
1095/// convention). When a future tool adds such a field, callers using
1096/// `recover_tool_args` with that tool's `expected_top_keys` will short-circuit
1097/// at step 1 and never invoke unwrap.
1098pub 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    // Step 1 — already flat schema shape? When all top-level keys are
1105    // declared in the tool's schema, the payload is legitimate as-is and
1106    // we must NOT touch it. This is the strict guard that protects tools
1107    // whose schema legitimately uses one of the wrapper key names
1108    // (e.g. write/todo declare `content`): if the model writes
1109    // {"file_path": "/x.json", "content": "{\"foo\": 1}"}, both keys are
1110    // schema-declared so we return None, leaving the JSON-shaped content
1111    // string untouched. Without this guard, has_wrapper_shape would
1112    // misidentify `content` as a wrapper and corrupt the payload.
1113    //
1114    // When schema is unknown (expected_top_keys empty — e.g. dynamic MCP
1115    // tools whose definition isn't loaded), we can't make this judgement,
1116    // so fall through to the permissive unwrap loop.
1117    if !expected_top_keys.is_empty() && all_keys_in_expected(&value, expected_top_keys) {
1118        return None;
1119    }
1120
1121    // Step 2/3 — unwrap loop, capped at 5 to defend against pathological inputs.
1122    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    // Step 4 — only return Some if we actually unwrapped something.
1137    // Returning None here means "raw is fine, use it as-is".
1138    if !progressed {
1139        return None;
1140    }
1141
1142    // Recovered object must contain at least one expected schema field
1143    // (when schema is known). With no schema, accept any flat object form
1144    // as a permissive fallback for unknown tools (e.g. dynamic MCP tools
1145    // whose schema isn't loaded yet).
1146    if !expected_top_keys.is_empty() && !has_expected_key(&value, expected_top_keys) {
1147        return None;
1148    }
1149    if has_wrapper_shape(&value) {
1150        // Still wrapped after the loop — couldn't recover within budget.
1151        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
1161/// Strict legitimacy check: every top-level key of `v` is declared in the
1162/// tool's schema. Used by the Step 1 short-circuit to identify payloads
1163/// that are already in valid schema shape and must be passed through
1164/// untouched, even if some of those keys happen to overlap with
1165/// `ARGS_WRAPPER_KEYS` (e.g. `content` in write/todo).
1166fn 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            // Wrapper if the wrapper key's value is itself an object, or is
1179            // a string that parses to an object.
1180            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    // Find the first wrapper key whose value resolves to an object.
1204    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    // Variant C support: merge sibling keys (other than the wrapper) that
1229    // are in `expected_top_keys` into the unwrapped object. This covers the
1230    // observed `{"arguments": "{...}", "timeout": 120}` form where wrapper
1231    // and a legitimate field both appear at the top.
1232    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        // canonicalize for comparison: macOS resolves /var → /private/var via
1408        // symlink, so the unresolved `target` won't byte-compare against
1409        // inspect_path_access's canonicalized result.
1410        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    // PermissionStore tests
1542
1543    #[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        // Session grant (user pressed [A]) DOES bypass RequireApproval.
1563        // The user explicitly chose "Always" — respect that. Bash still has
1564        // its own destructive-command detection as a separate safety layer.
1565        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        // Session grant still works for non-destructive (AutoApprove) tools.
1588        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        // Even AutoApprove is blocked.
1599        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        // Even AlwaysAllow override must NOT bypass RequireApproval.
1606        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        // Mutating isolated should not affect original
1620        *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        // Other tools are unaffected.
1650        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        // Already in schema shape — return None so caller uses raw unchanged.
1680        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        // Stream-mode atomgit wrap: {"arguments": "<stringified-json>"}.
1687        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        // Non-stream atomgit wrap: {"arguments": <object>}.
1695        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        // Two-layer wrap (datalog 6% form), both string-valued.
1705        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        // Three-layer object wrap (Bruno non-stream observed form).
1713        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        // {"arguments":"{\"command\":\"ls\"}", "timeout": 120}
1721        // The wrapper key contains the schema-shaped object; sibling keys
1722        // already in expected schema get merged into the recovered object.
1723        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        // Alternative wrapper key: "content" instead of "arguments".
1733        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        // "input" wrapper key (Anthropic-style — not seen in atomgit datalog
1743        // but documented in the spec; covered defensively).
1744        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        // Wrapper present but inner has no expected schema field — bail.
1752        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        // 100-layer recursive wrap — must terminate without OOM.
1759        let mut deep = String::from(r#"{"command":"ls"}"#);
1760        for _ in 0..100 {
1761            deep = format!(r#"{{"arguments":{}}}"#, deep);
1762        }
1763        // After 5 unwrap iterations we still won't reach the schema field
1764        // because the wrap is too deep — should return None, not panic.
1765        let result = recover_tool_args(&deep, &cmd_keys());
1766        // Either None (too deep to recover) or Some with the recovered form
1767        // — both are acceptable outcomes; what matters is termination.
1768        assert!(result.is_none() || result.is_some());
1769    }
1770
1771    #[test]
1772    fn recover_no_expected_keys_falls_back_permissive() {
1773        // Unknown tool — no schema available. Function falls back to:
1774        // unwrap if wrapper present, else None.
1775        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        // Verbatim from datalog 2026-04-25_22-02-07.jsonl step 4.
1786        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        // Verbatim from Bruno non-stream response captured during reproduction.
1795        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    // -------- Regression: schema fields overlapping ARGS_WRAPPER_KEYS --------
1810    //
1811    // The write tool's schema declares `content`, which is also one of the
1812    // wrapper keys. Earlier versions of recover_tool_args used a Step 1
1813    // short-circuit `has_expected_key && !has_wrapper_shape`. When a model
1814    // wrote a JSON-shaped string to a file, has_wrapper_shape misidentified
1815    // the legitimate `content` field as a wrapper, the unwrap loop stripped
1816    // it, and Variant C merge dropped the actual content value. The fix
1817    // changed Step 1 to an "all top-level keys are schema-declared" check,
1818    // which passes through legitimate payloads even when their values
1819    // happen to look like wrappers.
1820
1821    #[test]
1822    fn recover_write_with_json_object_content_passthrough() {
1823        // The classic break: writing a JSON file whose content is a JSON
1824        // object literal. content's string value parses to an object, so
1825        // the old short-circuit failed and Variant D unwrap corrupted the
1826        // payload. Must return None now (legitimate, all keys schema-declared).
1827        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        // Deeply-nested JSON content — would have unwrapped multiple layers
1834        // under the old logic.
1835        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        // Same class of bug for the todo tool — task description that
1842        // happens to be a JSON snippet.
1843        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        // Sanity: a genuinely wrapped write payload (atomgit gateway A2)
1850        // must still recover. The wrapper key here is `arguments` (not
1851        // declared in write schema), so all_keys_in_expected fails and
1852        // we fall through to unwrap.
1853        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        // Payload with a wrapper key + sibling that's NOT in schema:
1863        // {"arguments": "...", "foo": 1} — top-level keys {arguments, foo},
1864        // neither schema-declared, so all_keys_in_expected=false → unwrap.
1865        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        // In normal conditions, real_home_dir should return a valid path
1873        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        // Save original state
1882        let original_sudo_user = std::env::var("SUDO_USER").ok();
1883        let original_home = std::env::var("HOME").ok();
1884        
1885        // Simulate sudo scenario: HOME=/root, SUDO_USER=<current_user>
1886        // We can't actually change to root, but we can verify the logic works
1887        #[cfg(unix)]
1888        {
1889            // Get current user's home from dirs::home_dir()
1890            let normal_home = dirs::home_dir();
1891            
1892            // Set SUDO_USER to a user that exists (the current user)
1893            // This tests that get_user_home() works correctly
1894            if let Some(ref home) = normal_home {
1895                // The home directory should be valid
1896                assert!(home.is_absolute());
1897            }
1898        }
1899        
1900        // Restore original state
1901        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        // Test that ~/path is expanded correctly
1915        let home = real_home_dir().unwrap();
1916        let expanded = expand_user_path("~/test");
1917        assert_eq!(expanded, home.join("test"));
1918        
1919        // Test that ~ alone expands to home
1920        let expanded = expand_user_path("~");
1921        assert_eq!(expanded, home);
1922        
1923        // Test that non-tilde paths are preserved
1924        let expanded = expand_user_path("/absolute/path");
1925        assert_eq!(expanded, PathBuf::from("/absolute/path"));
1926    }
1927}