Skip to main content

lean_ctx/core/shell_allowlist/
mod.rs

1//! Shell command lexing and the registered shell-allowlist gate.
2//!
3//! The lexical layer (lexer, heredocs, tokenizer, compound and case constructs,
4//! PowerShell statements) and the security mode live here in the Engine. The
5//! allowlist POLICY (enforcement, approval, self-reconfiguration and side-door
6//! checks) lives above the Engine in crates/lean-ctx-shell-policy (lib
7//! `lean_ctx_shell_policy`): an edit there re-checks that crate, not the Engine.
8//! The Engine's own callers reach the policy through [`register_gate`], which the
9//! policy crate installs; without a registration every check fails closed.
10
11#[doc(hidden)]
12pub mod case_construct;
13#[doc(hidden)]
14pub mod compound;
15#[doc(hidden)]
16pub mod heredoc;
17#[doc(hidden)]
18pub mod lexer;
19#[doc(hidden)]
20pub mod mode;
21#[doc(hidden)]
22pub mod powershell;
23#[doc(hidden)]
24pub mod ps_statements;
25#[doc(hidden)]
26pub mod tokenizer;
27
28use std::sync::OnceLock;
29
30use crate::core::error::ShellError;
31
32pub use case_construct::{contains_double_semicolon, find_shell_word, rewrite_case_constructs};
33pub use compound::expand_to_leaf_segments;
34pub use heredoc::{
35    heredoc_delims, read_heredoc_delim, strip_all_heredoc_bodies, strip_for_analysis,
36};
37pub use mode::ShellSecurity;
38pub use ps_statements::{Dialect, with_dialect};
39pub use tokenizer::*;
40
41/// Longest command the gate validates; a longer one is refused in `enforce`
42/// mode. Every check re-scans the line, and a hook that runs out of time
43/// fails open.
44pub const MAX_VALIDATED_COMMAND_BYTES: usize = 64 * 1024;
45
46/// The shell-allowlist policy as the Engine sees it.
47#[derive(Clone, Copy)]
48pub struct ShellGate {
49    /// `check_shell_allowlist`: `Err` when the command must not run.
50    pub check: fn(&str) -> Result<(), ShellError>,
51    /// `warn_only_reason`: the reason text for a warn-only pass.
52    pub warn_only_reason: fn(&str) -> String,
53}
54
55static GATE: OnceLock<ShellGate> = OnceLock::new();
56
57/// Install the policy gate. The first registration wins and is never replaced:
58/// returns `false` when a gate was already registered, so the caller can
59/// refuse to start instead of running under a gate it did not install.
60#[must_use]
61pub fn register_gate(gate: ShellGate) -> bool {
62    GATE.set(gate).is_ok()
63}
64
65/// Engine unit tests cannot link the policy crate (it sits above the Engine),
66/// so they register this stand-in to exercise the gate wiring: when
67/// `LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE` is set, the first word of the command
68/// must be in that list; otherwise everything passes. The real policy is
69/// tested end to end in crates/lean-ctx-shell-policy/tests/attack_scenarios.rs.
70#[cfg(any(test, feature = "test-support"))]
71#[doc(hidden)]
72pub fn install_test_gate() {
73    fn check(command: &str) -> Result<(), ShellError> {
74        let Ok(list) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") else {
75            return Ok(());
76        };
77        let first = command.split_whitespace().next().unwrap_or_default();
78        if list.split(',').any(|entry| entry.trim() == first) {
79            Ok(())
80        } else {
81            Err(ShellError::from(format!(
82                "test gate: '{first}' is not in the override allowlist"
83            )))
84        }
85    }
86    let _ = GATE.set(ShellGate {
87        check,
88        warn_only_reason: str::to_string,
89    });
90}
91
92/// Whether a policy gate is installed.
93pub fn gate_registered() -> bool {
94    GATE.get().is_some()
95}
96
97/// Check `command` against the registered policy. Fails closed (refuses) when
98/// no policy is registered, so a binary that forgot the registration can never
99/// run unchecked commands.
100pub fn check_registered(command: &str) -> Result<(), ShellError> {
101    match GATE.get() {
102        Some(gate) => (gate.check)(command),
103        None => Err(ShellError::from(
104            "lean-ctx: no shell-allowlist policy is registered in this build; \
105             refusing to run the command (fail closed)",
106        )),
107    }
108}
109
110/// The warn-only reason through the registered policy (the message itself
111/// when none is registered).
112pub fn warn_only_reason_registered(message: &str) -> String {
113    match GATE.get() {
114        Some(gate) => (gate.warn_only_reason)(message),
115        None => message.to_string(),
116    }
117}