lean_ctx/core/shell_allowlist/
partial.rs1use 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#[derive(Debug, PartialEq, Eq)]
33pub struct PartialRun {
34 pub run: String,
36 pub skipped: Vec<String>,
38}
39
40const RESERVED: &[&str] = &[
43 "if", "then", "elif", "else", "fi", "for", "while", "until", "do", "done", "case", "esac",
44 "select", "function", "time", "coproc", "!", "{", "}", "[[",
45];
46
47const LINE_CONTROL: &[&str] = &[
51 "exit", "return", "set", "shopt", "command", "builtin", "eval", "exec", "source", ".", "trap",
52 "logout",
53];
54
55#[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 enforce_with(&run, granted, None).ok()?;
105 Some(PartialRun { run, skipped })
106}
107
108fn 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
128fn 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
138fn 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
161fn 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
171fn 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}