Skip to main content

safe_chains/
lib.rs

1// The generated test names spell the command and its flags verbatim, and FLAG CASE IS MEANINGFUL:
2// find takes both `-D` and `-d`, `-P` and `-p`, `-L` and `-l`, and commands like `asn1Decoding` and
3// `checkLocalKDC` are camel-case upstream. Lowercasing to satisfy `non_snake_case` would erase the
4// distinction the test exists to pin, so the generated items opt out instead.
5#[cfg(test)]
6macro_rules! safe {
7    ($($name:ident: $cmd:expr),* $(,)?) => {
8        $(#[test] #[allow(non_snake_case)] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
9    };
10}
11
12#[cfg(test)]
13macro_rules! denied {
14    ($($name:ident: $cmd:expr),* $(,)?) => {
15        $(#[test] #[allow(non_snake_case)] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
16    };
17}
18
19#[cfg(test)]
20macro_rules! inert {
21    ($($name:ident: $cmd:expr),* $(,)?) => {
22        $(#[test] #[allow(non_snake_case)] fn $name() {
23            assert_eq!(
24                crate::command_verdict($cmd),
25                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
26                "expected Inert: {}", $cmd,
27            );
28        })*
29    };
30}
31
32#[cfg(test)]
33macro_rules! safe_read {
34    ($($name:ident: $cmd:expr),* $(,)?) => {
35        $(#[test] #[allow(non_snake_case)] fn $name() {
36            assert_eq!(
37                crate::command_verdict($cmd),
38                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
39                "expected SafeRead: {}", $cmd,
40            );
41        })*
42    };
43}
44
45#[cfg(test)]
46macro_rules! safe_write {
47    ($($name:ident: $cmd:expr),* $(,)?) => {
48        $(#[test] #[allow(non_snake_case)] fn $name() {
49            assert_eq!(
50                crate::command_verdict($cmd),
51                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
52                "expected SafeWrite: {}", $cmd,
53            );
54        })*
55    };
56}
57
58pub mod cli;
59pub mod decisionlog;
60#[cfg(test)]
61mod composition;
62pub mod cst;
63#[cfg(test)]
64mod handler_property_tests;
65pub mod docs;
66pub mod engine;
67mod envvars;
68mod handlers;
69pub mod netloc;
70pub mod parse;
71pub mod pathctx;
72pub mod pathgate;
73pub mod policy;
74pub mod refusal;
75pub mod registry;
76pub mod suggest;
77pub mod allowlist;
78pub mod targets;
79pub mod verdict;
80
81pub use verdict::{SafetyLevel, Verdict};
82
83/// The facet profile behind a verdict, rendered for `--explain`.
84///
85/// Answers the question the boolean cannot: not "is this allowed" but "on which axis was it
86/// refused". Empty when no resolver claims the command — that is the answer too, since it means the
87/// legacy classifier decided and there are no facets to show.
88pub fn facet_breakdown(command: &str) -> String {
89    // One simple command only. `shell_words` has no idea what `&&` means, so on a chain it hands
90    // back one flat token list and the resolver reads the SECOND command's arguments as flags of
91    // the first — `aws dynamodb scan --table-name t && rm -rf /` produced a single worst-case
92    // profile belonging to neither segment. A diagnostic that invents a capability set no resolver
93    // emitted is worse than silence, and `render()` above already breaks the chain down per segment.
94    if cst::explain(command).segments.len() != 1 {
95        return "\n  (facet breakdown covers one command at a time; run --explain on a single segment)\n"
96            .to_string();
97    }
98    // A COMPOUND is one segment, so it reaches here — but the flat split cannot see into it.
99    // `(cat ~/.ssh/id_rsa)` tokenises to `["(cat", "~/.ssh/id_rsa)"]`, no resolver recognises
100    // `(cat`, and the refusal rendered with no reason at all. The command the caller has to change
101    // is INSIDE the construct, so describe that one and say so.
102    let inner = cst::denied_inner_words(command);
103    let words = match inner {
104        Some(ref w) => w.clone(),
105        None => match shell_words::split(command) {
106            Ok(w) => w,
107            Err(_) => return String::new(),
108        },
109    };
110    if words.is_empty() {
111        return String::new();
112    }
113    let tokens: Vec<parse::Token> = words.into_iter().map(parse::Token::from_raw).collect();
114    let Some(ex) = engine::bridge::explain_profile(&tokens) else {
115        return String::new();
116    };
117    let mut out = String::from("\n  resolved profile:\n");
118    if let Some(w) = &inner {
119        out.push_str(&format!("    (the refused command inside it: `{}`)\n", w.join(" ")));
120    }
121    for (because, facets) in &ex.capabilities {
122        out.push_str(&format!("    · {because}\n"));
123        for (name, term) in facets {
124            out.push_str(&format!("        {name:<28} {term}\n"));
125        }
126    }
127    match &ex.blocked_by {
128        Some((level, mismatch)) => {
129            out.push_str(&format!(
130                "\n  refused by `{level}` (the most permissive auto-approving level):\n    {mismatch}\n",
131            ));
132        }
133        None => out.push_str("\n  admitted by the auto-approve band.\n"),
134    }
135    out
136}
137
138pub fn is_safe_command(command: &str) -> bool {
139    command_verdict(command).is_allowed()
140}
141
142pub fn command_verdict(command: &str) -> Verdict {
143    cst::command_verdict(command)
144}
145
146/// Classify `command` against a named level. Every engine-resolved leaf is decided by
147/// `Level::admits` against `level` rather than by walking the band; a `Denied` on any segment
148/// dominates. Legacy (unresolved) leaves keep their local-safe `SafeWrite`-or-below verdict.
149///
150/// A pass returns the band the PROFILE earns (`to_legacy`), so the caller's `<= ceiling` gate
151/// still tightens — that is what lets `reader` (SafeRead) and `paranoid` (Inert) use this path at
152/// all. Upper-band levels have no legacy equivalent and keep the shared `SafeWrite`.
153pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
154    let _guard = engine::bridge::enter_eval_level(level);
155    cst::command_verdict(command)
156}
157
158/// The `&'static Level` for an UPPER-band level name, or `None` for the lower band (which the
159/// 3-value ceiling already handles) or an unknown name. The caller passes the CANONICAL name
160/// (legacy aliases already resolved).
161pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
162    if !matches!(name, "local-admin" | "network-admin" | "yolo") {
163        return None;
164    }
165    engine::authoring::default_levels().iter().find(|l| l.name == name)
166}
167
168/// Resolve a level NAME to its `(3-band ceiling, engine level for admits)`, or `None` for an unknown
169/// name. The ceiling gates the projected verdict; the engine level (when present) classifies per-level
170/// via `admits`, exposing distinctions the 3-band projection flattens — `editor` (no destroy, no
171/// sibling write) vs `developer`, and the upper band (git push, bulk-object-read, sudo). `paranoid`/
172/// `reader` are pure ceilings (their read/inert bands need no `admits`), and `developer` IS the default
173/// band, so those carry no engine level. Legacy aliases (`safe-write`) canonicalize first.
174pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
175    let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
176    let canonical = legacy_of.unwrap_or(name);
177    // EVERY named level classifies through `admits`. The levels are `extends`-chained
178    // (paranoid ⊂ reader ⊂ editor ⊂ developer ⊂ admin ⊂ yolo), so one mechanism across the whole
179    // ladder makes it monotone BY CONSTRUCTION: whatever a level admits, every looser level
180    // inherits.
181    //
182    // Only `editor` and the upper band used to, for a reason that turned out to be a bug rather
183    // than a design: `project` stamped every pass `SafeWrite`, which the `<= threshold` gate then
184    // refused at any lower ceiling, so handing `reader` an engine level denied its entire band
185    // (measured: 157 commands, `cat ./notes.txt` among them). With `project` returning the band
186    // the profile actually earns, that is gone.
187    //
188    // The mixture was itself the `level_monotonic` fuzz failure — `editor` decided by `admits`
189    // while `reader` and `developer` decided by projection, and nothing made two mechanisms
190    // adjacent in one ordered ladder agree.
191    let engine_level = match canonical {
192        "paranoid" | "reader" | "editor" | "developer" | "local-admin" | "network-admin"
193        | "yolo" => {
194            engine::authoring::default_levels().iter().find(|l| l.name == canonical)
195        }
196        _ => None,
197    };
198    Some((ceiling, engine_level))
199}
200
201/// The ceilinged verdict: classify `command` at `(threshold, engine_level)`, gating the projected
202/// level `<= threshold`. The single seam both the CLI (`--level`) and the hook (configured `level`)
203/// funnel through. `engine_level = Some` classifies via `Level::admits` (the fine per-level model);
204/// `None` uses the 3-band projection. Either way the result is gated to `threshold`, so a legacy leaf
205/// that bypasses the engine (a redirect write → `SafeWrite`) is still held under a lower ceiling.
206pub fn command_verdict_ceilinged(
207    command: &str,
208    threshold: SafetyLevel,
209    engine_level: Option<&'static engine::level::Level>,
210) -> Verdict {
211    let verdict = match engine_level {
212        Some(level) => command_verdict_at_level(command, level),
213        None => command_verdict(command),
214    };
215    match verdict {
216        Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
217        _ => Verdict::Denied,
218    }
219}
220
221/// The coverage-fallback explanation (built-in classifier + the user's `permissions.allow` patterns),
222/// computed UNDER the configured engine level so a covered command honors that level's rule — a
223/// worktree destroy an `editor` plan forbids classifies as denied here too, not re-admitted. `None`
224/// engine level → the plain 3-band coverage (paranoid/reader/default). The caller still gates the
225/// result's `overall <= threshold`; running under the level closes the last path a lower plan's
226/// tighter rule could leak through.
227/// Whether Claude Code's OWN permission files may contribute trust to this run.
228///
229/// safe-chains reads two things out of `~/.claude/settings.json`: `permissions.allow` command
230/// patterns (the coverage bridge, `allowlist.rs`) and `Read(...)` path approvals (the grant bridge,
231/// `regions.rs`). Both were loaded unconditionally, on every harness — so a file that exists purely
232/// to configure Claude Code was silently granting permissions under Codex, Cursor, Grok and agy.
233///
234/// On Codex that is not academic. Codex has no interactive approval, which is why safe-chains
235/// DENIES a gated command there; with a `Bash(curl:*)` rule sitting in the Claude file,
236/// `curl … | sh` went from denied to abstain, and abstain on Codex means it simply runs.
237///
238/// Defaults to FALSE, so a harness safe-chains does not recognize never inherits another tool's
239/// grants. Only the Claude target turns it on.
240static CLAUDE_CONFIG_TRUSTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
241
242/// Honor `~/.claude/settings.json` as a source of trust for the rest of this process.
243pub fn trust_claude_config() {
244    CLAUDE_CONFIG_TRUSTED.store(true, std::sync::atomic::Ordering::Relaxed);
245}
246
247pub(crate) fn claude_config_trusted() -> bool {
248    CLAUDE_CONFIG_TRUSTED.load(std::sync::atomic::Ordering::Relaxed)
249}
250
251pub fn explain_with_coverage_at_level(
252    command: &str,
253    engine_level: Option<&'static engine::level::Level>,
254) -> cst::Explanation {
255    let patterns = allowlist::Matcher::load();
256    let _guard = engine_level.map(engine::bridge::enter_eval_level);
257    cst::explain_with_coverage(command, &patterns)
258}
259
260/// The auto-approve ceiling the HOOK evaluates at, from the write-protected user config
261/// (`~/.config/safe-chains.toml`, `level = "…"`). No config, or an unknown name → the default
262/// `developer` band (`SafeWrite`, no engine level) — fail-safe. Honored ONLY from the user config,
263/// never a repo `.safe-chains.toml`; the file is write-denied, so an agent cannot set its own ceiling.
264pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
265    registry::user_config_level()
266        .and_then(|name| level_ceiling(&name))
267        .unwrap_or((SafetyLevel::SafeWrite, None))
268}
269
270/// Classify `command` with the harness-supplied directory context installed (HP-19), so
271/// relative paths resolve against the real `cwd`/`root`. `command_verdict(cmd)` is the
272/// no-context form (`PathCtx::default()`), preserving every existing caller.
273pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
274    let _guard = pathctx::enter(ctx);
275    cst::command_verdict(command)
276}
277
278/// Why a not-auto-approved command's path reach was flagged — so the nudge can explain the actual
279/// reason instead of a one-size-fits-all "outside the working directory". A peer's hidden file and a
280/// path genuinely above cwd both deny, but the remedy differs, and conflating them is what reads as
281/// "directory parsing is broken".
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum ReachReason {
284    /// A known credential store (`.ssh`, `.aws`, keychain…).
285    Credential,
286    /// A file safe-chains reads its OWN permissions from (`~/.config/safe-chains.toml`,
287    /// `~/.claude/settings.json`). Distinct from `OutsideWorkspace` because the generic remedy
288    /// there — grant the path — is not merely unhelpful but FALSE: the write face is frozen, so
289    /// following the advice changes nothing. For safe-chains' own config it is also circular,
290    /// telling the user to edit the file they are being stopped from editing.
291    FrozenTrustFile,
292    /// The DIRECTORY a trust file lives in. Distinct from `FrozenTrustFile` because only HALF of
293    /// it is refused: writing a file into `~/.config` is ordinary and stays allowed, and only
294    /// removing or replacing the directory is not. Copy that said "this is refused" flatly would
295    /// misdescribe a directory the user explicitly granted.
296    FrozenTrustRoot,
297    /// One of the files that decide who may log in (`/etc/passwd`, `/etc/sudoers`, `/etc/pam.d`,
298    /// the loader and boot). Same false-remedy problem as `FrozenTrustFile`.
299    FrozenSystemIntegrity,
300    /// A raw block or character device (`/dev/mem`, `/dev/rdisk0`, `/dev/sda`). Distinct from
301    /// `FrozenSystemIntegrity` because the `device` rung outranks `system-integrity`, so without
302    /// its own arm every device was explained as a file that "decides who may log in" — which is
303    /// both false and unhelpful about the thing that actually makes a device dangerous.
304    RawDevice,
305    /// Genuinely above/outside the working directory.
306    OutsideWorkspace,
307    /// A path built by an interpolation that nothing confines (`./out/$i`, `> $(cmd)`). It is not
308    /// outside anything — it names WHATEVER the value turns out to be, which is why it cannot be
309    /// admitted — so the remedy is to constrain the spelling, not to grant a directory.
310    Unconfined,
311    /// A temp path that is NOT this session's scratchpad. Reading and writing it is fine; RUNNING
312    /// code from it is not, because anonymous `/tmp` is where downloaded/foreign code lands. This
313    /// is the one reach whose remedy is usually "that IS my working directory" — so the nudge says
314    /// how to bless it rather than implying the agent did something wrong.
315    ForeignTemp,
316}
317
318/// Render command-derived text safely INSIDE one of our messages.
319///
320/// The explanation is read by a human deciding whether to approve, and on the Claude and Qwen
321/// targets it is injected into the model's context as `additionalContext`. Command text is not
322/// trustworthy input for either job: a command routinely carries data the agent picked up from a
323/// file, an issue title, a downloaded manifest. Echoed raw, a newline in it forged a whole extra
324/// line of our OWN output —
325///
326/// ```text
327///   ✗  cat "/etc/x
328///   ✓  ls   safe-chains: auto-approves.
329/// ```
330///
331/// — so the reader saw an approval that never happened, in our voice. Escaping the control
332/// characters keeps any echoed text to a single line of literal content, which is the property
333/// that makes forging a second line impossible. Bidi controls go too: they reorder what is
334/// DISPLAYED without changing the bytes, which is the same forgery by other means.
335///
336/// This neutralizes our own OUTPUT. It is not a check on the command and decides nothing.
337/// Render our INTERNAL substitution markers back as `$(…)` before any of them reach a human.
338///
339/// A path carrying a substitution is classified through a sentinel, and the operand the nudge
340/// reports is the expanded form — so the reader of `cat ~/p/out/$(seq 1 1)` was being shown
341/// `~/p/out/__SAFE_CHAINS_CMDSUB_ATOM__`, a path they never wrote. On the Claude and Qwen targets
342/// this text is injected into the MODEL's context, where an internal marker is worse than noise:
343/// it is a magic string the model can learn and start emitting, and a nudge that describes a path
344/// the user cannot find in their own command is one they have no reason to trust.
345///
346/// Covers every sentinel spelling at once — opaque, atom, and the locus-tagged forms — by keying
347/// on the shared prefix and consuming through the terminating `__`, so a sentinel added later is
348/// rendered without touching this. Text that merely LOOKS like a sentinel keeps its tail — only
349/// the marker itself is replaced — because what follows a bare prefix is the user's path, not our
350/// internals, and dropping it handed a crafted filename control over how much of the path the
351/// reader saw.
352fn render_sentinels(s: &str) -> std::borrow::Cow<'_, str> {
353    let prefix = cst::eval::TAGGED_PREFIX;
354    if !s.contains(prefix) {
355        return std::borrow::Cow::Borrowed(s);
356    }
357    let mut out = String::with_capacity(s.len());
358    let mut rest = s;
359    while let Some(at) = rest.find(prefix) {
360        out.push_str(&rest[..at]);
361        out.push_str("$(…)");
362        let after = &rest[at + prefix.len()..];
363        rest = if let Some(tail) = after.strip_prefix('_') {
364            // The opaque marker is the prefix plus a single `_`.
365            tail
366        } else if let Some(i) = after.find("__") {
367            // Atom and locus-tagged markers are the prefix, a term, then `__`.
368            &after[i + 2..]
369        } else {
370            // Not one of ours. Keep the text: dropping it let a CRAFTED filename decide how much
371            // of the path a human was shown — `cat ~/__SAFE_CHAINS_CMDSUB_.ssh/id_rsa` reported
372            // reaching `~/$(…)`, hiding `.ssh/id_rsa` from the one message used to decide.
373            after
374        };
375    }
376    out.push_str(rest);
377    std::borrow::Cow::Owned(out)
378}
379
380pub fn sanitize_display(s: &str) -> String {
381    let s = &render_sentinels(s);
382    let mut out = String::with_capacity(s.len());
383    for c in s.chars() {
384        match c {
385            '\n' => out.push_str("\\n"),
386            '\r' => out.push_str("\\r"),
387            '\t' => out.push_str("\\t"),
388            // C0/C1 controls, and the bidi overrides/isolates/marks.
389            c if c.is_control()
390                || matches!(c, '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' | '\u{200E}' | '\u{200F}') =>
391            {
392                out.push_str(&format!("\\u{{{:04x}}}", c as u32));
393            }
394            c => out.push(c),
395        }
396    }
397    out
398}
399
400impl ReachReason {
401    /// The self-contained nudge body ("it reaches `X`, …") including the reason-appropriate remedy.
402    /// Callers add their own framing (block / please-confirm) and the docs link.
403    /// Whether naming this path in `~/.config/safe-chains.toml` actually changes the verdict.
404    ///
405    /// The refusal copy offers a grant, or explains that granting will not help, and the two must
406    /// not diverge: advice that does nothing costs more than silence, because the reader follows
407    /// it, sees no change, and stops believing the rest. `a_refusal_offers_a_grant_only_when_one_
408    /// would_work` runs the verdict twice — once with the grant applied — and holds this to it.
409    ///
410    /// `Credential` is `true` as of 2026-09-01 and was `false` before it. A grant naming a store
411    /// used to move the locus and leave `reads_secret` set, so the message's "name that path"
412    /// advice was FALSE while reading exactly right. That is the failure this pairing exists to
413    /// catch, and it went unnoticed because nothing compared the sentence to the behaviour.
414    pub fn grant_helps(self) -> bool {
415        match self {
416            // A grant that NAMES the store clears the shield for reading it.
417            ReachReason::Credential | ReachReason::RawDevice => true,
418            // "That IS my working directory" is the usual answer here.
419            ReachReason::ForeignTemp | ReachReason::OutsideWorkspace => true,
420            // Frozen faces stay frozen however specifically they are named: an agent that can edit
421            // the trust file decides what is approved next, and one that can write `/etc/sudoers`
422            // owns the machine's authorization substrate.
423            ReachReason::FrozenTrustFile
424            | ReachReason::FrozenTrustRoot
425            | ReachReason::FrozenSystemIntegrity => false,
426            // There is no path to grant. The remedy is to constrain the spelling.
427            ReachReason::Unconfined => false,
428        }
429    }
430
431    pub fn message(self, path: &str) -> String {
432        let path = &sanitize_display(path);
433        match self {
434            ReachReason::Credential => format!(
435                "it reaches `{path}`, a credential store. The agent has no ordinary reason to touch \
436                 one. If that was not intended, stop it. If you do want to allow it, name that path \
437                 in ~/.config/safe-chains.toml. A grant on a parent directory does not reach a \
438                 credential store"
439            ),
440            ReachReason::RawDevice => format!(
441                "it reaches `{path}`, a raw device. Reading one is not reading a file — a disk \
442                 device hands over every file on it and memory hands over every secret in it — and \
443                 writing one goes underneath the filesystem entirely. If you do want the read, \
444                 name that path in ~/.config/safe-chains.toml; the write stays refused"
445            ),
446            ReachReason::FrozenTrustFile => format!(
447                "it reaches `{path}`. safe-chains reads its own permissions from that file, so a \
448                 write there is never auto-approved. Granting the path does not change that, \
449                 because an agent that can edit this file can decide what gets approved next. Edit \
450                 it yourself if you meant to change it"
451            ),
452            ReachReason::FrozenTrustRoot => format!(
453                "it reaches `{path}`. safe-chains reads its own permissions from a file in that \
454                 directory, so removing or replacing the directory itself is never auto-approved: \
455                 doing that would point the trust root somewhere else. Writing files into it is \
456                 fine, and granting the path does not change either half. Move or delete it \
457                 yourself if you meant to"
458            ),
459            ReachReason::FrozenSystemIntegrity => format!(
460                "it reaches `{path}`. That file decides who may log in and what they may do, so a \
461                 write there is never auto-approved. Granting the path does not change that. Edit \
462                 it yourself if you meant to change it"
463            ),
464            ReachReason::ForeignTemp => format!(
465                "it runs code from `{path}`, a temporary directory that is not this session's \
466                 scratchpad. Reading and writing temp files is fine. Running code from there is \
467                 not, because a downloaded script lands in the same place. If this is a working \
468                 directory you trust, grant it in ~/.config/safe-chains.toml. A scratchpad the \
469                 harness reports for this session is recognized on its own and needs no grant"
470            ),
471            ReachReason::Unconfined => format!(
472                "the path `{path}` is built by an interpolation, so what it names depends on a \
473                 value the command does not show. It could be anywhere, which is why it cannot be \
474                 auto-approved. If the interpolated part cannot contain a `/`, put literal text \
475                 beside it in the same path component. `out/dx_$i.txt` is approved where `out/$i` \
476                 is not: the first is a filename whatever `$i` holds, and the second could be `..`"
477            ),
478            ReachReason::OutsideWorkspace => match pathctx::cwd().map(|c| sanitize_display(&c)) {
479                Some(cwd) => format!(
480                    "it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
481                     running from the wrong directory, relaunch it where you meant to be. To allow \
482                     it from here, grant that path in ~/.config/safe-chains.toml"
483                ),
484                None => format!(
485                    "it reaches `{path}`, outside the working directory. To allow it, grant that \
486                     path in ~/.config/safe-chains.toml"
487                ),
488            },
489        }
490    }
491}
492
493/// If a NOT-auto-approved command reaches a path OUTSIDE the workspace, return that path (its
494/// original spelling) and WHY, so the hook can nudge instead of silently prompting. Resolves against
495/// the ambient `cwd`/`root`: relative worktree paths, `/tmp`, and `/dev` streams are admitted and
496/// skipped; an absolute or home path that isn't admitted for read *or* write is the reach. A
497/// credential store outranks the hidden-peer wording; a hidden peer path outranks the generic
498/// outside-workspace reason.
499pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
500    let tokens = operand_words(command)?;
501    tokens.into_iter().find_map(|t| {
502        if !policy::looks_like_path(&t) {
503            return None;
504        }
505        let resolved = pathctx::resolve(&t).into_owned();
506        // A temp path is READ/WRITE admitted, so the outside-test below never fires on it — but it
507        // is not EXECUTABLE unless it is this session's scratchpad. When the command was denied,
508        // that is the likely reason, and it is the one case where the fix is a grant rather than a
509        // correction, so surface it with those instructions.
510        if pathctx::under_temp_root(&resolved) && !pathctx::in_session_scratchpad(&resolved) {
511            return Some((t, ReachReason::ForeignTemp));
512        }
513        // The REBIND face is consulted too, or a granted trust-root directory denies in silence:
514        // the grant opens read and write, so `rm -rf ~/.config` looked ordinary here while the
515        // engine refused it. Only the trust-root directories can make this term true, since every
516        // other role's rebind face equals its write face.
517        let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
518            && (!engine::resolve::read_content_verdict(&resolved).is_allowed()
519                || !engine::resolve::write_target_verdict(&resolved).is_allowed()
520                || engine::resolve::rebind_is_stricter_than_write(&resolved));
521        if !outside {
522            return None;
523        }
524        // Asked on the LITERAL structure, so an interpolated component cannot strip the credential
525        // warning off a path that plainly names one. `cat ~/.ssh/$(id)` was reported as merely
526        // "built by an interpolation" — offering to flank it, which can never help, while dropping
527        // the one sentence that matters — and the CONFINED spelling fell through to
528        // "outside the working directory", whose remedy is to GRANT the path.
529        //
530        // Granting a credential store IS possible now (a grant covers what it names), so the
531        // objection is no longer "that remedy cannot work". It is that the generic wording says
532        // "grant that path" while meaning the ordinary parent-directory grant, which is exactly
533        // the form a credential store does not accept. The Credential arm spells out the
534        // difference instead.
535        let reason = if engine::resolve::names_credential_store(&resolved) {
536            ReachReason::Credential
537        } else if let Some(kind) = engine::resolve::frozen_write_kind(&resolved) {
538            // Ahead of Unconfined and OutsideWorkspace for the same reason Credential is: both of
539            // those end in "grant that path", which for a frozen write face is FALSE rather than
540            // merely vague, and for safe-chains' own config it is circular as well.
541            match kind {
542                engine::resolve::FrozenWrite::TrustFile => ReachReason::FrozenTrustFile,
543                engine::resolve::FrozenWrite::TrustRootDir => ReachReason::FrozenTrustRoot,
544                engine::resolve::FrozenWrite::SystemIntegrity => ReachReason::FrozenSystemIntegrity,
545                engine::resolve::FrozenWrite::RawDevice => ReachReason::RawDevice,
546            }
547        } else if engine::resolve::anchoring_of(&resolved) == crate::engine::facet::Anchoring::Opaque {
548            // Ahead of OutsideWorkspace because it is the more specific diagnosis of the SAME
549            // refusal, and the generic wording actively misleads here: it names a working-directory
550            // problem the user does not have and a remedy (grant the path) that cannot work,
551            // since the path is not a fixed path at all.
552            ReachReason::Unconfined
553        } else {
554            ReachReason::OutsideWorkspace
555        };
556        Some((t, reason))
557    })
558}
559
560/// The words a command actually RUNS with, for explaining a denial.
561///
562/// This must agree with the parse the verdict came from, so it walks the CST. Splitting the raw
563/// string instead (`shell_words::split`) tokenizes text the shell never treats as an argument —
564/// above all a heredoc BODY, which is data. `git commit -m "$(cat <<'EOF' … EOF)"` whose message
565/// merely MENTIONS `/etc/hosts` was reported as "reaches /etc/hosts", naming a false reason for the
566/// denial and advising the reader to grant that path — a config widening the command never needed.
567///
568/// Falls back to the raw split only when the command does not parse, where a best-effort nudge on
569/// approximate tokens still beats none.
570fn operand_words(command: &str) -> Option<Vec<String>> {
571    let Some(script) = cst::parse(command) else {
572        return shell_words::split(command).ok();
573    };
574    let mut out = Vec::new();
575    collect_script_words(&script, &mut out);
576    Some(out)
577}
578
579/// A word contributes its own expansions AND the words of any command substitution inside it: the
580/// inner command runs, so `notacommand $(cat /etc/shadow)` really does read the file, even though
581/// `expand()` renders the substitution as an opaque stand-in and hides the path.
582fn collect_word(word: &cst::Word, out: &mut Vec<String>) {
583    out.extend(word.expand());
584    for part in &word.0 {
585        collect_part_subs(part, out);
586    }
587}
588
589/// The words of any command SUBSTITUTION inside a word part, and nothing else — the part's own
590/// literal text is the caller's business, because whether it counts as an operand depends on where
591/// the word came from (a heredoc body's literal text never does).
592fn collect_part_subs(part: &cst::WordPart, out: &mut Vec<String>) {
593    use cst::WordPart;
594    match part {
595        WordPart::CmdSub(script) | WordPart::ProcSub(script) => collect_script_words(script, out),
596        WordPart::DQuote(inner) => collect_word(inner, out),
597        // Arithmetic contributes no operand of its own — its value is a number — but a `$( )`
598        // inside it runs, and that command's words are operands the verdict layer classifies.
599        WordPart::Arith(inner) => collect_word(inner, out),
600        WordPart::Lit(_)
601        | WordPart::Escape(_)
602        | WordPart::SQuote(_)
603        | WordPart::Backtick(_)
604        => {}
605    }
606}
607
608fn collect_script_words(script: &cst::Script, out: &mut Vec<String>) {
609    for stmt in &script.0 {
610        for cmd in &stmt.pipeline.commands {
611            collect_cmd_words(cmd, out);
612        }
613    }
614}
615
616/// A redirect TARGET is a path the command opens, so it is a reach and must be reported —
617/// `notacommand > /etc/passwd` names `/etc/passwd`. A heredoc DELIMITER is not a path at all, and
618/// its body never appears in the CST, which is the whole point.
619fn collect_redir_words(redirs: &[cst::Redir], out: &mut Vec<String>) {
620    use cst::Redir;
621    for redir in redirs {
622        match redir {
623            Redir::Write { target, .. }
624            | Redir::Read { target, .. }
625            | Redir::ReadWrite { target, .. }
626            | Redir::HereStr(target) => collect_word(target, out),
627            // Only the body's SUBSTITUTIONS, never its literal text. Behind a bare delimiter a
628            // `$(cat /etc/shadow)` in the body really runs, so it is a reach worth naming; the
629            // prose around it is data and naming it would state a false reason for the denial.
630            Redir::HereDoc { body, .. } => {
631                for part in &body.0 {
632                    collect_part_subs(part, out);
633                }
634            }
635            Redir::DupFd { .. } => {}
636        }
637    }
638}
639
640fn collect_cmd_words(cmd: &cst::Cmd, out: &mut Vec<String>) {
641    use cst::Cmd;
642    let words = |ws: &[cst::Word], out: &mut Vec<String>| {
643        for w in ws {
644            collect_word(w, out);
645        }
646    };
647    match cmd {
648        Cmd::Simple(s) => {
649            words(&s.words, out);
650            collect_redir_words(&s.redirs, out);
651        }
652        Cmd::Subshell { body, redirs } | Cmd::BraceGroup { body, redirs } => {
653            collect_script_words(body, out);
654            collect_redir_words(redirs, out);
655        }
656        Cmd::For {
657            items,
658            body,
659            redirs,
660            ..
661        } => {
662            words(items, out);
663            collect_script_words(body, out);
664            collect_redir_words(redirs, out);
665        }
666        Cmd::While { cond, body, redirs } | Cmd::Until { cond, body, redirs } => {
667            collect_script_words(cond, out);
668            collect_script_words(body, out);
669            collect_redir_words(redirs, out);
670        }
671        Cmd::If {
672            branches,
673            else_body,
674            redirs,
675        } => {
676            collect_redir_words(redirs, out);
677            for branch in branches {
678                collect_script_words(&branch.cond, out);
679                collect_script_words(&branch.body, out);
680            }
681            if let Some(body) = else_body {
682                collect_script_words(body, out);
683            }
684        }
685        Cmd::DoubleBracket { words: ws, redirs } => {
686            words(ws, out);
687            collect_redir_words(redirs, out);
688        }
689        Cmd::Case {
690            subject,
691            arms,
692            redirs,
693        } => {
694            collect_word(subject, out);
695            for arm in arms {
696                collect_script_words(&arm.body, out);
697            }
698            collect_redir_words(redirs, out);
699        }
700        Cmd::FunctionDef { body, .. } => collect_script_words(body, out),
701    }
702}
703
704#[cfg(test)]
705mod tests;