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 j in (i + 1)..n {
247                    let arg = tokens[j];
248                    if is_shell_sep(arg) {
249                        break;
250                    }
251                    if arg.starts_with('-') {
252                        continue;
253                    }
254                    if is_path_token(arg) {
255                        targets.push(expand_home(arg));
256                    }
257                    break;
258                }
259            }
260            "cp" | "mv" => {
261                // Destination = last non-flag absolute path argument
262                let mut last: Option<String> = None;
263                for j in (i + 1)..n {
264                    let arg = tokens[j];
265                    if is_shell_sep(arg) {
266                        break;
267                    }
268                    if !arg.starts_with('-') && is_path_token(arg) {
269                        last = Some(expand_home(arg));
270                    }
271                }
272                if let Some(p) = last {
273                    targets.push(p);
274                }
275            }
276            "truncate" => {
277                let mut j = i + 1;
278                while j < n {
279                    let arg = tokens[j];
280                    if is_shell_sep(arg) {
281                        break;
282                    }
283                    // -s / --size each consume the next token as the size value
284                    if arg == "-s" || arg == "--size" {
285                        j += 2;
286                        continue;
287                    }
288                    if !arg.starts_with('-') && is_path_token(arg) {
289                        targets.push(expand_home(arg));
290                    }
291                    j += 1;
292                }
293            }
294            _ => {}
295        }
296
297        i += 1;
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    // ---- canonicalize_lenient ----
306
307    #[test]
308    fn canonicalize_lenient_absolute_existing() {
309        let tmp = tempfile::tempdir().unwrap();
310        let p = tmp.path().to_path_buf();
311        let result = canonicalize_lenient(&p);
312        // Should resolve the tempdir path (may follow symlinks)
313        assert!(result.is_absolute());
314    }
315
316    #[test]
317    fn canonicalize_lenient_nonexistent_leaf() {
318        let tmp = tempfile::tempdir().unwrap();
319        let p = tmp.path().join("nonexistent.txt");
320        let result = canonicalize_lenient(&p);
321        // Parent must be resolved; leaf appended lexically
322        assert!(result.is_absolute());
323        assert_eq!(result.file_name().unwrap().to_str().unwrap(), "nonexistent.txt");
324    }
325
326    #[test]
327    fn canonicalize_lenient_dotdot_inside_existing() {
328        let tmp = tempfile::tempdir().unwrap();
329        let sub = tmp.path().join("sub");
330        std::fs::create_dir(&sub).unwrap();
331        // sub/.. == tmp.path()
332        let candidate = sub.join("..").join("other.txt");
333        let result = canonicalize_lenient(&candidate);
334        // result should be tmp.path()/other.txt
335        let expected_parent = std::fs::canonicalize(tmp.path()).unwrap();
336        assert_eq!(result.parent().unwrap(), expected_parent);
337    }
338
339    #[test]
340    fn canonicalize_lenient_dotdot_escape_stays_out() {
341        let tmp = tempfile::tempdir().unwrap();
342        let wt = tmp.path().join("worktree");
343        let sub = wt.join("subdir");
344        std::fs::create_dir_all(&sub).unwrap();
345        // worktree/subdir/../../etc/passwd
346        let path = sub.join("..").join("..").join("etc").join("passwd");
347        let result = canonicalize_lenient(&path);
348        // Should resolve to tmp.path()/etc/passwd — outside wt
349        let canon_wt = std::fs::canonicalize(&wt).unwrap();
350        assert!(!result.starts_with(&canon_wt));
351    }
352
353    #[test]
354    fn canonicalize_lenient_symlink_inside_worktree_resolves_outside() {
355        let tmp = tempfile::tempdir().unwrap();
356        let wt = tmp.path().join("wt");
357        std::fs::create_dir(&wt).unwrap();
358        let outside = tmp.path().join("outside");
359        std::fs::create_dir(&outside).unwrap();
360        let link = wt.join("link");
361        std::os::unix::fs::symlink(&outside, &link).unwrap();
362        let target = link.join("secret.txt");
363        let result = canonicalize_lenient(&target);
364        let canon_wt = std::fs::canonicalize(&wt).unwrap();
365        assert!(!result.starts_with(&canon_wt));
366    }
367
368    // ---- PathGuard::check_write ----
369
370    fn make_guard(wt: &Path) -> PathGuard {
371        PathGuard::new(wt, &[], &[]).unwrap()
372    }
373
374    #[test]
375    fn check_write_inside_worktree_allowed() {
376        let tmp = tempfile::tempdir().unwrap();
377        let wt = tmp.path().join("wt");
378        std::fs::create_dir(&wt).unwrap();
379        let guard = make_guard(&wt);
380        assert!(guard.check_write(&wt.join("file.txt")).is_ok());
381    }
382
383    #[test]
384    fn check_write_outside_worktree_rejected() {
385        let tmp = tempfile::tempdir().unwrap();
386        let wt = tmp.path().join("wt");
387        std::fs::create_dir(&wt).unwrap();
388        let guard = make_guard(&wt);
389        let outside = tmp.path().join("outside.txt");
390        let err = guard.check_write(&outside).unwrap_err();
391        assert!(err.contains("path outside ticket worktree"));
392        assert!(err.contains("APM_TICKET_WORKTREE"));
393    }
394
395    #[test]
396    fn check_write_rejection_message_contains_worktree() {
397        let tmp = tempfile::tempdir().unwrap();
398        let wt = tmp.path().join("wt");
399        std::fs::create_dir(&wt).unwrap();
400        let guard = make_guard(&wt);
401        let err = guard.check_write(&tmp.path().join("x")).unwrap_err();
402        assert!(err.contains("APM_TICKET_WORKTREE"));
403    }
404
405    #[test]
406    fn check_write_dotdot_escape_rejected() {
407        let tmp = tempfile::tempdir().unwrap();
408        let wt = tmp.path().join("wt");
409        let sub = wt.join("sub");
410        std::fs::create_dir_all(&sub).unwrap();
411        let guard = make_guard(&wt);
412        // wt/sub/../../etc/passwd
413        let path = sub.join("..").join("..").join("etc").join("passwd");
414        assert!(guard.check_write(&path).is_err());
415    }
416
417    #[test]
418    fn check_write_symlink_to_outside_rejected() {
419        let tmp = tempfile::tempdir().unwrap();
420        let wt = tmp.path().join("wt");
421        std::fs::create_dir(&wt).unwrap();
422        let outside = tmp.path().join("outside");
423        std::fs::create_dir(&outside).unwrap();
424        let link = wt.join("link");
425        std::os::unix::fs::symlink(&outside, &link).unwrap();
426        let guard = make_guard(&wt);
427        assert!(guard.check_write(&link.join("file.txt")).is_err());
428    }
429
430    #[test]
431    fn check_write_protected_inside_worktree_rejected() {
432        let tmp = tempfile::tempdir().unwrap();
433        let wt = tmp.path().join("wt");
434        std::fs::create_dir(&wt).unwrap();
435        // apm_bin inside the worktree (e.g. target/debug/apm)
436        let apm_bin = wt.join("target").join("debug").join("apm");
437        std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
438        std::fs::write(&apm_bin, "binary").unwrap();
439        let guard = PathGuard::new(&wt, &[], &[apm_bin.clone()]).unwrap();
440        let err = guard.check_write(&apm_bin).unwrap_err();
441        assert!(err.contains("path outside ticket worktree"));
442    }
443
444    #[test]
445    fn check_write_apm_bin_outside_worktree_rejected() {
446        let tmp = tempfile::tempdir().unwrap();
447        let wt = tmp.path().join("wt");
448        std::fs::create_dir(&wt).unwrap();
449        let apm_bin = tmp.path().join("usr").join("bin").join("apm");
450        std::fs::create_dir_all(apm_bin.parent().unwrap()).unwrap();
451        std::fs::write(&apm_bin, "binary").unwrap();
452        let guard = PathGuard::new(&wt, &[], &[apm_bin.clone()]).unwrap();
453        assert!(guard.check_write(&apm_bin).is_err());
454    }
455
456    // ---- detect_write_targets (bash heuristic) ----
457
458    #[test]
459    fn bash_redirect_gt_detected() {
460        let targets = detect_write_targets("echo foo > /outside/file");
461        assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
462    }
463
464    #[test]
465    fn bash_redirect_gtgt_detected() {
466        let targets = detect_write_targets("cat data >> /outside/append.log");
467        assert!(targets.iter().any(|t| t == "/outside/append.log"), "got: {targets:?}");
468    }
469
470    #[test]
471    fn bash_tee_detected() {
472        let targets = detect_write_targets("some-cmd | tee /outside/output.txt");
473        assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
474    }
475
476    #[test]
477    fn bash_tee_flag_skipped() {
478        let targets = detect_write_targets("some-cmd | tee -a /outside/output.txt");
479        assert!(targets.iter().any(|t| t == "/outside/output.txt"), "got: {targets:?}");
480    }
481
482    #[test]
483    fn bash_cp_dest_detected() {
484        let targets = detect_write_targets("cp /inside/src /outside/dest");
485        assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
486        // /inside/src should NOT be a target
487        assert!(!targets.iter().any(|t| t == "/inside/src"), "src should not be write target: {targets:?}");
488    }
489
490    #[test]
491    fn bash_mv_dest_detected() {
492        let targets = detect_write_targets("mv /inside/file /outside/dest");
493        assert!(targets.iter().any(|t| t == "/outside/dest"), "got: {targets:?}");
494    }
495
496    #[test]
497    fn bash_truncate_detected() {
498        let targets = detect_write_targets("truncate -s 0 /outside/file");
499        assert!(targets.iter().any(|t| t == "/outside/file"), "got: {targets:?}");
500    }
501
502    #[test]
503    fn bash_cat_not_detected() {
504        let targets = detect_write_targets("cat /etc/resolv.conf");
505        assert!(targets.is_empty(), "cat should produce no write targets: {targets:?}");
506    }
507
508    #[test]
509    fn bash_grep_not_detected() {
510        let targets = detect_write_targets("grep pattern /etc/hosts");
511        assert!(targets.is_empty(), "grep should produce no write targets: {targets:?}");
512    }
513
514    #[test]
515    fn bash_ls_not_detected() {
516        let targets = detect_write_targets("ls /outside/dir");
517        assert!(targets.is_empty(), "ls should produce no write targets: {targets:?}");
518    }
519
520    #[test]
521    fn bash_diff_not_detected() {
522        let targets = detect_write_targets("diff /file1 /file2");
523        assert!(targets.is_empty(), "diff should produce no write targets: {targets:?}");
524    }
525
526    #[test]
527    fn bash_wc_not_detected() {
528        let targets = detect_write_targets("wc -l /var/log/syslog");
529        assert!(targets.is_empty(), "wc should produce no write targets: {targets:?}");
530    }
531
532    #[test]
533    fn bash_echo_no_path_not_detected() {
534        let targets = detect_write_targets("echo hello");
535        assert!(targets.is_empty(), "echo without path should produce no write targets: {targets:?}");
536    }
537
538    // ---- PathGuard::check_bash ----
539
540    #[test]
541    fn check_bash_redirect_outside_rejected() {
542        let tmp = tempfile::tempdir().unwrap();
543        let wt = tmp.path().join("wt");
544        std::fs::create_dir(&wt).unwrap();
545        let guard = make_guard(&wt);
546        let outside = tmp.path().join("outside.txt");
547        let cmd = format!("echo foo > {}", outside.display());
548        assert!(guard.check_bash(&cmd).is_err());
549    }
550
551    #[test]
552    fn check_bash_redirect_inside_allowed() {
553        let tmp = tempfile::tempdir().unwrap();
554        let wt = tmp.path().join("wt");
555        std::fs::create_dir(&wt).unwrap();
556        let guard = make_guard(&wt);
557        let inside = wt.join("output.txt");
558        let cmd = format!("echo foo > {}", inside.display());
559        assert!(guard.check_bash(&cmd).is_ok());
560    }
561
562    #[test]
563    fn check_bash_cat_read_allowed() {
564        let tmp = tempfile::tempdir().unwrap();
565        let wt = tmp.path().join("wt");
566        std::fs::create_dir(&wt).unwrap();
567        let guard = make_guard(&wt);
568        assert!(guard.check_bash("cat /etc/resolv.conf").is_ok());
569    }
570
571    #[test]
572    fn check_bash_tilde_gitconfig_allowed() {
573        let tmp = tempfile::tempdir().unwrap();
574        let wt = tmp.path().join("wt");
575        std::fs::create_dir(&wt).unwrap();
576        let guard = make_guard(&wt);
577        assert!(guard.check_bash("cat ~/.gitconfig").is_ok());
578    }
579}