Skip to main content

safe_chains/
lib.rs

1#[cfg(test)]
2macro_rules! safe {
3    ($($name:ident: $cmd:expr),* $(,)?) => {
4        $(#[test] fn $name() { assert!(check($cmd), "expected safe: {}", $cmd); })*
5    };
6}
7
8#[cfg(test)]
9macro_rules! denied {
10    ($($name:ident: $cmd:expr),* $(,)?) => {
11        $(#[test] fn $name() { assert!(!check($cmd), "expected denied: {}", $cmd); })*
12    };
13}
14
15#[cfg(test)]
16macro_rules! inert {
17    ($($name:ident: $cmd:expr),* $(,)?) => {
18        $(#[test] fn $name() {
19            assert_eq!(
20                crate::command_verdict($cmd),
21                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::Inert),
22                "expected Inert: {}", $cmd,
23            );
24        })*
25    };
26}
27
28#[cfg(test)]
29macro_rules! safe_read {
30    ($($name:ident: $cmd:expr),* $(,)?) => {
31        $(#[test] fn $name() {
32            assert_eq!(
33                crate::command_verdict($cmd),
34                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeRead),
35                "expected SafeRead: {}", $cmd,
36            );
37        })*
38    };
39}
40
41#[cfg(test)]
42macro_rules! safe_write {
43    ($($name:ident: $cmd:expr),* $(,)?) => {
44        $(#[test] fn $name() {
45            assert_eq!(
46                crate::command_verdict($cmd),
47                crate::verdict::Verdict::Allowed(crate::verdict::SafetyLevel::SafeWrite),
48                "expected SafeWrite: {}", $cmd,
49            );
50        })*
51    };
52}
53
54pub mod cli;
55#[cfg(test)]
56mod composition;
57pub mod cst;
58#[cfg(test)]
59mod handler_property_tests;
60pub mod docs;
61pub mod engine;
62mod handlers;
63pub mod parse;
64pub mod pathctx;
65pub mod pathgate;
66pub mod policy;
67pub mod registry;
68pub mod suggest;
69pub mod allowlist;
70pub mod targets;
71pub mod verdict;
72
73pub use verdict::{SafetyLevel, Verdict};
74
75pub fn is_safe_command(command: &str) -> bool {
76    command_verdict(command).is_allowed()
77}
78
79pub fn command_verdict(command: &str) -> Verdict {
80    cst::command_verdict(command)
81}
82
83/// Classify `command` against an UPPER-band level (`local-admin`/`network-admin`/`yolo`), which
84/// has no 3-value legacy ceiling. Every engine-resolved leaf is decided by `Level::admits`
85/// against `level` instead of the lower-band projection; a `Denied` on any segment dominates.
86/// Legacy (unresolved) leaves keep their local-safe `SafeWrite`-or-below verdict, which every
87/// upper level admits. The result is `Allowed(SafeWrite)` (accepted by the shared upper ceiling)
88/// or `Denied`.
89pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
90    let _guard = engine::bridge::enter_eval_level(level);
91    cst::command_verdict(command)
92}
93
94/// The `&'static Level` for an UPPER-band level name, or `None` for the lower band (which the
95/// 3-value ceiling already handles) or an unknown name. The caller passes the CANONICAL name
96/// (legacy aliases already resolved).
97pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
98    if !matches!(name, "local-admin" | "network-admin" | "yolo") {
99        return None;
100    }
101    engine::authoring::default_levels().iter().find(|l| l.name == name)
102}
103
104/// Resolve a level NAME to its `(3-band ceiling, engine level for admits)`, or `None` for an unknown
105/// name. The ceiling gates the projected verdict; the engine level (when present) classifies per-level
106/// via `admits`, exposing distinctions the 3-band projection flattens — `editor` (no destroy, no
107/// sibling write) vs `developer`, and the upper band (git push, bulk-object-read, sudo). `paranoid`/
108/// `reader` are pure ceilings (their read/inert bands need no `admits`), and `developer` IS the default
109/// band, so those carry no engine level. Legacy aliases (`safe-write`) canonicalize first.
110pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
111    let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
112    let canonical = legacy_of.unwrap_or(name);
113    // Levels whose rule the 3-band projection can't express classify per-level via `admits`:
114    // `editor` (no destroy, no sibling write — distinct from developer) and the UPPER band (git push,
115    // bulk-object-read, sudo — above the band). `paranoid`/`reader` are pure ceilings (their
116    // inert/read bands need no `admits`; the `<= threshold` gate tightens), and `developer` IS the
117    // default band — those carry no engine level.
118    let engine_level = match canonical {
119        "editor" | "local-admin" | "network-admin" | "yolo" => {
120            engine::authoring::default_levels().iter().find(|l| l.name == canonical)
121        }
122        _ => None,
123    };
124    Some((ceiling, engine_level))
125}
126
127/// The ceilinged verdict: classify `command` at `(threshold, engine_level)`, gating the projected
128/// level `<= threshold`. The single seam both the CLI (`--level`) and the hook (configured `level`)
129/// funnel through. `engine_level = Some` classifies via `Level::admits` (the fine per-level model);
130/// `None` uses the 3-band projection. Either way the result is gated to `threshold`, so a legacy leaf
131/// that bypasses the engine (a redirect write → `SafeWrite`) is still held under a lower ceiling.
132pub fn command_verdict_ceilinged(
133    command: &str,
134    threshold: SafetyLevel,
135    engine_level: Option<&'static engine::level::Level>,
136) -> Verdict {
137    let verdict = match engine_level {
138        Some(level) => command_verdict_at_level(command, level),
139        None => command_verdict(command),
140    };
141    match verdict {
142        Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
143        _ => Verdict::Denied,
144    }
145}
146
147/// The coverage-fallback explanation (built-in classifier + the user's `permissions.allow` patterns),
148/// computed UNDER the configured engine level so a covered command honors that level's rule — a
149/// worktree destroy an `editor` plan forbids classifies as denied here too, not re-admitted. `None`
150/// engine level → the plain 3-band coverage (paranoid/reader/default). The caller still gates the
151/// result's `overall <= threshold`; running under the level closes the last path a lower plan's
152/// tighter rule could leak through.
153pub fn explain_with_coverage_at_level(
154    command: &str,
155    engine_level: Option<&'static engine::level::Level>,
156) -> cst::Explanation {
157    let patterns = allowlist::Matcher::load();
158    let _guard = engine_level.map(engine::bridge::enter_eval_level);
159    cst::explain_with_coverage(command, &patterns)
160}
161
162/// The auto-approve ceiling the HOOK evaluates at, from the write-protected user config
163/// (`~/.config/safe-chains.toml`, `level = "…"`). No config, or an unknown name → the default
164/// `developer` band (`SafeWrite`, no engine level) — fail-safe. Honored ONLY from the user config,
165/// never a repo `.safe-chains.toml`; the file is write-denied, so an agent cannot set its own ceiling.
166pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
167    registry::user_config_level()
168        .and_then(|name| level_ceiling(&name))
169        .unwrap_or((SafetyLevel::SafeWrite, None))
170}
171
172/// Classify `command` with the harness-supplied directory context installed (HP-19), so
173/// relative paths resolve against the real `cwd`/`root`. `command_verdict(cmd)` is the
174/// no-context form (`PathCtx::default()`), preserving every existing caller.
175pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
176    let _guard = pathctx::enter(ctx);
177    cst::command_verdict(command)
178}
179
180/// Why a not-auto-approved command's path reach was flagged — so the nudge can explain the actual
181/// reason instead of a one-size-fits-all "outside the working directory". A peer's hidden file and a
182/// path genuinely above cwd both deny, but the remedy differs, and conflating them is what reads as
183/// "directory parsing is broken".
184#[derive(Debug, Clone, Copy, PartialEq, Eq)]
185pub enum ReachReason {
186    /// A known credential store (`.ssh`, `.aws`, keychain…).
187    Credential,
188    /// A HIDDEN file inside a co-located peer project — the peer's ordinary source is readable as
189    /// `adjacent`, but its dotfiles/dotdirs are shielded.
190    HiddenPeer,
191    /// Genuinely above/outside the working directory.
192    OutsideWorkspace,
193}
194
195impl ReachReason {
196    /// The self-contained nudge body ("it reaches `X`, …") including the reason-appropriate remedy.
197    /// Callers add their own framing (block / please-confirm) and the docs link.
198    pub fn message(self, path: &str) -> String {
199        match self {
200            ReachReason::Credential => format!(
201                "it reaches `{path}`, a credential store the agent should almost certainly not touch. \
202                 If this was not intended, stop it"
203            ),
204            ReachReason::HiddenPeer => format!(
205                "it reaches `{path}`, a HIDDEN file inside a co-located peer project. The peer's \
206                 ordinary source is readable, but its hidden files (`.env`, `.git`, `.aws`, …) are \
207                 shielded — this is a deliberate guard, not a path error. To reach it, grant that \
208                 path in ~/.config/safe-chains.toml, or run the agent from the peer's parent \
209                 directory so the peer counts as in-workspace"
210            ),
211            ReachReason::OutsideWorkspace => match pathctx::cwd() {
212                Some(cwd) => format!(
213                    "it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
214                     running from the wrong directory — an easy thing to forget — relaunch it where \
215                     you meant to be; to allow it from here, grant that path in \
216                     ~/.config/safe-chains.toml"
217                ),
218                None => format!(
219                    "it reaches `{path}`, outside the working directory. To allow it, grant that \
220                     path in ~/.config/safe-chains.toml"
221                ),
222            },
223        }
224    }
225}
226
227/// If a NOT-auto-approved command reaches a path OUTSIDE the workspace, return that path (its
228/// original spelling) and WHY, so the hook can nudge instead of silently prompting. Resolves against
229/// the ambient `cwd`/`root`: relative worktree paths, `/tmp`, and `/dev` streams are admitted and
230/// skipped; an absolute or home path that isn't admitted for read *or* write is the reach. A
231/// credential store outranks the hidden-peer wording; a hidden peer path outranks the generic
232/// outside-workspace reason.
233pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
234    let tokens = shell_words::split(command).ok()?;
235    tokens.into_iter().find_map(|t| {
236        if !policy::looks_like_path(&t) {
237            return None;
238        }
239        let resolved = pathctx::resolve(&t).into_owned();
240        let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
241            && (!engine::resolve::read_content_verdict(&resolved).is_allowed()
242                || !engine::resolve::write_target_verdict(&resolved).is_allowed());
243        if !outside {
244            return None;
245        }
246        let reason = if engine::resolve::reads_secret(&resolved) {
247            ReachReason::Credential
248        } else if engine::resolve::hidden_peer_reach(&t) {
249            ReachReason::HiddenPeer
250        } else {
251            ReachReason::OutsideWorkspace
252        };
253        Some((t, reason))
254    })
255}
256
257#[cfg(test)]
258mod tests;