Skip to main content

apm_core/wrapper/
path_guard.rs

1use std::path::{Component, Path, PathBuf};
2use globset::{Glob, GlobSetBuilder};
3
4pub struct PathGuard {
5    worktree: PathBuf,             // canonicalised APM_TICKET_WORKTREE
6    write_protected: Vec<PathBuf>, // APM_BIN, APM_SYSTEM_PROMPT_FILE, APM_USER_MESSAGE_FILE
7}
8
9impl PathGuard {
10    pub fn new(
11        worktree: &Path,
12        read_allow_patterns: &[String],
13        write_protected: &[PathBuf],
14    ) -> anyhow::Result<Self> {
15        let worktree = std::fs::canonicalize(worktree)
16            .unwrap_or_else(|_| canonicalize_lenient(worktree));
17
18        // Validate read_allow patterns so callers get an error on bad globs,
19        // even though read-only commands are always permitted (they produce no
20        // write targets that need checking).
21        let mut builder = GlobSetBuilder::new();
22        for pattern in read_allow_patterns {
23            let expanded = expand_home_str(pattern);
24            builder.add(Glob::new(&expanded).map_err(|e| anyhow::anyhow!("invalid glob {pattern:?}: {e}"))?);
25        }
26        builder.build().map_err(|e| anyhow::anyhow!("glob build failed: {e}"))?;
27
28        let write_protected = write_protected
29            .iter()
30            .map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| canonicalize_lenient(p)))
31            .collect();
32
33        Ok(PathGuard { worktree, write_protected })
34    }
35
36    pub fn check_write(&self, path: &Path) -> Result<(), String> {
37        let resolved = canonicalize_lenient(path);
38
39        // write_protected entries are always rejected — even if inside worktree
40        if self.write_protected.iter().any(|p| p == &resolved) {
41            return Err(rejection_msg(path, &self.worktree));
42        }
43
44        if resolved.starts_with(&self.worktree) {
45            return Ok(());
46        }
47
48        Err(rejection_msg(path, &self.worktree))
49    }
50
51    pub fn check_bash(&self, cmd: &str) -> Result<(), String> {
52        let targets = detect_write_targets(cmd);
53        for target_str in targets {
54            let path = PathBuf::from(&target_str);
55            self.check_write(&path)?;
56        }
57        Ok(())
58    }
59}
60
61/// Build a human-readable rejection message.
62fn rejection_msg(requested: &Path, worktree: &Path) -> String {
63    format!(
64        "path outside ticket worktree; isolation enforced by APM wrapper.\n  Requested: {}\n  APM_TICKET_WORKTREE = {}",
65        requested.display(),
66        worktree.display()
67    )
68}
69
70/// Canonicalize a path, following symlinks for components that exist on disk
71/// and appending non-existent components lexically.
72///
73/// This ensures that existing intermediate symlinks are resolved while still
74/// accepting paths to files that do not yet exist (e.g. the target of a Write
75/// call that would create a new file).
76pub fn canonicalize_lenient(path: &Path) -> PathBuf {
77    let mut result = PathBuf::new();
78
79    for component in path.components() {
80        match component {
81            Component::Prefix(p) => {
82                result = PathBuf::from(p.as_os_str());
83            }
84            Component::RootDir => {
85                result.push(component);
86            }
87            Component::CurDir => {
88                // skip "."
89            }
90            Component::ParentDir => {
91                // Try to canonicalize the path with ".." so the OS resolves symlinks
92                let candidate = result.join("..");
93                if candidate.exists() {
94                    result = std::fs::canonicalize(&candidate).unwrap_or(candidate);
95                } else {
96                    // Non-existent parent: lexically remove the last component
97                    result.pop();
98                }
99            }
100            Component::Normal(_) => {
101                result.push(component);
102                if result.exists() {
103                    result = std::fs::canonicalize(&result).unwrap_or_else(|_| result.clone());
104                }
105            }
106        }
107    }
108
109    result
110}
111
112/// Expand `~` at the start of a path string to the user's home directory.
113fn expand_home_str(s: &str) -> String {
114    if let Some(rest) = s.strip_prefix("~/") {
115        if let Ok(home) = std::env::var("HOME") {
116            if !home.is_empty() {
117                return format!("{home}/{rest}");
118            }
119        }
120    }
121    s.to_string()
122}
123
124/// Expand `~/` prefix to the home directory.
125fn expand_home(s: &str) -> String {
126    expand_home_str(s)
127}
128
129fn is_path_token(s: &str) -> bool {
130    s.starts_with('/') || s.starts_with("~/")
131}
132
133fn is_shell_sep(s: &str) -> bool {
134    matches!(s, ";" | "&&" | "||" | "|" | "&")
135}
136
137/// Detect write-target paths from a bash command string.
138///
139/// Handles:
140/// - `>` and `>>` redirect targets (space-separated and embedded)
141/// - `tee` first non-flag argument
142/// - `cp` / `mv` last non-flag path argument (the destination)
143/// - `truncate` path argument
144///
145/// Known false negatives (documented limitation, not in scope):
146/// - Paths stored in shell variables: `OUT=/x; echo foo > "$OUT"`
147/// - Subshell expansion: `echo foo > $(cat /tmp/path)`
148/// - eval: `eval "echo foo > /x"`
149fn detect_write_targets(cmd: &str) -> Vec<String> {
150    let mut targets = Vec::new();
151
152    // Phase 1: scan for redirect operators (>, >>) at the character level
153    detect_redirects(cmd, &mut targets);
154
155    // Phase 2: command-specific write patterns via token scan
156    let tokens: Vec<&str> = cmd.split_whitespace().collect();
157    detect_command_writes(&tokens, &mut targets);
158
159    targets
160}
161
162/// Scan the command string character by character for `>` and `>>` operators,
163/// skipping quoted strings, and collecting the path that follows.
164fn detect_redirects(cmd: &str, targets: &mut Vec<String>) {
165    let chars: Vec<char> = cmd.chars().collect();
166    let n = chars.len();
167    let mut i = 0;
168
169    while i < n {
170        let c = chars[i];
171
172        // Skip single-quoted strings
173        if c == '\'' {
174            i += 1;
175            while i < n && chars[i] != '\'' {
176                i += 1;
177            }
178            if i < n {
179                i += 1;
180            }
181            continue;
182        }
183
184        // Skip double-quoted strings
185        if c == '"' {
186            i += 1;
187            while i < n && chars[i] != '"' {
188                if chars[i] == '\\' {
189                    i += 1; // skip escaped character
190                }
191                if i < n {
192                    i += 1;
193                }
194            }
195            if i < n {
196                i += 1;
197            }
198            continue;
199        }
200
201        if c == '>' {
202            let is_double = i + 1 < n && chars[i + 1] == '>';
203            let advance = if is_double { 2 } else { 1 };
204
205            // Skip whitespace after the redirect operator
206            let mut j = i + advance;
207            while j < n && chars[j] == ' ' {
208                j += 1;
209            }
210
211            // Collect path token if it starts with / or ~/
212            if j < n
213                && (chars[j] == '/'
214                    || (chars[j] == '~' && j + 1 < n && chars[j + 1] == '/'))
215            {
216                let path_start = j;
217                while j < n
218                    && !chars[j].is_whitespace()
219                    && !matches!(chars[j], ';' | '|' | '&' | '(')
220                {
221                    j += 1;
222                }
223                let path: String = chars[path_start..j].iter().collect();
224                targets.push(expand_home(&path));
225            }
226
227            i += advance;
228            continue;
229        }
230
231        i += 1;
232    }
233}
234
235/// Scan tokenised command for command-specific write patterns.
236fn detect_command_writes(tokens: &[&str], targets: &mut Vec<String>) {
237    let n = tokens.len();
238    let mut i = 0;
239
240    while i < n {
241        let tok = tokens[i];
242
243        match tok {
244            "tee" => {
245                // First non-flag absolute path argument
246                for &arg in &tokens[i + 1..n] {
247                    if is_shell_sep(arg) {
248                        break;
249                    }
250                    if arg.starts_with('-') {
251                        continue;
252                    }
253                    if is_path_token(arg) {
254                        targets.push(expand_home(arg));
255                    }
256                    break;
257                }
258            }
259            "cp" | "mv" => {
260                // Destination = last non-flag absolute path argument
261                let mut last: Option<String> = None;
262                for &arg in &tokens[i + 1..n] {
263                    if is_shell_sep(arg) {
264                        break;
265                    }
266                    if !arg.starts_with('-') && is_path_token(arg) {
267                        last = Some(expand_home(arg));
268                    }
269                }
270                if let Some(p) = last {
271                    targets.push(p);
272                }
273            }
274            "truncate" => {
275                let mut j = i + 1;
276                while j < n {
277                    let arg = tokens[j];
278                    if is_shell_sep(arg) {
279                        break;
280                    }
281                    // -s / --size each consume the next token as the size value
282                    if arg == "-s" || arg == "--size" {
283                        j += 2;
284                        continue;
285                    }
286                    if !arg.starts_with('-') && is_path_token(arg) {
287                        targets.push(expand_home(arg));
288                    }
289                    j += 1;
290                }
291            }
292            _ => {}
293        }
294
295        i += 1;
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    // ---- canonicalize_lenient ----
304
305    #[test]
306    fn canonicalize_lenient_absolute_existing() {
307        let tmp = tempfile::tempdir().unwrap();
308        let p = tmp.path().to_path_buf();
309        let result = canonicalize_lenient(&p);
310        // Should resolve the tempdir path (may follow symlinks)
311        assert!(result.is_absolute());
312    }
313
314    #[test]
315    fn canonicalize_lenient_nonexistent_leaf() {
316        let tmp = tempfile::tempdir().unwrap();
317        let p = tmp.path().join("nonexistent.txt");
318        let result = canonicalize_lenient(&p);
319        // Parent must be resolved; leaf appended lexically
320        assert!(result.is_absolute());
321        assert_eq!(result.file_name().unwrap().to_str().unwrap(), "nonexistent.txt");
322    }
323
324    #[test]
325    fn canonicalize_lenient_dotdot_inside_existing() {
326        let tmp = tempfile::tempdir().unwrap();
327        let sub = tmp.path().join("sub");
328        std::fs::create_dir(&sub).unwrap();
329        // sub/.. == tmp.path()
330        let candidate = sub.join("..").join("other.txt");
331        let result = canonicalize_lenient(&candidate);
332        // result should be tmp.path()/other.txt
333        let expected_parent = std::fs::canonicalize(tmp.path()).unwrap();
334        assert_eq!(result.parent().unwrap(), expected_parent);
335    }
336
337    #[test]
338    fn canonicalize_lenient_dotdot_escape_stays_out() {
339        let tmp = tempfile::tempdir().unwrap();
340        let wt = tmp.path().join("worktree");
341        let sub = wt.join("subdir");
342        std::fs::create_dir_all(&sub).unwrap();
343        // worktree/subdir/../../etc/passwd
344        let path = sub.join("..").join("..").join("etc").join("passwd");
345        let result = canonicalize_lenient(&path);
346        // Should resolve to tmp.path()/etc/passwd — outside wt
347        let canon_wt = std::fs::canonicalize(&wt).unwrap();
348        assert!(!result.starts_with(&canon_wt));
349    }
350
351    #[test]
352    fn canonicalize_lenient_symlink_inside_worktree_resolves_outside() {
353        let tmp = tempfile::tempdir().unwrap();
354        let wt = tmp.path().join("wt");
355        std::fs::create_dir(&wt).unwrap();
356        let outside = tmp.path().join("outside");
357        std::fs::create_dir(&outside).unwrap();
358        let link = wt.join("link");
359        std::os::unix::fs::symlink(&outside, &link).unwrap();
360        let target = link.join("secret.txt");
361        let result = canonicalize_lenient(&target);
362        let canon_wt = std::fs::canonicalize(&wt).unwrap();
363        assert!(!result.starts_with(&canon_wt));
364    }
365
366    // ---- PathGuard::check_write ----
367
368    fn make_guard(wt: &Path) -> PathGuard {
369        PathGuard::new(wt, &[], &[]).unwrap()
370    }
371
372    #[test]
373    fn check_write_inside_worktree_allowed() {
374        let tmp = tempfile::tempdir().unwrap();
375        let wt = tmp.path().join("wt");
376        std::fs::create_dir(&wt).unwrap();
377        let guard = make_guard(&wt);
378        assert!(guard.check_write(&wt.join("file.txt")).is_ok());
379    }
380
381    #[test]
382    fn check_write_outside_worktree_rejected() {
383        let tmp = tempfile::tempdir().unwrap();
384        let wt = tmp.path().join("wt");
385        std::fs::create_dir(&wt).unwrap();
386        let guard = make_guard(&wt);
387        let outside = tmp.path().join("outside.txt");
388        let err = guard.check_write(&outside).unwrap_err();
389        assert!(err.contains("path outside ticket worktree"));
390        assert!(err.contains("APM_TICKET_WORKTREE"));
391    }
392
393    #[test]
394    fn check_write_rejection_message_contains_worktree() {
395        let tmp = tempfile::tempdir().unwrap();
396        let wt = tmp.path().join("wt");
397        std::fs::create_dir(&wt).unwrap();
398        let guard = make_guard(&wt);
399        let err = guard.check_write(&tmp.path().join("x")).unwrap_err();
400        assert!(err.contains("APM_TICKET_WORKTREE"));
401    }
402
403    #[test]
404    fn check_write_dotdot_escape_rejected() {
405        let tmp = tempfile::tempdir().unwrap();
406        let wt = tmp.path().join("wt");
407        let sub = wt.join("sub");
408        std::fs::create_dir_all(&sub).unwrap();
409        let guard = make_guard(&wt);
410        // wt/sub/../../etc/passwd
411        let path = sub.join("..").join("..").join("etc").join("passwd");
412        assert!(guard.check_write(&path).is_err());
413    }
414
415    #[test]
416    fn check_write_symlink_to_outside_rejected() {
417        let tmp = tempfile::tempdir().unwrap();
418        let wt = tmp.path().join("wt");
419        std::fs::create_dir(&wt).unwrap();
420        let outside = tmp.path().join("outside");
421        std::fs::create_dir(&outside).unwrap();
422        let link = wt.join("link");
423        std::os::unix::fs::symlink(&outside, &link).unwrap();
424        let guard = make_guard(&wt);
425        assert!(guard.check_write(&link.join("file.txt")).is_err());
426    }
427
428    #[test]
429    fn check_write_protected_inside_worktree_rejected() {
430        let tmp = tempfile::tempdir().unwrap();
431        let wt = tmp.path().join("wt");
432        std::fs::create_dir(&wt).unwrap();
433        // apm_bin inside the worktree (e.g. target/debug/apm)
434        let apm_bin = wt.join("target").join("debug").join("apm");
435        std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
436        std::fs::write(&apm_bin, "binary").unwrap();
437        let guard = PathGuard::new(&wt, &[], std::slice::from_ref(&apm_bin)).unwrap();
438        let err = guard.check_write(&apm_bin).unwrap_err();
439        assert!(err.contains("path outside ticket worktree"));
440    }
441
442    #[test]
443    fn check_write_apm_bin_outside_worktree_rejected() {
444        let tmp = tempfile::tempdir().unwrap();
445        let wt = tmp.path().join("wt");
446        std::fs::create_dir(&wt).unwrap();
447        let apm_bin = tmp.path().join("usr").join("bin").join("apm");
448        std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
449        std::fs::write(&apm_bin, "binary").unwrap();
450        let guard = PathGuard::new(&wt, &[], std::slice::from_ref(&apm_bin)).unwrap();
451        assert!(guard.check_write(&apm_bin).is_err());
452    }
453
454    // ---- detect_write_targets (bash heuristic) ----
455
456    #[test]
457    fn bash_redirect_gt_detected() {
458        let targets = detect_write_targets("echo foo > /outside/file");
459        assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
460    }
461
462    #[test]
463    fn bash_redirect_gtgt_detected() {
464        let targets = detect_write_targets("cat data >> /outside/append.log");
465        assert!(targets.iter().any(|t| t == "/outside/append.log"), "got: {targets:?}");
466    }
467
468    #[test]
469    fn bash_tee_detected() {
470        let targets = detect_write_targets("some-cmd | tee /outside/output.txt");
471        assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
472    }
473
474    #[test]
475    fn bash_tee_flag_skipped() {
476        let targets = detect_write_targets("some-cmd | tee -a /outside/output.txt");
477        assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
478    }
479
480    #[test]
481    fn bash_cp_dest_detected() {
482        let targets = detect_write_targets("cp /inside/src /outside/dest");
483        assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
484        // /inside/src should NOT be a target
485        assert!(!targets.iter().any(|t| t == "/inside/src"), "src should not be write target: {targets:?}");
486    }
487
488    #[test]
489    fn bash_mv_dest_detected() {
490        let targets = detect_write_targets("mv /inside/file /outside/dest");
491        assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
492    }
493
494    #[test]
495    fn bash_truncate_detected() {
496        let targets = detect_write_targets("truncate -s 0 /outside/file");
497        assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
498    }
499
500    #[test]
501    fn bash_cat_not_detected() {
502        let targets = detect_write_targets("cat /etc/resolv.conf");
503        assert!(targets.is_empty(), "cat should produce no write targets: {targets:?}");
504    }
505
506    #[test]
507    fn bash_grep_not_detected() {
508        let targets = detect_write_targets("grep pattern /etc/hosts");
509        assert!(targets.is_empty(), "grep should produce no write targets: {targets:?}");
510    }
511
512    #[test]
513    fn bash_ls_not_detected() {
514        let targets = detect_write_targets("ls /outside/dir");
515        assert!(targets.is_empty(), "ls should produce no write targets: {targets:?}");
516    }
517
518    #[test]
519    fn bash_diff_not_detected() {
520        let targets = detect_write_targets("diff /file1 /file2");
521        assert!(targets.is_empty(), "diff should produce no write targets: {targets:?}");
522    }
523
524    #[test]
525    fn bash_wc_not_detected() {
526        let targets = detect_write_targets("wc -l /var/log/syslog");
527        assert!(targets.is_empty(), "wc should produce no write targets: {targets:?}");
528    }
529
530    #[test]
531    fn bash_echo_no_path_not_detected() {
532        let targets = detect_write_targets("echo hello");
533        assert!(targets.is_empty(), "echo without path should produce no write targets: {targets:?}");
534    }
535
536    // ---- PathGuard::check_bash ----
537
538    #[test]
539    fn check_bash_redirect_outside_rejected() {
540        let tmp = tempfile::tempdir().unwrap();
541        let wt = tmp.path().join("wt");
542        std::fs::create_dir(&wt).unwrap();
543        let guard = make_guard(&wt);
544        let outside = tmp.path().join("outside.txt");
545        let cmd = format!("echo foo > {}", outside.display());
546        assert!(guard.check_bash(&cmd).is_err());
547    }
548
549    #[test]
550    fn check_bash_redirect_inside_allowed() {
551        let tmp = tempfile::tempdir().unwrap();
552        let wt = tmp.path().join("wt");
553        std::fs::create_dir(&wt).unwrap();
554        let guard = make_guard(&wt);
555        let inside = wt.join("output.txt");
556        let cmd = format!("echo foo > {}", inside.display());
557        assert!(guard.check_bash(&cmd).is_ok());
558    }
559
560    #[test]
561    fn check_bash_cat_read_allowed() {
562        let tmp = tempfile::tempdir().unwrap();
563        let wt = tmp.path().join("wt");
564        std::fs::create_dir(&wt).unwrap();
565        let guard = make_guard(&wt);
566        assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
567    }
568
569    #[test]
570    fn check_bash_tilde_gitconfig_allowed() {
571        let tmp = tempfile::tempdir().unwrap();
572        let wt = tmp.path().join("wt");
573        std::fs::create_dir(&wt).unwrap();
574        let guard = make_guard(&wt);
575        assert!(guard.check_bash("cat ~/.gitconfig").is_ok());
576    }
577}