Skip to main content

lex_extension_host/trust/
decision.rs

1//! Trust decision matrix.
2//!
3//! [`TrustGate::evaluate`] takes the four input axes (source, surface,
4//! transport, capability) and returns a [`TrustDecision`]. The
5//! decision encodes the β/γ-correct policy described in the
6//! master-issue correction #1: subprocess handlers always require
7//! explicit approval; declared `capabilities: { fs/net: false }` is
8//! ignored until PR 12 lands OS-level enforcement.
9
10use std::sync::Arc;
11
12use super::store::{TrustKey, TrustStore};
13
14/// Where the schema came from. Combined with [`Surface`] and
15/// [`Transport`] this determines whether the handler may run.
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Source {
18    /// `--ext-schema ./local.yaml` — user passed the schema path
19    /// explicitly on the command line.
20    LocalFile { path: std::path::PathBuf },
21    /// `[labels]` block in `lex.toml` — the workspace owner declared
22    /// the namespace.
23    LexTomlNamespace { name: String },
24    /// Schema fetched from a marketplace / registry / cache. The host
25    /// did not see an explicit user gesture pointing at this schema.
26    CacheOnly { uri: String },
27}
28
29/// Which host surface is consulting the gate.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Surface {
32    /// `lexd` CLI, single-shot. No interactive prompt.
33    CliOneShot,
34    /// `lex-lsp` running inside an editor. Has the prompt callback.
35    LspSession,
36    /// CI environment, auto-detected from env vars (see
37    /// [`detect_ci_environment`]).
38    Ci,
39}
40
41/// Schema's declared capability set. Stored on the evaluator and
42/// passed to the matrix for forward-compat with PR 12; today it does
43/// not influence the decision (β/γ matrix prompts subprocess
44/// regardless).
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum Capability {
47    /// `capabilities: { fs: false, net: false }` — the handler
48    /// declares it doesn't need filesystem or network access. Trusted
49    /// to run under sandbox once PR 12 ships.
50    Pure,
51    /// At least one of `fs: true` or `net: true`. Always prompts even
52    /// post-δ.
53    Full,
54}
55
56impl Capability {
57    /// Build from the schema's `Capabilities` struct. Maps the bool
58    /// pair to the binary classifier the gate cares about.
59    pub fn from_schema(caps: lex_extension::schema::Capabilities) -> Self {
60        if caps.is_pure() {
61            Capability::Pure
62        } else {
63            Capability::Full
64        }
65    }
66}
67
68/// Which transport the handler will use. The gate's matrix only
69/// distinguishes Native (trusted by linkage) from everything else
70/// (Subprocess and Wasm both prompt). Wasm shouldn't reach the gate
71/// — the schema loader rejects it — but the variant is here so a
72/// future enable can be a single-line matrix change.
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
74pub enum Transport {
75    Native,
76    Subprocess,
77    Wasm,
78}
79
80impl Transport {
81    /// Build from the schema's `HandlerTransport`.
82    pub fn from_schema(t: lex_extension::schema::HandlerTransport) -> Self {
83        match t {
84            lex_extension::schema::HandlerTransport::Native => Transport::Native,
85            lex_extension::schema::HandlerTransport::Subprocess => Transport::Subprocess,
86            lex_extension::schema::HandlerTransport::Wasm => Transport::Wasm,
87            // HandlerTransport is #[non_exhaustive]; conservatively
88            // treat unknown variants as Subprocess — they'll prompt,
89            // which is the safer default than silently allowing.
90            _ => Transport::Subprocess,
91        }
92    }
93}
94
95/// The verdict the gate returns for one (source, surface, transport,
96/// capability) tuple.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub enum TrustDecision {
99    /// Handler may run.
100    Trusted,
101    /// Handler may NOT run. The string is a user-facing diagnostic
102    /// explaining why and what to do (e.g., "use --enable-handlers").
103    Denied { reason: String },
104    /// LSP-only: prompt the user via the [`TrustPromptHandler`]
105    /// callback. The result is pinned in the trust store keyed by the
106    /// `(workspace, namespace, command_string)` tuple inside
107    /// [`TrustPromptContext`].
108    Pending,
109}
110
111/// Context handed to a [`TrustPromptHandler`] when the gate needs a
112/// user decision. The same fields make up the [`TrustKey`] that
113/// pins the answer in the trust store, so a re-prompt only happens
114/// when one of those changes (typically the `command_string` after
115/// a schema bump).
116#[derive(Debug, Clone, PartialEq, Eq)]
117pub struct TrustPromptContext {
118    pub namespace: String,
119    /// The schema's `handler.command` joined into a single string.
120    /// Pin granularity — a different command string means a new
121    /// prompt.
122    pub command_string: String,
123    /// Where the schema came from. Surfaces in the prompt UI so the
124    /// user can tell `--ext-schema ./acme.yaml` from a
125    /// marketplace-fetched namespace.
126    pub source: Source,
127    /// What the schema declares it needs. Surfaces in the prompt UI
128    /// for the user's awareness; doesn't change the matrix outcome
129    /// in β/γ.
130    pub capability: Capability,
131}
132
133/// User-defined callback the gate invokes when [`TrustDecision::Pending`]
134/// is reached. CLI installs a callback that returns `Denied`
135/// (interactive trust prompts in CLI mode would be a mid-pipeline
136/// TTY interrupt — instead, the policy is "use `--enable-handlers`
137/// upfront or it's denied"). LSP installs a callback that surfaces
138/// a `lex/trustRequest` notification and waits for the editor's
139/// response (PR 10).
140pub trait TrustPromptHandler: Send + Sync {
141    /// Decide trust for one prompt context. The returned variant must
142    /// be either [`TrustDecision::Trusted`] or
143    /// [`TrustDecision::Denied`] — returning `Pending` is a
144    /// programmer error and is treated as `Denied`.
145    fn prompt(&self, ctx: &TrustPromptContext) -> TrustDecision;
146}
147
148/// The gate. Constructed once per host session with a [`Surface`],
149/// a [`TrustStore`] for persistence, and a [`TrustPromptHandler`]
150/// for the LSP path. Owned (not `Clone`): the contained
151/// `Box<dyn TrustPromptHandler>` and the file-backed `TrustStore`
152/// don't have a sensible cheap copy, and the gate is a session-
153/// scoped singleton in practice. Wrap in `Arc<Mutex<…>>` if a
154/// caller really needs shared mutability.
155pub struct TrustGate {
156    surface: Surface,
157    /// `--enable-handlers` flag — when set, CLI/CI surfaces treat
158    /// subprocess invocations as `Trusted` for the run.
159    enable_handlers: bool,
160    store: TrustStore,
161    prompt: Box<dyn TrustPromptHandler>,
162    /// OS-level enforcement available to the host. With
163    /// [`crate::sandbox::NullSandbox`] (the default for PR
164    /// 12-plumbing), [`crate::sandbox::Sandbox::supports`] returns
165    /// `false` for every capability set and the post-δ pure-handler
166    /// auto-trust path is inactive — behaviour matches β/γ. PR 12d
167    /// (the matrix flip) becomes a one-line wiring change in
168    /// `lex-fmt` to install the OS-appropriate impl here.
169    ///
170    /// Stored as `Arc` so the host can hand the same instance to
171    /// the gate and to [`crate::transport::SubprocessHandler::spawn_with_sandbox`]
172    /// — guaranteeing the gate's auto-trust decision is anchored on
173    /// the same sandbox that will actually enforce the policy at
174    /// spawn time.
175    sandbox: Arc<dyn crate::sandbox::Sandbox>,
176}
177
178impl TrustGate {
179    pub fn new(
180        surface: Surface,
181        enable_handlers: bool,
182        store: TrustStore,
183        prompt: Box<dyn TrustPromptHandler>,
184    ) -> Self {
185        Self {
186            surface,
187            enable_handlers,
188            store,
189            prompt,
190            sandbox: Arc::new(crate::sandbox::NullSandbox),
191        }
192    }
193
194    /// Install an OS-level sandbox for post-δ auto-trust of declared-
195    /// pure handlers. Replaces the default
196    /// [`crate::sandbox::NullSandbox`]. Today (PR 12-plumbing) the
197    /// gate consults [`crate::sandbox::Sandbox::supports`] but the
198    /// only available impls report `false`, so behaviour doesn't
199    /// change yet. PR 12a/b/c ship per-OS impls; PR 12d flips the
200    /// matrix to actually use the result.
201    ///
202    /// Takes `Arc<dyn Sandbox>` so the host can hand the same
203    /// instance to [`crate::transport::SubprocessHandler::spawn_with_sandbox`]
204    /// — guaranteeing the auto-trust decision is anchored on the
205    /// sandbox that actually enforces policy at spawn time. Without
206    /// this contract, a host could (e.g.) install a real sandbox on
207    /// the gate but forget to wire it into spawn, silently auto-
208    /// trusting pure handlers that then run unrestrained.
209    pub fn set_sandbox(&mut self, sandbox: Arc<dyn crate::sandbox::Sandbox>) {
210        self.sandbox = sandbox;
211    }
212
213    /// Borrow the sandbox installed on this gate. Mainly here for
214    /// host-side diagnostics (e.g., "this workspace would auto-
215    /// trust pure handlers" status output) and for callers that
216    /// need to pass the same instance into
217    /// [`crate::transport::SubprocessHandler::spawn_with_sandbox`].
218    pub fn sandbox(&self) -> Arc<dyn crate::sandbox::Sandbox> {
219        Arc::clone(&self.sandbox)
220    }
221
222    /// Surface the gate was constructed with. Useful for diagnostics
223    /// that want to mention the active mode.
224    pub fn surface(&self) -> Surface {
225        self.surface
226    }
227
228    /// Whether `--enable-handlers` was set.
229    pub fn enable_handlers(&self) -> bool {
230        self.enable_handlers
231    }
232
233    /// Apply the matrix to one handler invocation.
234    ///
235    /// `command_string` is the schema's `handler.command` joined by
236    /// spaces — this is what the trust store keys on for pin
237    /// granularity. A different command string is a different trust
238    /// decision.
239    pub fn evaluate(
240        &mut self,
241        source: &Source,
242        transport: Transport,
243        capability: Capability,
244        namespace: &str,
245        command_string: &str,
246    ) -> TrustDecision {
247        // Native handlers run by linkage. Bundled `lex.*` built-ins
248        // hit this path; PR 12d will extend it to declared-pure
249        // subprocess handlers under an enforced sandbox.
250        if matches!(transport, Transport::Native) {
251            return TrustDecision::Trusted;
252        }
253
254        // WASM should never reach the gate (schema loader rejects).
255        // If it does — defence in depth — treat as denied.
256        if matches!(transport, Transport::Wasm) {
257            return TrustDecision::Denied {
258                reason: "WASM handlers are not yet supported".into(),
259            };
260        }
261
262        // Post-δ pure-handler auto-trust path (the matrix flip
263        // tracked at lex#528 / PR 12d). Only fires when both:
264        //   1. the handler declared `pure` capabilities, AND
265        //   2. the installed sandbox supports enforcing that
266        //      declaration on the running platform.
267        //
268        // PR 12-plumbing wires this consultation but ships
269        // [`crate::sandbox::NullSandbox`] as the default, which
270        // reports `supports(_) == false` for every capability set.
271        // PR 12a/b/c add real per-OS impls; PR 12d switches the
272        // default install in `lex-fmt` from `NullSandbox` to the
273        // OS-appropriate impl so this branch starts firing.
274        //
275        // We pass `Capabilities::default()` to `supports()` because
276        // `Capability::Pure` is exactly the
277        // `Capabilities::is_pure()` classifier — the default-shape
278        // capability set. Future granular capability variants would
279        // pass the actual schema `Capabilities` through `evaluate`
280        // and on to `supports()` here.
281        //
282        // Independent of `surface`: a pure handler under an enforced
283        // sandbox is trustworthy regardless of whether we're in CLI,
284        // CI, or LSP mode.
285        if matches!(capability, Capability::Pure)
286            && self
287                .sandbox
288                .supports(lex_extension::schema::Capabilities::default())
289        {
290            return TrustDecision::Trusted;
291        }
292
293        match self.surface {
294            Surface::CliOneShot | Surface::Ci => {
295                if self.enable_handlers {
296                    TrustDecision::Trusted
297                } else {
298                    TrustDecision::Denied {
299                        reason: format!(
300                            "subprocess handler `{namespace}` requires --enable-handlers in {} mode",
301                            match self.surface {
302                                Surface::Ci => "CI",
303                                _ => "CLI",
304                            }
305                        ),
306                    }
307                }
308            }
309            Surface::LspSession => {
310                let key = TrustKey {
311                    namespace: namespace.to_string(),
312                    command_string: command_string.to_string(),
313                };
314                if let Some(stored) = self.store.get(&key) {
315                    return stored.clone();
316                }
317                let ctx = TrustPromptContext {
318                    namespace: namespace.to_string(),
319                    command_string: command_string.to_string(),
320                    source: source.clone(),
321                    capability,
322                };
323                let decision = self.prompt.prompt(&ctx);
324                let to_store = match &decision {
325                    TrustDecision::Trusted => Some(decision.clone()),
326                    TrustDecision::Denied { .. } => Some(decision.clone()),
327                    // Programmer error — `prompt()` must not return
328                    // Pending. Treat as Denied for safety; don't
329                    // persist (the prompt may be retriable on a
330                    // subsequent session).
331                    TrustDecision::Pending => None,
332                };
333                if let Some(decision_to_store) = to_store {
334                    if let Err(e) = self.store.set(key, decision_to_store) {
335                        // Persist failed — most often a read-only
336                        // workspace. The store's atomicity contract
337                        // guarantees in-memory matches disk, so the
338                        // pin really wasn't recorded. We honor the
339                        // prompt's verdict for *this* session
340                        // (returning `decision` below) and log the
341                        // failure so the user can see why their
342                        // approval isn't sticking. Next session
343                        // they'll be prompted again with the same
344                        // diagnostic visible.
345                        eprintln!(
346                            "[lex-extension-host] trust store persist failed for `{namespace}`: {e}; approval applies for this session only"
347                        );
348                    }
349                }
350                match decision {
351                    TrustDecision::Pending => TrustDecision::Denied {
352                        reason: format!(
353                            "trust prompt for `{namespace}` returned Pending — treating as denied"
354                        ),
355                    },
356                    other => other,
357                }
358            }
359        }
360    }
361
362    /// Borrow the underlying store for inspection. Tests use this; PR
363    /// 10's editor UI will too (so it can show "currently trusted
364    /// namespaces").
365    pub fn store(&self) -> &TrustStore {
366        &self.store
367    }
368}
369
370/// Detect whether the host process is running in a CI environment.
371///
372/// Checks the standard set of env vars shipped by major providers
373/// (the `CI` superset variable plus a few well-known specific
374/// flags). Returns `true` if any one is set, regardless of value.
375///
376/// This is the auto-detection the `lexd` CLI uses to choose
377/// [`Surface::Ci`] over [`Surface::CliOneShot`] when no explicit
378/// surface override is supplied.
379pub fn detect_ci_environment<F>(env_lookup: F) -> bool
380where
381    F: Fn(&str) -> Option<String>,
382{
383    const CI_VARS: &[&str] = &[
384        "CI",
385        "CONTINUOUS_INTEGRATION",
386        "GITHUB_ACTIONS",
387        "GITLAB_CI",
388        "BUILDKITE",
389        "CIRCLECI",
390        "TRAVIS",
391        "JENKINS_URL",
392    ];
393    CI_VARS.iter().any(|v| env_lookup(v).is_some())
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    /// Always returns the configured decision. Used to drive
401    /// matrix-cell tests for the LSP path without touching the
402    /// real prompt UI.
403    struct FixedPrompt(TrustDecision);
404    impl TrustPromptHandler for FixedPrompt {
405        fn prompt(&self, _ctx: &TrustPromptContext) -> TrustDecision {
406            self.0.clone()
407        }
408    }
409
410    fn store_in_tmp() -> (TrustStore, tempfile::TempDir) {
411        let dir = tempfile::tempdir().expect("tempdir");
412        let store = TrustStore::open(dir.path()).expect("open");
413        (store, dir)
414    }
415
416    fn gate_with_surface(
417        surface: Surface,
418        enable_handlers: bool,
419        prompt_decision: TrustDecision,
420    ) -> (TrustGate, tempfile::TempDir) {
421        let (store, dir) = store_in_tmp();
422        let gate = TrustGate::new(
423            surface,
424            enable_handlers,
425            store,
426            Box::new(FixedPrompt(prompt_decision)),
427        );
428        (gate, dir)
429    }
430
431    #[test]
432    fn native_is_trusted_under_every_surface() {
433        for surface in [Surface::CliOneShot, Surface::LspSession, Surface::Ci] {
434            let (mut gate, _dir) = gate_with_surface(
435                surface,
436                false,
437                TrustDecision::Denied {
438                    reason: "should not be called".into(),
439                },
440            );
441            let d = gate.evaluate(
442                &Source::LexTomlNamespace { name: "lex".into() },
443                Transport::Native,
444                Capability::Full,
445                "lex",
446                "/usr/bin/never-spawned",
447            );
448            assert_eq!(d, TrustDecision::Trusted, "surface={surface:?}");
449        }
450    }
451
452    #[test]
453    fn cli_subprocess_without_flag_is_denied() {
454        let (mut gate, _dir) = gate_with_surface(
455            Surface::CliOneShot,
456            false,
457            TrustDecision::Denied {
458                reason: "n/a".into(),
459            },
460        );
461        let d = gate.evaluate(
462            &Source::LexTomlNamespace {
463                name: "acme".into(),
464            },
465            Transport::Subprocess,
466            Capability::Pure,
467            "acme",
468            "acme-handler",
469        );
470        match d {
471            TrustDecision::Denied { reason } => {
472                assert!(reason.contains("--enable-handlers"));
473                assert!(reason.contains("acme"));
474            }
475            other => panic!("expected Denied, got: {other:?}"),
476        }
477    }
478
479    #[test]
480    fn cli_subprocess_with_flag_is_trusted() {
481        let (mut gate, _dir) = gate_with_surface(
482            Surface::CliOneShot,
483            true,
484            TrustDecision::Denied {
485                reason: "n/a".into(),
486            },
487        );
488        let d = gate.evaluate(
489            &Source::LexTomlNamespace {
490                name: "acme".into(),
491            },
492            Transport::Subprocess,
493            Capability::Pure,
494            "acme",
495            "acme-handler",
496        );
497        assert_eq!(d, TrustDecision::Trusted);
498    }
499
500    #[test]
501    fn cli_with_flag_does_not_persist_to_store() {
502        let (mut gate, _dir) = gate_with_surface(
503            Surface::CliOneShot,
504            true,
505            TrustDecision::Denied {
506                reason: "n/a".into(),
507            },
508        );
509        gate.evaluate(
510            &Source::LexTomlNamespace {
511                name: "acme".into(),
512            },
513            Transport::Subprocess,
514            Capability::Pure,
515            "acme",
516            "acme-handler",
517        );
518        let key = TrustKey {
519            namespace: "acme".into(),
520            command_string: "acme-handler".into(),
521        };
522        assert!(
523            gate.store().get(&key).is_none(),
524            "CLI --enable-handlers must not persist trust"
525        );
526    }
527
528    #[test]
529    fn ci_subprocess_without_flag_is_denied() {
530        let (mut gate, _dir) = gate_with_surface(
531            Surface::Ci,
532            false,
533            TrustDecision::Denied {
534                reason: "n/a".into(),
535            },
536        );
537        let d = gate.evaluate(
538            &Source::LexTomlNamespace {
539                name: "acme".into(),
540            },
541            Transport::Subprocess,
542            Capability::Pure,
543            "acme",
544            "acme-handler",
545        );
546        match d {
547            TrustDecision::Denied { reason } => assert!(reason.contains("CI")),
548            other => panic!("expected Denied, got: {other:?}"),
549        }
550    }
551
552    #[test]
553    fn ci_subprocess_with_flag_is_trusted() {
554        let (mut gate, _dir) = gate_with_surface(
555            Surface::Ci,
556            true,
557            TrustDecision::Denied {
558                reason: "n/a".into(),
559            },
560        );
561        let d = gate.evaluate(
562            &Source::LexTomlNamespace {
563                name: "acme".into(),
564            },
565            Transport::Subprocess,
566            Capability::Pure,
567            "acme",
568            "acme-handler",
569        );
570        assert_eq!(d, TrustDecision::Trusted);
571    }
572
573    #[test]
574    fn lsp_first_call_invokes_prompt_and_persists_trusted() {
575        let (mut gate, _dir) =
576            gate_with_surface(Surface::LspSession, false, TrustDecision::Trusted);
577        let d = gate.evaluate(
578            &Source::LexTomlNamespace {
579                name: "acme".into(),
580            },
581            Transport::Subprocess,
582            Capability::Pure,
583            "acme",
584            "acme-handler",
585        );
586        assert_eq!(d, TrustDecision::Trusted);
587        // Pinned for next time.
588        let key = TrustKey {
589            namespace: "acme".into(),
590            command_string: "acme-handler".into(),
591        };
592        assert_eq!(gate.store().get(&key), Some(&TrustDecision::Trusted));
593    }
594
595    #[test]
596    fn lsp_subsequent_call_uses_pinned_decision_without_prompt() {
597        // Prompt would deny — but the store was pre-populated as
598        // Trusted, so the gate must short-circuit.
599        let (store, _dir) = store_in_tmp();
600        let mut store = store;
601        let key = TrustKey {
602            namespace: "acme".into(),
603            command_string: "acme-handler".into(),
604        };
605        store.set(key.clone(), TrustDecision::Trusted).unwrap();
606
607        let mut gate = TrustGate::new(
608            Surface::LspSession,
609            false,
610            store,
611            Box::new(FixedPrompt(TrustDecision::Denied {
612                reason: "MUST NOT FIRE".into(),
613            })),
614        );
615        let d = gate.evaluate(
616            &Source::LexTomlNamespace {
617                name: "acme".into(),
618            },
619            Transport::Subprocess,
620            Capability::Pure,
621            "acme",
622            "acme-handler",
623        );
624        assert_eq!(d, TrustDecision::Trusted);
625    }
626
627    #[test]
628    fn lsp_command_string_change_re_prompts() {
629        // Pin trust for the v1 command, then ask about a v2 command.
630        // The store key includes command_string, so the second call
631        // misses and re-prompts.
632        let (store, _dir) = store_in_tmp();
633        let mut store = store;
634        store
635            .set(
636                TrustKey {
637                    namespace: "acme".into(),
638                    command_string: "acme-handler-v1".into(),
639                },
640                TrustDecision::Trusted,
641            )
642            .unwrap();
643
644        let mut gate = TrustGate::new(
645            Surface::LspSession,
646            false,
647            store,
648            Box::new(FixedPrompt(TrustDecision::Denied {
649                reason: "v2 command needs fresh approval".into(),
650            })),
651        );
652        let d = gate.evaluate(
653            &Source::LexTomlNamespace {
654                name: "acme".into(),
655            },
656            Transport::Subprocess,
657            Capability::Pure,
658            "acme",
659            "acme-handler-v2",
660        );
661        match d {
662            TrustDecision::Denied { reason } => {
663                assert!(reason.contains("v2"));
664            }
665            other => panic!("expected fresh prompt to deny, got: {other:?}"),
666        }
667    }
668
669    #[test]
670    fn lsp_denied_decision_persists() {
671        // Denied decisions are also pinned so a future session
672        // doesn't re-prompt unless the command changes.
673        let (mut gate, _dir) = gate_with_surface(
674            Surface::LspSession,
675            false,
676            TrustDecision::Denied {
677                reason: "user rejected".into(),
678            },
679        );
680        let _ = gate.evaluate(
681            &Source::LexTomlNamespace {
682                name: "acme".into(),
683            },
684            Transport::Subprocess,
685            Capability::Pure,
686            "acme",
687            "acme-handler",
688        );
689        let key = TrustKey {
690            namespace: "acme".into(),
691            command_string: "acme-handler".into(),
692        };
693        assert!(matches!(
694            gate.store().get(&key),
695            Some(TrustDecision::Denied { .. })
696        ));
697    }
698
699    #[test]
700    fn wasm_transport_is_denied_defensively() {
701        // Schema loader rejects WASM upfront so the gate shouldn't
702        // see it, but if it does the matrix denies rather than
703        // silently accepts.
704        let (mut gate, _dir) = gate_with_surface(Surface::CliOneShot, true, TrustDecision::Trusted);
705        let d = gate.evaluate(
706            &Source::LexTomlNamespace {
707                name: "acme".into(),
708            },
709            Transport::Wasm,
710            Capability::Pure,
711            "acme",
712            "acme.wasm",
713        );
714        assert!(matches!(d, TrustDecision::Denied { .. }));
715    }
716
717    #[test]
718    fn ci_detection_recognises_standard_env_vars() {
719        for var in ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "BUILDKITE", "CIRCLECI"] {
720            let lookup = |name: &str| -> Option<String> {
721                if name == var {
722                    Some("1".into())
723                } else {
724                    None
725                }
726            };
727            assert!(detect_ci_environment(lookup), "var={var}");
728        }
729    }
730
731    #[test]
732    fn ci_detection_returns_false_when_no_var_set() {
733        assert!(!detect_ci_environment(|_| None));
734    }
735
736    // ───────── Sandbox plumbing (lex#528, PR 12-plumbing) ─────────
737
738    /// Test sandbox impl whose `supports()` returns the configured
739    /// flag for every capability set, and `apply_to` is a no-op.
740    /// Used to drive the post-δ auto-trust path without depending
741    /// on any real OS sandbox.
742    struct FixedSupportSandbox(bool);
743    impl crate::sandbox::Sandbox for FixedSupportSandbox {
744        fn apply_to(
745            &self,
746            _cmd: &mut std::process::Command,
747            _caps: lex_extension::schema::Capabilities,
748        ) -> Result<(), crate::sandbox::SandboxError> {
749            Ok(())
750        }
751        fn supports(&self, _caps: lex_extension::schema::Capabilities) -> bool {
752            self.0
753        }
754    }
755
756    #[test]
757    fn default_gate_installs_null_sandbox_which_supports_nothing() {
758        let (gate, _dir) = gate_with_surface(
759            Surface::LspSession,
760            false,
761            TrustDecision::Denied {
762                reason: "n/a".into(),
763            },
764        );
765        // Default sandbox is NullSandbox, which reports supports(_)
766        // == false for every capability set. The post-δ auto-trust
767        // branch never fires.
768        assert!(!gate
769            .sandbox()
770            .supports(lex_extension::schema::Capabilities::default()));
771    }
772
773    #[test]
774    fn pure_handler_auto_trusts_when_sandbox_supports_pure() {
775        // PR 12d-style behavior, tested with a mock sandbox so the
776        // plumbing PR can lock in the contract before any per-OS
777        // impl ships. With sandbox.supports(default) == true and the
778        // handler declaring pure capabilities, the gate auto-trusts
779        // without consulting the prompt — regardless of surface.
780        for surface in [Surface::CliOneShot, Surface::LspSession, Surface::Ci] {
781            let (mut gate, _dir) = gate_with_surface(
782                surface,
783                false,
784                TrustDecision::Denied {
785                    reason: "prompt should not fire".into(),
786                },
787            );
788            gate.set_sandbox(Arc::new(FixedSupportSandbox(true)));
789            let d = gate.evaluate(
790                &Source::LexTomlNamespace {
791                    name: "acme".into(),
792                },
793                Transport::Subprocess,
794                Capability::Pure,
795                "acme",
796                "acme-handler",
797            );
798            assert_eq!(d, TrustDecision::Trusted, "surface={surface:?}");
799        }
800    }
801
802    #[test]
803    fn full_capability_handler_does_not_auto_trust_even_under_sandbox() {
804        // Auto-trust is reserved for `pure` declarations. A handler
805        // that declared `fs: true` or `net: true` still prompts /
806        // requires --enable-handlers, because the sandbox can only
807        // enforce what was declared.
808        let (mut gate, _dir) = gate_with_surface(
809            Surface::CliOneShot,
810            false,
811            TrustDecision::Denied {
812                reason: "n/a".into(),
813            },
814        );
815        gate.set_sandbox(Arc::new(FixedSupportSandbox(true)));
816        let d = gate.evaluate(
817            &Source::LexTomlNamespace {
818                name: "acme".into(),
819            },
820            Transport::Subprocess,
821            Capability::Full,
822            "acme",
823            "acme-handler",
824        );
825        match d {
826            TrustDecision::Denied { reason } => {
827                assert!(reason.contains("--enable-handlers"));
828            }
829            other => panic!("expected Denied (full caps still prompts), got: {other:?}"),
830        }
831    }
832
833    #[test]
834    fn pure_handler_falls_back_to_prompt_when_sandbox_unsupported() {
835        // The path PR 12-plumbing ships with NullSandbox: pure
836        // handler, but the sandbox doesn't support that capability
837        // set, so behaviour matches β/γ — CLI without the flag is
838        // denied, LSP would prompt.
839        let (mut gate, _dir) = gate_with_surface(
840            Surface::CliOneShot,
841            false,
842            TrustDecision::Denied {
843                reason: "n/a".into(),
844            },
845        );
846        gate.set_sandbox(Arc::new(FixedSupportSandbox(false)));
847        let d = gate.evaluate(
848            &Source::LexTomlNamespace {
849                name: "acme".into(),
850            },
851            Transport::Subprocess,
852            Capability::Pure,
853            "acme",
854            "acme-handler",
855        );
856        match d {
857            TrustDecision::Denied { reason } => {
858                assert!(reason.contains("--enable-handlers"));
859            }
860            other => panic!("expected Denied without enforced sandbox, got: {other:?}"),
861        }
862    }
863
864    #[test]
865    fn sandbox_arc_is_shared_with_callers() {
866        // The Arc<dyn Sandbox> contract: the gate must hand out the
867        // SAME instance to callers (e.g., `lex-fmt` wiring this
868        // through to `SubprocessHandler::spawn_with_sandbox`). If
869        // the gate and the transport were to hold separate
870        // instances, a future bug could silently auto-trust pure
871        // handlers under one config while spawning them under
872        // another.
873        let (mut gate, _dir) = gate_with_surface(
874            Surface::LspSession,
875            false,
876            TrustDecision::Denied {
877                reason: "n/a".into(),
878            },
879        );
880        let original: Arc<dyn crate::sandbox::Sandbox> = Arc::new(FixedSupportSandbox(true));
881        gate.set_sandbox(Arc::clone(&original));
882        let from_gate = gate.sandbox();
883        // Same Arc allocation — pointer identity check.
884        assert!(Arc::ptr_eq(&original, &from_gate));
885    }
886}