1use 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
16pub const MAX_IMPORT_DEPTH: u8 = 5;
19
20pub const IMPORT_MAX_BYTES: usize = 64 * 1024;
22
23pub const IMPORT_TOTAL_BUDGET: usize = 256 * 1024;
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum ImportApproval {
30 AlwaysAllow,
32 AllowOnce,
34 Deny,
36}
37
38pub type ApprovalCallback<'a> = dyn Fn(&Path, &Path) -> ImportApproval + Send + Sync + 'a;
43
44#[derive(Debug, Default, Serialize, Deserialize)]
46pub struct ImportAllowlist {
47 #[serde(default = "default_version")]
49 pub version: u32,
50 #[serde(default)]
52 pub approved: Vec<ApprovedEntry>,
53}
54
55fn default_version() -> u32 {
56 1
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ApprovedEntry {
62 pub path: PathBuf,
64 pub approved_at: String,
66 #[serde(default)]
68 pub approved_session: Option<String>,
69}
70
71impl ImportAllowlist {
72 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 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 #[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 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
122pub enum ApprovalMode<'a> {
124 Interactive(Box<ApprovalCallback<'a>>),
127 AutoAllow,
129 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
143pub struct ImportState<'a> {
149 pub workspace_root: PathBuf,
152 pub allowlist: ImportAllowlist,
154 pub allowlist_path: Option<PathBuf>,
156 pub approval: ApprovalMode<'a>,
158 pub loaded: BTreeSet<PathBuf>,
160 pub depth: u8,
162 pub import_stack: Vec<PathBuf>,
164 pub bytes_emitted: usize,
166 pub bytes_shed: usize,
168 pub session_allow_once: BTreeSet<PathBuf>,
170}
171
172impl<'a> ImportState<'a> {
173 #[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 #[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#[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 if token.is_empty() {
215 return None;
216 }
217 if !(token.contains('/') || token.starts_with('~') || token.contains('.')) {
219 return None;
220 }
221 Some(token)
222}
223
224#[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
254pub fn resolve_imports(body: &str, importer: &Path, state: &mut ImportState<'_>) -> String {
260 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 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 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 if state.import_stack.iter().any(|p| p == &canonical) {
314 push_failure(&mut out, line, token, &ImportFailure::Cycle);
315 continue;
316 }
317
318 if state.loaded.contains(&canonical) {
320 let _ = writeln!(out, "[@-import already loaded: {token}]");
321 continue;
322 }
323
324 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 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 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 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 let _ = writeln!(out, "[@-import skipped ({why}): {token}]");
400 if matches!(
404 why,
405 ImportFailure::UnsupportedScheme | ImportFailure::InvalidPath
406 ) {
407 out.push_str(line);
408 out.push('\n');
409 }
410}
411
412fn 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
429fn 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 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#[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
492fn 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#[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 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 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 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 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 let mut allow = ImportAllowlist::default();
697 allow.add(&external.join("rules.md"), None);
698
699 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}