1use crate::core::config;
2
3const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
5const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; const HEAVY_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(10);
7
8pub(in crate::shell) fn exec_limits(command: &str) -> (usize, std::time::Duration) {
9 let max_bytes = if is_heavy_command(command) {
10 HEAVY_MAX_BYTES
11 } else {
12 DEFAULT_MAX_BYTES
13 };
14 (max_bytes, shell_timeout(command))
15}
16
17#[must_use]
30pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
31 shell_timeout_with_override(command, None)
32}
33
34pub const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000; #[must_use]
44pub fn shell_timeout_with_override(command: &str, override_ms: Option<u64>) -> std::time::Duration {
45 if let Some(ms) = env_u64("LEAN_CTX_SHELL_TIMEOUT_MS") {
46 return std::time::Duration::from_millis(ms);
47 }
48 if let Some(ms) = override_ms.filter(|n| *n > 0) {
49 return std::time::Duration::from_millis(ms.min(MAX_CALL_TIMEOUT_MS));
50 }
51 if is_heavy_command(command) {
52 if let Some(secs) = env_u64("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS")
53 .or_else(|| config::Config::load().shell_heavy_timeout_secs)
54 {
55 return std::time::Duration::from_secs(secs);
56 }
57 HEAVY_TIMEOUT
58 } else {
59 if let Some(secs) = env_u64("LEAN_CTX_SHELL_TIMEOUT_SECS")
60 .or_else(|| config::Config::load().shell_timeout_secs)
61 {
62 return std::time::Duration::from_secs(secs);
63 }
64 DEFAULT_TIMEOUT
65 }
66}
67
68fn env_u64(var: &str) -> Option<u64> {
71 std::env::var(var)
72 .ok()
73 .and_then(|v| v.parse::<u64>().ok())
74 .filter(|n| *n > 0)
75}
76
77fn is_loop_command(lower: &str) -> bool {
81 let trimmed = lower.trim_start();
82 trimmed.starts_with("while ")
83 || trimmed.starts_with("until ")
84 || (trimmed.starts_with("for ") && trimmed.contains(" in "))
85}
86
87fn strip_env_prefix(cmd: &str) -> &str {
90 let mut rest = cmd;
91 loop {
92 let trimmed = rest.trim_start();
93 if let Some(eq_pos) = trimmed.find('=') {
94 let before_eq = &trimmed[..eq_pos];
95 if !before_eq.is_empty()
96 && before_eq
97 .bytes()
98 .all(|b| b.is_ascii_alphanumeric() || b == b'_')
99 && let Some(space_pos) = trimmed[eq_pos..].find(' ')
100 {
101 rest = &trimmed[eq_pos + space_pos..];
102 continue;
103 }
104 }
105 return trimmed;
106 }
107}
108
109fn is_heavy_command(command: &str) -> bool {
110 let cmd = command.trim();
111 let lower = cmd.to_lowercase();
112 if is_loop_command(&lower) {
116 return true;
117 }
118 static HEAVY_PREFIXES: &[&str] = &[
119 "cargo build",
120 "cargo test",
121 "cargo nextest",
122 "cargo clippy",
123 "cargo check",
124 "cargo install",
125 "cargo bench",
126 "npm run build",
127 "npm install",
128 "npm ci",
129 "pnpm install",
130 "pnpm build",
131 "yarn install",
132 "yarn build",
133 "bun install",
134 "make",
135 "cmake",
136 "bazel build",
137 "bazel test",
138 "gradle build",
139 "gradle test",
140 "mvn package",
141 "mvn install",
142 "mvn test",
143 "go build",
144 "go test",
145 "dotnet build",
146 "dotnet test",
147 "swift build",
148 "swift test",
149 "flutter build",
150 "docker build",
151 "docker compose build",
152 "pip install",
153 "poetry install",
154 "uv sync",
155 "bundle install",
156 "mix compile",
157 "git commit",
164 "git push",
165 "python3 ",
169 "python ",
170 "pytest",
171 "bash scripts/",
172 "sh scripts/",
173 "./scripts/",
174 "timeout ",
176 "mise ",
180 "just ",
181 ];
182
183 let cfg_prefixes = config::Config::load().shell_heavy_prefixes;
184 let matches_heavy = |s: &str| {
185 HEAVY_PREFIXES.iter().any(|p| s.starts_with(p))
186 || cfg_prefixes.iter().any(|p| s.starts_with(p.as_str()))
187 };
188
189 if matches_heavy(&lower) {
190 return true;
191 }
192
193 let stripped = strip_env_prefix(&lower);
196 if stripped != lower && matches_heavy(stripped) {
197 return true;
198 }
199
200 let final_cmd = lower
203 .rsplit_once("&&")
204 .or_else(|| lower.rsplit_once(';'))
205 .map_or("", |(_, rhs)| rhs.trim());
206
207 if final_cmd.is_empty() {
208 return false;
209 }
210 let final_stripped = strip_env_prefix(final_cmd);
211 matches_heavy(final_cmd) || matches_heavy(final_stripped)
212}
213
214#[cfg(test)]
215mod tests {
216 #[test]
217 fn heavy_commands_get_higher_byte_limits() {
218 for cmd in [
223 "cargo build --release",
224 "cargo test --lib",
225 "cargo nextest run",
226 "npm run build",
227 "docker build -t myapp .",
228 "git commit --amend --no-edit",
231 "git push -u origin HEAD",
232 "cd /some/path && cargo test --lib",
235 "cd /foo/bar && cargo build --release",
236 "cd /workspace; npm ci",
237 ] {
238 let (bytes, _) = super::exec_limits(cmd);
239 assert_eq!(bytes, super::HEAVY_MAX_BYTES, "heavy byte limit for {cmd}");
240 }
241 }
242
243 #[test]
244 fn normal_commands_get_default_byte_limits() {
245 for cmd in ["echo hello", "git status", "git log --oneline -5"] {
248 let (bytes, _) = super::exec_limits(cmd);
249 assert_eq!(
250 bytes,
251 super::DEFAULT_MAX_BYTES,
252 "default byte limit for {cmd}"
253 );
254 }
255 }
256
257 #[test]
258 fn env_prefixed_commands_get_correct_heavy_classification() {
259 for cmd in [
260 "LEAN_CTX_DISABLED=1 cargo test --lib",
261 "FOO=bar RUST_LOG=debug cargo test --lib",
262 "LEAN_CTX_DISABLED=1 cargo build --release",
263 "NODE_ENV=production npm run build",
264 "cd /path && LEAN_CTX_DISABLED=1 cargo test --lib",
265 "cd /path; FOO=bar npm run build",
266 "cargo test --lib",
267 "npm run build",
268 "go test ./...",
269 "cargo clippy --all-features -- -D warnings",
270 ] {
271 assert!(
272 super::is_heavy_command(cmd),
273 "expected heavy command: {cmd}"
274 );
275 }
276 }
277
278 #[test]
279 fn ordinary_commands_remain_non_heavy_with_or_without_env_prefixes() {
280 for cmd in [
281 "FOO=bar ls -la",
282 "LEAN_CTX_DISABLED=1 echo hello",
283 "ls -la",
284 "echo hello",
285 "cat file.txt",
286 ] {
287 assert!(
288 !super::is_heavy_command(cmd),
289 "expected non-heavy command: {cmd}"
290 );
291 }
292 }
293
294 #[test]
295 fn shell_timeout_resolves_heavy_normal_and_env_overrides() {
296 let _lock = crate::core::data_dir::test_env_lock();
298 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
299 let saved_secs = std::env::var("LEAN_CTX_SHELL_TIMEOUT_SECS").ok();
300 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
301 for v in [
302 "LEAN_CTX_SHELL_TIMEOUT_MS",
303 "LEAN_CTX_SHELL_TIMEOUT_SECS",
304 "LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS",
305 ] {
306 crate::test_env::remove_var(v);
307 }
308
309 assert_eq!(
312 super::shell_timeout("cargo install --path ."),
313 super::HEAVY_TIMEOUT
314 );
315 assert_eq!(
316 super::shell_timeout("cargo nextest run"),
317 super::HEAVY_TIMEOUT
318 );
319 assert_eq!(
320 super::shell_timeout("git commit -m 'wip'"),
321 super::HEAVY_TIMEOUT
322 );
323 assert_eq!(
324 super::shell_timeout("git push origin main"),
325 super::HEAVY_TIMEOUT
326 );
327 assert_eq!(super::shell_timeout("git status"), super::DEFAULT_TIMEOUT);
328 assert_eq!(super::shell_timeout("ls -la"), super::DEFAULT_TIMEOUT);
329 assert_eq!(
331 super::shell_timeout("cd /some/project && cargo test --lib"),
332 super::HEAVY_TIMEOUT
333 );
334 assert_eq!(
335 super::shell_timeout("cd /workspace && cargo build --release"),
336 super::HEAVY_TIMEOUT
337 );
338 assert_eq!(
339 super::shell_timeout("cd /app; npm ci"),
340 super::HEAVY_TIMEOUT
341 );
342
343 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", "90");
346 assert_eq!(
347 super::shell_timeout("cargo build"),
348 std::time::Duration::from_secs(90)
349 );
350 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
351
352 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_SECS", "30");
353 assert_eq!(
354 super::shell_timeout("git status"),
355 std::time::Duration::from_secs(30)
356 );
357 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_SECS");
358
359 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
361 assert_eq!(
362 super::shell_timeout("cargo build"),
363 std::time::Duration::from_secs(5)
364 );
365 assert_eq!(
366 super::shell_timeout("git status"),
367 std::time::Duration::from_secs(5)
368 );
369 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
370
371 for (var, saved) in [
372 ("LEAN_CTX_SHELL_TIMEOUT_MS", saved_ms),
373 ("LEAN_CTX_SHELL_TIMEOUT_SECS", saved_secs),
374 ("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", saved_heavy),
375 ] {
376 if let Some(v) = saved {
377 crate::test_env::set_var(var, v);
378 }
379 }
380 }
381
382 #[test]
386 fn task_runners_get_heavy_ceiling() {
387 let _lock = crate::core::data_dir::test_env_lock();
388 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
389 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
390 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
391 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
392
393 assert_eq!(super::shell_timeout("mise gate"), super::HEAVY_TIMEOUT);
394 assert_eq!(super::shell_timeout("mise run gate"), super::HEAVY_TIMEOUT);
395 assert_eq!(super::shell_timeout("just build"), super::HEAVY_TIMEOUT);
396
397 if let Some(v) = saved_ms {
398 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
399 }
400 if let Some(v) = saved_heavy {
401 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
402 }
403 }
404
405 #[test]
406 fn scripts_and_timeout_get_heavy_ceiling() {
407 let _lock = crate::core::data_dir::test_env_lock();
408 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
409 let saved_heavy = std::env::var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS").ok();
410 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
411 crate::test_env::remove_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS");
412
413 assert_eq!(
414 super::shell_timeout("python3 scripts/audit.py"),
415 super::HEAVY_TIMEOUT
416 );
417 assert_eq!(
418 super::shell_timeout("python scripts/gate.py --root ."),
419 super::HEAVY_TIMEOUT
420 );
421 assert_eq!(super::shell_timeout("pytest tests/"), super::HEAVY_TIMEOUT);
422 assert_eq!(
423 super::shell_timeout("bash scripts/loc-gate.sh"),
424 super::HEAVY_TIMEOUT
425 );
426 assert_eq!(
427 super::shell_timeout("sh scripts/run.sh"),
428 super::HEAVY_TIMEOUT
429 );
430 assert_eq!(
431 super::shell_timeout("./scripts/deploy.sh"),
432 super::HEAVY_TIMEOUT
433 );
434 assert_eq!(
435 super::shell_timeout("timeout 300 python3 audit.py"),
436 super::HEAVY_TIMEOUT
437 );
438
439 assert_eq!(
440 super::shell_timeout("cd /repo && python3 gate.py"),
441 super::HEAVY_TIMEOUT
442 );
443 assert_eq!(
444 super::shell_timeout("cd /repo && timeout 600 make"),
445 super::HEAVY_TIMEOUT
446 );
447
448 if let Some(v) = saved_ms {
449 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
450 }
451 if let Some(v) = saved_heavy {
452 crate::test_env::set_var("LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS", v);
453 }
454 }
455
456 #[test]
460 fn per_call_timeout_override_resolves_and_clamps() {
461 let _lock = crate::core::data_dir::test_env_lock();
462 let saved_ms = std::env::var("LEAN_CTX_SHELL_TIMEOUT_MS").ok();
463 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
464
465 assert_eq!(
466 super::shell_timeout_with_override("git status", Some(300_000)),
467 std::time::Duration::from_mins(5)
468 );
469 assert_eq!(
470 super::shell_timeout_with_override("cargo build", Some(30_000)),
471 std::time::Duration::from_secs(30)
472 );
473 assert_eq!(
474 super::shell_timeout_with_override("git status", Some(999_000_000)),
475 std::time::Duration::from_millis(super::MAX_CALL_TIMEOUT_MS)
476 );
477 assert_eq!(
478 super::shell_timeout_with_override("git status", Some(0)),
479 super::DEFAULT_TIMEOUT
480 );
481 assert_eq!(
482 super::shell_timeout_with_override("git status", None),
483 super::DEFAULT_TIMEOUT
484 );
485
486 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", "5000");
487 assert_eq!(
488 super::shell_timeout_with_override("git status", Some(300_000)),
489 std::time::Duration::from_secs(5)
490 );
491 crate::test_env::remove_var("LEAN_CTX_SHELL_TIMEOUT_MS");
492 if let Some(v) = saved_ms {
493 crate::test_env::set_var("LEAN_CTX_SHELL_TIMEOUT_MS", v);
494 }
495 }
496}