Skip to main content

safe_chains/
pathctx.rs

1//! The directory context the harness supplies (HP-19): the working directory a command
2//! runs in, and the project root. It exists to make relative-path classification honest —
3//! `cd /etc && echo > ./x` must be seen as writing `/etc/x`, not a worktree file.
4//!
5//! The context is **ambient** for one command evaluation: a single `cwd`/`root` pair threads
6//! logically through the whole recursive verdict tree (script → pipeline → cmd → redirect →
7//! leaf). Rather than add a pass-through parameter to ~15 recursive functions across `cst`,
8//! `handlers`, and `engine`, it lives in a scoped thread-local, installed by
9//! [`enter`] at the top of an evaluation and read at exactly two leaves: legacy
10//! `is_safe_write_target` and engine `classify_locus`, both via [`resolve`].
11//!
12//! Everything is fail-open to *today's* behavior: with no `cwd`/`root` (or an unresolvable
13//! path) [`resolve`] returns the path unchanged, so the classifiers behave exactly as before
14//! — the signal tightens when present, never a regression when absent.
15
16use std::borrow::Cow;
17use std::cell::RefCell;
18
19pub mod anchor;
20pub mod folder;
21pub(crate) use folder::judging;
22mod home;
23pub mod item_shape;
24mod lexical;
25use home::home_path_inside_root;
26pub use item_shape::{Binding, Facts, ItemShape, ItemShapeGuard, binding, enter_loop_shape, enter_stdin_shape, loop_shape, stdin_shape};
27use lexical::{expand_home, express_relative_to_root, lexical_join};
28
29/// The working directory and project root for the command under evaluation. Both optional:
30/// a harness may supply neither (e.g. opencode), and classification falls back to the
31/// relative-is-worktree assumption.
32#[derive(Clone, Default)]
33pub struct PathCtx {
34    pub cwd: Option<String>,
35    pub root: Option<String>,
36    /// The harness's session id, when it supplies one. Only used to recognize this session's
37    /// SCRATCHPAD — see [`in_session_scratchpad`].
38    pub session_id: Option<String>,
39}
40
41thread_local! {
42    static CURRENT: RefCell<PathCtx> = RefCell::new(PathCtx::default());
43}
44
45/// Install `ctx` as the ambient context for the duration of the returned guard; the previous
46/// context is restored on drop (panic-safe, so a failing test can't leak into the next).
47#[must_use]
48pub fn enter(ctx: PathCtx) -> Guard {
49    Guard(CURRENT.with(|c| c.replace(ctx)))
50}
51
52/// Restores the previous [`PathCtx`] when dropped.
53pub struct Guard(PathCtx);
54
55impl Drop for Guard {
56    fn drop(&mut self) {
57        CURRENT.with(|c| *c.borrow_mut() = std::mem::take(&mut self.0));
58    }
59}
60
61/// Run `f` with the ambient `cwd` temporarily replaced (root unchanged) — used by intra-line
62/// `cd` tracking as it walks a chain's statements. Restored on drop.
63#[must_use]
64pub fn enter_cwd(cwd: Option<String>) -> Guard {
65    Guard(CURRENT.with(|c| {
66        let mut b = c.borrow_mut();
67        // `session_id` is carried through unchanged: a `cd` mid-chain must not drop scratchpad
68        // recognition (the session is the same session whatever directory it walks into).
69        PathCtx { cwd: std::mem::replace(&mut b.cwd, cwd), root: b.root.clone(), session_id: b.session_id.clone() }
70    }))
71}
72
73/// The ambient working directory, if known.
74pub fn cwd() -> Option<String> {
75    CURRENT.with(|c| c.borrow().cwd.clone())
76}
77
78/// The workspace ROOT (project dir), if known — falling back to `cwd` (the hook defaults root to
79/// cwd). Used by the adjacent-sibling classifier to find the workspace's parent.
80pub fn root() -> Option<String> {
81    CURRENT.with(|c| {
82        let b = c.borrow();
83        b.root.clone().or_else(|| b.cwd.clone())
84    })
85}
86
87/// Whether `path` lies inside THIS session's scratchpad — the harness's own per-session working
88/// directory (e.g. Claude Code's `/private/tmp/claude-<uid>/<project-slug>/<session-id>/scratchpad`).
89///
90/// The anchor is the **session id as a whole path component**, not the surrounding layout. That is
91/// deliberate and is what makes this safe *and* durable:
92///
93/// - **Unforgeable.** The session id arrives in the harness's own hook envelope, never from the
94///   agent's shell. An attacker cannot pre-plant `/tmp/<this-session-id>/evil.sh` because the id is
95///   unknown until the session exists (and it is unique per session). Compare a *layout* pattern
96///   ("anything under `/tmp/claude-*`"), which anyone can create.
97/// - **Durable.** It survives the harness reorganizing the parts around the id — the uid suffix,
98///   the slug, `/tmp` vs `/private/tmp`, the trailing directory name. Those are internal details
99///   (Claude Code does not document or expose the scratchpad path, and declined to; see
100///   docs/design/agent-scratchpad.md), so matching them exactly would be brittle.
101/// - **Fail-closed.** No session id, or a path that does not contain it, simply does not match:
102///   the path keeps its ordinary classification (`/tmp` → `temp`, i.e. foreign). A harness that
103///   supplies no id, or whose scratchpad omits it, is exactly as restricted as before — never worse.
104///
105/// Requiring a TEMP-root prefix as well keeps the id from blessing something outside the scratch
106/// area on a harness that happens to embed the id elsewhere (a log path under `$HOME`, say).
107pub fn in_session_scratchpad(path: &str) -> bool {
108    let Some(id) = CURRENT.with(|c| c.borrow().session_id.clone()) else {
109        return false;
110    };
111    // A short or trivial id could collide with an ordinary directory name; require something
112    // id-shaped before trusting it as an anchor.
113    if id.len() < 8 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
114        return false;
115    }
116    if !under_temp_root(path) {
117        return false;
118    }
119    path.split('/').any(|seg| seg == id)
120}
121
122/// Whether `path` is under a temporary-filesystem root. macOS's `/tmp` is a symlink to
123/// `/private/tmp`, and harnesses report either spelling, so both are accepted (as is `$TMPDIR`).
124pub fn under_temp_root(path: &str) -> bool {
125    const ROOTS: &[&str] = &["/tmp/", "/private/tmp/", "/var/tmp/", "/private/var/tmp/"];
126    if ROOTS.iter().any(|r| path.starts_with(r)) {
127        return true;
128    }
129    std::env::var("TMPDIR").ok().is_some_and(|t| {
130        let t = t.trim_end_matches('/');
131        !t.is_empty() && t.starts_with('/') && path.starts_with(&format!("{t}/"))
132    })
133}
134
135/// A bound `for` loop variable: `$name` in the body inherits the loop's `in`-list locus (the
136/// `find … {}`→path binding, one layer up). Read and write representatives can differ — a list
137/// like `/etc/hosts ~/notes` reads worst at `~/notes` but writes worst at `/etc/hosts`.
138struct LoopVar {
139    name: String,
140    read_repr: String,
141    write_repr: String,
142}
143
144thread_local! {
145    static LOOP_VARS: RefCell<Vec<LoopVar>> = const { RefCell::new(Vec::new()) };
146}
147
148/// A bound `VAR=value` assignment or a function positional (`$1`). Unlike a loop var, a certain
149/// literal value has ONE representative for both read and write.
150struct VarBinding {
151    name: String,
152    value: String,
153}
154
155thread_local! {
156    static VARS: RefCell<Vec<VarBinding>> = const { RefCell::new(Vec::new()) };
157}
158
159/// Bind `name` to a CERTAIN literal `value` (a `VAR=/path` assignment, or `$1` at a function call)
160/// for the duration of the guard; consulted by `expand_vars` AFTER loop vars, so the innermost/latest
161/// binding wins. The caller binds only values it is certain of — an uncertain value (`VAR=$(cmd)`,
162/// `VAR=$UNBOUND`) is bound to the unpinnable sentinel so `$VAR` still fail-closes rather than
163/// resolving to a stale or dropped value.
164#[must_use]
165pub fn enter_var(name: String, value: String) -> VarGuard {
166    VARS.with(|v| v.borrow_mut().push(VarBinding { name, value }));
167    VarGuard
168}
169
170pub struct VarGuard;
171
172impl Drop for VarGuard {
173    fn drop(&mut self) {
174        VARS.with(|v| {
175            v.borrow_mut().pop();
176        });
177    }
178}
179
180/// Bind loop variable `name` to its list's representative items for the duration of the guard
181/// (the loop body's classification). Nested loops stack; the innermost binding of a name wins.
182#[must_use]
183pub fn enter_loop_var(name: String, read_repr: String, write_repr: String) -> LoopGuard {
184    LOOP_VARS.with(|v| v.borrow_mut().push(LoopVar { name, read_repr, write_repr }));
185    LoopGuard
186}
187
188/// Pops the loop binding when dropped.
189pub struct LoopGuard;
190
191impl Drop for LoopGuard {
192    fn drop(&mut self) {
193        LOOP_VARS.with(|v| {
194            v.borrow_mut().pop();
195        });
196    }
197}
198
199thread_local! {
200    static STDIN_REPR: RefCell<Vec<String>> = const { RefCell::new(Vec::new()) };
201}
202
203/// Bind the representative PATH of the items arriving on stdin, for the duration of the guard —
204/// set by the pipeline walker to the previous stage's output-path locus. An operand-injecting
205/// consumer (`xargs`) reads it so `find / | xargs cat` gates the injected operand at `/`, while
206/// `find ./src | xargs cat` gates it at the workspace (mirrors `find -exec`'s `{}` binding).
207#[must_use]
208pub fn enter_stdin_repr(repr: String) -> StdinReprGuard {
209    STDIN_REPR.with(|v| v.borrow_mut().push(repr));
210    StdinReprGuard
211}
212
213/// The current stdin-item representative, or `None` when the source is unknown (no pipe / an
214/// unmodeled producer) — in which case the consumer worst-cases the injected operand.
215pub fn stdin_item_repr() -> Option<String> {
216    STDIN_REPR.with(|v| v.borrow().last().cloned())
217}
218
219pub struct StdinReprGuard;
220
221impl Drop for StdinReprGuard {
222    fn drop(&mut self) {
223        STDIN_REPR.with(|v| {
224            v.borrow_mut().pop();
225        });
226    }
227}
228
229/// Expand any bound loop variable (`$name` / `${name}`) in `path` to its representative list
230/// item — the read representative when `want_write` is false, the write representative when
231/// true. Unbound `$…` is left untouched (so it still fail-closes to machine). Returns `path`
232/// unchanged when nothing is bound.
233pub fn expand_vars(path: &str, want_write: bool) -> Cow<'_, str> {
234    if !path.contains('$') {
235        return Cow::Borrowed(path);
236    }
237    let replaced = LOOP_VARS.with(|lv| {
238        VARS.with(|v| {
239            let loops = lv.borrow();
240            let vars = v.borrow();
241            if loops.is_empty() && vars.is_empty() { None } else { expand_with(path, &loops, &vars, want_write) }
242        })
243    });
244    replaced.map_or(Cow::Borrowed(path), Cow::Owned)
245}
246
247fn expand_with(path: &str, loops: &[LoopVar], vars: &[VarBinding], want_write: bool) -> Option<String> {
248    let mut out = String::with_capacity(path.len());
249    let mut rest = path;
250    let mut replaced = false;
251    while let Some(dollar) = rest.find('$') {
252        out.push_str(&rest[..dollar]);
253        let after = &rest[dollar + 1..];
254        match parse_var(after) {
255            Some((name, consumed)) => {
256                // Loop bindings first (they carry read/write reprs), then assignment/positional
257                // bindings; innermost/latest wins in each. Unbound in BOTH → left untouched.
258                if let Some(lv) = loops.iter().rev().find(|v| v.name == name) {
259                    out.push_str(if want_write { &lv.write_repr } else { &lv.read_repr });
260                    replaced = true;
261                } else if let Some(vb) = vars.iter().rev().find(|v| v.name == name) {
262                    out.push_str(&vb.value);
263                    replaced = true;
264                } else {
265                    out.push('$');
266                    out.push_str(&after[..consumed]);
267                }
268                rest = &after[consumed..];
269            }
270            None => {
271                out.push('$');
272                rest = after;
273            }
274        }
275    }
276    out.push_str(rest);
277    replaced.then_some(out)
278}
279
280/// Parse a shell variable name immediately after a `$`: `name`, `{name}`, a single-digit positional
281/// (`$1`; bash reads `$12` as `$1` then `2`), or a braced positional (`${10}`). Returns the name and
282/// how many bytes of `after` it consumed, or `None` if it isn't a variable reference.
283fn parse_var(after: &str) -> Option<(&str, usize)> {
284    if let Some(braced) = after.strip_prefix('{') {
285        let close = braced.find('}')?;
286        let name = &braced[..close];
287        is_var_name(name).then_some((name, close + 2)) // '{' + name + '}'
288    } else if after.as_bytes().first().is_some_and(u8::is_ascii_digit) {
289        Some((&after[..1], 1)) // unbraced positional: exactly one digit
290    } else {
291        let len = after.bytes().take_while(|&b| b.is_ascii_alphanumeric() || b == b'_').count();
292        let name = &after[..len];
293        is_var_name(name).then_some((name, len))
294    }
295}
296
297/// A `${…}` interior is a variable name — an identifier (`[A-Za-z_][A-Za-z0-9_]*`) OR an all-digit
298/// positional (`${10}`).
299fn is_var_name(s: &str) -> bool {
300    if s.is_empty() {
301        return false;
302    }
303    if s.bytes().all(|b| b.is_ascii_digit()) {
304        return true;
305    }
306    let mut bytes = s.bytes();
307    matches!(bytes.next(), Some(b) if b.is_ascii_alphabetic() || b == b'_') && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
308}
309
310/// Resolve a path argument for classification against the ambient `cwd`/`root`. Returns a
311/// path the *existing* classifiers (`classify_locus`, `is_safe_write_target`) can score
312/// unchanged. When `cwd` and `root` are both known and absolute, a **relative** path is lexically
313/// joined onto `cwd` (no filesystem access) and an **absolute** path is normalized in place; then
314/// either way, if the result is inside `root` it comes back as a **root-relative** path (so the
315/// classifiers see "worktree"), and if it escaped `root` (e.g. `cwd` is `/etc`, or an absolute
316/// `/etc/hosts`) it comes back **absolute** (so they see `machine`/etc.). This makes the absolute
317/// and relative spellings of the SAME in-root file classify identically — safety on the OPERATION,
318/// not the SYNTAX. A `~/…` path that expands into `root` comes back root-relative too, as its
319/// `$HOME/…` spelling does; any other `~` path, a `$`-unpinnable one, or no context, is returned
320/// as-is for the home classifiers. In an unknown folder (`folder`), a placed path joins the root.
321pub fn resolve(path: &str) -> Cow<'_, str> {
322    resolve_placed(path, None)
323}
324
325/// As [`resolve`], for a path put to `use_`. A write or a rebind is recorded as one the command leaf
326/// being judged NAMED (`folder`).
327pub fn resolve_for(path: &str, use_: anchor::Use) -> Cow<'_, str> {
328    if use_.mutates() {
329        folder::note_named_write();
330    }
331    resolve_placed(path, Some(use_))
332}
333
334fn resolve_placed(path: &str, use_: Option<anchor::Use>) -> Cow<'_, str> {
335    if path.is_empty() || path.contains('$') {
336        return Cow::Borrowed(path);
337    }
338    if path.starts_with('~') {
339        return home_path_inside_root(path).map_or(Cow::Borrowed(path), Cow::Owned);
340    }
341    let resolved = CURRENT.with(|c| {
342        let ctx = c.borrow();
343        match (ctx.cwd.as_deref(), ctx.root.as_deref()) {
344            (Some(cwd), Some(root)) if cwd.starts_with('/') && root.starts_with('/') => {
345                // Relative → join onto cwd; absolute → normalize in place. Then express relative
346                // to root if inside (worktree), else absolute.
347                let abs = if path.starts_with('/') {
348                    lexical_join("/", path)
349                } else {
350                    folder::place(cwd, path, use_).map_or_else(|| lexical_join(cwd, path), |placed| lexical_join(root, &placed))
351                };
352                Some(express_relative_to_root(&abs, root))
353            }
354            _ => None,
355        }
356    });
357    resolved.map_or(Cow::Borrowed(path), Cow::Owned)
358}
359
360/// The cwd after a `cd` whose target cannot be pinned. It looks like a path AND is unpinnable, so
361/// every later relative path resolved against it worst-cases in both gate layers.
362pub(crate) const UNRESOLVED_CWD: &str = "/__SAFE_CHAINS_CMDSUB__";
363
364/// Resolve a `cd` target to a new working directory. `cur` is the current cwd, needed for a
365/// *relative* target. Used by intra-line `cd` tracking (HP-19 #2).
366///
367/// A target that cannot be pinned yields `UNRESOLVED_CWD`, NOT `None`. This used to return `None`
368/// for `~…` and `$VAR`, and the caller reads `None` as "no cd happened" and keeps the previous cwd
369/// — so the `cd` was silently ignored and every later relative path was judged against a workspace
370/// the shell had already left. `cd ~/.aws && cat credentials` and `cd ~/.claude && echo … >
371/// settings.json` both auto-approved that way, the second writing the very file the allowlist
372/// bridge trusts. `None` now means only what the caller can act on: `cd_target` already returned
373/// `None` for "not a cd" (bare `cd`, `cd -`), so reaching here means the shell definitely moved.
374///
375/// `~`/`~/…` are EXPANDED rather than blanket-refused, because they are pinnable — that keeps
376/// `cd ~/.aws && cat credentials` gated as the credential read it is instead of coarsely denying
377/// everything downstream.
378pub fn join_cwd(cur: Option<&str>, target: &str) -> Option<String> {
379    let expanded = match expand_home(target) {
380        Some(t) => t,
381        None => return Some(UNRESOLVED_CWD.to_string()), // `~` with no HOME
382    };
383    // Another user's home, a variable, an UNDECLARED substitution: the shell moved somewhere we
384    // cannot name. Fail closed rather than pretend it stayed put.
385    //
386    // A DECLARED substitution (`cd $(pwd)`) is deliberately not in that set. Its sentinel carries a
387    // locus, so letting it through the joins below keeps the cwd at that locus — `cd $(pwd) && cat
388    // f` stays a worktree read, while `cd $(fd d /etc) && cat f` is a machine one. Refusing both
389    // would have been sound but needlessly coarse.
390    if expanded.starts_with('~') || expanded.contains('$') || crate::cst::check::is_opaque_value(&expanded) {
391        return Some(UNRESOLVED_CWD.to_string());
392    }
393    if expanded.starts_with('/') {
394        return Some(lexical_join("/", &expanded)); // absolute — normalize
395    }
396    // Relative with no known base: the base was already unknown before the `cd`, so this changes
397    // nothing about how later paths are judged. Left as "no update" rather than tightened, so the
398    // fix stays aimed at the hole (a cd to a KNOWN-elsewhere place being dropped).
399    cur.filter(|c| c.starts_with('/')).map(|c| lexical_join(c, &expanded))
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405
406    const SID: &str = "7676dbc5-a265-43b3-a0f8-49666792bd9b";
407
408    fn with_session<T>(id: Option<&str>, f: impl FnOnce() -> T) -> T {
409        let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), session_id: id.map(str::to_string) });
410        f()
411    }
412
413    /// The recognition rule is a SECURITY boundary: matching a path that is not really this
414    /// session's scratchpad would hand `sandbox-scope` (and therefore EXECUTE) to foreign code. So
415    /// enumerate the spoofing class rather than one example — every way a hostile or unrelated path
416    /// could try to look like the scratchpad must fail, and every legitimate spelling must match.
417    #[test]
418    fn only_this_sessions_scratchpad_is_recognized() {
419        let scratch = format!("/private/tmp/claude-501/-Users-u-proj/{SID}/scratchpad");
420        let matching: &[String] = &[
421            format!("{scratch}/build.sh"),
422            format!("{scratch}/nested/deep/gen.py"),
423            scratch.clone(),
424            // layout around the id varies by harness/platform — the id is the anchor, not the shape
425            format!("/tmp/{SID}/x.sh"),
426            format!("/tmp/some-other-harness/{SID}/work/x.sh"),
427            format!("/var/tmp/{SID}/x.sh"),
428        ];
429        let rejected: &[String] = &[
430            // a DIFFERENT session's scratchpad — same layout, wrong id
431            "/private/tmp/claude-501/-Users-u-proj/00000000-1111-2222-3333-444444444444/scratchpad/x.sh".into(),
432            // the id as a SUBSTRING of a component, not a component (the prefix/suffix attack)
433            format!("/tmp/{SID}-evil/x.sh"),
434            format!("/tmp/evil-{SID}/x.sh"),
435            format!("/tmp/a{SID}/x.sh"),
436            // right id, but OUTSIDE any temp root — the id must not bless arbitrary locations
437            format!("/home/u/{SID}/x.sh"),
438            format!("~/.ssh/{SID}/id_rsa"),
439            format!("/etc/{SID}/passwd"),
440            // anonymous temp files carry no id at all
441            "/tmp/evil.sh".into(),
442            "/private/tmp/downloaded.sh".into(),
443        ];
444        with_session(Some(SID), || {
445            for p in matching {
446                assert!(in_session_scratchpad(p), "should be recognized: {p}");
447            }
448            for p in rejected {
449                assert!(!in_session_scratchpad(p), "must NOT be recognized: {p}");
450            }
451        });
452    }
453
454    /// Fail closed on every degenerate session id: no harness id, or one too short/odd to be a
455    /// trustworthy anchor, must recognize NOTHING — never widen a path.
456    #[test]
457    fn a_missing_or_unusable_session_id_recognizes_nothing() {
458        let path = format!("/tmp/{SID}/x.sh");
459        with_session(None, || {
460            assert!(!in_session_scratchpad(&path), "no session id → no recognition");
461        });
462        for weak in ["", "abc", "1234567", "..", "/", "a/b", "id with space", "x*y"] {
463            with_session(Some(weak), || {
464                assert!(!in_session_scratchpad(&format!("/tmp/{weak}/x.sh")), "weak id {weak:?} must not anchor recognition",);
465            });
466        }
467    }
468
469    #[test]
470    fn no_context_leaves_paths_unchanged() {
471        assert_eq!(resolve("./x"), "./x");
472        assert_eq!(resolve("config"), "config");
473        assert_eq!(resolve("/etc/x"), "/etc/x");
474    }
475
476    #[test]
477    fn relative_inside_the_project_stays_worktree_relative() {
478        let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
479        assert_eq!(resolve("x"), "sub/x", "cwd under root → root-relative");
480        assert_eq!(resolve("./y"), "sub/y");
481        assert_eq!(resolve("../z"), "z", ".. that stays inside root");
482    }
483
484    #[test]
485    fn relative_outside_the_project_becomes_absolute() {
486        let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
487        assert_eq!(resolve("x"), "/etc/x", "cd /etc → the real target");
488        assert_eq!(resolve("passwd"), "/etc/passwd");
489        assert_eq!(resolve("*"), "/etc/*");
490    }
491
492    #[test]
493    fn dotdot_escaping_the_project_becomes_absolute() {
494        let _g = enter(PathCtx { cwd: Some("/home/u/proj".into()), root: Some("/home/u/proj".into()), ..Default::default() });
495        assert_eq!(resolve("../../../etc/x"), "/etc/x");
496    }
497
498    #[test]
499    fn absolute_in_root_becomes_root_relative_outside_stays_absolute() {
500        let _g = enter(PathCtx { cwd: Some("/home/u/proj/sub".into()), root: Some("/home/u/proj".into()), ..Default::default() });
501        // absolute INSIDE root → root-relative (worktree), matching the relative spelling
502        assert_eq!(resolve("/home/u/proj/main.rs"), "main.rs");
503        assert_eq!(resolve("/home/u/proj/sub/x"), "sub/x");
504        assert_eq!(resolve("/home/u/proj/a/../b"), "b", "normalized in place");
505        assert_eq!(resolve("/home/u/proj"), ".", "the project root itself");
506        // absolute OUTSIDE root → unchanged (classified as machine)
507        assert_eq!(resolve("/usr/bin/x"), "/usr/bin/x");
508        assert_eq!(resolve("/home/u/proj/../../etc/x"), "/home/etc/x", "climbs to /home, still outside root");
509        assert_eq!(resolve("/home/u/proj/../../../etc/x"), "/etc/x", "escapes to /etc via ..");
510        assert_eq!(
511            resolve("/home/u/proj-evil/secret"),
512            "/home/u/proj-evil/secret",
513            "a sibling dir is not confused for inside by bare string prefix",
514        );
515        // home / unpinnable → returned as-is (the classifiers handle these)
516        assert_eq!(resolve("$HOME/x"), "$HOME/x");
517        assert_eq!(resolve("~/x"), "~/x");
518    }
519
520    #[test]
521    fn a_home_spelled_path_inside_root_becomes_root_relative() {
522        let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
523            return;
524        };
525        let root = format!("{home}/projects/app");
526        let _g = enter(PathCtx { cwd: Some(format!("{root}/sub")), root: Some(root), ..Default::default() });
527        assert_eq!(resolve("~/projects/app/src/main.rs"), "src/main.rs");
528        assert_eq!(resolve("~/projects/app"), ".");
529        assert_eq!(resolve("~/projects/app/"), ".");
530        let under = |rest: &str| format!("~/projects/{rest}");
531        assert_eq!(resolve(&under("app/a/../b")), "b");
532        assert_eq!(resolve(&under("app/.git/hooks/pre-commit")), ".git/hooks/pre-commit");
533        for (outside, why) in [("peer/x", "a sibling"), ("app-evil/x", "no bare string prefix"), ("app/../../.ssh/id_rsa", "an escape")] {
534            assert_eq!(resolve(&under(outside)), under(outside), "{why} stays home-spelled");
535        }
536        assert_eq!(resolve("~/.ssh/id_rsa"), "~/.ssh/id_rsa");
537        assert_eq!(resolve("~"), "~");
538        assert_eq!(resolve("~bob/projects/app/x"), "~bob/projects/app/x", "another user's home is not ours");
539    }
540
541    #[test]
542    fn a_home_spelled_path_needs_an_absolute_root() {
543        let _none = enter(PathCtx { cwd: Some("/w".into()), root: None, ..Default::default() });
544        assert_eq!(resolve("~/x"), "~/x");
545        drop(_none);
546        let _rel = enter(PathCtx { cwd: Some("/w".into()), root: Some("w".into()), ..Default::default() });
547        assert_eq!(resolve("~/x"), "~/x");
548    }
549
550    #[test]
551    fn a_home_rooted_workspace_leaves_home_paths_to_the_home_classifiers() {
552        let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
553            return;
554        };
555        let _g = enter(PathCtx { cwd: Some(home.clone()), root: Some(home.clone()), ..Default::default() });
556        assert_eq!(resolve("~"), "~");
557        assert_eq!(resolve("~/notes.txt"), "~/notes.txt");
558        assert_eq!(resolve("notes.txt"), format!("{home}/notes.txt"));
559        assert_eq!(resolve("."), home);
560    }
561
562    #[test]
563    fn a_home_spelled_path_is_not_resolved_from_outside_the_root_or_through_a_glob() {
564        let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
565            return;
566        };
567        let root = format!("{home}/projects/app");
568        let outside = enter(PathCtx { cwd: Some("/etc".into()), root: Some(root.clone()), ..Default::default() });
569        assert_eq!(resolve("~/projects/app/x"), "~/projects/app/x", "a quoted `~` would name a directory under the cwd");
570        drop(outside);
571        let _g = enter(PathCtx { cwd: Some(root.clone()), root: Some(root), ..Default::default() });
572        for glob in ["app/.ss?/id_rsa", "app/*"].map(|rest| format!("~/projects/{rest}")) {
573            assert_eq!(resolve(&glob), glob);
574        }
575    }
576
577    #[test]
578    fn a_home_spelled_path_is_not_resolved_into_a_protected_root() {
579        let Some(home) = std::env::var("HOME").ok().filter(|h| h.starts_with('/') && h.len() > 1) else {
580            return;
581        };
582        let root = format!("{home}/.ssh");
583        let _g = enter(PathCtx { cwd: Some(root.clone()), root: Some(root), ..Default::default() });
584        assert_eq!(resolve("~/.ssh/id_rsa"), "~/.ssh/id_rsa");
585    }
586
587    #[test]
588    fn a_root_holding_a_protected_place_keeps_every_path_absolute() {
589        let _g = enter(PathCtx { cwd: Some("/".into()), root: Some("/".into()), ..Default::default() });
590        assert_eq!(resolve("/etc/shadow"), "/etc/shadow");
591        assert_eq!(resolve("etc/sudoers"), "/etc/sudoers");
592        assert_eq!(resolve("etc"), "/etc");
593        assert_eq!(resolve("/srv/app/x"), "/srv/app/x");
594        assert_eq!(resolve("."), "/");
595        drop(_g);
596        let _etc = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/etc".into()), ..Default::default() });
597        assert_eq!(resolve("hosts"), "hosts", "an unprotected file in a root below every home is the worktree");
598        assert_eq!(resolve("sudoers"), "/etc/sudoers");
599        assert_eq!(resolve("."), "/etc", "the root itself is above a protected place");
600        drop(_etc);
601        let _inside = enter(PathCtx { cwd: Some("/root/app".into()), root: Some("/root/app".into()), ..Default::default() });
602        assert_eq!(resolve("/root/app/x"), "x", "a root inside the protected place is where the user works");
603    }
604
605    #[test]
606    fn loop_var_expands_to_its_representative_per_face() {
607        let _g = enter_loop_var("f".into(), "read_item".into(), "write_item".into());
608        assert_eq!(expand_vars("$f", false), "read_item");
609        assert_eq!(expand_vars("$f", true), "write_item");
610        assert_eq!(expand_vars("${f}", false), "read_item");
611        assert_eq!(expand_vars("$f.bak", false), "read_item.bak", "compound suffix");
612        assert_eq!(expand_vars("pre/$f", false), "pre/read_item");
613        assert_eq!(expand_vars("$foo", false), "$foo", "$foo is not $f");
614        assert_eq!(expand_vars("$g", false), "$g", "unbound var untouched");
615        assert_eq!(expand_vars("plain", false), "plain");
616    }
617
618    #[test]
619    fn loop_var_binding_is_scoped_and_nests() {
620        assert_eq!(expand_vars("$f", false), "$f", "no binding");
621        {
622            let _outer = enter_loop_var("f".into(), "outer".into(), "outer".into());
623            {
624                let _inner = enter_loop_var("f".into(), "inner".into(), "inner".into());
625                assert_eq!(expand_vars("$f", false), "inner", "innermost wins");
626            }
627            assert_eq!(expand_vars("$f", false), "outer", "inner popped on drop");
628        }
629        assert_eq!(expand_vars("$f", false), "$f", "all popped");
630    }
631
632    #[test]
633    fn the_guard_restores_on_drop() {
634        {
635            let _g = enter(PathCtx { cwd: Some("/etc".into()), root: Some("/r".into()), ..Default::default() });
636            assert_eq!(resolve("x"), "/etc/x");
637        }
638        assert_eq!(resolve("x"), "x", "context cleared after the guard drops");
639    }
640}