Skip to main content

lean_ctx/tools/registered/
ctx_shell.rs

1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::server::tool_trait::{
6    McpTool, ShellOutcome, ToolContext, ToolOutput, get_bool, get_int, get_str,
7};
8use crate::tool_defs::tool_def;
9
10pub struct CtxShellTool;
11
12impl McpTool for CtxShellTool {
13    fn name(&self) -> &'static str {
14        "ctx_shell"
15    }
16
17    fn tool_def(&self) -> Tool {
18        tool_def(
19            "ctx_shell",
20            "WORKFLOW: preferred — auto-compresses output (build/test/log).\n\
21              raw=true for verbatim output; inline=true for moderately-sized verbatim output.\n\
22             [exit:N] on errors (lossless).\n\
23             POLICY (by design): allowlisted read-only path; ctx_execute is the trusted script path.\n\
24             A [BLOCKED] command is permanent — escalate to ctx_execute(language=\"shell\"), do not retry here.\n\
25             ANTIPATTERN: multi-line scripts, sh/bash script.sh, $var-as-command → ctx_execute.",
26            json!({
27                "type": "object",
28                "properties": {
29                    "command": { "type": "string", "description": "Shell command" },
30                    "raw": { "type": "boolean", "description": "Skip compression (verbatim)" },
31                    "inline": { "type": "boolean", "description": "Return verbatim output inline up to archive.inline_max_bytes; larger output uses the archive/firewall" },
32                    "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
33                    "timeout_ms": { "type": "integer", "description": "Job lifetime in ms (max 3600000) — NOT the inline wait. A command still running at the ~110s foreground cap detaches to a pollable shell_* job and keeps running up to timeout_ms. Overridden by LEAN_CTX_SHELL_TIMEOUT_MS." },
34                    "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } },
35                    "run_in_background": { "type": "boolean", "description": "Detach immediately and return a job id. The command keeps timeout_ms; poll or cancel with background_action and job_id." },
36                    "background_action": { "type": "string", "enum": ["status", "cancel"], "description": "Inspect or cancel a background ctx_shell job." },
37                    "job_id": { "type": "string", "description": "Job id returned by run_in_background." }
38                },
39                "anyOf": [
40                    { "required": ["command"] },
41                    { "required": ["background_action", "job_id"] }
42                ]
43            }),
44        )
45    }
46
47    fn handle(
48        &self,
49        args: &Map<String, Value>,
50        ctx: &ToolContext,
51    ) -> Result<ToolOutput, ErrorData> {
52        if let Some(action) = get_str(args, "background_action") {
53            let id = get_str(args, "job_id").ok_or_else(|| {
54                ErrorData::invalid_params("job_id is required with background_action", None)
55            })?;
56            let is_cancel = action == "cancel";
57            let state = match action.as_str() {
58                "status" => crate::server::background_shell::status(&id),
59                "cancel" => crate::server::background_shell::cancel(&id),
60                _ => {
61                    return Err(ErrorData::invalid_params(
62                        "background_action must be status or cancel",
63                        None,
64                    ));
65                }
66            };
67            let (text, exit_code) = format_background_state(&id, is_cancel, state);
68            return Ok(ToolOutput {
69                shell_outcome: Some(ShellOutcome::Exit(exit_code)),
70                content_blocks: None,
71                ..ToolOutput::simple(text)
72            });
73        }
74        let command = get_str(args, "command")
75            .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
76        let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
77
78        // The write-doctrine check (no `>`, `tee`, heredoc-to-file, curl -o, …)
79        // is an MCP-payload-safety convention, not a security boundary, so it is
80        // opt-out via `shell_allow_writes` (#523). The real command gating
81        // (`check_shell_allowlist`, below) is NOT affected by this flag.
82        let config = crate::core::config::Config::load();
83        let write_allow_paths = config.shell_write_allow_paths_effective();
84        let project_root = crate::core::config::Config::find_project_root();
85        if !config.shell_allow_writes_effective()
86            && let Some(rejection) =
87                crate::tools::ctx_shell::validate_command_with_write_allow_paths(
88                    &command,
89                    &write_allow_paths,
90                    project_root.as_deref(),
91                )
92        {
93            // The command never ran — report as a tool error so MCP clients
94            // (guards, retry logic) can detect it programmatically (#389).
95            return Ok(ToolOutput {
96                shell_outcome: Some(ShellOutcome::Blocked),
97                content_blocks: None,
98                ..ToolOutput::simple(rejection)
99            });
100        }
101
102        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
103            return Ok(ToolOutput {
104                shell_outcome: Some(ShellOutcome::Blocked),
105                content_blocks: None,
106                ..ToolOutput::simple(msg.to_string())
107            });
108        }
109
110        warn_shell_secret_paths(&command);
111
112        // #842: a bare `cat <file>` is better served by ctx_read — it delivers
113        // content inline instead of firewalling/archiving the output, avoiding
114        // a mandatory ctx_expand round-trip for agents with cat-muscle-memory.
115        if let Some(read_path) = detect_bare_cat_file(&command)
116            && let Some(cache_lock) = ctx.cache.as_ref()
117            && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
118        {
119            let result = crate::tools::ctx_read::handle_with_task_resolved(
120                &mut cache,
121                &read_path,
122                "full",
123                crate::tools::CrpMode::Off,
124                None,
125            );
126            let note = format!(
127                "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
128                         Use ctx_read(path=\"{read_path}\") directly next time.]"
129            );
130            let out = format!("{}{note}", result.content);
131            let sent = crate::core::tokens::count_tokens(&out);
132            return Ok(ToolOutput {
133                text: out,
134                original_tokens: sent,
135                saved_tokens: 0,
136                mode: Some("cat-redirect".to_string()),
137                path: Some(read_path),
138                changed: false,
139                shell_outcome: Some(ShellOutcome::Exit(0)),
140                content_blocks: None,
141            });
142        }
143
144        tokio::task::block_in_place(|| {
145            let session_lock = ctx
146                .session
147                .as_ref()
148                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
149
150            let explicit_cwd = get_str(args, "cwd");
151            let had_explicit_cwd = explicit_cwd.is_some();
152            let (effective_cwd, cwd_jail_reason) = {
153                let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
154                match guard {
155                    Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
156                    None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
157                }
158            };
159            // A `cwd` rejected by the project-root jail is silently replaced with
160            // the root (deliberate sandboxing). Surface that swap as a one-line
161            // hint so the caller does not mistake the run dir for the requested
162            // one (#629); appended at the end of the output like the other hints.
163            let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
164            let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
165                format!(
166                    "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
167                )
168            });
169
170            {
171                let Some(mut session) =
172                    crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
173                else {
174                    tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
175                    let cmd_clone = command.clone();
176                    let cwd_clone = effective_cwd.clone();
177                    let extra_env: std::collections::HashMap<String, String> = args
178                        .get("env")
179                        .and_then(|v| v.as_object())
180                        .map(|obj| {
181                            obj.iter()
182                                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
183                                .filter(|(k, _)| !is_dangerous_env_key(k))
184                                .collect()
185                        })
186                        .unwrap_or_default();
187                    let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
188                        &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
189                    );
190                    let output = redact_shell_output_secrets(&raw_output);
191                    // Keep failure reporting consistent on this degraded path:
192                    // same [exit:N] footer and the same structured outcome (#389).
193                    let exit_suffix = match exit_code {
194                        0 => String::new(),
195                        124 => "\n[exit:124 — command timed out]".to_string(),
196                        _ => format!("\n[exit:{exit_code}]"),
197                    };
198                    return Ok(ToolOutput {
199                        shell_outcome: Some(ShellOutcome::Exit(exit_code)),
200                        content_blocks: None,
201                        ..ToolOutput::simple(format!("{output}{exit_suffix}"))
202                    });
203                };
204                // #707: a jail-accepted explicit `cwd` param is the client
205                // telling us where it now works (worktree switches arrive
206                // this way, not as `cd` commands) — persist it so path
207                // resolution's divergence check tracks the live checkout.
208                if had_explicit_cwd && cwd_jail_reason_was_none {
209                    session.note_explicit_cwd(&effective_cwd);
210                }
211                session.update_shell_cwd(&command);
212                let root_missing = session
213                    .project_root
214                    .as_deref()
215                    .is_none_or(|r| r.trim().is_empty());
216                if root_missing {
217                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
218                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
219                        && home.as_deref() != Some(root.as_str())
220                    {
221                        session.project_root = Some(root.clone());
222                        crate::core::index_orchestrator::ensure_all_background(&root);
223                    }
224                }
225            }
226
227            let arg_raw = get_bool(args, "raw").unwrap_or(false);
228            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
229            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
230            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
231            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
232
233            let crp_mode = ctx.crp_mode;
234            let cmd_clone = command.clone();
235            let cwd_clone = effective_cwd;
236            let proactive_block = if raw {
237                None
238            } else {
239                crate::core::relevance_tracker::proactive_context(&format!(
240                    "ctx_shell command={cmd_clone} cwd={cwd_clone}"
241                ))
242            };
243
244            let extra_env: std::collections::HashMap<String, String> = args
245                .get("env")
246                .and_then(|v| v.as_object())
247                .map(|obj| {
248                    obj.iter()
249                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
250                        .filter(|(k, _)| !is_dangerous_env_key(k))
251                        .collect()
252                })
253                .unwrap_or_default();
254
255            let auto_background = should_auto_background(&cmd_clone, timeout_ms);
256            if get_bool(args, "run_in_background").unwrap_or(false) || auto_background {
257                let job_id = crate::server::background_shell::start(
258                    cmd_clone, cwd_clone, extra_env, timeout_ms,
259                );
260                let mode = if auto_background {
261                    "auto-background"
262                } else {
263                    "background"
264                };
265                return Ok(ToolOutput {
266                    shell_outcome: Some(ShellOutcome::Exit(0)),
267                    content_blocks: None,
268                    ..ToolOutput::simple(format!(
269                        "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
270                    ))
271                });
272            }
273
274            // Foreground runs still detach onto a pollable job if they outlast
275            // the soft cap, so the MCP host's ~120s abort never strands the
276            // result behind an unresolvable task id (#1106).
277            //
278            // #1173: `timeout_ms` is the *job's* lifetime, never the foreground
279            // wait, so it must not raise this cap. Raising it bought no extra
280            // inline wait — the host aborts at ~120s regardless — it only
281            // suppressed our own detach, producing exactly the unresolvable
282            // task id the cap exists to prevent. Separate knobs, one direction:
283            // `LEAN_CTX_SHELL_FG_CAP_MS` moves the cap, `timeout_ms` does not.
284            let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
285            let progress_sender = ctx.progress_sender.clone();
286            let progress_label: String = cmd_clone.chars().take(60).collect();
287            let cap_secs = soft_cap.as_secs_f64();
288            let on_tick = |elapsed: std::time::Duration| {
289                #[allow(clippy::unwrap_or_default)]
290                if let Some(ref ps) = progress_sender
291                    && let Some(sender) = ps
292                        .lock()
293                        .unwrap_or_else(std::sync::PoisonError::into_inner)
294                        .as_ref()
295                {
296                    sender.send(
297                        elapsed.as_secs_f64(),
298                        Some(cap_secs),
299                        Some(format!(
300                            "ctx_shell: {}s elapsed — {progress_label}",
301                            elapsed.as_secs()
302                        )),
303                    );
304                }
305            };
306            let (raw_output, exit_code) =
307                match crate::server::background_shell::run_foreground_or_detach(
308                    cmd_clone.clone(),
309                    cwd_clone.clone(),
310                    extra_env.clone(),
311                    timeout_ms,
312                    soft_cap,
313                    Some(&on_tick),
314                ) {
315                    crate::server::background_shell::ForegroundResult::Finished {
316                        output,
317                        exit_code,
318                    } => (output, exit_code),
319                    crate::server::background_shell::ForegroundResult::Detached { job_id } => {
320                        return Ok(ToolOutput {
321                            shell_outcome: Some(ShellOutcome::Exit(0)),
322                            content_blocks: None,
323                            // #1173: a detach is not a failure — the command is
324                            // alive and its output is recoverable, so say so
325                            // rather than leaving the caller to infer a hang.
326                            ..ToolOutput::simple(format!(
327                                "[auto-background:{job_id} still running — passed the {}s foreground cap, not an error; output is kept and delivered by ctx_shell(background_action=\"status\", job_id=\"{job_id}\"), or background_action=\"cancel\" to stop it]",
328                                soft_cap.as_secs()
329                            ))
330                        });
331                    }
332                };
333
334            // Structured diagnostics (#499) — same hook as the CLI path.
335            crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
336
337            let output = redact_shell_output_secrets(&raw_output);
338
339            let inline = get_bool(args, "inline").unwrap_or(false);
340            let (result_out, original, saved, tee_hint) = if raw || inline {
341                let tokens = crate::core::tokens::count_tokens(&output);
342                (output, tokens, 0, String::new())
343            } else {
344                let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
345                let result =
346                    crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
347                let original = crate::core::tokens::count_tokens(&output);
348                let sent = crate::core::tokens::count_tokens(&result);
349                let saved = original.saturating_sub(sent);
350
351                let cfg = crate::core::config::Config::load();
352                // Shared tee policy (#811): identical decision to the CLI path —
353                // `Failures` keys off the real exit code, not a substring match.
354                let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
355                let tee_hint = if crate::shell::tee_policy::should_tee(
356                    &cfg.tee_mode,
357                    exit_code,
358                    output.trim().is_empty() || timeout_notice_only,
359                    crate::shell::tee_policy::output_was_elided(&output, &result),
360                    original,
361                    sent,
362                ) {
363                    crate::shell::save_tee(&cmd_clone, &output)
364                        .map(|p| {
365                            if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
366                            {
367                                let pct = crate::shell::tee_policy::savings_pct(original, sent);
368                                // Recovery grammar is path-first: agents without ctx_expand
369                                // can still read the saved artifact directly (#936).
370                                format!(
371                                    "\n[compressed {pct:.0}%: full output at {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\") for a slice]"
372                                )
373                            } else {
374                                format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
375                            }
376                        })
377                        .unwrap_or_default()
378                } else {
379                    String::new()
380                };
381
382                (result, original, saved, tee_hint)
383            };
384
385            let mode = if bypass {
386                Some("bypass".to_string())
387            } else if raw {
388                Some("raw".to_string())
389            } else {
390                None
391            };
392
393            let shell_mismatch = if cfg!(windows) && !raw {
394                shell_mismatch_hint(&command, &result_out)
395            } else {
396                String::new()
397            };
398
399            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
400            // #815: exit 124 = timeout signal (from timeout(1) / lean-ctx
401            // shell timeout). Make it explicit so agents don't confuse a
402            // timed-out command with a successful empty result.
403            let exit_suffix = match exit_code {
404                0 => String::new(),
405                124 => "\n[exit:124 — command timed out]".to_string(),
406                _ => format!("\n[exit:{exit_code}]"),
407            };
408            let nudge = if raw { "" } else { search_tool_nudge(&command) };
409            let final_out = format!(
410                "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
411            );
412            let final_out = if let Some(block) = proactive_block {
413                format!("{final_out}{block}")
414            } else {
415                final_out
416            };
417
418            Ok(ToolOutput {
419                text: final_out,
420                original_tokens: original,
421                saved_tokens: saved,
422                mode,
423                path: None,
424                changed: false,
425                shell_outcome: Some(ShellOutcome::Exit(exit_code)),
426                content_blocks: None,
427            })
428        })
429    }
430}
431
432#[allow(clippy::fn_params_excessive_bools)]
433fn resolve_shell_raw_flags(
434    arg_raw: bool,
435    arg_bypass: bool,
436    env_disabled: bool,
437    env_raw: bool,
438) -> (bool, bool) {
439    let bypass = arg_bypass || env_raw;
440    let raw = arg_raw || bypass || env_disabled;
441    (raw, bypass)
442}
443
444/// A timeout notice is framework metadata, not recoverable command output. Do
445/// not archive it as a tee artifact: expanding it cannot recover any bytes (#995).
446///
447/// Keyed on what precedes the marker rather than on the notice's exact shape,
448/// so enriching it (the idle-timeout wording, the still-running segment list)
449/// cannot silently turn every timeout back into an archived artifact (#1173).
450fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
451    exit_code == 124
452        && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
453}
454
455fn search_tool_nudge(command: &str) -> &'static str {
456    let cmd = command.trim();
457    let first_word = cmd.split_whitespace().next().unwrap_or("");
458    if !cmd.contains('|') {
459        match first_word {
460            "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
461                return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
462            }
463            "find" => {
464                return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
465            }
466            "ls" | "exa" | "eza" => {
467                return "\n[hint: use ctx_tree for structured directory listing]";
468            }
469            _ => {}
470        }
471    }
472    ""
473}
474
475fn shell_mismatch_hint(command: &str, output: &str) -> String {
476    let shell = crate::shell::shell_name();
477    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
478    let has_error = output.contains("is not recognized")
479        || output.contains("not found")
480        || output.contains("command not found");
481
482    if !has_error {
483        return String::new();
484    }
485
486    let powershell_cmds = [
487        "Get-Content",
488        "Select-Object",
489        "Get-ChildItem",
490        "Set-Location",
491        "Where-Object",
492        "ForEach-Object",
493        "Select-String",
494        "Invoke-Expression",
495        "Write-Output",
496    ];
497    let uses_powershell = powershell_cmds
498        .iter()
499        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
500
501    if is_posix && uses_powershell {
502        format!(
503            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
504        )
505    } else {
506        String::new()
507    }
508}
509
510fn is_dangerous_env_key(key: &str) -> bool {
511    const BLOCKED: &[&str] = &[
512        // Dynamic linker injection
513        "LD_PRELOAD",
514        "LD_LIBRARY_PATH",
515        "DYLD_INSERT_LIBRARIES",
516        "DYLD_LIBRARY_PATH",
517        "DYLD_FRAMEWORK_PATH",
518        // Shell re-entry / startup injection
519        "BASH_ENV",
520        "ENV",
521        "PROMPT_COMMAND",
522        "SHELL",
523        "IFS",
524        "CDPATH",
525        // Binary resolution hijacking
526        "PATH",
527        "GIT_EXEC_PATH",
528        "GIT_SSH",
529        "GIT_SSH_COMMAND",
530        // Identity / home directory manipulation
531        "HOME",
532        "USER",
533        "LOGNAME",
534        "XDG_CONFIG_HOME",
535        "XDG_DATA_HOME",
536        "XDG_STATE_HOME",
537        "XDG_CACHE_HOME",
538        // Language runtime search path hijacking
539        "PYTHONPATH",
540        "PYTHONSTARTUP",
541        "PYTHONHOME",
542        "NODE_PATH",
543        "NODE_OPTIONS",
544        "RUBYOPT",
545        "RUBYLIB",
546        "GEM_PATH",
547        "GEM_HOME",
548        "PERL5LIB",
549        "PERL5OPT",
550        "CLASSPATH",
551        "JAVA_HOME",
552        "CARGO_HOME",
553        "RUSTUP_HOME",
554        "GOPATH",
555        "GOROOT",
556    ];
557    let upper = key.to_uppercase();
558    if BLOCKED.contains(&upper.as_str()) {
559        return true;
560    }
561    if upper.starts_with("LD_") && upper.ends_with("_PATH") {
562        return true;
563    }
564    // Block all lean-ctx config overrides from env
565    if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
566        return true;
567    }
568    false
569}
570
571/// Warn when shell reads secret-like paths via cat/head/tail/less/more.
572/// WARN-ONLY: command still executes, this is purely observational.
573fn warn_shell_secret_paths(command: &str) {
574    const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
575    let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
576    for seg in &segments {
577        let trimmed = seg.trim();
578        let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
579        if tokens.is_empty() {
580            continue;
581        }
582        let base = tokens[0]
583            .rsplit('/')
584            .next()
585            .unwrap_or(&tokens[0])
586            .to_string();
587        if !READ_CMDS.contains(&base.as_str()) {
588            continue;
589        }
590        for tok in &tokens[1..] {
591            if tok.starts_with('-') {
592                continue;
593            }
594            let path = std::path::Path::new(tok.as_str());
595            if crate::core::io_boundary::is_secret_like(path).is_some() {
596                tracing::warn!(
597                    "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
598                );
599            }
600        }
601    }
602}
603
604/// Render a `background_action` result.
605///
606/// #1246: a caller-requested cancel is a success, not a tool failure. The
607/// process's own SIGINT exit (130) used to be reported as the tool's exit code,
608/// which tripped the client's failure hook and told the agent to fix something
609/// it had deliberately done. A cancel therefore never reports a non-zero exit,
610/// and is idempotent: cancelling an already-cancelled, already-finished or
611/// already-pruned job is equally benign. The first cancel also gets its own
612/// wording so it cannot be mistaken for a status poll that did nothing.
613fn format_background_state(
614    id: &str,
615    is_cancel: bool,
616    state: Option<crate::server::background_shell::JobState>,
617) -> (String, i32) {
618    use crate::server::background_shell::JobState;
619    let Some(state) = state else {
620        return if is_cancel {
621            (
622                format!("[background:{id} not found — already finished or cancelled]"),
623                0,
624            )
625        } else {
626            (format!("[background:{id} not found]"), 1)
627        };
628    };
629    match state {
630        JobState::Running { output } => {
631            // #1217: show the captured-so-far output so a poll of a
632            // long-running job reflects progress instead of a bare
633            // "running" with no signal of whether it is advancing.
634            let body = redact_shell_output_secrets(&output);
635            let head = if is_cancel {
636                format!(
637                    "[background:{id} cancel requested — job is stopping; poll status for the final output]"
638                )
639            } else {
640                format!("[background:{id} running]")
641            };
642            if body.trim().is_empty() {
643                (head, 0)
644            } else {
645                (format!("{head}\n{body}"), 0)
646            }
647        }
648        JobState::Completed { output, exit_code } => (
649            format!(
650                "[background:{id} completed]\n{}{}",
651                redact_shell_output_secrets(&output),
652                if exit_code == 0 {
653                    String::new()
654                } else {
655                    format!("\n[exit:{exit_code}]")
656                }
657            ),
658            if is_cancel { 0 } else { exit_code },
659        ),
660        JobState::Cancelled { output } => (
661            format!(
662                "[background:{id} cancelled]\n{}\n[cancelled: {id}, exit 130]",
663                redact_shell_output_secrets(&output)
664            ),
665            0,
666        ),
667    }
668}
669
670fn redact_shell_output_secrets(output: &str) -> String {
671    let cfg = crate::core::config::Config::load();
672    if !cfg.secret_detection.enabled {
673        return output.to_string();
674    }
675    let (redacted, matches) =
676        crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
677    if !matches.is_empty() {
678        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
679        tracing::warn!(
680            "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
681            matches.len(),
682            names.join(", ")
683        );
684    }
685    redacted
686}
687
688/// The Codex MCP client abandons a tool call after five minutes. A Cargo test
689/// with an explicit five-minute-or-longer shell timeout cannot reliably finish
690/// inside that transport deadline (and can wait on Cargo's target lock), so
691/// detach it and return a pollable job instead.
692fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
693    timeout_ms.is_some_and(|timeout| timeout >= 300_000)
694        && command
695            .lines()
696            .any(|line| line.trim_start().starts_with("cargo test"))
697}
698
699/// Foreground wait budget before a still-running command is detached into a
700/// pollable background job. Kept below the MCP host's ~120s tool-call abort so
701/// the caller always receives a real `shell_*` job id instead of an
702/// unresolvable task id (#1106). Override with `LEAN_CTX_SHELL_FG_CAP_MS`.
703fn foreground_soft_cap_ms() -> u64 {
704    std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
705        .ok()
706        .and_then(|v| v.parse().ok())
707        .filter(|&ms| ms > 0)
708        .unwrap_or(110_000)
709}
710
711/// #842: detect a bare `cat <single_file>` command (no pipes, redirects, flags).
712fn detect_bare_cat_file(command: &str) -> Option<String> {
713    let trimmed = command.trim();
714    let rest = trimmed.strip_prefix("cat ")?;
715    let rest = rest.trim();
716    if rest.is_empty()
717        || rest.contains('|')
718        || rest.contains('>')
719        || rest.contains('<')
720        || rest.contains(';')
721        || rest.contains('&')
722        || rest.contains('$')
723        || rest.starts_with('-')
724    {
725        return None;
726    }
727    let parts: Vec<&str> = rest.split_whitespace().collect();
728    if parts.len() != 1 {
729        return None;
730    }
731    let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
732    if file_path.is_empty() {
733        return None;
734    }
735    Some(file_path.to_string())
736}
737
738#[cfg(test)]
739mod tests {
740    use super::{format_background_state, is_timeout_notice_only, should_auto_background};
741    use crate::server::background_shell::JobState;
742
743    /// #1246: a cancel must never come back as a tool error, and must not read
744    /// like a status poll that did nothing.
745    #[test]
746    fn cancel_is_acknowledged_and_never_reports_a_failure() {
747        let running = JobState::Running {
748            output: String::new(),
749        };
750        let (text, exit) = format_background_state("shell_x", true, Some(running.clone()));
751        assert_eq!(exit, 0);
752        assert!(text.contains("cancel requested"), "{text}");
753
754        // A status poll of the same state keeps the old wording.
755        let (text, exit) = format_background_state("shell_x", false, Some(running));
756        assert_eq!(exit, 0);
757        assert!(text.contains("[background:shell_x running]"), "{text}");
758
759        // The terminal state is data, not an error — no exit 130 leaks out.
760        let (text, exit) = format_background_state(
761            "shell_x",
762            true,
763            Some(JobState::Cancelled {
764                output: "[cancelled: command stopped on request]".to_string(),
765            }),
766        );
767        assert_eq!(exit, 0);
768        assert!(text.contains("[cancelled: shell_x, exit 130]"), "{text}");
769
770        // Idempotent: already finished, or finished and pruned.
771        let finished = JobState::Completed {
772            output: "boom".to_string(),
773            exit_code: 1,
774        };
775        assert_eq!(
776            format_background_state("shell_x", true, Some(finished.clone())).1,
777            0
778        );
779        assert_eq!(
780            format_background_state("shell_x", false, Some(finished)).1,
781            1
782        );
783        assert_eq!(format_background_state("shell_x", true, None).1, 0);
784        assert_eq!(format_background_state("shell_x", false, None).1, 1);
785    }
786
787    #[test]
788    fn long_cargo_test_is_auto_backgrounded() {
789        assert!(should_auto_background(
790            "cargo test --lib a\ncargo test --lib b",
791            Some(3_600_000)
792        ));
793        assert!(should_auto_background("cargo test --lib a", Some(300_000)));
794        assert!(!should_auto_background("cargo test --lib a", Some(299_999)));
795    }
796
797    #[test]
798    fn timeout_notice_without_child_output_is_not_recoverable() {
799        assert!(is_timeout_notice_only(
800            "ERROR: command timed out after 200ms",
801            124
802        ));
803        assert!(is_timeout_notice_only(
804            "  ERROR: command timed out after 200ms\n",
805            124
806        ));
807        assert!(!is_timeout_notice_only(
808            "useful output\nERROR: command timed out after 200ms",
809            124
810        ));
811        assert!(!is_timeout_notice_only(
812            "ERROR: command timed out after 200ms",
813            1
814        ));
815        // #1173: the notice now carries the idle wording and the still-running
816        // segment list. It is still pure metadata — nothing to recover — so it
817        // must not become a tee artifact just because it grew.
818        assert!(is_timeout_notice_only(
819            "ERROR: command timed out after 200ms without new output\n\
820             [still running at timeout: sleep 300]",
821            124
822        ));
823        assert!(!is_timeout_notice_only(
824            "useful output\nERROR: command timed out after 200ms\n\
825             [still running at timeout: sleep 300]",
826            124
827        ));
828        // Exit 124 from something that is not our watchdog carries no marker.
829        assert!(!is_timeout_notice_only("some tool output", 124));
830    }
831}