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::Level;
12use super::resolve;
13use crate::parse::Token;
14use crate::verdict::{SafetyLevel, Verdict};
15
16thread_local! {
17    /// The level a `--level` threshold selected, when it is one of the UPPER band
18    /// (`local-admin`/`network-admin`/`yolo`) that has no 3-value legacy equivalent. When set,
19    /// `project` decides via `Level::admits` against THIS level instead of the lower-band
20    /// projection — the only way a profile that only an upper level admits (`git push`, `sudo`)
21    /// can be approved. `None` (the default) keeps the byte-for-byte lower-band behavior, so
22    /// `command_verdict` / `is_safe_command` and every existing test are unaffected.
23    static EVAL_LEVEL: Cell<Option<&'static Level>> = const { Cell::new(None) };
24}
25
26/// Evaluate the enclosed classification against `level` (an upper-band level). Restores the
27/// previous context on drop. Mirrors `pathctx::enter`.
28pub fn enter_eval_level(level: &'static Level) -> EvalLevelGuard {
29    EvalLevelGuard(EVAL_LEVEL.with(|c| c.replace(Some(level))))
30}
31
32pub struct EvalLevelGuard(Option<&'static Level>);
33
34impl Drop for EvalLevelGuard {
35    fn drop(&mut self) {
36        EVAL_LEVEL.with(|c| c.set(self.0));
37    }
38}
39
40/// The engine's verdict for a command whose resolver exists, or `None` if it has none
41/// (the caller keeps the legacy verdict).
42pub fn engine_verdict(tokens: &[Token]) -> Option<Verdict> {
43    resolve::resolve(tokens).map(|p| project(&p))
44}
45
46/// Project a resolved profile to a legacy [`Verdict`]: the **lowest** authored level
47/// that admits it, mapped back to its legacy [`SafetyLevel`]; `Denied` if no
48/// legacy-mapped level admits it (above the auto-approve band → worst-case, §0).
49/// `default_levels()` builds the ascending chain (paranoid ⊂ reader ⊂ editor ⊂
50/// developer), so the first match among the mapped levels is the minimum.
51pub fn project(profile: &Profile) -> Verdict {
52    if profile.capabilities.is_empty() {
53        // Fail-closed (§0): an empty profile means the resolver produced NO capability.
54        // Every level vacuously admits it (`all` of zero capabilities is true), so without
55        // this guard it would project to the lowest level (`paranoid`) — the *most*
56        // permissive, inverting the principle. A genuinely-inert command emits an explicit
57        // observe capability, never an empty profile.
58        return Verdict::Denied;
59    }
60    if let Some(level) = EVAL_LEVEL.with(Cell::get) {
61        // An upper-band `--level` is authoritative via `admits`. Pass projects to `SafeWrite`
62        // — the legacy ceiling every upper level shares — so `run_cli`'s existing `<= ceiling`
63        // gate accepts it; a profile the level does not admit is `Denied`, dominating the chain.
64        return if level.admits(profile) {
65            Verdict::Allowed(SafetyLevel::SafeWrite)
66        } else {
67            Verdict::Denied
68        };
69    }
70    for level in default_levels() {
71        // Only the auto-approvable band (paranoid..developer) has a 3-value legacy
72        // equivalent. The levels above it (local-admin, network-admin, yolo) have NO
73        // legacy mapping, so a profile that only THEY admit projects to Denied — never
74        // silently to SafeWrite (the old `_ => SafeWrite` catch-all would have
75        // auto-approved sudo/terraform the moment those levels were added). Selecting
76        // an upper level as a threshold is the separate harness-config change.
77        if let Some(sl) = to_legacy(&level.name)
78            && level.admits(profile)
79        {
80            return Verdict::Allowed(sl);
81        }
82    }
83    Verdict::Denied
84}
85
86fn to_legacy(level_name: &str) -> Option<SafetyLevel> {
87    match level_name {
88        "paranoid" => Some(SafetyLevel::Inert),
89        "reader" => Some(SafetyLevel::SafeRead),
90        "editor" | "developer" => Some(SafetyLevel::SafeWrite),
91        _ => None, // local-admin, network-admin, yolo — above the legacy 3-value ceiling
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::engine::facet::*;
99
100    fn toks(parts: &[&str]) -> Vec<Token> {
101        parts.iter().map(|p| Token::from_test(p)).collect()
102    }
103
104    #[test]
105    fn project_maps_profiles_to_the_lowest_admitting_level() {
106        // echo — inert
107        let echo = Profile::of(vec![{
108            let mut c = Capability::new(Operation::Observe);
109            c.disclosure.audience = DisclosureAudience::LocalProcess;
110            c
111        }]);
112        assert_eq!(project(&echo), Verdict::Allowed(SafetyLevel::Inert));
113
114        // cat ./notes — read-local
115        let read = Profile::of(vec![{
116            let mut c = Capability::new(Operation::Observe);
117            c.locus.local = LocalLocus::Worktree;
118            c.disclosure.audience = DisclosureAudience::LocalProcess;
119            c
120        }]);
121        assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead));
122
123        // cat ~/.ssh/id_rsa — above the authored ladder → Denied
124        let home = Profile::of(vec![{
125            let mut c = Capability::new(Operation::Observe);
126            c.locus.local = LocalLocus::User;
127            c.disclosure.audience = DisclosureAudience::LocalProcess;
128            c
129        }]);
130        assert_eq!(project(&home), Verdict::Denied);
131
132        // touch build/out — create·worktree·data → write-local → SafeWrite (the
133        // to_legacy `_ => SafeWrite` arm; no resolver emits this yet)
134        let write = Profile::of(vec![{
135            let mut c = Capability::new(Operation::Create);
136            c.locus.local = LocalLocus::Worktree;
137            c.scale = Scale::Bounded;
138            c.reversibility = Reversibility::Recoverable;
139            c.persistence.level = PersistenceLevel::Data;
140            c
141        }]);
142        assert_eq!(project(&write), Verdict::Allowed(SafetyLevel::SafeWrite));
143
144        // an EMPTY profile must fail closed (Denied), NOT project to inert — every level
145        // vacuously admits it, so the guard is what stops "resolved to nothing" = "safe".
146        assert_eq!(project(&Profile::of(vec![])), Verdict::Denied);
147    }
148
149    /// The fail-open this refactor had to avoid: the levels above developer (local-admin,
150    /// network-admin, yolo) have NO legacy `SafetyLevel`, so a profile only they admit must
151    /// project to `Denied` — never to `SafeWrite`. The old `_ => SafeWrite` catch-all in
152    /// `to_legacy` would have auto-approved every one of these.
153    #[test]
154    fn profiles_needing_an_upper_level_project_to_denied_not_safewrite() {
155        // sudo systemctl restart — elevated authority on machine locus (local-admin)
156        let sudo = Profile::of(vec![{
157            let mut c = Capability::new(Operation::Control);
158            c.locus.local = LocalLocus::Machine;
159            c.authority = Authority::Root;
160            c
161        }]);
162        assert_eq!(project(&sudo), Verdict::Denied, "sudo must not auto-approve");
163
164        // terraform apply — remote reach over outbound network (network-admin)
165        let remote = Profile::of(vec![{
166            let mut c = Capability::new(Operation::Mutate);
167            c.locus.remote = RemoteReach::Fixed;
168            c.network.direction = NetDirection::Outbound;
169            c
170        }]);
171        assert_eq!(project(&remote), Verdict::Denied, "remote infra must not auto-approve");
172
173        // terraform destroy — irreversible remote destroy (yolo only)
174        let catastrophe = Profile::of(vec![{
175            let mut c = Capability::new(Operation::Destroy);
176            c.locus.remote = RemoteReach::Fixed;
177            c.reversibility = Reversibility::Irreversible;
178            c
179        }]);
180        assert_eq!(project(&catastrophe), Verdict::Denied, "irreversible destroy must not auto-approve");
181    }
182
183    /// The legacy classifier's leaf verdict for `cmd` — what the engine falls back to for a
184    /// command it can't resolve, and the baseline the never-looser gates compare against.
185    fn legacy(cmd: &str) -> Verdict {
186        crate::handlers::dispatch(&toks(&cmd.split_whitespace().collect::<Vec<_>>()))
187    }
188
189    #[test]
190    fn the_engine_is_authoritative_with_legacy_fallback() {
191        // a resolved command → the engine's (finer) verdict, end to end
192        assert_eq!(
193            crate::command_verdict("cat ./notes.md"),
194            Verdict::Allowed(SafetyLevel::SafeRead),
195            "cat resolves → engine tightens inert to read-local",
196        );
197        // an unresolvable command → the legacy classifier still decides
198        let unresolved = resolve::UNRESOLVED_CMD.join(" ");
199        assert_eq!(
200            crate::command_verdict(&unresolved),
201            legacy(&unresolved),
202            "no resolver → legacy verdict",
203        );
204    }
205
206    #[test]
207    fn engine_verdict_is_none_for_unresearched_commands() {
208        assert!(engine_verdict(&toks(resolve::UNRESOLVED_CMD)).is_none());
209        assert_eq!(engine_verdict(&toks(&["echo", "hi"])), Some(Verdict::Allowed(SafetyLevel::Inert)));
210        assert_eq!(
211            engine_verdict(&toks(&["cat", "./notes.md"])),
212            Some(Verdict::Allowed(SafetyLevel::SafeRead)),
213        );
214        assert_eq!(engine_verdict(&toks(&["cat", "~/.ssh/id_rsa"])), Some(Verdict::Denied));
215    }
216
217    /// The engine may deny what legacy allowed (intended tightening) or classify higher,
218    /// but must **never allow what legacy denied**, nor classify lower.
219    fn not_looser(legacy: Verdict, engine: Verdict) -> bool {
220        match (legacy, engine) {
221            (_, Verdict::Denied) => true,
222            (Verdict::Denied, Verdict::Allowed(_)) => false,
223            (Verdict::Allowed(l), Verdict::Allowed(e)) => e >= l,
224        }
225    }
226
227    /// The rollout safety gate on hand-picked forms — including the ones the wiring and
228    /// the review flushed (unrecognized/dangerous flags, and pattern-less grep, which
229    /// legacy denies as a usage error).
230    #[test]
231    fn the_engine_is_never_looser_than_legacy() {
232        let cases = [
233            "echo hi", "echo", "cat ./notes.md", "cat -n ./notes.md", "cat ~/.ssh/id_rsa",
234            "cat /etc/hosts", "cat a.txt b.txt", "grep foo src/main.rs", "grep -r foo src/",
235            "grep -r foo ~", "grep foo bar.txt",
236            // PCRE (-P/--perl-regexp) is benign — PCRE2 execs no code, just a regex engine
237            "grep -P foo file", "grep -oP foo file", "grep --perl-regexp foo file",
238            // unrecognized / dangerous flags must worst-case
239            "cat --unknownflag ./x", "cat -Z ./x", "grep --wat foo file",
240            // pattern-less grep (C1): legacy denies as a usage error, engine must too
241            "grep", "grep -r", "grep -i", "grep -e foo", "grep -f patterns.txt",
242        ];
243        for cmd in cases {
244            let base = legacy(cmd);
245            let t = toks(&cmd.split_whitespace().collect::<Vec<_>>());
246            let Some(engine) = engine_verdict(&t) else { continue };
247            assert!(
248                not_looser(base, engine),
249                "engine LOOSER than legacy for `{cmd}`: legacy {base}, engine {engine}",
250            );
251        }
252    }
253
254    /// The never-looser invariant above holds over the commands legacy *allowlisted*. The
255    /// `developer` level is the deliberate exception: it admits well-modeled operations the
256    /// hand-built allowlist could only DENY — e.g. deleting your own project files. This
257    /// test pins that divergence as intended, not a regression: it is exactly the kind of
258    /// finer classification the engine exists to make, now that it is authoritative.
259    #[test]
260    fn developer_intentionally_admits_worktree_destroy_that_legacy_denies() {
261        let rm = "rm -rf ./node_modules";
262        assert_eq!(legacy(rm), Verdict::Denied, "legacy allowlist denies rm deletion");
263        assert_eq!(crate::command_verdict(rm), Verdict::Allowed(SafetyLevel::SafeWrite), "engine (developer) admits it — intended");
264        assert!(!not_looser(Verdict::Denied, Verdict::Allowed(SafetyLevel::SafeWrite)), "and it IS looser than legacy, by design");
265    }
266
267    /// sed/tar keep coarse legacy HANDLERS (`coreutils::sed`/`tar`) that `handlers::dispatch`
268    /// consults before the TOML — so `legacy()` for them is that handler, which denies an in-place
269    /// edit. The behavioral engine models `sed -i` on a worktree file correctly (a SafeWrite) and is
270    /// authoritative. Pin the divergence as intended (not a regression) — the same shape as `rm`
271    /// above — because the corpus gate's sed examples deliberately avoid this looser case.
272    #[test]
273    fn engine_intentionally_admits_worktree_in_place_edit_that_legacy_sed_handler_denies() {
274        let sed = "sed -i s/a/b/ ./file.txt";
275        assert_eq!(legacy(sed), Verdict::Denied, "legacy sed handler denies in-place edit");
276        assert_eq!(crate::command_verdict(sed), Verdict::Allowed(SafetyLevel::SafeWrite), "engine admits worktree -i — intended");
277        assert!(!not_looser(Verdict::Denied, Verdict::Allowed(SafetyLevel::SafeWrite)), "and it IS looser than the legacy sed handler, by design");
278    }
279
280    /// The data-driven corpus gate (the systematic test C1 slipped past): run **every**
281    /// command's real `examples_safe`/`examples_denied` through the engine and assert,
282    /// per resolvable example, the dimensions that hold today —
283    ///   1. **never looser** than legacy (engine ≤ legacy; also subsumes "an
284    ///      examples_denied that resolves stays denied", since legacy denies it),
285    ///   2. **justified** — every resolved capability cites a `because` (§5),
286    ///   3. **total** — resolution and projection never panic.
287    /// It grows automatically as commands convert; today it exercises the resolvable
288    /// commands and skips the rest. Only bare single commands are comparable at the leaf
289    /// (chains/redirects/substitutions are the CST's job). The full per-facet completeness
290    /// dimension is the golden-profile check (`resolve::golden_profiles_cover_every_facet`)
291    /// and becomes TOML-derived when commands carry profile data (§7).
292    #[test]
293    fn the_engine_corpus_gate() {
294        let mut exercised = 0usize;
295        for (name, safe, denied) in crate::registry::corpus_examples() {
296            for ex in safe.iter().chain(denied.iter()) {
297                if ex.contains(['|', '>', '<', '&', ';', '$', '`', '(', '\n']) {
298                    continue; // not a bare single command
299                }
300                let t = toks(&ex.split_whitespace().collect::<Vec<_>>());
301                let Some(profile) = crate::engine::resolve::resolve(&t) else { continue };
302                exercised += 1;
303
304                for c in &profile.capabilities {
305                    assert!(!c.because.is_empty(), "unjustified capability for `{ex}` ({name})");
306                }
307
308                // A PROFILED sub's legacy kind is deny-all — a fail-closed placeholder for when the
309                // engine ABSTAINS (a global flag before the sub), NOT a real hand-built verdict. So the
310                // never-looser comparison is meaningless for it: the engine is authoritative and
311                // legitimately admits below the line (`npm ci --ignore-scripts` at developer). Its
312                // landing is pinned by the archetype tests, not here.
313                if crate::registry::sub_archetypes(&t).is_some() {
314                    continue;
315                }
316                let engine = project(&profile);
317                let base = legacy(ex);
318                assert!(
319                    not_looser(base, engine),
320                    "engine LOOSER than legacy for `{ex}` ({name}): legacy {base}, engine {engine}",
321                );
322            }
323        }
324        // non-vacuity: the gate must actually resolve engine examples, or it is a green
325        // test proving nothing (the trap that hid its own emptiness). Every resolvable
326        // command must contribute at least one example.
327        assert!(exercised >= 5, "corpus gate exercised only {exercised} engine resolutions — vacuous?");
328    }
329
330    /// Per-level threshold wiring end to end: an UPPER-band `--level` classifies via `admits`,
331    /// unlocking profiles that only an upper level admits, while the lower band and the
332    /// allowlist-only fail-closed reflex are untouched.
333    #[test]
334    fn upper_band_levels_admit_via_the_engine_end_to_end() {
335        let net = crate::upper_level_by_name("network-admin").expect("network-admin exists");
336        let yolo = crate::upper_level_by_name("yolo").expect("yolo exists");
337
338        // git push origin — a network-admin op. THE payoff: denied at the default (developer)
339        // band, admitted once the threshold IS an upper level.
340        assert_eq!(crate::command_verdict("git push origin main"), Verdict::Denied, "developer denies push");
341        assert!(crate::command_verdict_at_level("git push origin main", net).is_allowed(), "network-admin admits push");
342        assert!(crate::command_verdict_at_level("git push origin main", yolo).is_allowed(), "yolo admits push");
343
344        // rm -rf / — the one thing even yolo denies (destroy·irreversible·unbounded).
345        assert_eq!(crate::command_verdict_at_level("rm -rf /", yolo), Verdict::Denied, "yolo denies rm -rf /");
346
347        // a plain read passes at every upper level (they extend reader).
348        assert!(crate::command_verdict_at_level("cat ./README.md", net).is_allowed(), "reads pass at network-admin");
349
350        // a legacy-DENIED / unmodeled command stays denied even at yolo — allowlist-only: what
351        // the engine cannot certify, no threshold can approve.
352        assert_eq!(crate::command_verdict_at_level("frobnicate --wombat", yolo), Verdict::Denied, "unmodeled denied at yolo");
353
354        // a chain is admitted only if EVERY segment is (a Denied dominates the combine).
355        assert_eq!(crate::command_verdict_at_level("git push && rm -rf /", yolo), Verdict::Denied, "one bad segment sinks the chain");
356
357        // the upper-band lookup rejects lower-band and unknown names (they keep the 3-value ceiling).
358        assert!(crate::upper_level_by_name("developer").is_none());
359        assert!(crate::upper_level_by_name("reader").is_none());
360        assert!(crate::upper_level_by_name("nonsense").is_none());
361
362        // the lower band is UNCHANGED — no eval-level context, projection still tightens cat to read.
363        assert_eq!(crate::command_verdict("cat ./README.md"), Verdict::Allowed(SafetyLevel::SafeRead), "lower band untouched");
364    }
365}