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}