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