Skip to main content

lean_ctx/core/shell_allowlist/
partial.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Partial runs of a `;`/newline command list (owner decision 2026-10-29).
4//!
5//! A blocked inline script used to reject the whole call, so an agent that
6//! chained `pytest …; python3 -c …` lost the test run too and repeated it.
7//! When the line is a plain list of independent statements and every blocked
8//! statement is a plain inline-interpreter call, the other statements run and
9//! each skipped one is named in one short line.
10//!
11//! Narrow by construction (security review 2026-10-29). Nothing runs that the
12//! gate would not run as a command of its own: the statements come from the
13//! gate's own scanner, and the joined statements that will run are checked
14//! again, as one command, by the full enforcer. A skipped statement must be a
15//! bare interpreter invocation — one command, no `&&`/`||`/pipe/`&`, no
16//! unquoted redirect, substitution, group or separator, no assignment prefix —
17//! so skipping it cannot change what a later statement does. The whole call
18//! stays rejected for lines with comments (comment text must never become a
19//! command), heredocs, line continuations, ambiguous line breaks, lexer
20//! problems in the line or any statement, control structures, and any use of
21//! the words that change how later statements run (`exit`, `set`, `command`,
22//! `eval`, `trap` …).
23
24use super::enforcement::{INTERPRETER_COMMANDS, enforce_with};
25use super::lexer::lex;
26use super::mode::ShellSecurity;
27use super::tokenizer::{
28    extract_base_from_segment, segments_with_separators, sequence_statements, skip_env_assignments,
29};
30
31/// The statements that may run, and one note per skipped statement.
32#[derive(Debug, PartialEq, Eq)]
33pub struct PartialRun {
34    /// The allowed statements, in order, one per line.
35    pub run: String,
36    /// `skipped: <statement> (<reason>)`, one per blocked statement.
37    pub skipped: Vec<String>,
38}
39
40/// Words that open or continue a compound command: cutting a list that
41/// contains one at `;` would break the construct apart.
42const RESERVED: &[&str] = &[
43    "if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
44    "select", "function", "time", "coproc", "!", "{", "}", "[[",
45];
46
47/// Words that change how the rest of the line runs or ends (error modes,
48/// early exits, wrappers that hide the real command, traps): a line using one
49/// anywhere is never cut.
50const LINE_CONTROL: &[&str] = &[
51    "exit", "return", "set", "shopt", "command", "builtin", "eval", "exec", "source", ".", "trap",
52    "logout",
53];
54
55/// Plans a partial run for `command`, which the gate refused as a whole.
56/// `None` keeps the whole-call rejection.
57#[must_use]
58pub fn partial_run(command: &str, granted: &[String]) -> Option<PartialRun> {
59    if ShellSecurity::resolve() != ShellSecurity::Enforce || cfg!(windows) {
60        return None;
61    }
62    if command.contains("<<")
63        || command.contains(['\r', '\u{2028}', '\u{2029}'])
64        || command.contains("\\\n")
65        || !lexes_clean(command)
66    {
67        return None;
68    }
69    let statements = sequence_statements(command);
70    if statements.len() < 2 {
71        return None;
72    }
73    let mut run = Vec::new();
74    let mut skipped = Vec::new();
75    for statement in statements {
76        if !lexes_clean(statement) {
77            return None;
78        }
79        let segments = segments_with_separators(statement);
80        if segments.iter().any(|(segment, _)| controls_line(segment)) {
81            return None;
82        }
83        match enforce_with(statement, granted, None) {
84            Ok(()) => run.push(statement),
85            Err(error) => {
86                if !is_bare_interpreter_call(statement, segments.len()) {
87                    return None;
88                }
89                skipped.push(format!(
90                    "skipped: {} ({})",
91                    preview(statement),
92                    short_reason(error.message())
93                ));
94            }
95        }
96    }
97    if run.is_empty() || skipped.is_empty() {
98        return None;
99    }
100    let run = run.join("\n");
101    // What runs is checked once more as one command: no context-dependent
102    // rule (a `cd` before a command, a pipe into an interpreter) is lost by
103    // judging the statements one at a time.
104    enforce_with(&run, granted, None).ok()?;
105    Some(PartialRun { run, skipped })
106}
107
108/// No comment (its text must never be re-joined into a command line) and no
109/// construct the shells read differently.
110fn lexes_clean(text: &str) -> bool {
111    let lexed = lex(text);
112    lexed.problem.is_none() && lexed.comments.is_empty()
113}
114
115fn first_word(segment: &str) -> &str {
116    let rest = skip_env_assignments(segment.trim());
117    rest.split_whitespace().next().unwrap_or("")
118}
119
120fn controls_line(segment: &str) -> bool {
121    let word = first_word(segment);
122    RESERVED.contains(&word)
123        || word.starts_with('(')
124        || word.starts_with('{')
125        || LINE_CONTROL.contains(&extract_base_from_segment(segment).as_str())
126}
127
128/// One interpreter command and nothing else: no operator, no assignment
129/// prefix, and no unquoted redirect, substitution, group or separator.
130fn is_bare_interpreter_call(statement: &str, segment_count: usize) -> bool {
131    let trimmed = statement.trim();
132    segment_count == 1
133        && skip_env_assignments(trimmed) == trimmed
134        && !has_unquoted_meta(trimmed)
135        && INTERPRETER_COMMANDS.contains(&extract_base_from_segment(trimmed).as_str())
136}
137
138/// An unquoted shell metacharacter that could redirect, substitute, group or
139/// separate. Quoted text is data to the skipped command, which never runs.
140fn has_unquoted_meta(text: &str) -> bool {
141    let mut chars = text.chars();
142    let (mut single, mut double) = (false, false);
143    while let Some(c) = chars.next() {
144        match c {
145            '\'' if !double => single = !single,
146            '"' if !single => double = !double,
147            '\\' if !single => {
148                chars.next();
149            }
150            '<' | '>' | '|' | '&' | ';' | '(' | ')' | '$' | '`' | '{' | '}'
151                if !single && !double =>
152            {
153                return true;
154            }
155            _ => {}
156        }
157    }
158    single || double
159}
160
161/// The statement on one line, at most 60 characters.
162fn preview(statement: &str) -> String {
163    let flat = statement.split_whitespace().collect::<Vec<_>>().join(" ");
164    if flat.chars().count() <= 60 {
165        return flat;
166    }
167    let cut: String = flat.chars().take(57).collect();
168    format!("{cut}…")
169}
170
171/// The first sentence of a block message, without its marker.
172fn short_reason(message: &str) -> String {
173    let first = message.lines().next().unwrap_or_default();
174    let reason = first
175        .strip_prefix("[BLOCKED — DO NOT RETRY] ")
176        .or_else(|| first.strip_prefix("[BLOCKED] "))
177        .unwrap_or(first);
178    let reason = reason.split(". ").next().unwrap_or(reason);
179    reason.trim_end_matches('.').to_string()
180}