Skip to main content

caliban_memory/
project_imports.rs

1//! `@path/file` imports inside CLAUDE.md / AGENTS.md / `.caliban.md` /
2//! rules files. Recursion depth ≤ 5, cycle detection by canonical path, and a
3//! first-time approval dialog for files outside the workspace root.
4//!
5//! Part of ADR 0036. See
6//! `docs/superpowers/specs/2026-05-24-claudemd-ancestry-design.md`.
7
8use std::collections::BTreeSet;
9use std::fmt::Write as _;
10use std::path::{Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13
14use crate::auto::strip_html_comments;
15
16/// Recursion depth cap. Depth = number of imports above the current parse;
17/// the top-level file is depth 0, its imports are depth 1, etc.
18pub const MAX_IMPORT_DEPTH: u8 = 5;
19
20/// Per-imported-file size cap (64 KB).
21pub const IMPORT_MAX_BYTES: usize = 64 * 1024;
22
23/// Total per-tier import budget (256 KB). Once breached, further imports are
24/// skipped with a `tracing::warn!`.
25pub const IMPORT_TOTAL_BUDGET: usize = 256 * 1024;
26
27/// Approval verdict for a candidate import path.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ImportApproval {
30    /// Allow this import (and persist as "always approved").
31    AlwaysAllow,
32    /// Allow this import for the current session only (no persistence).
33    AllowOnce,
34    /// Deny this import (skip and continue).
35    Deny,
36}
37
38/// Decision callback the loader uses to ask the user about external paths.
39///
40/// Implementations live in the binary (TUI prompt). Tests use `auto-deny`
41/// or `auto-allow` closures.
42pub type ApprovalCallback<'a> = dyn Fn(&Path, &Path) -> ImportApproval + Send + Sync + 'a;
43
44/// Persistent allowlist on disk.
45#[derive(Debug, Default, Serialize, Deserialize)]
46pub struct ImportAllowlist {
47    /// Schema version.
48    #[serde(default = "default_version")]
49    pub version: u32,
50    /// Approved entries.
51    #[serde(default)]
52    pub approved: Vec<ApprovedEntry>,
53}
54
55fn default_version() -> u32 {
56    1
57}
58
59/// One persisted approval row.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ApprovedEntry {
62    /// Canonical path that was approved.
63    pub path: PathBuf,
64    /// RFC-3339 timestamp.
65    pub approved_at: String,
66    /// Session identifier under which the approval was granted (optional).
67    #[serde(default)]
68    pub approved_session: Option<String>,
69}
70
71impl ImportAllowlist {
72    /// Load the allowlist from `path`, returning an empty default on
73    /// not-found. Other IO errors propagate.
74    ///
75    /// # Errors
76    ///
77    /// Returns [`std::io::Error`] for non-NotFound IO failures or
78    /// [`serde_json::Error`] wrapped as `Other` for malformed JSON.
79    pub fn load(path: &Path) -> std::io::Result<Self> {
80        match std::fs::read(path) {
81            Ok(bytes) => serde_json::from_slice(&bytes)
82                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())),
83            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
84            Err(e) => Err(e),
85        }
86    }
87
88    /// Atomically persist via tmp + rename. Creates the parent directory if
89    /// it doesn't exist.
90    ///
91    /// # Errors
92    ///
93    /// Returns [`std::io::Error`] on any disk failure.
94    pub fn save(&self, path: &Path) -> std::io::Result<()> {
95        let bytes = serde_json::to_vec_pretty(self)
96            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
97        caliban_common::fs::write_atomic(path, &bytes)
98    }
99
100    /// True if `path` (canonicalized) is already approved.
101    #[must_use]
102    pub fn contains(&self, path: &Path) -> bool {
103        let needle = canonical_or(path);
104        self.approved
105            .iter()
106            .any(|e| canonical_or(&e.path) == needle)
107    }
108
109    /// Add `path` (canonicalized) to the approved list. Idempotent.
110    pub fn add(&mut self, path: &Path, session_id: Option<&str>) {
111        if self.contains(path) {
112            return;
113        }
114        self.approved.push(ApprovedEntry {
115            path: canonical_or(path),
116            approved_at: chrono::Utc::now().to_rfc3339(),
117            approved_session: session_id.map(String::from),
118        });
119    }
120}
121
122/// Approval / interactive mode for the import resolver.
123pub enum ApprovalMode<'a> {
124    /// Interactive — invoke the callback the first time an external path is
125    /// seen. Decisions may be persisted to the allowlist when `AlwaysAllow`.
126    Interactive(Box<ApprovalCallback<'a>>),
127    /// Approve everything silently (e.g. `CALIBAN_APPROVE_IMPORTS=1`).
128    AutoAllow,
129    /// Deny everything external silently (e.g. `--print` / `--bare`).
130    AutoDeny,
131}
132
133impl std::fmt::Debug for ApprovalMode<'_> {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        match self {
136            Self::Interactive(_) => write!(f, "Interactive(<fn>)"),
137            Self::AutoAllow => write!(f, "AutoAllow"),
138            Self::AutoDeny => write!(f, "AutoDeny"),
139        }
140    }
141}
142
143/// Mutable state shared across all `@`-imports during a single tier load.
144///
145/// Holds the depth counter, the import stack (cycle detection), the
146/// already-loaded set (dedupe), the running byte budget, and the approval
147/// mode.
148pub struct ImportState<'a> {
149    /// Workspace root — any resolved path **not** under this directory
150    /// requires approval (also tolerated under `~/.config/caliban`).
151    pub workspace_root: PathBuf,
152    /// Allowlist of pre-approved external paths.
153    pub allowlist: ImportAllowlist,
154    /// Path to the allowlist file on disk (used when persisting "always").
155    pub allowlist_path: Option<PathBuf>,
156    /// Approval mode (interactive / auto-allow / auto-deny).
157    pub approval: ApprovalMode<'a>,
158    /// Files already loaded — second `@`-import of the same path skips.
159    pub loaded: BTreeSet<PathBuf>,
160    /// Current recursion depth (top-level body is depth 0).
161    pub depth: u8,
162    /// Cycle-detection stack of canonical paths currently being resolved.
163    pub import_stack: Vec<PathBuf>,
164    /// Bytes of imported content emitted so far.
165    pub bytes_emitted: usize,
166    /// Bytes shed by the per-tier budget cap.
167    pub bytes_shed: usize,
168    /// Allow-once tracking (in-memory, session-scoped).
169    pub session_allow_once: BTreeSet<PathBuf>,
170}
171
172impl<'a> ImportState<'a> {
173    /// Build a fresh state with the given approval mode and workspace root.
174    #[must_use]
175    pub fn new(workspace_root: PathBuf, approval: ApprovalMode<'a>) -> Self {
176        Self {
177            workspace_root,
178            allowlist: ImportAllowlist::default(),
179            allowlist_path: None,
180            approval,
181            loaded: BTreeSet::new(),
182            depth: 0,
183            import_stack: Vec::new(),
184            bytes_emitted: 0,
185            bytes_shed: 0,
186            session_allow_once: BTreeSet::new(),
187        }
188    }
189
190    /// Attach an allowlist (loaded from disk) and remember the persistence path.
191    #[must_use]
192    pub fn with_allowlist(mut self, allowlist: ImportAllowlist, path: Option<PathBuf>) -> Self {
193        self.allowlist = allowlist;
194        self.allowlist_path = path;
195        self
196    }
197}
198
199/// Parsed import directive — either a clean import line we should process, or
200/// `None` if the line should be left untouched.
201///
202/// Rules (mirroring Claude Code's parser):
203/// - The first non-whitespace token starts with `@`.
204/// - The token must contain `/`, start with `~`, or contain `.` somewhere
205///   (otherwise it's a `@mention` / `@interface_name`, not a path).
206/// - HTTP/S schemes are rejected at resolve time (here we still parse them so
207///   the resolver can warn).
208#[must_use]
209pub fn parse_import_directive(line: &str) -> Option<&str> {
210    let trimmed = line.trim_start();
211    let rest = trimmed.strip_prefix('@')?;
212    let token = rest.split_whitespace().next()?;
213    // Empty token (`@ rest`) — not an import.
214    if token.is_empty() {
215        return None;
216    }
217    // Bare mention (no `/`, no `~`, no `.`): not a path.
218    if !(token.contains('/') || token.starts_with('~') || token.contains('.')) {
219        return None;
220    }
221    Some(token)
222}
223
224/// Error reasons for [`resolve_imports`]. These never propagate to the
225/// caller — they're surfaced as inline `<!-- … -->` markers in the resolved
226/// body so the model can see the failure mode.
227#[derive(Debug)]
228enum ImportFailure {
229    UnsupportedScheme,
230    NotFound,
231    TooLarge { bytes: usize },
232    BudgetExceeded,
233    Denied,
234    Cycle,
235    DepthCap,
236    InvalidPath,
237}
238
239impl std::fmt::Display for ImportFailure {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        match self {
242            Self::UnsupportedScheme => write!(f, "unsupported-scheme"),
243            Self::NotFound => write!(f, "not-found"),
244            Self::TooLarge { bytes } => write!(f, "too-large ({bytes} bytes)"),
245            Self::BudgetExceeded => write!(f, "tier-budget-exceeded"),
246            Self::Denied => write!(f, "denied"),
247            Self::Cycle => write!(f, "cycle"),
248            Self::DepthCap => write!(f, "depth-cap"),
249            Self::InvalidPath => write!(f, "invalid-path"),
250        }
251    }
252}
253
254/// Recursively resolve every `@`-import in `body`. Lines that aren't pure
255/// imports are passed through verbatim. Imported content is wrapped in
256/// `<!-- imported from … -->` … `<!-- end … -->` markers and stripped of HTML
257/// comments before splicing (so import provenance survives but content
258/// comments don't bloat the prompt).
259pub fn resolve_imports(body: &str, importer: &Path, state: &mut ImportState<'_>) -> String {
260    // Track the importer on the cycle stack so a nested `@./importer` is
261    // detected even at depth 0.
262    let importer_canonical = canonical_or(importer);
263    let pushed_importer = if state.import_stack.iter().any(|p| p == &importer_canonical) {
264        false
265    } else {
266        state.import_stack.push(importer_canonical.clone());
267        state.loaded.insert(importer_canonical.clone());
268        true
269    };
270
271    let out = resolve_imports_inner(body, importer, state);
272
273    if pushed_importer {
274        state.import_stack.pop();
275    }
276    out
277}
278
279fn resolve_imports_inner(body: &str, importer: &Path, state: &mut ImportState<'_>) -> String {
280    let mut out = String::with_capacity(body.len());
281    for line in body.lines() {
282        let Some(token) = parse_import_directive(line) else {
283            out.push_str(line);
284            out.push('\n');
285            continue;
286        };
287
288        // Reject HTTP/S schemes outright (we don't fetch over the network).
289        if token.starts_with("http://") || token.starts_with("https://") {
290            push_failure(&mut out, line, token, &ImportFailure::UnsupportedScheme);
291            continue;
292        }
293
294        let Some(resolved) = resolve_relative(token, importer) else {
295            push_failure(&mut out, line, token, &ImportFailure::InvalidPath);
296            continue;
297        };
298        let canonical = canonical_or(&resolved);
299
300        // Depth cap.
301        if state.depth >= MAX_IMPORT_DEPTH {
302            tracing::warn!(
303                target: caliban_common::tracing_targets::TARGET_MEMORY,
304                importer = %importer.display(),
305                token,
306                "@-import depth cap reached",
307            );
308            push_failure(&mut out, line, token, &ImportFailure::DepthCap);
309            continue;
310        }
311
312        // Cycle detection.
313        if state.import_stack.iter().any(|p| p == &canonical) {
314            push_failure(&mut out, line, token, &ImportFailure::Cycle);
315            continue;
316        }
317
318        // De-duplicate: already loaded earlier in this tier.
319        if state.loaded.contains(&canonical) {
320            let _ = writeln!(out, "[@-import already loaded: {token}]");
321            continue;
322        }
323
324        // Approval gate for external paths.
325        if needs_approval(&canonical, &state.workspace_root)
326            && !approval_grants(&canonical, importer, state)
327        {
328            push_failure(&mut out, line, token, &ImportFailure::Denied);
329            continue;
330        }
331
332        // Read with per-file size cap.
333        let raw = match std::fs::metadata(&canonical) {
334            Ok(md) if md.is_file() => {
335                let len_usize = usize::try_from(md.len()).unwrap_or(usize::MAX);
336                if len_usize > IMPORT_MAX_BYTES {
337                    push_failure(
338                        &mut out,
339                        line,
340                        token,
341                        &ImportFailure::TooLarge { bytes: len_usize },
342                    );
343                    continue;
344                }
345                if let Ok(bytes) = std::fs::read(&canonical) {
346                    String::from_utf8_lossy(&bytes).into_owned()
347                } else {
348                    push_failure(&mut out, line, token, &ImportFailure::NotFound);
349                    continue;
350                }
351            }
352            _ => {
353                push_failure(&mut out, line, token, &ImportFailure::NotFound);
354                continue;
355            }
356        };
357
358        // Per-tier budget check (account before recursing).
359        let projected = state.bytes_emitted.saturating_add(raw.len());
360        if projected > IMPORT_TOTAL_BUDGET {
361            state.bytes_shed = state.bytes_shed.saturating_add(raw.len());
362            push_failure(&mut out, line, token, &ImportFailure::BudgetExceeded);
363            continue;
364        }
365        state.bytes_emitted = projected;
366        state.loaded.insert(canonical.clone());
367        state.depth += 1;
368        state.import_stack.push(canonical.clone());
369
370        // Recurse into the imported file. Use the inner helper so we don't
371        // re-push the canonical onto the cycle stack (we already did just
372        // above). Strip HTML comments from the resolved sub-body so importer
373        // comments don't bloat the prompt.
374        let sub = resolve_imports_inner(&raw, &canonical, state);
375        let sub_stripped = strip_html_comments(&sub);
376
377        state.import_stack.pop();
378        state.depth -= 1;
379
380        let _ = writeln!(
381            out,
382            "<!-- imported from {} (depth={}) -->",
383            canonical.display(),
384            state.depth + 1,
385        );
386        out.push_str(&sub_stripped);
387        if !sub_stripped.ends_with('\n') {
388            out.push('\n');
389        }
390        let _ = writeln!(out, "<!-- end {} -->", canonical.display());
391    }
392    out
393}
394
395fn push_failure(out: &mut String, line: &str, token: &str, why: &ImportFailure) {
396    // Use brackets (not HTML comments) so the marker survives any subsequent
397    // `strip_html_comments` pass — operators + the model both need to see why
398    // an `@`-import was skipped.
399    let _ = writeln!(out, "[@-import skipped ({why}): {token}]");
400    // For UnsupportedScheme + InvalidPath, leave the original line so the
401    // operator notices it visually too. For NotFound / Denied / etc. we
402    // suppress the directive (it would mislead the model otherwise).
403    if matches!(
404        why,
405        ImportFailure::UnsupportedScheme | ImportFailure::InvalidPath
406    ) {
407        out.push_str(line);
408        out.push('\n');
409    }
410}
411
412/// True when `resolved` falls outside the workspace and outside the user's
413/// caliban config dir (`~/.config/caliban`). External paths require approval.
414fn needs_approval(resolved: &Path, workspace_root: &Path) -> bool {
415    let resolved_c = canonical_or(resolved);
416    let workspace_c = canonical_or(workspace_root);
417    if resolved_c.starts_with(&workspace_c) || resolved.starts_with(workspace_root) {
418        return false;
419    }
420    if let Some(config_dir) = dirs::config_dir().map(|d| d.join("caliban")) {
421        let cfg_c = canonical_or(&config_dir);
422        if resolved_c.starts_with(&cfg_c) || resolved.starts_with(&config_dir) {
423            return false;
424        }
425    }
426    true
427}
428
429/// True iff approval is granted (and any persistent decision has been recorded).
430fn approval_grants(resolved: &Path, importer: &Path, state: &mut ImportState<'_>) -> bool {
431    let canon = canonical_or(resolved);
432    if state.allowlist.contains(&canon) || state.session_allow_once.contains(&canon) {
433        return true;
434    }
435    match &state.approval {
436        ApprovalMode::AutoAllow => {
437            // Treat env-flag auto-approval as "always" — persist if we have a path.
438            state.allowlist.add(&canon, None);
439            if let Some(p) = state.allowlist_path.as_deref() {
440                let _ = state.allowlist.save(p);
441            }
442            true
443        }
444        ApprovalMode::AutoDeny => {
445            tracing::warn!(
446                target: caliban_common::tracing_targets::TARGET_MEMORY,
447                path = %canon.display(),
448                "external @-import auto-denied (non-interactive mode)",
449            );
450            false
451        }
452        ApprovalMode::Interactive(cb) => match cb(&canon, importer) {
453            ImportApproval::AlwaysAllow => {
454                state.allowlist.add(&canon, None);
455                if let Some(p) = state.allowlist_path.as_deref() {
456                    let _ = state.allowlist.save(p);
457                }
458                true
459            }
460            ImportApproval::AllowOnce => {
461                state.session_allow_once.insert(canon);
462                true
463            }
464            ImportApproval::Deny => false,
465        },
466    }
467}
468
469/// Resolve `token` (the bit after `@`) into a filesystem path. `~` expands
470/// against the home directory; relative paths join the importer's directory.
471/// Returns `None` for empty or malformed tokens.
472#[must_use]
473fn resolve_relative(token: &str, importer: &Path) -> Option<PathBuf> {
474    if token.is_empty() {
475        return None;
476    }
477    if let Some(rest) = token.strip_prefix("~/") {
478        let home = dirs::home_dir()?;
479        return Some(home.join(rest));
480    }
481    if token == "~" {
482        return dirs::home_dir();
483    }
484    let p = Path::new(token);
485    if p.is_absolute() {
486        return Some(p.to_path_buf());
487    }
488    let base = importer.parent().unwrap_or_else(|| Path::new("."));
489    Some(normalize(&base.join(p)))
490}
491
492/// Normalize `..` / `.` segments without touching the filesystem.
493fn normalize(p: &Path) -> PathBuf {
494    let mut out = PathBuf::new();
495    for c in p.components() {
496        match c {
497            std::path::Component::ParentDir => {
498                out.pop();
499            }
500            std::path::Component::CurDir => {}
501            other => out.push(other.as_os_str()),
502        }
503    }
504    out
505}
506
507/// Best-effort canonicalize; falls back to a normalized form when the path
508/// doesn't yet exist (we still want a stable key for cycle detection).
509#[must_use]
510pub fn canonical_or(p: &Path) -> PathBuf {
511    std::fs::canonicalize(p).unwrap_or_else(|_| normalize(p))
512}
513
514#[cfg(test)]
515mod tests {
516    use super::*;
517    use std::fs;
518    use tempfile::TempDir;
519
520    fn deny_cb<'a>() -> ApprovalMode<'a> {
521        ApprovalMode::AutoDeny
522    }
523
524    #[test]
525    fn parse_import_directive_recognizes_path_like_tokens() {
526        assert_eq!(parse_import_directive("@./foo.md"), Some("./foo.md"));
527        assert_eq!(
528            parse_import_directive("@~/notes/api.md"),
529            Some("~/notes/api.md"),
530        );
531        assert_eq!(
532            parse_import_directive("@/abs/path.md"),
533            Some("/abs/path.md")
534        );
535        assert_eq!(parse_import_directive("@foo.md"), Some("foo.md"));
536        // Indented import is still recognized.
537        assert_eq!(parse_import_directive("    @./foo.md"), Some("./foo.md"));
538    }
539
540    #[test]
541    fn parse_import_directive_rejects_user_mentions_and_interface_names() {
542        assert_eq!(parse_import_directive("@someone"), None);
543        assert_eq!(parse_import_directive("@MyInterface"), None);
544        assert_eq!(parse_import_directive("ping @someone here"), None);
545        assert_eq!(parse_import_directive("@_underscore"), None);
546        assert_eq!(parse_import_directive("@"), None);
547    }
548
549    #[test]
550    fn resolve_imports_inlines_referenced_file() {
551        let tmp = TempDir::new().unwrap();
552        let root = tmp.path();
553        let importer = root.join("CLAUDE.md");
554        fs::write(root.join("part.md"), "PART-BODY\n").unwrap();
555        fs::write(&importer, "header\n@./part.md\nfooter\n").unwrap();
556        let body = fs::read_to_string(&importer).unwrap();
557
558        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
559        let out = resolve_imports(&body, &importer, &mut state);
560        assert!(out.contains("header"));
561        assert!(out.contains("PART-BODY"));
562        assert!(out.contains("footer"));
563        assert!(out.contains("imported from"));
564        assert!(out.contains("end"));
565    }
566
567    #[test]
568    fn resolve_imports_enforces_depth_cap_at_five() {
569        let tmp = TempDir::new().unwrap();
570        let root = tmp.path();
571        // Build a chain: top → a → b → c → d → e → f (7 levels). e (depth 5
572        // from top) imports f (depth 6) — the depth-6 read must be rejected
573        // and the directive elided.
574        fs::write(root.join("top.md"), "@./a.md\n").unwrap();
575        fs::write(root.join("a.md"), "A-LEVEL\n@./b.md\n").unwrap();
576        fs::write(root.join("b.md"), "B-LEVEL\n@./c.md\n").unwrap();
577        fs::write(root.join("c.md"), "C-LEVEL\n@./d.md\n").unwrap();
578        fs::write(root.join("d.md"), "D-LEVEL\n@./e.md\n").unwrap();
579        fs::write(root.join("e.md"), "E-LEVEL\n@./f.md\n").unwrap();
580        fs::write(root.join("f.md"), "F-SHOULD-NOT-APPEAR\n").unwrap();
581
582        let body = fs::read_to_string(root.join("top.md")).unwrap();
583        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
584        let out = resolve_imports(&body, &root.join("top.md"), &mut state);
585        assert!(out.contains("A-LEVEL"));
586        assert!(out.contains("E-LEVEL"));
587        assert!(
588            !out.contains("F-SHOULD-NOT-APPEAR"),
589            "depth-6 file should have been rejected: {out}",
590        );
591        assert!(out.contains("depth-cap"));
592    }
593
594    #[test]
595    fn resolve_imports_allows_exactly_five_levels() {
596        let tmp = TempDir::new().unwrap();
597        let root = tmp.path();
598        fs::write(root.join("top.md"), "@./a.md\n").unwrap();
599        fs::write(root.join("a.md"), "A-LEVEL\n@./b.md\n").unwrap();
600        fs::write(root.join("b.md"), "B-LEVEL\n@./c.md\n").unwrap();
601        fs::write(root.join("c.md"), "C-LEVEL\n@./d.md\n").unwrap();
602        fs::write(root.join("d.md"), "D-LEVEL\n@./e.md\n").unwrap();
603        fs::write(root.join("e.md"), "E-LEAF\n").unwrap();
604
605        let body = fs::read_to_string(root.join("top.md")).unwrap();
606        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
607        let out = resolve_imports(&body, &root.join("top.md"), &mut state);
608        assert!(out.contains("E-LEAF"));
609    }
610
611    #[test]
612    fn resolve_imports_detects_cycles() {
613        let tmp = TempDir::new().unwrap();
614        let root = tmp.path();
615        fs::write(root.join("a.md"), "A-BODY\n@./b.md\n").unwrap();
616        fs::write(root.join("b.md"), "B-BODY\n@./a.md\n").unwrap();
617        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
618        let body = fs::read_to_string(root.join("a.md")).unwrap();
619        let out = resolve_imports(&body, &root.join("a.md"), &mut state);
620        assert!(out.contains("A-BODY"));
621        assert!(out.contains("B-BODY"));
622        assert!(out.contains("cycle"), "no cycle marker: {out}");
623    }
624
625    #[test]
626    fn resolve_imports_rejects_http_urls() {
627        let tmp = TempDir::new().unwrap();
628        let root = tmp.path();
629        let importer = root.join("CLAUDE.md");
630        fs::write(&importer, "header\n@https://example.com/x.md\nfooter\n").unwrap();
631        let body = fs::read_to_string(&importer).unwrap();
632        let mut state = ImportState::new(root.to_path_buf(), ApprovalMode::AutoAllow);
633        let out = resolve_imports(&body, &importer, &mut state);
634        assert!(out.contains("unsupported-scheme"));
635    }
636
637    #[test]
638    fn first_time_external_import_prompts_then_denies() {
639        let tmp = TempDir::new().unwrap();
640        let external = tmp.path().join("outside");
641        fs::create_dir_all(&external).unwrap();
642        fs::write(external.join("rules.md"), "EXTERNAL").unwrap();
643        let workspace = tmp.path().join("ws");
644        fs::create_dir_all(&workspace).unwrap();
645        let importer = workspace.join("CLAUDE.md");
646        let import_token = format!("@{}", external.join("rules.md").display());
647        fs::write(&importer, format!("{import_token}\n")).unwrap();
648        let body = fs::read_to_string(&importer).unwrap();
649
650        // Non-interactive: AutoDeny.
651        let mut state = ImportState::new(workspace.clone(), ApprovalMode::AutoDeny);
652        let out = resolve_imports(&body, &importer, &mut state);
653        assert!(!out.contains("EXTERNAL"));
654        assert!(out.contains("denied"));
655    }
656
657    #[test]
658    fn first_time_external_import_can_be_approved() {
659        let tmp = TempDir::new().unwrap();
660        let external = tmp.path().join("outside");
661        fs::create_dir_all(&external).unwrap();
662        fs::write(external.join("rules.md"), "EXTERNAL").unwrap();
663        let workspace = tmp.path().join("ws");
664        fs::create_dir_all(&workspace).unwrap();
665        let importer = workspace.join("CLAUDE.md");
666        let import_token = format!("@{}", external.join("rules.md").display());
667        fs::write(&importer, format!("{import_token}\n")).unwrap();
668        let body = fs::read_to_string(&importer).unwrap();
669
670        // Interactive: always-allow on first ask.
671        let cb: Box<ApprovalCallback<'static>> =
672            Box::new(|_p: &Path, _i: &Path| ImportApproval::AlwaysAllow);
673        let mut state = ImportState::new(workspace.clone(), ApprovalMode::Interactive(cb));
674        let out = resolve_imports(&body, &importer, &mut state);
675        assert!(out.contains("EXTERNAL"), "expected EXTERNAL inlined: {out}");
676        assert!(
677            state.allowlist.contains(&external.join("rules.md")),
678            "always-allow should add to allowlist",
679        );
680    }
681
682    #[test]
683    fn cached_approval_skips_dialog_on_second_load() {
684        let tmp = TempDir::new().unwrap();
685        let external = tmp.path().join("outside");
686        fs::create_dir_all(&external).unwrap();
687        fs::write(external.join("rules.md"), "EXTERNAL").unwrap();
688        let workspace = tmp.path().join("ws");
689        fs::create_dir_all(&workspace).unwrap();
690        let importer = workspace.join("CLAUDE.md");
691        let import_token = format!("@{}", external.join("rules.md").display());
692        fs::write(&importer, format!("{import_token}\n")).unwrap();
693        let body = fs::read_to_string(&importer).unwrap();
694
695        // Pre-populate the allowlist with the external path.
696        let mut allow = ImportAllowlist::default();
697        allow.add(&external.join("rules.md"), None);
698
699        // Callback that panics if invoked — proving the allowlist short-circuits.
700        let cb: Box<ApprovalCallback<'static>> =
701            Box::new(|_p: &Path, _i: &Path| panic!("dialog should not be invoked"));
702        let mut state = ImportState::new(workspace.clone(), ApprovalMode::Interactive(cb))
703            .with_allowlist(allow, None);
704        let out = resolve_imports(&body, &importer, &mut state);
705        assert!(out.contains("EXTERNAL"));
706    }
707
708    #[test]
709    fn allowlist_round_trips_through_disk() {
710        let tmp = TempDir::new().unwrap();
711        let path = tmp.path().join(".caliban").join("imports-allowlist.json");
712        let mut allow = ImportAllowlist::default();
713        allow.add(Path::new("/Users/x/notes/api.md"), Some("session-1"));
714        allow.save(&path).unwrap();
715        let loaded = ImportAllowlist::load(&path).unwrap();
716        assert_eq!(loaded.approved.len(), 1);
717        assert!(loaded.contains(Path::new("/Users/x/notes/api.md")));
718    }
719
720    #[test]
721    fn html_comments_stripped_from_imported_content() {
722        let tmp = TempDir::new().unwrap();
723        let root = tmp.path();
724        let importer = root.join("CLAUDE.md");
725        fs::write(
726            root.join("part.md"),
727            "VISIBLE\n<!-- secret stuff -->\nMORE\n",
728        )
729        .unwrap();
730        fs::write(&importer, "@./part.md\n").unwrap();
731        let body = fs::read_to_string(&importer).unwrap();
732        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
733        let out = resolve_imports(&body, &importer, &mut state);
734        assert!(out.contains("VISIBLE"));
735        assert!(out.contains("MORE"));
736        assert!(
737            !out.contains("secret stuff"),
738            "html comment leaked into output: {out}",
739        );
740    }
741
742    #[test]
743    fn empty_body_after_stripping_does_not_panic() {
744        let tmp = TempDir::new().unwrap();
745        let root = tmp.path();
746        let importer = root.join("CLAUDE.md");
747        fs::write(root.join("part.md"), "<!-- nothing -->\n").unwrap();
748        fs::write(&importer, "@./part.md\n").unwrap();
749        let body = fs::read_to_string(&importer).unwrap();
750        let mut state = ImportState::new(root.to_path_buf(), deny_cb());
751        let _ = resolve_imports(&body, &importer, &mut state);
752    }
753}