Skip to main content

microclaw_engine/
context_references.rs

1//! `@`-prefix context references in user messages.
2//!
3//! Lets the user write `@file:src/main.rs`, `@folder:src/`, `@diff`, `@staged`,
4//! or `@url:https://example.com` and have those expand server-side into an
5//! attached context block before the LLM sees the message. Avoids round-trips
6//! through `read_file` / `web_fetch` for trivial cases that the user clearly
7//! intends.
8//!
9//! Hermes reference: `agent/context_references.py`. This is a focused port:
10//! we cover file (with optional `:line` or `:start-end` suffix), folder
11//! (1-deep listing), diff/staged, and url. We don't yet implement quoted
12//! values (`@file:"path with spaces"`) or `@git:` refs.
13//!
14//! Sensitive-path guard: refuses to read out of `~/.ssh`, `~/.aws`, `~/.gnupg`,
15//! `~/.kube`, `~/.docker`, dotfiles like `~/.bashrc`/`~/.netrc`/`~/.npmrc`.
16//! The existing `path_guard` module catches these too, but enforcing here
17//! avoids spending time fetching only to reject.
18//!
19//! URL fetching delegates to `crate::internal::tool_runtime::web_fetch::fetch_url_with_timeout`,
20//! which already enforces the SSRF guard from PR #335.
21
22use std::path::{Path, PathBuf};
23use std::sync::OnceLock;
24
25use regex::Regex;
26
27const MAX_FILE_BYTES: usize = 64 * 1024;
28const MAX_FOLDER_ENTRIES: usize = 200;
29const MAX_DIFF_BYTES: usize = 32 * 1024;
30const MAX_URL_BYTES: usize = 64 * 1024;
31const URL_FETCH_TIMEOUT_SECS: u64 = 15;
32
33fn reference_re() -> &'static Regex {
34    static RE: OnceLock<Regex> = OnceLock::new();
35    RE.get_or_init(|| {
36        // Matches:  @diff, @staged, @file:VALUE, @folder:VALUE, @url:VALUE.
37        // VALUE is whitespace-terminated, optionally followed by `:N` or `:N-M`.
38        // The leading boundary uses `\s` rather than a look-behind because the
39        // `regex` crate's default engine doesn't support look-around; we
40        // post-filter for "start-of-line OR preceded by whitespace" separately.
41        Regex::new(r"@(?:(?P<simple>diff|staged)\b|(?P<kind>file|folder|url):(?P<value>\S+))")
42            .expect("static regex")
43    })
44}
45
46const SENSITIVE_HOME_DIRS: &[&str] = &[".ssh", ".aws", ".gnupg", ".kube", ".docker", ".azure"];
47
48const SENSITIVE_HOME_FILES: &[&str] = &[
49    ".bashrc",
50    ".zshrc",
51    ".profile",
52    ".bash_profile",
53    ".zprofile",
54    ".netrc",
55    ".pgpass",
56    ".npmrc",
57    ".pypirc",
58];
59
60/// Result of reference expansion.
61#[derive(Debug, Clone)]
62pub struct ExpansionResult {
63    /// The user-visible part of the message, with `@…` tokens stripped.
64    pub stripped_message: String,
65    /// Final message to pass to the LLM (stripped + attached blocks + warnings).
66    pub final_message: String,
67    /// Per-reference warnings to surface to the user.
68    pub warnings: Vec<String>,
69    /// Whether any reference was successfully expanded.
70    pub expanded: bool,
71}
72
73/// Expand all `@`-prefix references in `message` against `cwd`. URL fetches
74/// honor the same SSRF guard `web_fetch` uses.
75///
76/// If `message` contains no references, returns the original text unchanged.
77pub async fn expand_references(message: &str, cwd: &Path) -> ExpansionResult {
78    let refs: Vec<ReferenceMatch> = parse_references(message);
79    if refs.is_empty() {
80        return ExpansionResult {
81            stripped_message: message.to_string(),
82            final_message: message.to_string(),
83            warnings: Vec::new(),
84            expanded: false,
85        };
86    }
87
88    let mut blocks: Vec<String> = Vec::new();
89    let mut warnings: Vec<String> = Vec::new();
90
91    for r in &refs {
92        match expand_one(r, cwd).await {
93            Ok(block) => blocks.push(block),
94            Err(w) => warnings.push(w),
95        }
96    }
97
98    let stripped = strip_reference_tokens(message, &refs);
99    let mut final_msg = stripped.clone();
100    if !warnings.is_empty() {
101        final_msg.push_str("\n\n--- Context Warnings ---\n");
102        for w in &warnings {
103            final_msg.push_str("- ");
104            final_msg.push_str(w);
105            final_msg.push('\n');
106        }
107    }
108    if !blocks.is_empty() {
109        final_msg.push_str("\n\n--- Attached Context ---\n\n");
110        final_msg.push_str(&blocks.join("\n\n"));
111    }
112
113    ExpansionResult {
114        stripped_message: stripped,
115        final_message: final_msg.trim().to_string(),
116        warnings,
117        expanded: !blocks.is_empty(),
118    }
119}
120
121#[derive(Debug, Clone)]
122struct ReferenceMatch {
123    #[allow(dead_code)]
124    raw: String,
125    kind: ReferenceKind,
126    target: String,
127    line_start: Option<usize>,
128    line_end: Option<usize>,
129    /// Byte offsets in the original message of the `@…` token, for stripping.
130    token_start: usize,
131    token_end: usize,
132}
133
134#[derive(Debug, Clone, Copy)]
135enum ReferenceKind {
136    File,
137    Folder,
138    Url,
139    Diff,
140    Staged,
141}
142
143fn parse_references(message: &str) -> Vec<ReferenceMatch> {
144    let mut out = Vec::new();
145    for cap in reference_re().captures_iter(message) {
146        let m = cap.get(0).unwrap();
147        // Reject mid-word matches: `foo@file:bar` should not parse. The match
148        // must be at start-of-string or preceded by whitespace.
149        if m.start() > 0 {
150            let prev = message[..m.start()].chars().next_back();
151            if let Some(c) = prev {
152                if !c.is_whitespace() {
153                    continue;
154                }
155            }
156        }
157        if let Some(simple) = cap.name("simple") {
158            let kind = if simple.as_str() == "staged" {
159                ReferenceKind::Staged
160            } else {
161                ReferenceKind::Diff
162            };
163            out.push(ReferenceMatch {
164                raw: m.as_str().to_string(),
165                kind,
166                target: String::new(),
167                line_start: None,
168                line_end: None,
169                token_start: m.start(),
170                token_end: m.end(),
171            });
172            continue;
173        }
174
175        let kind = match cap.name("kind").map(|m| m.as_str()) {
176            Some("file") => ReferenceKind::File,
177            Some("folder") => ReferenceKind::Folder,
178            Some("url") => ReferenceKind::Url,
179            _ => continue,
180        };
181        let value_raw = cap.name("value").map(|m| m.as_str()).unwrap_or("");
182        let value = strip_trailing_punctuation(value_raw);
183        let (target, line_start, line_end) = match kind {
184            ReferenceKind::File => parse_file_value(value),
185            _ => (value.to_string(), None, None),
186        };
187        out.push(ReferenceMatch {
188            raw: m.as_str().to_string(),
189            kind,
190            target,
191            line_start,
192            line_end,
193            token_start: m.start(),
194            token_end: m.end(),
195        });
196    }
197    out
198}
199
200fn strip_trailing_punctuation(value: &str) -> &str {
201    value.trim_end_matches([',', '.', ';', '!', '?'])
202}
203
204fn parse_file_value(value: &str) -> (String, Option<usize>, Option<usize>) {
205    // Last `:` separates path from line spec, but only if the suffix is digits
206    // (or digits-digits). Otherwise treat the whole thing as a path (handles
207    // Windows drive letters).
208    if let Some((rest, suffix)) = value.rsplit_once(':') {
209        if let Some((a, b)) = suffix.split_once('-') {
210            if let (Ok(a), Ok(b)) = (a.parse(), b.parse()) {
211                return (rest.to_string(), Some(a), Some(b));
212            }
213        } else if let Ok(n) = suffix.parse() {
214            return (rest.to_string(), Some(n), Some(n));
215        }
216    }
217    (value.to_string(), None, None)
218}
219
220fn strip_reference_tokens(message: &str, refs: &[ReferenceMatch]) -> String {
221    if refs.is_empty() {
222        return message.to_string();
223    }
224    let mut out = String::with_capacity(message.len());
225    let mut last = 0;
226    for r in refs {
227        if r.token_start >= last {
228            out.push_str(&message[last..r.token_start]);
229            last = r.token_end;
230        }
231    }
232    out.push_str(&message[last..]);
233    // Collapse runs of whitespace introduced by removal.
234    let collapsed = out.trim().to_string();
235    collapsed
236}
237
238async fn expand_one(r: &ReferenceMatch, cwd: &Path) -> Result<String, String> {
239    match r.kind {
240        ReferenceKind::File => expand_file(&r.target, r.line_start, r.line_end, cwd).await,
241        ReferenceKind::Folder => expand_folder(&r.target, cwd).await,
242        ReferenceKind::Url => expand_url(&r.target).await,
243        ReferenceKind::Diff => expand_git_diff(false, cwd).await,
244        ReferenceKind::Staged => expand_git_diff(true, cwd).await,
245    }
246}
247
248fn resolve_path_safe(target: &str, cwd: &Path) -> Result<PathBuf, String> {
249    let raw = PathBuf::from(target);
250    let path = if raw.is_absolute() {
251        raw
252    } else {
253        cwd.join(raw)
254    };
255    let canonical = path
256        .canonicalize()
257        .map_err(|e| format!("@{} not found: {e}", target))?;
258    // Sensitive-path block list, evaluated against canonical. We walk the
259    // resolved path's components and reject when any component name matches
260    // a known sensitive directory — works on both Unix and Windows because
261    // we never split on a separator string.
262    let component_names: Vec<String> = canonical
263        .components()
264        .filter_map(|c| match c {
265            std::path::Component::Normal(s) => Some(s.to_string_lossy().to_string()),
266            _ => None,
267        })
268        .collect();
269    for d in SENSITIVE_HOME_DIRS {
270        if component_names.iter().any(|name| name == d) {
271            return Err(format!("@{} blocked: refuses to read inside `{d}`", target));
272        }
273    }
274    if let Some(home) = dirs_home() {
275        for d in SENSITIVE_HOME_DIRS {
276            let blocked = home.join(d);
277            if canonical.starts_with(&blocked) {
278                return Err(format!(
279                    "@{} blocked: refuses to read inside {}",
280                    target,
281                    blocked.display()
282                ));
283            }
284        }
285        for f in SENSITIVE_HOME_FILES {
286            if canonical == home.join(f) {
287                return Err(format!(
288                    "@{} blocked: refuses to read sensitive dotfile {}",
289                    target, f
290                ));
291            }
292        }
293    }
294    Ok(canonical)
295}
296
297fn dirs_home() -> Option<PathBuf> {
298    std::env::var_os("HOME").map(PathBuf::from)
299}
300
301async fn expand_file(
302    target: &str,
303    line_start: Option<usize>,
304    line_end: Option<usize>,
305    cwd: &Path,
306) -> Result<String, String> {
307    let path = resolve_path_safe(target, cwd)?;
308    let bytes = tokio::fs::read(&path)
309        .await
310        .map_err(|e| format!("@file:{target} read failed: {e}"))?;
311    if bytes.len() > MAX_FILE_BYTES * 8 {
312        return Err(format!(
313            "@file:{target} too large ({} bytes); read with read_file instead",
314            bytes.len()
315        ));
316    }
317    let text = String::from_utf8_lossy(&bytes).into_owned();
318    let snippet = match (line_start, line_end) {
319        (Some(s), Some(e)) if e >= s => {
320            let lines: Vec<&str> = text.lines().collect();
321            let s = s.saturating_sub(1).min(lines.len());
322            let e = e.min(lines.len());
323            lines[s..e].join("\n")
324        }
325        _ => text,
326    };
327    let snippet = if snippet.len() > MAX_FILE_BYTES {
328        format!(
329            "{}\n... [truncated to {} bytes]",
330            &snippet[..MAX_FILE_BYTES],
331            MAX_FILE_BYTES
332        )
333    } else {
334        snippet
335    };
336    let mut header = format!("@file:{}", target);
337    if let (Some(s), Some(e)) = (line_start, line_end) {
338        header.push_str(&format!(" (lines {s}-{e})"));
339    }
340    Ok(format!("**{}**\n```\n{}\n```", header, snippet))
341}
342
343async fn expand_folder(target: &str, cwd: &Path) -> Result<String, String> {
344    let path = resolve_path_safe(target, cwd)?;
345    let mut rd = tokio::fs::read_dir(&path)
346        .await
347        .map_err(|e| format!("@folder:{target} read failed: {e}"))?;
348    let mut entries: Vec<String> = Vec::new();
349    let mut total = 0usize;
350    while let Ok(Some(entry)) = rd.next_entry().await {
351        if total >= MAX_FOLDER_ENTRIES {
352            entries.push(format!("... (more, capped at {MAX_FOLDER_ENTRIES})"));
353            break;
354        }
355        let name = entry.file_name();
356        let name = name.to_string_lossy();
357        let kind = entry
358            .file_type()
359            .await
360            .ok()
361            .map(|t| if t.is_dir() { "/" } else { "" })
362            .unwrap_or("");
363        entries.push(format!("{name}{kind}"));
364        total += 1;
365    }
366    entries.sort();
367    Ok(format!(
368        "**@folder:{}** ({} entries)\n```\n{}\n```",
369        target,
370        entries.len(),
371        entries.join("\n")
372    ))
373}
374
375async fn expand_url(target: &str) -> Result<String, String> {
376    let body = crate::internal::tool_runtime::web_fetch::fetch_url_with_timeout(
377        target,
378        URL_FETCH_TIMEOUT_SECS,
379    )
380    .await
381    .map_err(|e| format!("@url:{target} fetch failed: {e}"))?;
382    let snippet = if body.len() > MAX_URL_BYTES {
383        format!(
384            "{}\n... [truncated to {} bytes]",
385            &body[..MAX_URL_BYTES],
386            MAX_URL_BYTES
387        )
388    } else {
389        body
390    };
391    Ok(format!("**@url:{}**\n```\n{}\n```", target, snippet))
392}
393
394async fn expand_git_diff(staged: bool, cwd: &Path) -> Result<String, String> {
395    let args: &[&str] = if staged {
396        &["diff", "--cached"]
397    } else {
398        &["diff"]
399    };
400    let label = if staged { "@staged" } else { "@diff" };
401    let output = tokio::process::Command::new("git")
402        .args(args)
403        .current_dir(cwd)
404        .output()
405        .await
406        .map_err(|e| format!("{label}: git not available ({e})"))?;
407    if !output.status.success() {
408        return Err(format!(
409            "{label} failed: {}",
410            String::from_utf8_lossy(&output.stderr).trim()
411        ));
412    }
413    let body = String::from_utf8_lossy(&output.stdout).into_owned();
414    if body.trim().is_empty() {
415        return Ok(format!("**{label}** (no changes)"));
416    }
417    let snippet = if body.len() > MAX_DIFF_BYTES {
418        format!(
419            "{}\n... [truncated to {} bytes]",
420            &body[..MAX_DIFF_BYTES],
421            MAX_DIFF_BYTES
422        )
423    } else {
424        body
425    };
426    Ok(format!("**{label}**\n```diff\n{}\n```", snippet))
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432
433    fn tmp_dir() -> PathBuf {
434        let dir = std::env::temp_dir().join(format!("microclaw_ctxref_{}", uuid::Uuid::new_v4()));
435        std::fs::create_dir_all(&dir).unwrap();
436        dir
437    }
438
439    #[tokio::test]
440    async fn no_refs_passthrough() {
441        let cwd = tmp_dir();
442        let r = expand_references("plain text, no refs", &cwd).await;
443        assert!(!r.expanded);
444        assert_eq!(r.final_message, "plain text, no refs");
445        let _ = std::fs::remove_dir_all(&cwd);
446    }
447
448    #[tokio::test]
449    async fn expands_file_reference() {
450        let cwd = tmp_dir();
451        std::fs::write(cwd.join("hello.txt"), "world").unwrap();
452        let r = expand_references("look at @file:hello.txt please", &cwd).await;
453        assert!(r.expanded);
454        assert!(r.final_message.contains("@file:hello.txt"));
455        assert!(r.final_message.contains("world"));
456        assert!(r.final_message.contains("look at"));
457        assert!(r.final_message.contains("please"));
458        // The @file:hello.txt token itself was stripped from the user-visible
459        // sentence (it appears only inside the attached-context header).
460        assert!(!r.stripped_message.contains("@file:hello.txt"));
461        let _ = std::fs::remove_dir_all(&cwd);
462    }
463
464    #[tokio::test]
465    async fn file_with_line_range() {
466        let cwd = tmp_dir();
467        std::fs::write(cwd.join("multi.txt"), "line1\nline2\nline3\nline4\n").unwrap();
468        let r = expand_references("@file:multi.txt:2-3", &cwd).await;
469        assert!(r.expanded);
470        assert!(r.final_message.contains("line2\nline3"));
471        assert!(!r.final_message.contains("line1"));
472        assert!(!r.final_message.contains("line4"));
473        let _ = std::fs::remove_dir_all(&cwd);
474    }
475
476    #[tokio::test]
477    async fn folder_lists_entries() {
478        let cwd = tmp_dir();
479        std::fs::create_dir_all(cwd.join("sub/inner")).unwrap();
480        std::fs::write(cwd.join("sub/a.txt"), "a").unwrap();
481        std::fs::write(cwd.join("sub/b.txt"), "b").unwrap();
482        let r = expand_references("@folder:sub", &cwd).await;
483        assert!(r.expanded);
484        assert!(r.final_message.contains("a.txt"));
485        assert!(r.final_message.contains("b.txt"));
486        assert!(r.final_message.contains("inner/"));
487        let _ = std::fs::remove_dir_all(&cwd);
488    }
489
490    #[tokio::test]
491    async fn missing_file_warns_does_not_panic() {
492        let cwd = tmp_dir();
493        let r = expand_references("@file:nope.txt", &cwd).await;
494        assert!(!r.expanded);
495        assert!(r.warnings.iter().any(|w| w.contains("nope.txt")));
496        let _ = std::fs::remove_dir_all(&cwd);
497    }
498
499    #[test]
500    fn sensitive_path_blocked() {
501        // Component-based block list works on both Unix and Windows. We
502        // run the expansion under env_lock and then drop the lock before
503        // asserting, so a failed assertion can't poison the lock for other
504        // tests.
505        let home = std::env::temp_dir().join(format!("fake_home_{}", uuid::Uuid::new_v4()));
506        let secrets = home.join(".ssh");
507        std::fs::create_dir_all(&secrets).unwrap();
508        std::fs::write(secrets.join("id_rsa"), "secret").unwrap();
509        let target = format!("@file:{}/id_rsa", secrets.display());
510
511        let r = {
512            let _guard = crate::test_support::env_lock();
513            let prev_home = std::env::var_os("HOME");
514            std::env::set_var("HOME", &home);
515
516            let r = tokio::runtime::Builder::new_current_thread()
517                .enable_all()
518                .build()
519                .unwrap()
520                .block_on(expand_references(&target, &home));
521
522            if let Some(prev) = prev_home {
523                std::env::set_var("HOME", prev);
524            } else {
525                std::env::remove_var("HOME");
526            }
527            r
528            // _guard dropped here, before any assert!
529        };
530
531        let _ = std::fs::remove_dir_all(&home);
532        assert!(!r.expanded, "expected expansion to be blocked");
533        assert!(
534            r.warnings.iter().any(|w| w.contains("blocked")),
535            "expected a 'blocked' warning, got: {:?}",
536            r.warnings
537        );
538    }
539
540    #[test]
541    fn parse_extracts_token_offsets() {
542        let msg = "before @file:foo.txt:10-20 after";
543        let refs = parse_references(msg);
544        assert_eq!(refs.len(), 1);
545        assert_eq!(refs[0].target, "foo.txt");
546        assert_eq!(refs[0].line_start, Some(10));
547        assert_eq!(refs[0].line_end, Some(20));
548        assert_eq!(refs[0].token_start, 7);
549        assert_eq!(
550            &msg[refs[0].token_start..refs[0].token_end],
551            "@file:foo.txt:10-20"
552        );
553    }
554
555    #[test]
556    fn parse_handles_trailing_punctuation() {
557        let refs = parse_references("see @file:hello.txt, please");
558        assert_eq!(refs.len(), 1);
559        assert_eq!(refs[0].target, "hello.txt");
560    }
561
562    #[test]
563    fn parse_recognizes_diff_and_staged() {
564        let refs = parse_references("@diff and @staged");
565        assert_eq!(refs.len(), 2);
566        assert!(matches!(refs[0].kind, ReferenceKind::Diff));
567        assert!(matches!(refs[1].kind, ReferenceKind::Staged));
568    }
569
570    #[test]
571    fn ref_at_start_of_message_matches() {
572        let refs = parse_references("@file:foo.txt explain");
573        assert_eq!(refs.len(), 1);
574        assert_eq!(refs[0].target, "foo.txt");
575    }
576}