Skip to main content

lean_ctx/core/
command_cwd.rs

1//! Which directory a command's segments actually run in (GH #1661, #1662).
2//!
3//! A tool call carries one `cwd`, but the command it runs may move: `cd repo &&
4//! grep -rn … .` walks a tree the call never named, and `cd "$SCRATCH" && curl
5//! -o shot.png …` writes to a directory that is sanctioned for exactly that.
6//! Judging either against the call's `cwd` alone gets the answer wrong — too
7//! lenient in the first case, too strict in the second.
8//!
9//! Only a plain `cd <literal>` segment is followed, and only where it is
10//! certain to have run before the next command (#1850). A target spelled
11//! through a variable is followed when that variable is known — `$TMPDIR`, or
12//! one this same command set to a literal (#2079, `command_vars`). Everything
13//! else that can move the shell — any other variable, a substitution,
14//! `pushd`/`popd`, a `cd`
15//! inside a subshell, a brace group, a loop or a function, a `cd` that may be
16//! skipped (`a || cd x`) or may fail (`cd missing; …`) — leaves the directory
17//! unknown, and an unknown directory is reported as such rather than guessed
18//! at. The write guard relies on that: judging a later command against a
19//! directory it does not run in is how a redirect lands in the project.
20//!
21//! Quoted text is data: `printf '… cd /tmp …' > f` does not move anything
22//! (#2081). The exception is `eval`, which runs text in this shell, so any
23//! `eval` leaves the directory unknown.
24
25use std::path::{Path, PathBuf};
26
27use super::command_vars::{KnownVars, expand_leading_variable, literal_assignments_before_each};
28use super::shell_allowlist::Separator;
29
30/// One command segment together with the directory it runs in.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct SegmentCwd {
33    pub segment: String,
34    /// `None` once a `cd` has moved somewhere this cannot resolve statically,
35    /// or when more than one directory is possible.
36    pub cwd: Option<PathBuf>,
37}
38
39/// Split `command` into segments, tracking `cd` as it goes.
40///
41/// Segmentation is delegated to the shell tokenizer so this never disagrees
42/// with the allowlist about where one command ends and the next begins.
43pub fn segments_with_cwd(command: &str, base: Option<&Path>) -> Vec<SegmentCwd> {
44    track(command, base).0
45}
46
47/// The directory in effect once every segment has run.
48pub fn final_cwd(command: &str, base: Option<&Path>) -> Option<PathBuf> {
49    track(command, base).1
50}
51
52/// A `cd` that may not be in effect: it can fail (its target does not exist
53/// yet) or be skipped (`a && cd x`, `a || cd x`). Commands reached from it only
54/// through `&&` — and the pipelines among them — run in `moved`; anything that
55/// can also be reached when the `cd` did not happen runs in either directory.
56struct Pending {
57    moved: Option<PathBuf>,
58    /// Where the shell is if this `cd` did not take effect.
59    old: Option<PathBuf>,
60    /// Behind `||`: a successful left side skips the `cd` entirely.
61    skippable: bool,
62    /// `cd x || …`: the next segment is the fallback, which runs only if the
63    /// `cd` did not take effect.
64    fallback: bool,
65}
66
67impl Pending {
68    fn either(self) -> Option<PathBuf> {
69        merge(self.moved, self.old.as_deref())
70    }
71}
72
73fn track(command: &str, base: Option<&Path>) -> (Vec<SegmentCwd>, Option<PathBuf>) {
74    let segments = super::shell_allowlist::segments_with_separators(command);
75    let vars = literal_assignments_before_each(&segments);
76    // `CDPATH` reroutes a relative `cd sub` to wherever it finds `sub` first.
77    let cdpath = std::env::var_os("CDPATH").is_some_and(|v| !v.is_empty())
78        || segments.iter().any(|(s, _)| s.contains("CDPATH"));
79
80    // The directory the next segment starts in.
81    let mut cwd: Option<PathBuf> = base.map(Path::to_path_buf);
82    // Where the current and-or list started: `a && cd x &` runs the whole list
83    // in a background subshell, so nothing in it moves what comes after.
84    let mut list_base = cwd.clone();
85    let mut pending: Option<Pending> = None;
86    let mut prev: Option<Separator> = None;
87    let mut out = Vec::with_capacity(segments.len());
88
89    for ((segment, sep), vars) in segments.into_iter().zip(vars) {
90        // Every segment runs where it starts — a `cd` included: its own
91        // redirect is opened before it moves, and a failed `cd` stays put.
92        let here = cwd.clone();
93        let mut own = here.clone();
94
95        if let Some(p) = pending.take_if(|p| p.fallback) {
96            // `cd x || exit 1; …` — past the fallback, only a working `cd` is
97            // left. A skippable `cd` is not: `a || cd x || exit` exits on neither
98            // path when `a` succeeds.
99            cwd = if exits(&segment) && !p.skippable {
100                p.moved
101            } else if cd_target(&segment).is_some() || moves_opaquely(&segment) {
102                None
103            } else {
104                p.either()
105            };
106        } else if let Some(target) = cd_target(&segment) {
107            let moved = if cdpath && is_cdpath_candidate(target) {
108                None
109            } else {
110                resolve_cd(here.as_deref(), target, &vars)
111            };
112            // With an uncertain `cd` still open (`cd a && cd b`), not taking
113            // this one leaves the shell in `a` *or* where `a` started.
114            let old = match pending.take() {
115                Some(p) => merge(here.clone(), p.old.as_deref()),
116                None => here.clone(),
117            };
118            let runs_unconditionally = matches!(
119                prev,
120                None | Some(Separator::Sequence | Separator::Background)
121            );
122            if prev == Some(Separator::Pipe) || sep == Some(Separator::Pipe) {
123                // A pipeline element runs in a subshell — in bash. zsh runs the
124                // last one in the current shell, so neither reading is safe.
125                cwd = merge(moved, old.as_deref());
126            } else if runs_unconditionally && moved.as_deref().is_some_and(Path::is_dir) {
127                // Runs, and into a directory that exists: it takes effect.
128                cwd = moved;
129            } else {
130                let skippable = prev == Some(Separator::Or);
131                let p = Pending {
132                    moved,
133                    old,
134                    skippable,
135                    fallback: sep == Some(Separator::Or),
136                };
137                cwd = match sep {
138                    Some(Separator::Or) => p.old.clone(),
139                    Some(Separator::Sequence) => merge(p.moved.clone(), p.old.as_deref()),
140                    _ if skippable => merge(p.moved.clone(), p.old.as_deref()),
141                    _ => p.moved.clone(),
142                };
143                if matches!(sep, Some(Separator::Or | Separator::And) | None) {
144                    pending = Some(p);
145                }
146            }
147        } else {
148            if moves_opaquely(&segment) {
149                own = None;
150                // A subshell `( … )` cannot move what follows it; anything else
151                // (a brace group, a loop body, a function, `pushd`) can.
152                if !segment.starts_with('(') {
153                    cwd = None;
154                    pending = None;
155                }
156            }
157            // Leaving the `&&` chain of an uncertain `cd`: what comes next can
158            // also be reached when that `cd` did not happen.
159            if let Some(p) = pending.take() {
160                match sep {
161                    Some(Separator::And | Separator::Pipe) | None => pending = Some(p),
162                    Some(Separator::Or | Separator::Sequence | Separator::Background) => {
163                        cwd = p.either();
164                    }
165                }
166            }
167        }
168
169        out.push(SegmentCwd { segment, cwd: own });
170        match sep {
171            Some(Separator::Background) => {
172                cwd.clone_from(&list_base);
173                pending = None;
174            }
175            Some(Separator::Sequence) => list_base.clone_from(&cwd),
176            _ => {}
177        }
178        prev = sep;
179    }
180    (out, cwd)
181}
182
183/// One directory when both possibilities agree, otherwise unknown.
184fn merge(a: Option<PathBuf>, b: Option<&Path>) -> Option<PathBuf> {
185    if a.as_deref() == b { a } else { None }
186}
187
188/// A `cd x || exit` fallback: nothing after it runs unless the `cd` worked.
189fn exits(segment: &str) -> bool {
190    matches!(segment.split_whitespace().next(), Some("exit" | "return"))
191}
192
193/// A relative name `CDPATH` would look up (`./x` and `../x` bypass it).
194fn is_cdpath_candidate(target: &str) -> bool {
195    let t = target.trim_matches(['"', '\'']);
196    !(t.starts_with('/')
197        || t.starts_with('~')
198        || t.starts_with("./")
199        || t.starts_with("../")
200        || t == "."
201        || t == ".."
202        || Path::new(t).is_absolute())
203}
204
205/// Whether a segment that is not a plain `cd <literal>` may still change the
206/// directory: `pushd`, `popd`, `cd` after `do`/`then`/`builtin`/`command`, or
207/// any of them inside a subshell, brace group, substitution or function body.
208/// Deliberately broad — a false positive only makes the directory unknown.
209///
210/// Only words the shell runs count (#2081): `printf 'cd /tmp' > f` prints the
211/// words, it does not run them. Two scans together cover that:
212///
213/// - the segment with its quoted text blanked, split at whitespace and
214///   operators — this sees `{ cd x; }`, `${ cd x; }`, `do cd x`;
215/// - the shell's own words, quotes removed, split only at operators — this sees
216///   a quoted command name (`"cd" x`, `c'd' x`), which still runs.
217///
218/// A substitution inside double quotes (`"$(cd x)"`) runs in a subshell and
219/// cannot move this one. `eval` runs arbitrary text in this shell, so any
220/// `eval` makes the directory unknown.
221fn moves_opaquely(segment: &str) -> bool {
222    const MOVERS: [&str; 4] = ["cd", "pushd", "popd", "chdir"];
223    let split_words = |text: &str| -> Vec<String> {
224        text.split(|c: char| c.is_whitespace() || OPERATORS.contains(c))
225            .map(str::to_string)
226            .collect()
227    };
228    let mut words = split_words(&blank_quoted(segment));
229    words.extend(
230        super::shell_allowlist::shell_tokenize(segment)
231            .iter()
232            .flat_map(|token| token.split(|c: char| OPERATORS.contains(c)))
233            .map(str::to_string),
234    );
235    words
236        .iter()
237        .any(|w| w == "eval" || MOVERS.contains(&w.as_str()))
238}
239
240/// Characters that end a shell word besides whitespace.
241const OPERATORS: &str = "(){};&|`";
242
243/// `segment` with every quoted character — quotes included — replaced by a
244/// space, so a word scan sees only what is not quoted. A backslash escapes the
245/// next character outside single quotes, as in the shell.
246fn blank_quoted(segment: &str) -> String {
247    let mut out = String::with_capacity(segment.len());
248    let mut chars = segment.chars();
249    let mut single = false;
250    let mut double = false;
251    while let Some(c) = chars.next() {
252        match c {
253            '\\' if !single => {
254                // Consumed here, the escaped character never toggles a quote.
255                let next = chars.next();
256                if double {
257                    out.push(' ');
258                    if next.is_some() {
259                        out.push(' ');
260                    }
261                } else {
262                    out.push(c);
263                    out.extend(next);
264                }
265            }
266            '\'' if !double => {
267                single = !single;
268                out.push(' ');
269            }
270            '"' if !single => {
271                double = !double;
272                out.push(' ');
273            }
274            _ if single || double => out.push(' '),
275            _ => out.push(c),
276        }
277    }
278    out
279}
280
281/// The literal argument of a segment that is exactly `cd <path>`.
282fn cd_target(segment: &str) -> Option<&str> {
283    let mut words = segment.split_whitespace();
284    if words.next()? != "cd" {
285        return None;
286    }
287    let target = words.next()?;
288    if words.next().is_some() {
289        // `cd a b` is not something to reason about.
290        return None;
291    }
292    Some(target)
293}
294
295/// Apply one `cd`, or give up on knowing the directory.
296///
297/// `cd` with no resolvable target is not "stay here": it moves somewhere this
298/// cannot see. Returning `None` keeps callers from judging a later segment
299/// against a directory it does not run in.
300///
301/// A target that starts with a known variable (`cd $TMPDIR/x`, `D=/tmp/x; cd
302/// "$D"`) is followed like its literal value (#2079). Inside single quotes `$`
303/// is a literal character, and any other variable is unknown.
304fn resolve_cd(from: Option<&Path>, target: &str, vars: &KnownVars) -> Option<PathBuf> {
305    let unquoted = target.trim_matches(['"', '\'']);
306    let expanded;
307    let unquoted = if unquoted.starts_with('$') && !target.starts_with('\'') {
308        expanded = expand_leading_variable(unquoted, vars)?;
309        expanded.as_str()
310    } else {
311        unquoted
312    };
313    if unquoted.is_empty() || unquoted.contains('$') || unquoted.contains('`') || unquoted == "-" {
314        return None;
315    }
316    // `cd ~` and `cd ~/x` are the shell's expansion, not a relative path.
317    if let Some(rest) = unquoted.strip_prefix('~') {
318        let home = std::env::var_os("HOME").map(PathBuf::from)?;
319        return Some(join_in(&home, rest.trim_start_matches('/')));
320    }
321    // This is shell text, so `/tmp` is an absolute POSIX path even on Windows,
322    // where `Path::is_absolute` says otherwise. Agents write Unix paths there
323    // routinely (Git Bash, WSL, generated commands) — the same reason
324    // `is_unix_scratch_prefix` exists (#1467). Treating them as relative made a
325    // `cd /tmp` resolve to nothing on Windows, and the guard then judged the
326    // download target against no directory at all.
327    if unquoted.starts_with('/') {
328        return Some(PathBuf::from(normalize_posix(unquoted)));
329    }
330    let path = Path::new(unquoted);
331    if path.is_absolute() {
332        return Some(normalize(path));
333    }
334    Some(join_in(from?, unquoted))
335}
336
337/// Join `rel` under `base`, keeping a POSIX-rooted base POSIX.
338///
339/// `Path::join` would splice a backslash into `/tmp` on Windows, and the
340/// scratch-prefix checks match on `/tmp/`.
341pub fn join_in(base: &Path, rel: &str) -> PathBuf {
342    let base_str = base.to_string_lossy();
343    if base_str.starts_with('/') {
344        return PathBuf::from(normalize_posix(&format!(
345            "{}/{rel}",
346            base_str.trim_end_matches('/')
347        )));
348    }
349    normalize(&base.join(rel))
350}
351
352/// Resolve `.` and `..` in a POSIX path textually, on any platform.
353fn normalize_posix(path: &str) -> String {
354    let mut out: Vec<&str> = Vec::new();
355    for part in path.split('/') {
356        match part {
357            "" | "." => {}
358            ".." => {
359                out.pop();
360            }
361            other => out.push(other),
362        }
363    }
364    format!("/{}", out.join("/"))
365}
366
367/// Resolve `.` and `..` textually. The directory may not exist yet, so this
368/// deliberately does not touch the filesystem.
369fn normalize(path: &Path) -> PathBuf {
370    use std::path::Component;
371    let mut out = PathBuf::new();
372    for component in path.components() {
373        match component {
374            Component::CurDir => {}
375            Component::ParentDir => {
376                out.pop();
377            }
378            other => out.push(other),
379        }
380    }
381    out
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn a_leading_cd_moves_the_following_segments() {
390        let segs = segments_with_cwd(
391            "cd /tmp/work && curl -o x.png http://e/x",
392            Some(Path::new("/proj")),
393        );
394        assert_eq!(
395            segs.last().unwrap().cwd.as_deref(),
396            Some(Path::new("/tmp/work"))
397        );
398    }
399
400    #[test]
401    fn without_a_cd_every_segment_keeps_the_call_directory() {
402        let segs = segments_with_cwd("echo a && echo b", Some(Path::new("/proj")));
403        assert!(
404            segs.iter()
405                .all(|s| s.cwd.as_deref() == Some(Path::new("/proj")))
406        );
407    }
408
409    #[test]
410    fn a_relative_cd_is_joined_onto_the_call_directory() {
411        assert_eq!(
412            final_cwd("cd sub/dir && ls", Some(Path::new("/proj"))),
413            Some(PathBuf::from("/proj/sub/dir"))
414        );
415        assert_eq!(
416            final_cwd("cd ../side && ls", Some(Path::new("/proj/here"))),
417            Some(PathBuf::from("/proj/side"))
418        );
419    }
420
421    /// A directory the shell would have to compute is unknown, not the old one
422    /// — judging a later segment against a stale directory is how a guard ends
423    /// up permitting or blocking the wrong thing.
424    #[test]
425    fn an_unresolvable_cd_makes_the_directory_unknown() {
426        assert_eq!(
427            final_cwd(
428                "cd \"$LEAN_CTX_2079_NEVER_SET\" && ls",
429                Some(Path::new("/proj"))
430            ),
431            None
432        );
433        assert_eq!(final_cwd("cd - && ls", Some(Path::new("/proj"))), None);
434    }
435
436    /// A Unix path in shell text stays a Unix path on Windows too (#1467).
437    /// This is the case that broke CI: `cd /tmp` resolved to nothing there, so
438    /// the download guard judged `shot.png` against no directory at all.
439    #[test]
440    fn a_unix_path_in_shell_text_is_absolute_on_every_platform() {
441        assert_eq!(
442            final_cwd("cd /tmp && curl -o shot.png https://e/x", None),
443            Some(PathBuf::from("/tmp"))
444        );
445        assert_eq!(
446            final_cwd("cd /private/tmp/session/../session && ls", None),
447            Some(PathBuf::from("/private/tmp/session"))
448        );
449        assert_eq!(
450            join_in(Path::new("/tmp/work"), "sub/shot.png"),
451            PathBuf::from("/tmp/work/sub/shot.png"),
452            "and joins with a forward slash, which the scratch prefixes match on"
453        );
454    }
455
456    #[test]
457    fn no_base_and_no_cd_is_unknown() {
458        assert_eq!(final_cwd("ls -la", None), None);
459    }
460
461    fn cwds(command: &str) -> Vec<Option<PathBuf>> {
462        segments_with_cwd(command, Some(Path::new("/proj")))
463            .into_iter()
464            .map(|s| s.cwd)
465            .collect()
466    }
467
468    const MISSING: &str = "/lean-ctx-1850-does-not-exist";
469
470    /// A `cd` runs where it starts: its own redirect is opened before it moves.
471    #[test]
472    fn a_cd_segment_runs_in_the_directory_before_it() {
473        assert_eq!(cwds("cd /tmp && ls")[0], Some(PathBuf::from("/proj")));
474    }
475
476    /// #1850: `cd x && a; b` — if the `cd` fails, `a` is skipped but `b` still
477    /// runs, in the old directory. Past the `;` both are possible.
478    #[test]
479    fn a_cd_that_may_fail_does_not_carry_past_a_sequence() {
480        let got = cwds(&format!("cd {MISSING} && true; echo x"));
481        assert_eq!(got[1], Some(PathBuf::from(MISSING)), "reached only via &&");
482        assert_eq!(got[2], None);
483    }
484
485    #[test]
486    fn a_cd_that_may_be_skipped_leaves_the_directory_unknown() {
487        assert_eq!(cwds("false && cd /tmp; echo x")[2], None);
488        assert_eq!(cwds("true || cd /tmp && echo x")[2], None);
489        assert_eq!(cwds("cd /tmp | cat; echo x")[2], None, "a pipeline element");
490    }
491
492    /// `cd a && cd .` must not merge the two possibilities into one directory
493    /// just because both `cd`s name the same place.
494    #[test]
495    fn a_second_cd_does_not_make_a_failed_first_one_certain() {
496        assert_eq!(cwds(&format!("cd {MISSING} && cd . ; echo x"))[2], None);
497    }
498
499    #[test]
500    fn cd_or_exit_leaves_only_the_moved_directory() {
501        let got = cwds(&format!("cd {MISSING} || exit 1; echo x"));
502        assert_eq!(got[1], Some(PathBuf::from("/proj")), "the fallback");
503        assert_eq!(got[2], Some(PathBuf::from(MISSING)));
504    }
505
506    /// `&` runs the whole and-or list in a background subshell.
507    #[test]
508    fn a_backgrounded_list_does_not_move_what_follows() {
509        assert_eq!(
510            cwds("cd /tmp && echo a & echo b")[2],
511            Some(PathBuf::from("/proj"))
512        );
513    }
514
515    #[test]
516    fn opaque_movers_make_the_directory_unknown() {
517        for command in [
518            "pushd /tmp && echo x",
519            "builtin cd /tmp && echo x",
520            "{ cd /tmp; } && echo x",
521        ] {
522            assert_eq!(cwds(command).last(), Some(&None), "{command}");
523        }
524        let subshell = cwds("(cd /tmp) && echo x");
525        assert_eq!(subshell[0], None, "its own directory is not followed");
526        assert_eq!(
527            subshell[1],
528            Some(PathBuf::from("/proj")),
529            "but it cannot move the parent shell"
530        );
531    }
532
533    #[test]
534    fn cdpath_makes_a_relative_cd_unknown() {
535        assert_eq!(cwds("CDPATH=/elsewhere; cd sub && ls")[2], None);
536        assert_eq!(
537            cwds("CDPATH=/elsewhere; cd ./sub && ls")[2],
538            Some(PathBuf::from("/proj/sub")),
539            "`./` bypasses CDPATH"
540        );
541    }
542
543    /// #2081: quoted text is data. The reporter's form: a log line that
544    /// mentions `cd $TMPDIR`, written with a relative redirect.
545    #[test]
546    fn a_cd_inside_a_quoted_argument_does_not_move_anything() {
547        for command in [
548            "printf '%s\\n' 'note: cd /tmp mention' > f.txt",
549            "cd /proj && printf '| %s | (cd $TMPDIR not followed) |\\n' x >> f.txt",
550            "echo \"pushd here, popd there, then cd back\" > f.txt",
551            "echo \"$(cd /tmp; pwd)\" > f.txt",
552            "echo 'it'\\''s cd' > f.txt",
553        ] {
554            assert_eq!(
555                cwds(command).last(),
556                Some(&Some(PathBuf::from("/proj"))),
557                "{command}"
558            );
559        }
560    }
561
562    /// Quoting the command name does not stop the shell running it, and `eval`
563    /// runs text in this shell: those still leave the directory unknown.
564    #[test]
565    fn a_quoted_command_name_or_eval_still_moves_the_shell() {
566        for command in [
567            "\"cd\" /tmp && echo x",
568            "c'd' /tmp && echo x",
569            "\\cd /tmp && echo x",
570            "builtin 'cd' /tmp && echo x",
571            "eval 'cd /tmp' && echo x",
572            "'eval' \"c\"\"d /tmp\" && echo x",
573            "echo ${ cd /tmp; } && echo x",
574        ] {
575            assert_eq!(cwds(command).last(), Some(&None), "{command}");
576        }
577    }
578
579    /// #2079: a `cd` through a variable this command set to a literal, or
580    /// through `$TMPDIR`, is followed like the literal.
581    #[test]
582    fn a_cd_through_a_known_variable_is_followed() {
583        assert_eq!(
584            cwds("D=/tmp && cd $D && echo x").last(),
585            Some(&Some(PathBuf::from("/tmp")))
586        );
587        assert_eq!(
588            cwds("D=/tmp; cd \"${D}/sub\" && echo x").last(),
589            Some(&Some(PathBuf::from("/tmp/sub")))
590        );
591        assert_eq!(
592            cwds("D=sub; cd $D && echo x").last(),
593            Some(&Some(PathBuf::from("/proj/sub"))),
594            "a relative value is joined like a relative literal"
595        );
596        let expected = std::env::var("TMPDIR")
597            .ok()
598            .filter(|t| !t.is_empty() && !t.contains(['$', '`']))
599            .map(|t| resolve_cd(None, &t, &KnownVars::default()));
600        assert_eq!(
601            final_cwd("cd $TMPDIR && ls", Some(Path::new("/proj"))),
602            expected.flatten(),
603            "$TMPDIR resolves exactly as its value spelled out"
604        );
605    }
606
607    /// Anything the variable resolution cannot vouch for stays unknown.
608    #[test]
609    fn a_cd_through_an_unknown_variable_stays_unknown() {
610        for command in [
611            "cd $D && echo x",
612            "true && D=/tmp; cd $D && echo x",
613            "D=/tmp; read D; cd $D && echo x",
614            "TMPDIR=$PWD; cd $TMPDIR && echo x",
615            "unset TMPDIR; cd $TMPDIR && echo x",
616            // Inside single quotes `$D` is the literal name of a directory.
617            "D=/tmp; cd '$D' && echo x",
618            "D=/tmp; cd $Dx && echo x",
619        ] {
620            assert_eq!(cwds(command).last(), Some(&None), "{command}");
621        }
622    }
623}