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 netloc;
64pub mod parse;
65pub mod pathctx;
66pub mod pathgate;
67pub mod policy;
68pub mod registry;
69pub mod suggest;
70pub mod allowlist;
71pub mod targets;
72pub mod verdict;
73
74pub use verdict::{SafetyLevel, Verdict};
75
76/// The facet profile behind a verdict, rendered for `--explain`.
77///
78/// Answers the question the boolean cannot: not "is this allowed" but "on which axis was it
79/// refused". Empty when no resolver claims the command — that is the answer too, since it means the
80/// legacy classifier decided and there are no facets to show.
81pub fn facet_breakdown(command: &str) -> String {
82 // One simple command only. `shell_words` has no idea what `&&` means, so on a chain it hands
83 // back one flat token list and the resolver reads the SECOND command's arguments as flags of
84 // the first — `aws dynamodb scan --table-name t && rm -rf /` produced a single worst-case
85 // profile belonging to neither segment. A diagnostic that invents a capability set no resolver
86 // emitted is worse than silence, and `render()` above already breaks the chain down per segment.
87 if cst::explain(command).segments.len() != 1 {
88 return "\n (facet breakdown covers one command at a time; run --explain on a single segment)\n"
89 .to_string();
90 }
91 let Ok(words) = shell_words::split(command) else {
92 return String::new();
93 };
94 if words.is_empty() {
95 return String::new();
96 }
97 let tokens: Vec<parse::Token> = words.into_iter().map(parse::Token::from_raw).collect();
98 let Some(ex) = engine::bridge::explain_profile(&tokens) else {
99 return String::new();
100 };
101 let mut out = String::from("\n resolved profile:\n");
102 for (because, facets) in &ex.capabilities {
103 out.push_str(&format!(" · {because}\n"));
104 for (name, term) in facets {
105 out.push_str(&format!(" {name:<28} {term}\n"));
106 }
107 }
108 match &ex.blocked_by {
109 Some((level, mismatch)) => {
110 out.push_str(&format!(
111 "\n refused by `{level}` (the most permissive auto-approving level):\n {mismatch}\n",
112 ));
113 }
114 None => out.push_str("\n admitted by the auto-approve band.\n"),
115 }
116 out
117}
118
119pub fn is_safe_command(command: &str) -> bool {
120 command_verdict(command).is_allowed()
121}
122
123pub fn command_verdict(command: &str) -> Verdict {
124 cst::command_verdict(command)
125}
126
127/// Classify `command` against an UPPER-band level (`local-admin`/`network-admin`/`yolo`), which
128/// has no 3-value legacy ceiling. Every engine-resolved leaf is decided by `Level::admits`
129/// against `level` instead of the lower-band projection; a `Denied` on any segment dominates.
130/// Legacy (unresolved) leaves keep their local-safe `SafeWrite`-or-below verdict, which every
131/// upper level admits. The result is `Allowed(SafeWrite)` (accepted by the shared upper ceiling)
132/// or `Denied`.
133pub fn command_verdict_at_level(command: &str, level: &'static engine::level::Level) -> Verdict {
134 let _guard = engine::bridge::enter_eval_level(level);
135 cst::command_verdict(command)
136}
137
138/// The `&'static Level` for an UPPER-band level name, or `None` for the lower band (which the
139/// 3-value ceiling already handles) or an unknown name. The caller passes the CANONICAL name
140/// (legacy aliases already resolved).
141pub fn upper_level_by_name(name: &str) -> Option<&'static engine::level::Level> {
142 if !matches!(name, "local-admin" | "network-admin" | "yolo") {
143 return None;
144 }
145 engine::authoring::default_levels().iter().find(|l| l.name == name)
146}
147
148/// Resolve a level NAME to its `(3-band ceiling, engine level for admits)`, or `None` for an unknown
149/// name. The ceiling gates the projected verdict; the engine level (when present) classifies per-level
150/// via `admits`, exposing distinctions the 3-band projection flattens — `editor` (no destroy, no
151/// sibling write) vs `developer`, and the upper band (git push, bulk-object-read, sudo). `paranoid`/
152/// `reader` are pure ceilings (their read/inert bands need no `admits`), and `developer` IS the default
153/// band, so those carry no engine level. Legacy aliases (`safe-write`) canonicalize first.
154pub fn level_ceiling(name: &str) -> Option<(SafetyLevel, Option<&'static engine::level::Level>)> {
155 let (ceiling, legacy_of) = verdict::SafetyLevel::resolve_threshold(name)?;
156 let canonical = legacy_of.unwrap_or(name);
157 // Levels whose rule the 3-band projection can't express classify per-level via `admits`:
158 // `editor` (no destroy, no sibling write — distinct from developer) and the UPPER band (git push,
159 // bulk-object-read, sudo — above the band). `paranoid`/`reader` are pure ceilings (their
160 // inert/read bands need no `admits`; the `<= threshold` gate tightens), and `developer` IS the
161 // default band — those carry no engine level.
162 let engine_level = match canonical {
163 "editor" | "local-admin" | "network-admin" | "yolo" => {
164 engine::authoring::default_levels().iter().find(|l| l.name == canonical)
165 }
166 _ => None,
167 };
168 Some((ceiling, engine_level))
169}
170
171/// The ceilinged verdict: classify `command` at `(threshold, engine_level)`, gating the projected
172/// level `<= threshold`. The single seam both the CLI (`--level`) and the hook (configured `level`)
173/// funnel through. `engine_level = Some` classifies via `Level::admits` (the fine per-level model);
174/// `None` uses the 3-band projection. Either way the result is gated to `threshold`, so a legacy leaf
175/// that bypasses the engine (a redirect write → `SafeWrite`) is still held under a lower ceiling.
176pub fn command_verdict_ceilinged(
177 command: &str,
178 threshold: SafetyLevel,
179 engine_level: Option<&'static engine::level::Level>,
180) -> Verdict {
181 let verdict = match engine_level {
182 Some(level) => command_verdict_at_level(command, level),
183 None => command_verdict(command),
184 };
185 match verdict {
186 Verdict::Allowed(level) if level <= threshold => Verdict::Allowed(level),
187 _ => Verdict::Denied,
188 }
189}
190
191/// The coverage-fallback explanation (built-in classifier + the user's `permissions.allow` patterns),
192/// computed UNDER the configured engine level so a covered command honors that level's rule — a
193/// worktree destroy an `editor` plan forbids classifies as denied here too, not re-admitted. `None`
194/// engine level → the plain 3-band coverage (paranoid/reader/default). The caller still gates the
195/// result's `overall <= threshold`; running under the level closes the last path a lower plan's
196/// tighter rule could leak through.
197pub fn explain_with_coverage_at_level(
198 command: &str,
199 engine_level: Option<&'static engine::level::Level>,
200) -> cst::Explanation {
201 let patterns = allowlist::Matcher::load();
202 let _guard = engine_level.map(engine::bridge::enter_eval_level);
203 cst::explain_with_coverage(command, &patterns)
204}
205
206/// The auto-approve ceiling the HOOK evaluates at, from the write-protected user config
207/// (`~/.config/safe-chains.toml`, `level = "…"`). No config, or an unknown name → the default
208/// `developer` band (`SafeWrite`, no engine level) — fail-safe. Honored ONLY from the user config,
209/// never a repo `.safe-chains.toml`; the file is write-denied, so an agent cannot set its own ceiling.
210pub fn configured_hook_ceiling() -> (SafetyLevel, Option<&'static engine::level::Level>) {
211 registry::user_config_level()
212 .and_then(|name| level_ceiling(&name))
213 .unwrap_or((SafetyLevel::SafeWrite, None))
214}
215
216/// Classify `command` with the harness-supplied directory context installed (HP-19), so
217/// relative paths resolve against the real `cwd`/`root`. `command_verdict(cmd)` is the
218/// no-context form (`PathCtx::default()`), preserving every existing caller.
219pub fn command_verdict_in(command: &str, ctx: pathctx::PathCtx) -> Verdict {
220 let _guard = pathctx::enter(ctx);
221 cst::command_verdict(command)
222}
223
224/// Why a not-auto-approved command's path reach was flagged — so the nudge can explain the actual
225/// reason instead of a one-size-fits-all "outside the working directory". A peer's hidden file and a
226/// path genuinely above cwd both deny, but the remedy differs, and conflating them is what reads as
227/// "directory parsing is broken".
228#[derive(Debug, Clone, Copy, PartialEq, Eq)]
229pub enum ReachReason {
230 /// A known credential store (`.ssh`, `.aws`, keychain…).
231 Credential,
232 /// A HIDDEN file inside a co-located peer project — the peer's ordinary source is readable as
233 /// `adjacent`, but its dotfiles/dotdirs are shielded.
234 HiddenPeer,
235 /// Genuinely above/outside the working directory.
236 OutsideWorkspace,
237 /// A temp path that is NOT this session's scratchpad. Reading and writing it is fine; RUNNING
238 /// code from it is not, because anonymous `/tmp` is where downloaded/foreign code lands. This
239 /// is the one reach whose remedy is usually "that IS my working directory" — so the nudge says
240 /// how to bless it rather than implying the agent did something wrong.
241 ForeignTemp,
242}
243
244impl ReachReason {
245 /// The self-contained nudge body ("it reaches `X`, …") including the reason-appropriate remedy.
246 /// Callers add their own framing (block / please-confirm) and the docs link.
247 pub fn message(self, path: &str) -> String {
248 match self {
249 ReachReason::Credential => format!(
250 "it reaches `{path}`, a credential store the agent should almost certainly not touch. \
251 If this was not intended, stop it"
252 ),
253 ReachReason::HiddenPeer => format!(
254 "it reaches `{path}`, a HIDDEN file inside a co-located peer project. The peer's \
255 ordinary source is readable, but its hidden files (`.env`, `.git`, `.aws`, …) are \
256 shielded — this is a deliberate guard, not a path error. To reach it, grant that \
257 path in ~/.config/safe-chains.toml, or run the agent from the peer's parent \
258 directory so the peer counts as in-workspace"
259 ),
260 ReachReason::ForeignTemp => format!(
261 "it runs code from `{path}`, a temporary directory that is not this session's \
262 scratchpad. Temp files can be read and written freely, but code there is treated \
263 as FOREIGN (a downloaded script lands in the same place), so running it is not \
264 auto-approved. If this is a working directory you trust, grant it in \
265 ~/.config/safe-chains.toml; a scratchpad the harness reports for this session is \
266 recognized automatically and needs no grant"
267 ),
268 ReachReason::OutsideWorkspace => match pathctx::cwd() {
269 Some(cwd) => format!(
270 "it reaches `{path}`, outside the working directory `{cwd}`. If the agent is \
271 running from the wrong directory — an easy thing to forget — relaunch it where \
272 you meant to be; to allow it from here, grant that path in \
273 ~/.config/safe-chains.toml"
274 ),
275 None => format!(
276 "it reaches `{path}`, outside the working directory. To allow it, grant that \
277 path in ~/.config/safe-chains.toml"
278 ),
279 },
280 }
281 }
282}
283
284/// If a NOT-auto-approved command reaches a path OUTSIDE the workspace, return that path (its
285/// original spelling) and WHY, so the hook can nudge instead of silently prompting. Resolves against
286/// the ambient `cwd`/`root`: relative worktree paths, `/tmp`, and `/dev` streams are admitted and
287/// skipped; an absolute or home path that isn't admitted for read *or* write is the reach. A
288/// credential store outranks the hidden-peer wording; a hidden peer path outranks the generic
289/// outside-workspace reason.
290pub fn workspace_overreach(command: &str) -> Option<(String, ReachReason)> {
291 let tokens = shell_words::split(command).ok()?;
292 tokens.into_iter().find_map(|t| {
293 if !policy::looks_like_path(&t) {
294 return None;
295 }
296 let resolved = pathctx::resolve(&t).into_owned();
297 // A temp path is READ/WRITE admitted, so the outside-test below never fires on it — but it
298 // is not EXECUTABLE unless it is this session's scratchpad. When the command was denied,
299 // that is the likely reason, and it is the one case where the fix is a grant rather than a
300 // correction, so surface it with those instructions.
301 if pathctx::under_temp_root(&resolved) && !pathctx::in_session_scratchpad(&resolved) {
302 return Some((t, ReachReason::ForeignTemp));
303 }
304 let outside = (resolved.starts_with('/') || resolved.starts_with('~'))
305 && (!engine::resolve::read_content_verdict(&resolved).is_allowed()
306 || !engine::resolve::write_target_verdict(&resolved).is_allowed());
307 if !outside {
308 return None;
309 }
310 let reason = if engine::resolve::reads_secret(&resolved) {
311 ReachReason::Credential
312 } else if engine::resolve::hidden_peer_reach(&t) {
313 ReachReason::HiddenPeer
314 } else {
315 ReachReason::OutsideWorkspace
316 };
317 Some((t, reason))
318 })
319}
320
321#[cfg(test)]
322mod tests;