Skip to main content

safe_chains/engine/
bridge.rs

1//! Engine bridge (v1.4 §4.5; annex `…-engine` §4). Projects a resolved capability profile
2//! back to a legacy [`Verdict`] so the existing ceiling gate (`main::run_cli`) keeps working
3//! unchanged. The engine is authoritative for every command it can resolve
4//! (`resolve::resolve` → `Some`); the legacy classifier handles the rest. There is no
5//! opt-out — `cst::check::leaf_verdict` calls `engine_verdict(tokens).unwrap_or(legacy)`.
6
7use std::cell::Cell;
8
9use super::authoring::default_levels;
10use super::facet::Profile;
11use super::level::{FacetMismatch, Level};
12use super::resolve;
13use crate::parse::Token;
14use crate::verdict::{SafetyLevel, Verdict};
15
16thread_local! {
17    /// The level a `--level` threshold selected. When set, `project` decides via `Level::admits`
18    /// against THIS level instead of walking the band for the lowest level that admits — which is
19    /// the only way a profile that just one level admits (`git push`, `sudo`) can be approved, and
20    /// the only way a level whose rule the 3-value projection cannot express (`editor`: no
21    /// destroy, no sibling write) can be enforced.
22    ///
23    /// Every named level sets it now. It was upper-band-only while `project` stamped a pass as
24    /// `SafeWrite`, which a lower ceiling then refused; see the note there.
25    ///
26    /// `None` (the default — no `--level` given) keeps the byte-for-byte default-band behavior, so
27    /// `command_verdict` / `is_safe_command` and every existing test are unaffected.
28    static EVAL_LEVEL: Cell<Option<&'static Level>> = const { Cell::new(None) };
29}
30
31/// Evaluate the enclosed classification against `level` (an upper-band level). Restores the
32/// previous context on drop. Mirrors `pathctx::enter`.
33pub fn enter_eval_level(level: &'static Level) -> EvalLevelGuard {
34    EvalLevelGuard(EVAL_LEVEL.with(|c| c.replace(Some(level))))
35}
36
37pub struct EvalLevelGuard(Option<&'static Level>);
38
39impl Drop for EvalLevelGuard {
40    fn drop(&mut self) {
41        EVAL_LEVEL.with(|c| c.set(self.0));
42    }
43}
44
45/// The engine's verdict for a command whose resolver exists, or `None` if it has none
46/// (the caller keeps the legacy verdict).
47pub fn engine_verdict(tokens: &[Token]) -> Option<Verdict> {
48    resolve::resolve(tokens).map(|p| project(&p))
49}
50
51/// Project a resolved profile to a legacy [`Verdict`]: the **lowest** authored level
52/// that admits it, mapped back to its legacy [`SafetyLevel`]; `Denied` if no
53/// legacy-mapped level admits it (above the auto-approve band → worst-case, §0).
54/// `default_levels()` builds the ascending chain (paranoid ⊂ reader ⊂ editor ⊂
55/// developer), so the first match among the mapped levels is the minimum.
56pub fn project(profile: &Profile) -> Verdict {
57    if profile.capabilities.is_empty() {
58        // Fail-closed (§0): an empty profile means the resolver produced NO capability.
59        // Every level vacuously admits it (`all` of zero capabilities is true), so without
60        // this guard it would project to the lowest level (`paranoid`) — the *most*
61        // permissive, inverting the principle. A genuinely-inert command emits an explicit
62        // observe capability, never an empty profile.
63        return Verdict::Denied;
64    }
65    if let Some(level) = EVAL_LEVEL.with(Cell::get) {
66        // A selected `--level` is authoritative via `admits`: a profile it does not admit is
67        // `Denied`, dominating the chain.
68        if !level.admits(profile) {
69            return Verdict::Denied;
70        }
71        // A pass projects to the band the PROFILE earns — not a fixed `SafeWrite`.
72        //
73        // It was fixed, and correctly so for every level this path originally served: the upper
74        // band and `editor` all carry a `SafeWrite` ceiling, so `run_cli`'s `<= ceiling` gate
75        // accepted the stamp. It silently breaks any level whose ceiling is LOWER. At `reader`
76        // (SafeRead) or `paranoid` (Inert) the gate then refused the very profile the level had
77        // just admitted — `cat ./notes.txt` denied at `--level reader`.
78        //
79        // That is why `level_ceiling` handed an engine level to `editor` and the upper band only:
80        // giving one to reader/paranoid would have denied their whole band. The workaround became
81        // the cause of the `level_monotonic` failure — `editor` classified by `admits` while its
82        // neighbours classified by projection, so two mechanisms sat adjacent in one ordered
83        // ladder with nothing making them agree.
84        //
85        // `unwrap_or(SafeWrite)` keeps the upper band byte-identical: those levels have no legacy
86        // equivalent, so `to_legacy` yields `None` for them and they keep the shared ceiling.
87        return Verdict::Allowed(to_legacy(&level.name).unwrap_or(SafetyLevel::SafeWrite));
88    }
89    for level in default_levels() {
90        // Only the auto-approvable band (paranoid..developer) has a 3-value legacy
91        // equivalent. The levels above it (local-admin, network-admin, yolo) have NO
92        // legacy mapping, so a profile that only THEY admit projects to Denied — never
93        // silently to SafeWrite (the old `_ => SafeWrite` catch-all would have
94        // auto-approved sudo/terraform the moment those levels were added). Selecting
95        // an upper level as a threshold is the separate harness-config change.
96        if let Some(sl) = to_legacy(&level.name)
97            && level.admits(profile)
98        {
99            return Verdict::Allowed(sl);
100        }
101    }
102    Verdict::Denied
103}
104
105/// The most permissive level in the default auto-approve band.
106fn default_band_top_level() -> Option<&'static Level> {
107    default_levels().iter().rfind(|l| to_legacy(&l.name).is_some())
108}
109
110/// The NAME of that level — what a refusal is reported against when the user has set no ceiling of
111/// their own (`developer`, today).
112///
113/// Exposed so `--explain`'s "refused by `X`" and the decision log's `level` field come from one
114/// definition. They were briefly two: the log took `SafetyLevel::to_string()` and recorded the
115/// LEGACY band name (`safe-write`) for the same run `--explain` called `developer`, which is the
116/// kind of disagreement that makes a diagnostic worse than no diagnostic.
117pub fn default_band_top_name() -> &'static str {
118    default_band_top_level().map_or("developer", |l| l.name.as_str())
119}
120
121fn to_legacy(level_name: &str) -> Option<SafetyLevel> {
122    match level_name {
123        "paranoid" => Some(SafetyLevel::Inert),
124        "reader" => Some(SafetyLevel::SafeRead),
125        "editor" | "developer" => Some(SafetyLevel::SafeWrite),
126        _ => None, // local-admin, network-admin, yolo — above the legacy 3-value ceiling
127    }
128}
129
130/// A human-readable account of what a command resolved to and, when the band rejects it, which
131/// facet said no.
132///
133/// The engine was write-only before this: `admits` answered yes/no and nothing reported the axis.
134/// Both a user asking "why was this denied" and an author debugging a resolver were left bisecting
135/// by editing facets and re-running — which is exactly how an incomplete loopback delta got
136/// mistaken for a flawed approach rather than a missing line.
137pub struct ProfileExplanation {
138    /// One entry per capability: its `because` and the facets it sets.
139    pub capabilities: Vec<(String, Vec<(&'static str, &'static str)>)>,
140    /// `(level name, why it refuses)` — absent when the band admits the profile.
141    pub blocked_by: Option<(String, FacetMismatch)>,
142}
143
144/// Resolve `tokens` and explain the result. `None` when no resolver claims the command, which is
145/// itself the answer: the engine never saw it and the legacy classifier decided.
146pub fn explain_profile(tokens: &[Token]) -> Option<ProfileExplanation> {
147    let profile = resolve::resolve(tokens)?;
148    let capabilities = profile.capabilities.iter().map(|c| (c.because.clone(), c.set_facets())).collect();
149
150    // Report against the MOST PERMISSIVE level in the auto-approve band. If the top of the band
151    // refuses a capability, every level below it does too, so its complaint is the binding one —
152    // a lower level's would just be the first of several walls.
153    let blocked_by = default_band_top_level()
154        .and_then(|top| profile.capabilities.iter().find_map(|c| top.nearest_miss(c).map(|m| (top.name.clone(), m))));
155
156    Some(ProfileExplanation { capabilities, blocked_by })
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::engine::facet::*;
163
164    fn toks(parts: &[&str]) -> Vec<Token> {
165        parts.iter().map(|p| Token::from_test(p)).collect()
166    }
167
168    #[test]
169    fn project_maps_profiles_to_the_lowest_admitting_level() {
170        // echo — inert
171        let echo = Profile::of(vec![{
172            let mut c = Capability::new(Operation::Observe);
173            c.disclosure.audience = DisclosureAudience::LocalProcess;
174            c
175        }]);
176        assert_eq!(project(&echo), Verdict::Allowed(SafetyLevel::Inert));
177
178        // cat ./notes — read-local
179        let read = Profile::of(vec![{
180            let mut c = Capability::new(Operation::Observe);
181            c.locus.local = LocalLocus::Worktree;
182            c.disclosure.audience = DisclosureAudience::LocalProcess;
183            c
184        }]);
185        assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead));
186
187        // cat ~/notes.txt — observe·user·no-secret is inside the reader band now
188        let home = Profile::of(vec![{
189            let mut c = Capability::new(Operation::Observe);
190            c.locus.local = LocalLocus::User;
191            c.disclosure.audience = DisclosureAudience::LocalProcess;
192            c
193        }]);
194        assert_eq!(project(&home), Verdict::Allowed(SafetyLevel::SafeRead));
195
196        // cat ~/.ssh/id_rsa — the SAME rung, refused on the secret claim rather than the locus.
197        // The pair is the point: one facet apart, and it is the facet that names the harm.
198        let secret = Profile::of(vec![{
199            let mut c = Capability::new(Operation::Observe);
200            c.locus.local = LocalLocus::User;
201            c.disclosure.audience = DisclosureAudience::LocalProcess;
202            c.secret.level = SecretLevel::Reads;
203            c
204        }]);
205        assert_eq!(project(&secret), Verdict::Denied);
206
207        // touch build/out — create·worktree·data → write-local → SafeWrite (the
208        // to_legacy `_ => SafeWrite` arm; no resolver emits this yet)
209        let write = Profile::of(vec![{
210            let mut c = Capability::new(Operation::Create);
211            c.locus.local = LocalLocus::Worktree;
212            c.scale = Scale::Bounded;
213            c.reversibility = Reversibility::Recoverable;
214            c.persistence.level = PersistenceLevel::Data;
215            c
216        }]);
217        assert_eq!(project(&write), Verdict::Allowed(SafetyLevel::SafeWrite));
218
219        // an EMPTY profile must fail closed (Denied), NOT project to inert — every level
220        // vacuously admits it, so the guard is what stops "resolved to nothing" = "safe".
221        assert_eq!(project(&Profile::of(vec![])), Verdict::Denied);
222    }
223
224    /// The fail-open this refactor had to avoid: the levels above developer (local-admin,
225    /// network-admin, yolo) have NO legacy `SafetyLevel`, so a profile only they admit must
226    /// project to `Denied` — never to `SafeWrite`. The old `_ => SafeWrite` catch-all in
227    /// `to_legacy` would have auto-approved every one of these.
228    #[test]
229    fn profiles_needing_an_upper_level_project_to_denied_not_safewrite() {
230        // sudo systemctl restart — elevated authority on machine locus (local-admin)
231        let sudo = Profile::of(vec![{
232            let mut c = Capability::new(Operation::Control);
233            c.locus.local = LocalLocus::Machine;
234            c.authority = Authority::Root;
235            c
236        }]);
237        assert_eq!(project(&sudo), Verdict::Denied, "sudo must not auto-approve");
238
239        // terraform apply — remote reach over outbound network (network-admin)
240        let remote = Profile::of(vec![{
241            let mut c = Capability::new(Operation::Mutate);
242            c.locus.remote = RemoteReach::Fixed;
243            c.network.direction = NetDirection::Outbound;
244            c
245        }]);
246        assert_eq!(project(&remote), Verdict::Denied, "remote infra must not auto-approve");
247
248        // terraform destroy — irreversible remote destroy (yolo only)
249        let catastrophe = Profile::of(vec![{
250            let mut c = Capability::new(Operation::Destroy);
251            c.locus.remote = RemoteReach::Fixed;
252            c.reversibility = Reversibility::Irreversible;
253            c
254        }]);
255        assert_eq!(project(&catastrophe), Verdict::Denied, "irreversible destroy must not auto-approve");
256    }
257
258    /// The legacy classifier's leaf verdict for `cmd` — what the engine falls back to for a
259    /// command it can't resolve, and the baseline the never-looser gates compare against.
260    fn legacy(cmd: &str) -> Verdict {
261        crate::handlers::dispatch(&toks(&cmd.split_whitespace().collect::<Vec<_>>()))
262    }
263
264    #[test]
265    fn the_engine_is_authoritative_with_legacy_fallback() {
266        // a resolved command → the engine's (finer) verdict, end to end
267        assert_eq!(
268            crate::command_verdict("cat ./notes.md"),
269            Verdict::Allowed(SafetyLevel::SafeRead),
270            "cat resolves → engine tightens inert to read-local",
271        );
272        // an unresolvable command → the legacy classifier still decides
273        let unresolved = resolve::UNRESOLVED_CMD.join(" ");
274        assert_eq!(crate::command_verdict(&unresolved), legacy(&unresolved), "no resolver → legacy verdict",);
275    }
276
277    #[test]
278    fn engine_verdict_is_none_for_unresearched_commands() {
279        assert!(engine_verdict(&toks(resolve::UNRESOLVED_CMD)).is_none());
280        assert_eq!(engine_verdict(&toks(&["echo", "hi"])), Some(Verdict::Allowed(SafetyLevel::Inert)));
281        assert_eq!(engine_verdict(&toks(&["cat", "./notes.md"])), Some(Verdict::Allowed(SafetyLevel::SafeRead)),);
282        assert_eq!(engine_verdict(&toks(&["cat", "~/.ssh/id_rsa"])), Some(Verdict::Denied));
283    }
284
285    /// The engine may deny what legacy allowed (intended tightening) or classify higher,
286    /// but must **never allow what legacy denied**, nor classify lower.
287    fn not_looser(legacy: Verdict, engine: Verdict) -> bool {
288        match (legacy, engine) {
289            (_, Verdict::Denied) => true,
290            (Verdict::Denied, Verdict::Allowed(_)) => false,
291            (Verdict::Allowed(l), Verdict::Allowed(e)) => e >= l,
292        }
293    }
294
295    /// The rollout safety gate on hand-picked forms — including the ones the wiring and
296    /// the review flushed (unrecognized/dangerous flags, and pattern-less grep, which
297    /// legacy denies as a usage error).
298    #[test]
299    fn the_engine_is_never_looser_than_legacy() {
300        let cases = [
301            "echo hi", "echo", "cat ./notes.md", "cat -n ./notes.md", "cat ~/.ssh/id_rsa", "cat /etc/hosts", "cat a.txt b.txt",
302            "grep foo src/main.rs", "grep -r foo src/", "grep -r foo ~", "grep foo bar.txt",
303            // PCRE (-P/--perl-regexp) is benign — PCRE2 execs no code, just a regex engine
304            "grep -P foo file", "grep -oP foo file", "grep --perl-regexp foo file",
305            // unrecognized / dangerous flags must worst-case
306            "cat --unknownflag ./x", "cat -Z ./x", "grep --wat foo file",
307            // pattern-less grep (C1): legacy denies as a usage error, engine must too
308            "grep", "grep -r", "grep -i", "grep -e foo", "grep -f patterns.txt",
309        ];
310        for cmd in cases {
311            let base = legacy(cmd);
312            let t = toks(&cmd.split_whitespace().collect::<Vec<_>>());
313            let Some(engine) = engine_verdict(&t) else { continue };
314            assert!(not_looser(base, engine), "engine LOOSER than legacy for `{cmd}`: legacy {base}, engine {engine}",);
315        }
316    }
317
318    /// The never-looser invariant above holds over the commands legacy *allowlisted*. The
319    /// `developer` level is the deliberate exception: it admits well-modeled operations the
320    /// hand-built allowlist could only DENY — e.g. deleting your own project files. This
321    /// test pins that divergence as intended, not a regression: it is exactly the kind of
322    /// finer classification the engine exists to make, now that it is authoritative.
323    #[test]
324    fn developer_intentionally_admits_worktree_destroy_that_legacy_denies() {
325        let rm = "rm -rf ./node_modules";
326        assert_eq!(legacy(rm), Verdict::Denied, "legacy allowlist denies rm deletion");
327        assert_eq!(crate::command_verdict(rm), Verdict::Allowed(SafetyLevel::SafeWrite), "engine (developer) admits it — intended");
328        assert!(!not_looser(Verdict::Denied, Verdict::Allowed(SafetyLevel::SafeWrite)), "and it IS looser than legacy, by design");
329    }
330
331    /// sed/tar keep coarse legacy HANDLERS (`coreutils::sed`/`tar`) that `handlers::dispatch`
332    /// consults before the TOML — so `legacy()` for them is that handler, which denies an in-place
333    /// edit. The behavioral engine models `sed -i` on a worktree file correctly (a SafeWrite) and is
334    /// authoritative. Pin the divergence as intended (not a regression) — the same shape as `rm`
335    /// above — because the corpus gate's sed examples deliberately avoid this looser case.
336    #[test]
337    fn engine_intentionally_admits_worktree_in_place_edit_that_legacy_sed_handler_denies() {
338        let sed = "sed -i s/a/b/ ./file.txt";
339        assert_eq!(legacy(sed), Verdict::Denied, "legacy sed handler denies in-place edit");
340        assert_eq!(crate::command_verdict(sed), Verdict::Allowed(SafetyLevel::SafeWrite), "engine admits worktree -i — intended");
341        assert!(
342            !not_looser(Verdict::Denied, Verdict::Allowed(SafetyLevel::SafeWrite)),
343            "and it IS looser than the legacy sed handler, by design"
344        );
345    }
346
347    /// The data-driven corpus gate (the systematic test C1 slipped past): run **every**
348    /// command's real `examples_safe`/`examples_denied` through the engine and assert,
349    /// per resolvable example, the dimensions that hold today —
350    ///   1. **never looser** than legacy (engine ≤ legacy; also subsumes "an
351    ///      examples_denied that resolves stays denied", since legacy denies it),
352    ///   2. **justified** — every resolved capability cites a `because` (§5),
353    ///   3. **total** — resolution and projection never panic.
354    ///
355    /// It grows automatically as commands convert; today it exercises the resolvable
356    /// commands and skips the rest. Only bare single commands are comparable at the leaf
357    /// (chains/redirects/substitutions are the CST's job). The full per-facet completeness
358    /// dimension is the golden-profile check (`resolve::golden_profiles_cover_every_facet`)
359    /// and becomes TOML-derived when commands carry profile data (§7).
360    #[test]
361    fn the_engine_corpus_gate() {
362        let mut exercised = 0usize;
363        for (name, safe, denied) in crate::registry::corpus_examples() {
364            for ex in safe.iter().chain(denied.iter()) {
365                if ex.contains(['|', '>', '<', '&', ';', '$', '`', '(', '\n']) {
366                    continue; // not a bare single command
367                }
368                let t = toks(&ex.split_whitespace().collect::<Vec<_>>());
369                let Some(profile) = crate::engine::resolve::resolve(&t) else { continue };
370                exercised += 1;
371
372                for c in &profile.capabilities {
373                    assert!(!c.because.is_empty(), "unjustified capability for `{ex}` ({name})");
374                }
375
376                // A PROFILED sub's legacy kind is deny-all — a fail-closed placeholder for when the
377                // engine ABSTAINS (a global flag before the sub), NOT a real hand-built verdict. So the
378                // never-looser comparison is meaningless for it: the engine is authoritative and
379                // legitimately admits below the line (`npm ci --ignore-scripts` at developer). Its
380                // landing is pinned by the archetype tests, not here.
381                if crate::registry::sub_archetypes(&t).is_some() {
382                    continue;
383                }
384                // The one deliberate refinement. An informational invocation (`rm --help`) prints
385                // usage and exits, so it is genuinely Inert, while the legacy path classified it at
386                // the command's declared WRITE level. That reads as "looser" to this ratchet
387                // because Inert is admitted by stricter user levels (a `paranoid` plan accepts it),
388                // but it is the engine being more accurate, not more permissive about effects.
389                //
390                // Deliberately narrow: long-form help/version ONLY, and no other token. The risk it
391                // would carry — an informational flag laundering a real operand — is what
392                // `an_informational_flag_is_not_a_write_but_never_launders_an_operand` exists to
393                // rule out, and it is asserted over the whole registry rather than here.
394                if t.len() >= 2 && t[1..].iter().all(|x| matches!(x.as_str(), "--help" | "--version")) {
395                    continue;
396                }
397                let engine = project(&profile);
398                let base = legacy(ex);
399                assert!(not_looser(base, engine), "engine LOOSER than legacy for `{ex}` ({name}): legacy {base}, engine {engine}",);
400            }
401        }
402        // non-vacuity: the gate must actually resolve engine examples, or it is a green
403        // test proving nothing (the trap that hid its own emptiness). Every resolvable
404        // command must contribute at least one example.
405        assert!(exercised >= 5, "corpus gate exercised only {exercised} engine resolutions — vacuous?");
406    }
407
408    /// Per-level threshold wiring end to end: an UPPER-band `--level` classifies via `admits`,
409    /// unlocking profiles that only an upper level admits, while the lower band and the
410    /// allowlist-only fail-closed reflex are untouched.
411    #[test]
412    fn upper_band_levels_admit_via_the_engine_end_to_end() {
413        let net = crate::upper_level_by_name("network-admin").expect("network-admin exists");
414        let yolo = crate::upper_level_by_name("yolo").expect("yolo exists");
415
416        // git push origin — a network-admin op. THE payoff: denied at the default (developer)
417        // band, admitted once the threshold IS an upper level.
418        assert_eq!(crate::command_verdict("git push origin main"), Verdict::Denied, "developer denies push");
419        assert!(crate::command_verdict_at_level("git push origin main", net).is_allowed(), "network-admin admits push");
420        assert!(crate::command_verdict_at_level("git push origin main", yolo).is_allowed(), "yolo admits push");
421
422        // rm -rf / — the one thing even yolo denies (destroy·irreversible·unbounded).
423        assert_eq!(crate::command_verdict_at_level("rm -rf /", yolo), Verdict::Denied, "yolo denies rm -rf /");
424
425        // a plain read passes at every upper level (they extend reader).
426        assert!(crate::command_verdict_at_level("cat ./README.md", net).is_allowed(), "reads pass at network-admin");
427
428        // a legacy-DENIED / unmodeled command stays denied even at yolo — allowlist-only: what
429        // the engine cannot certify, no threshold can approve.
430        assert_eq!(crate::command_verdict_at_level("frobnicate --wombat", yolo), Verdict::Denied, "unmodeled denied at yolo");
431
432        // a chain is admitted only if EVERY segment is (a Denied dominates the combine).
433        assert_eq!(crate::command_verdict_at_level("git push && rm -rf /", yolo), Verdict::Denied, "one bad segment sinks the chain");
434
435        // the upper-band lookup rejects lower-band and unknown names (they keep the 3-value ceiling).
436        assert!(crate::upper_level_by_name("developer").is_none());
437        assert!(crate::upper_level_by_name("reader").is_none());
438        assert!(crate::upper_level_by_name("nonsense").is_none());
439
440        // the lower band is UNCHANGED — no eval-level context, projection still tightens cat to read.
441        assert_eq!(crate::command_verdict("cat ./README.md"), Verdict::Allowed(SafetyLevel::SafeRead), "lower band untouched");
442    }
443}