Skip to main content

lean_ctx/core/shell_allowlist/
approval.rs

1// SPDX-License-Identifier: Apache-2.0
2//! User approval for commands the allowlist does not name.
3//!
4//! The allowlist answers "is this program known?"; the user can answer it for
5//! one command instead. A command qualifies only when the *sole* reason it is
6//! refused is that programs are missing from the list: the full check walks
7//! the command once more with misses recorded instead of refused, so a hard
8//! block (eval, inline interpreter code, dangerous flags, an unconditionally
9//! blocked program, self-reconfiguration, a redaction marker) still ends it
10//! and is never put to the user.
11//!
12//! An approval never switches the gate off. It names programs, and the command
13//! is checked again with exactly those programs counted as listed
14//! ([`check_shell_allowlist_granted`]), so every other check still decides.
15
16use std::sync::{Arc, Mutex};
17
18use crate::core::error::ShellError;
19
20use super::ShellSecurity;
21use super::enforcement::{enforce_shell_allowlist, enforce_with, refuse_redaction_marker};
22
23/// A program the command runs that the allowlist does not name.
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct ApprovalMiss {
26    /// The name the allowlist matches on (basename, as in `lean-ctx allow`).
27    pub program: String,
28    /// Only a one-time approval is offered: the command names it by path,
29    /// the user scoped it with subcommand entries (`terraform plan *`), or it
30    /// is an interpreter or shell. A lasting grant matches on the basename and
31    /// would admit far more than the user saw.
32    pub once_only: bool,
33}
34
35/// What a user is asked to approve for one command.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct ApprovalRequest {
38    /// Missing programs, in the order the command runs them.
39    pub misses: Vec<ApprovalMiss>,
40}
41
42impl ApprovalRequest {
43    /// The program names, for the prompt and the grant.
44    #[must_use]
45    pub fn programs(&self) -> Vec<String> {
46        self.misses.iter().map(|m| m.program.clone()).collect()
47    }
48
49    /// Whether approval may outlive this one command (session or always).
50    #[must_use]
51    pub fn allows_lasting_approval(&self) -> bool {
52        !self.misses.iter().any(|m| m.once_only)
53    }
54}
55
56/// The approval `command` needs, or `None` when it needs none (it passes) or
57/// cannot get one (a hard block, or a shell security mode other than
58/// `enforce`, where nothing is refused for a missing program).
59#[must_use]
60pub fn approval_request(command: &str) -> Option<ApprovalRequest> {
61    if refuse_redaction_marker(command).is_err()
62        || ShellSecurity::resolve() != ShellSecurity::Enforce
63        || enforce_shell_allowlist(command).is_ok()
64    {
65        return None;
66    }
67    approval_request_enforced(command)
68}
69
70/// The collecting walk, independent of the active mode (tests drive it).
71pub(super) fn approval_request_enforced(command: &str) -> Option<ApprovalRequest> {
72    let log = Arc::new(Mutex::new(Vec::new()));
73    enforce_with(command, &[], Some(&log)).ok()?;
74    let misses: Vec<ApprovalMiss> = std::mem::take(
75        &mut *log
76            .lock()
77            .unwrap_or_else(std::sync::PoisonError::into_inner),
78    );
79    // A name the prompt and the config could misread (a comma splits a
80    // `shell_allowlist_extra` entry in two, whitespace makes a subcommand
81    // entry, control characters hide text) is never put to the user.
82    if misses.is_empty() || !misses.iter().all(|m| is_approvable_name(&m.program)) {
83        return None;
84    }
85    Some(ApprovalRequest { misses })
86}
87
88/// Plain program names only: letters, digits and `._+-`, at most 64 bytes.
89fn is_approvable_name(name: &str) -> bool {
90    !name.is_empty()
91        && name.len() <= 64
92        && !name.starts_with('-')
93        && !name.bytes().all(|b| b == b'.')
94        && name
95            .bytes()
96            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'+' | b'-'))
97}
98
99/// [`super::check_shell_allowlist`] with `granted` programs counted as listed.
100/// An empty grant is exactly the ordinary check.
101pub fn check_shell_allowlist_granted(command: &str, granted: &[String]) -> Result<(), ShellError> {
102    if granted.is_empty() {
103        return super::check_shell_allowlist(command);
104    }
105    refuse_redaction_marker(command)?;
106    match ShellSecurity::resolve() {
107        ShellSecurity::Off | ShellSecurity::Warn => super::check_shell_allowlist(command),
108        ShellSecurity::Enforce => enforce_with(command, granted, None).inspect_err(|_| {
109            crate::core::security_events::note(
110                crate::core::security_events::SecurityKind::ShellBlocked,
111                1,
112            );
113        }),
114    }
115}
116
117/// Adds `programs` to the global `shell_allowlist_extra`, as `lean-ctx allow`
118/// does, without printing. Returns the entries that were new. Used when the
119/// user answers an approval prompt with "always allow".
120pub fn allow_programs(programs: &[String]) -> Result<Vec<String>, String> {
121    crate::core::config::setter::add_to_global_list("shell_allowlist_extra", programs)
122}
123
124/// Adds `languages` to the global `execute_allowed_languages`: the "always
125/// allow" answer to a `ctx_execute` approval prompt.
126pub fn allow_execute_languages(languages: &[String]) -> Result<Vec<String>, String> {
127    crate::core::config::setter::add_to_global_list("execute_allowed_languages", languages)
128}
129
130#[cfg(test)]
131#[path = "approval_tests.rs"]
132mod tests;