Skip to main content

lean_ctx/shell/exec/
timeout.rs

1use crate::core::config;
2
3const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; // 8 MB
4const DEFAULT_TIMEOUT: std::time::Duration = std::time::Duration::from_mins(2);
5const HEAVY_MAX_BYTES: usize = 32 * 1024 * 1024; // 32 MB
6const 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/// Resolve the timeout `ctx_shell` / the shell hook grants a command.
18///
19/// Heavy builds/tests (cargo install/nextest/build, npm ci, git commit/push, …)
20/// get the long ceiling instead of being killed at the 2-minute default, keeping
21/// the MCP path and the interactive hook consistent. The constants are
22/// overridable so operators can pin any value. Precedence (first match wins):
23///
24/// 1. `LEAN_CTX_SHELL_TIMEOUT_MS` — universal override, in milliseconds.
25/// 2. heavy command → `LEAN_CTX_SHELL_HEAVY_TIMEOUT_SECS` / config
26///    `shell_heavy_timeout_secs`, else [`HEAVY_TIMEOUT`].
27/// 3. normal command → `LEAN_CTX_SHELL_TIMEOUT_SECS` / config
28///    `shell_timeout_secs`, else [`DEFAULT_TIMEOUT`].
29#[must_use]
30pub(crate) fn shell_timeout(command: &str) -> std::time::Duration {
31    shell_timeout_with_override(command, None)
32}
33
34/// Hard ceiling for a per-call `timeout_ms` override: generous enough for any
35/// legitimate build/release job, low enough that a typo'd value cannot wedge
36/// the executor for days.
37pub const MAX_CALL_TIMEOUT_MS: u64 = 3_600_000; // 1 hour
38
39/// `shell_timeout` with an optional per-call override (ctx_shell's
40/// `timeout_ms` arg). Precedence: operator env pin (`LEAN_CTX_SHELL_TIMEOUT_MS`)
41/// > per-call override (clamped to [`MAX_CALL_TIMEOUT_MS`], zero ignored)
42/// > per-tier env/config > built-in heavy/normal ceilings.
43#[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
68/// Parse a positive `u64` from an env var, ignoring absent/empty/zero/invalid
69/// values so the caller falls through to the next precedence tier.
70fn 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
77/// #1113: detect shell loop constructs that are intentionally long-running.
78/// Matches `while true`, `while :`, `while sleep`, `for … in`, and similar
79/// patterns used for poll loops and background monitors.
80fn 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
87/// Strip leading `KEY=value` env-var assignments from a command string.
88/// Local copy of `rewrite_registry::strip_env_prefix` for decoupling.
89fn 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    // #1113: shell loops (`while true; do ... done`, `for i in ...`) are
113    // intentionally long-running poll/monitor scripts. Killing them at 120s
114    // breaks CI watchers and background monitors.
115    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 commands that fire build/test hooks: a `pre-commit` running
158        // `cargo clippy` or a `pre-push` running a full preflight can take
159        // minutes, far past the 2-minute default. Killing git mid-hook leaves
160        // the working tree staged-but-uncommitted and the push half-done, so
161        // these get the heavy ceiling. `git status`/`log`/`diff` stay default
162        // because the prefix is the full `git <verb>`.
163        "git commit",
164        "git push",
165        // Scripts and test runners that scan repos, run audits, or invoke
166        // subprocesses with their own timeouts. Killing them mid-run wastes
167        // agent tokens on retries.
168        "python3 ",
169        "python ",
170        "pytest",
171        "bash scripts/",
172        "sh scripts/",
173        "./scripts/",
174        // `timeout N cmd` wraps an intentionally long command — respect it.
175        "timeout ",
176        // Task runners wrap builds/test gates; the underlying job is what's
177        // heavy, so the wrapper gets the same ceiling. A fast subcommand
178        // (`mise ls`) merely inherits a longer kill deadline — harmless.
179        "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    // Agents prefix commands with `LEAN_CTX_DISABLED=1`, `RUST_LOG=debug`, etc.
194    // Strip the assignments so the underlying command can match a heavy prefix.
195    let stripped = strip_env_prefix(&lower);
196    if stripped != lower && matches_heavy(stripped) {
197        return true;
198    }
199
200    // Agents often prefix commands with `cd /path && ...` or `cd /path;`.
201    // Extract the final segment after the last `&&` or `;` and check that too.
202    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        // exec_limits owns the byte ceiling; timeout resolution is covered by
219        // `shell_timeout_resolves_heavy_normal_and_env_overrides` (which is
220        // env/config-isolated, so these stay deterministic regardless of the
221        // operator's config.toml).
222        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 verbs that fire build/test hooks (pre-commit clippy, pre-push
229            // preflight) must not be killed at the default ceiling (#854).
230            "git commit --amend --no-edit",
231            "git push -u origin HEAD",
232            // Agents prefix with `cd /path && ...` — heavy detection must
233            // look through it to avoid 120s timeout on builds.
234            "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        // Read-only git verbs stay on the default ceiling — only `commit`/`push`
246        // (which fire the cargo-heavy hooks) are promoted.
247        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        // Serialize env mutation so this never races other env-reading tests.
297        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        // Heavy builds/tests and hook-firing git verbs get the heavy ceiling;
310        // read-only verbs stay on the default. Preserves the #854 promotion.
311        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        // `cd ... && heavy` must resolve to HEAVY so agents don't get killed at 120s.
330        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        // Per-tier env overrides win over the built-in constants. (Non-round
344        // second values keep the literals clippy-clean and unambiguous.)
345        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        // The universal millisecond override wins over everything.
360        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    // Task runners (mise/just) wrap builds and test gates that routinely run
383    // past the 2-minute default; killing them mid-run loses the whole job.
384    // They get the heavy ceiling like the underlying build tools they invoke.
385    #[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    // Per-call `timeout_ms` (ctx_shell tool arg): explicit caller intent beats
457    // the built-in tiers in both directions, absurd values clamp to the 1h
458    // ceiling, zero is ignored, and the operator's universal env pin stays top.
459    #[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}