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.\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                    "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
32                    "timeout_ms": { "type": "integer", "description": "Per-call timeout in ms (max 3600000). Overridden by LEAN_CTX_SHELL_TIMEOUT_MS." },
33                    "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } }
34                },
35                "required": ["command"]
36            }),
37        )
38    }
39
40    fn handle(
41        &self,
42        args: &Map<String, Value>,
43        ctx: &ToolContext,
44    ) -> Result<ToolOutput, ErrorData> {
45        let command = get_str(args, "command")
46            .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
47        let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
48
49        // The write-doctrine check (no `>`, `tee`, heredoc-to-file, curl -o, …)
50        // is an MCP-payload-safety convention, not a security boundary, so it is
51        // opt-out via `shell_allow_writes` (#523). The real command gating
52        // (`check_shell_allowlist`, below) is NOT affected by this flag.
53        if !crate::core::config::Config::load().shell_allow_writes_effective()
54            && let Some(rejection) = crate::tools::ctx_shell::validate_command(&command)
55        {
56            // The command never ran — report as a tool error so MCP clients
57            // (guards, retry logic) can detect it programmatically (#389).
58            return Ok(ToolOutput {
59                shell_outcome: Some(ShellOutcome::Blocked),
60                content_blocks: None,
61                ..ToolOutput::simple(rejection)
62            });
63        }
64
65        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
66            return Ok(ToolOutput {
67                shell_outcome: Some(ShellOutcome::Blocked),
68                content_blocks: None,
69                ..ToolOutput::simple(msg.to_string())
70            });
71        }
72
73        warn_shell_secret_paths(&command);
74
75        // #842: a bare `cat <file>` is better served by ctx_read — it delivers
76        // content inline instead of firewalling/archiving the output, avoiding
77        // a mandatory ctx_expand round-trip for agents with cat-muscle-memory.
78        if let Some(read_path) = detect_bare_cat_file(&command)
79            && let Some(cache_lock) = ctx.cache.as_ref()
80            && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
81        {
82            let result = crate::tools::ctx_read::handle_with_task_resolved(
83                &mut cache,
84                &read_path,
85                "full",
86                crate::tools::CrpMode::Off,
87                None,
88            );
89            let note = format!(
90                "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
91                         Use ctx_read(path=\"{read_path}\") directly next time.]"
92            );
93            let out = format!("{}{note}", result.content);
94            let sent = crate::core::tokens::count_tokens(&out);
95            return Ok(ToolOutput {
96                text: out,
97                original_tokens: sent,
98                saved_tokens: 0,
99                mode: Some("cat-redirect".to_string()),
100                path: Some(read_path),
101                changed: false,
102                shell_outcome: Some(ShellOutcome::Exit(0)),
103                content_blocks: None,
104            });
105        }
106
107        tokio::task::block_in_place(|| {
108            let session_lock = ctx
109                .session
110                .as_ref()
111                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
112
113            let explicit_cwd = get_str(args, "cwd");
114            let had_explicit_cwd = explicit_cwd.is_some();
115            let (effective_cwd, cwd_jail_reason) = {
116                let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
117                match guard {
118                    Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
119                    None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
120                }
121            };
122            // A `cwd` rejected by the project-root jail is silently replaced with
123            // the root (deliberate sandboxing). Surface that swap as a one-line
124            // hint so the caller does not mistake the run dir for the requested
125            // one (#629); appended at the end of the output like the other hints.
126            let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
127            let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
128                format!(
129                    "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
130                )
131            });
132
133            {
134                let Some(mut session) =
135                    crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
136                else {
137                    tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
138                    let cmd_clone = command.clone();
139                    let cwd_clone = effective_cwd.clone();
140                    let extra_env: std::collections::HashMap<String, String> = args
141                        .get("env")
142                        .and_then(|v| v.as_object())
143                        .map(|obj| {
144                            obj.iter()
145                                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
146                                .filter(|(k, _)| !is_dangerous_env_key(k))
147                                .collect()
148                        })
149                        .unwrap_or_default();
150                    let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
151                        &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
152                    );
153                    let output = redact_shell_output_secrets(&raw_output);
154                    // Keep failure reporting consistent on this degraded path:
155                    // same [exit:N] footer and the same structured outcome (#389).
156                    let exit_suffix = match exit_code {
157                        0 => String::new(),
158                        124 => "\n[exit:124 — command timed out]".to_string(),
159                        _ => format!("\n[exit:{exit_code}]"),
160                    };
161                    return Ok(ToolOutput {
162                        shell_outcome: Some(ShellOutcome::Exit(exit_code)),
163                        content_blocks: None,
164                        ..ToolOutput::simple(format!("{output}{exit_suffix}"))
165                    });
166                };
167                // #707: a jail-accepted explicit `cwd` param is the client
168                // telling us where it now works (worktree switches arrive
169                // this way, not as `cd` commands) — persist it so path
170                // resolution's divergence check tracks the live checkout.
171                if had_explicit_cwd && cwd_jail_reason_was_none {
172                    session.note_explicit_cwd(&effective_cwd);
173                }
174                session.update_shell_cwd(&command);
175                let root_missing = session
176                    .project_root
177                    .as_deref()
178                    .is_none_or(|r| r.trim().is_empty());
179                if root_missing {
180                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
181                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
182                        && home.as_deref() != Some(root.as_str())
183                    {
184                        session.project_root = Some(root.clone());
185                        crate::core::index_orchestrator::ensure_all_background(&root);
186                    }
187                }
188            }
189
190            let arg_raw = get_bool(args, "raw").unwrap_or(false);
191            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
192            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
193            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
194            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
195
196            let crp_mode = ctx.crp_mode;
197            let cmd_clone = command.clone();
198            let cwd_clone = effective_cwd;
199
200            let extra_env: std::collections::HashMap<String, String> = args
201                .get("env")
202                .and_then(|v| v.as_object())
203                .map(|obj| {
204                    obj.iter()
205                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
206                        .filter(|(k, _)| !is_dangerous_env_key(k))
207                        .collect()
208                })
209                .unwrap_or_default();
210
211            let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
212                &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
213            );
214
215            // Structured diagnostics (#499) — same hook as the CLI path.
216            crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
217
218            let output = redact_shell_output_secrets(&raw_output);
219
220            let (result_out, original, saved, tee_hint) = if raw {
221                let tokens = crate::core::tokens::count_tokens(&output);
222                (output, tokens, 0, String::new())
223            } else {
224                let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
225                let result =
226                    crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
227                let original = crate::core::tokens::count_tokens(&output);
228                let sent = crate::core::tokens::count_tokens(&result);
229                let saved = original.saturating_sub(sent);
230
231                let cfg = crate::core::config::Config::load();
232                // Shared tee policy (#811): identical decision to the CLI path —
233                // `Failures` keys off the real exit code, not a substring match.
234                let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
235                let tee_hint = if crate::shell::tee_policy::should_tee(
236                    &cfg.tee_mode,
237                    exit_code,
238                    output.trim().is_empty() || timeout_notice_only,
239                    crate::shell::tee_policy::output_was_elided(&output, &result),
240                    original,
241                    sent,
242                ) {
243                    crate::shell::save_tee(&cmd_clone, &output)
244                        .map(|p| {
245                            if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
246                            {
247                                let pct = crate::shell::tee_policy::savings_pct(original, sent);
248                                // Recovery grammar is path-first: agents without ctx_expand
249                                // can still read the saved artifact directly (#936).
250                                format!(
251                                    "\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]"
252                                )
253                            } else {
254                                format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
255                            }
256                        })
257                        .unwrap_or_default()
258                } else {
259                    String::new()
260                };
261
262                (result, original, saved, tee_hint)
263            };
264
265            let mode = if bypass {
266                Some("bypass".to_string())
267            } else if raw {
268                Some("raw".to_string())
269            } else {
270                None
271            };
272
273            let shell_mismatch = if cfg!(windows) && !raw {
274                shell_mismatch_hint(&command, &result_out)
275            } else {
276                String::new()
277            };
278
279            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
280            // #815: exit 124 = timeout signal (from timeout(1) / lean-ctx
281            // shell timeout). Make it explicit so agents don't confuse a
282            // timed-out command with a successful empty result.
283            let exit_suffix = match exit_code {
284                0 => String::new(),
285                124 => "\n[exit:124 — command timed out]".to_string(),
286                _ => format!("\n[exit:{exit_code}]"),
287            };
288            let nudge = if raw { "" } else { search_tool_nudge(&command) };
289            let final_out = format!(
290                "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
291            );
292
293            Ok(ToolOutput {
294                text: final_out,
295                original_tokens: original,
296                saved_tokens: saved,
297                mode,
298                path: None,
299                changed: false,
300                shell_outcome: Some(ShellOutcome::Exit(exit_code)),
301                content_blocks: None,
302            })
303        })
304    }
305}
306
307#[allow(clippy::fn_params_excessive_bools)]
308fn resolve_shell_raw_flags(
309    arg_raw: bool,
310    arg_bypass: bool,
311    env_disabled: bool,
312    env_raw: bool,
313) -> (bool, bool) {
314    let bypass = arg_bypass || env_raw;
315    let raw = arg_raw || bypass || env_disabled;
316    (raw, bypass)
317}
318
319/// A timeout notice is framework metadata, not recoverable command output. Do
320/// not archive it as a tee artifact: expanding it cannot recover any bytes (#995).
321fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
322    exit_code == 124
323        && output
324            .trim()
325            .strip_prefix("ERROR: command timed out after ")
326            .is_some_and(|rest| {
327                rest.strip_suffix("ms")
328                    .is_some_and(|n| n.trim().parse::<u128>().is_ok())
329            })
330}
331
332fn search_tool_nudge(command: &str) -> &'static str {
333    let cmd = command.trim();
334    let first_word = cmd.split_whitespace().next().unwrap_or("");
335    if !cmd.contains('|') {
336        match first_word {
337            "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
338                return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
339            }
340            "find" => {
341                return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
342            }
343            "ls" | "exa" | "eza" => {
344                return "\n[hint: use ctx_tree for structured directory listing]";
345            }
346            _ => {}
347        }
348    }
349    ""
350}
351
352fn shell_mismatch_hint(command: &str, output: &str) -> String {
353    let shell = crate::shell::shell_name();
354    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
355    let has_error = output.contains("is not recognized")
356        || output.contains("not found")
357        || output.contains("command not found");
358
359    if !has_error {
360        return String::new();
361    }
362
363    let powershell_cmds = [
364        "Get-Content",
365        "Select-Object",
366        "Get-ChildItem",
367        "Set-Location",
368        "Where-Object",
369        "ForEach-Object",
370        "Select-String",
371        "Invoke-Expression",
372        "Write-Output",
373    ];
374    let uses_powershell = powershell_cmds
375        .iter()
376        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
377
378    if is_posix && uses_powershell {
379        format!(
380            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
381        )
382    } else {
383        String::new()
384    }
385}
386
387fn is_dangerous_env_key(key: &str) -> bool {
388    const BLOCKED: &[&str] = &[
389        // Dynamic linker injection
390        "LD_PRELOAD",
391        "LD_LIBRARY_PATH",
392        "DYLD_INSERT_LIBRARIES",
393        "DYLD_LIBRARY_PATH",
394        "DYLD_FRAMEWORK_PATH",
395        // Shell re-entry / startup injection
396        "BASH_ENV",
397        "ENV",
398        "PROMPT_COMMAND",
399        "SHELL",
400        "IFS",
401        "CDPATH",
402        // Binary resolution hijacking
403        "PATH",
404        "GIT_EXEC_PATH",
405        "GIT_SSH",
406        "GIT_SSH_COMMAND",
407        // Identity / home directory manipulation
408        "HOME",
409        "USER",
410        "LOGNAME",
411        "XDG_CONFIG_HOME",
412        "XDG_DATA_HOME",
413        "XDG_STATE_HOME",
414        "XDG_CACHE_HOME",
415        // Language runtime search path hijacking
416        "PYTHONPATH",
417        "PYTHONSTARTUP",
418        "PYTHONHOME",
419        "NODE_PATH",
420        "NODE_OPTIONS",
421        "RUBYOPT",
422        "RUBYLIB",
423        "GEM_PATH",
424        "GEM_HOME",
425        "PERL5LIB",
426        "PERL5OPT",
427        "CLASSPATH",
428        "JAVA_HOME",
429        "CARGO_HOME",
430        "RUSTUP_HOME",
431        "GOPATH",
432        "GOROOT",
433    ];
434    let upper = key.to_uppercase();
435    if BLOCKED.contains(&upper.as_str()) {
436        return true;
437    }
438    if upper.starts_with("LD_") && upper.ends_with("_PATH") {
439        return true;
440    }
441    // Block all lean-ctx config overrides from env
442    if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
443        return true;
444    }
445    false
446}
447
448/// Warn when shell reads secret-like paths via cat/head/tail/less/more.
449/// WARN-ONLY: command still executes, this is purely observational.
450fn warn_shell_secret_paths(command: &str) {
451    const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
452    let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
453    for seg in &segments {
454        let trimmed = seg.trim();
455        let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
456        if tokens.is_empty() {
457            continue;
458        }
459        let base = tokens[0]
460            .rsplit('/')
461            .next()
462            .unwrap_or(&tokens[0])
463            .to_string();
464        if !READ_CMDS.contains(&base.as_str()) {
465            continue;
466        }
467        for tok in &tokens[1..] {
468            if tok.starts_with('-') {
469                continue;
470            }
471            let path = std::path::Path::new(tok.as_str());
472            if crate::core::io_boundary::is_secret_like(path).is_some() {
473                tracing::warn!(
474                    "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
475                );
476            }
477        }
478    }
479}
480
481fn redact_shell_output_secrets(output: &str) -> String {
482    let cfg = crate::core::config::Config::load();
483    if !cfg.secret_detection.enabled {
484        return output.to_string();
485    }
486    let (redacted, matches) =
487        crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
488    if !matches.is_empty() {
489        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
490        tracing::warn!(
491            "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
492            matches.len(),
493            names.join(", ")
494        );
495    }
496    redacted
497}
498
499/// #842: detect a bare `cat <single_file>` command (no pipes, redirects, flags).
500fn detect_bare_cat_file(command: &str) -> Option<String> {
501    let trimmed = command.trim();
502    let rest = trimmed.strip_prefix("cat ")?;
503    let rest = rest.trim();
504    if rest.is_empty()
505        || rest.contains('|')
506        || rest.contains('>')
507        || rest.contains('<')
508        || rest.contains(';')
509        || rest.contains('&')
510        || rest.contains('$')
511        || rest.starts_with('-')
512    {
513        return None;
514    }
515    let parts: Vec<&str> = rest.split_whitespace().collect();
516    if parts.len() != 1 {
517        return None;
518    }
519    let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
520    if file_path.is_empty() {
521        return None;
522    }
523    Some(file_path.to_string())
524}
525
526#[cfg(test)]
527mod tests {
528    use super::is_timeout_notice_only;
529
530    #[test]
531    fn timeout_notice_without_child_output_is_not_recoverable() {
532        assert!(is_timeout_notice_only(
533            "ERROR: command timed out after 200ms",
534            124
535        ));
536        assert!(is_timeout_notice_only(
537            "  ERROR: command timed out after 200ms\n",
538            124
539        ));
540        assert!(!is_timeout_notice_only(
541            "useful output\nERROR: command timed out after 200ms",
542            124
543        ));
544        assert!(!is_timeout_notice_only(
545            "ERROR: command timed out after 200ms",
546            1
547        ));
548    }
549}