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                ..ToolOutput::simple(rejection)
59            });
60        }
61
62        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
63            return Ok(ToolOutput {
64                shell_outcome: Some(ShellOutcome::Blocked),
65                ..ToolOutput::simple(msg.to_string())
66            });
67        }
68
69        warn_shell_secret_paths(&command);
70
71        tokio::task::block_in_place(|| {
72            let session_lock = ctx
73                .session
74                .as_ref()
75                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
76
77            let explicit_cwd = get_str(args, "cwd");
78            let had_explicit_cwd = explicit_cwd.is_some();
79            let (effective_cwd, cwd_jail_reason) = {
80                let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
81                match guard {
82                    Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
83                    None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
84                }
85            };
86            // A `cwd` rejected by the project-root jail is silently replaced with
87            // the root (deliberate sandboxing). Surface that swap as a one-line
88            // hint so the caller does not mistake the run dir for the requested
89            // one (#629); appended at the end of the output like the other hints.
90            let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
91            let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
92                format!(
93                    "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
94                )
95            });
96
97            {
98                let Some(mut session) =
99                    crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
100                else {
101                    tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
102                    let cmd_clone = command.clone();
103                    let cwd_clone = effective_cwd.clone();
104                    let extra_env: std::collections::HashMap<String, String> = args
105                        .get("env")
106                        .and_then(|v| v.as_object())
107                        .map(|obj| {
108                            obj.iter()
109                                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
110                                .filter(|(k, _)| !is_dangerous_env_key(k))
111                                .collect()
112                        })
113                        .unwrap_or_default();
114                    let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
115                        &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
116                    );
117                    let output = redact_shell_output_secrets(&raw_output);
118                    // Keep failure reporting consistent on this degraded path:
119                    // same [exit:N] footer and the same structured outcome (#389).
120                    let exit_suffix = if exit_code != 0 {
121                        format!("\n[exit:{exit_code}]")
122                    } else {
123                        String::new()
124                    };
125                    return Ok(ToolOutput {
126                        shell_outcome: Some(ShellOutcome::Exit(exit_code)),
127                        ..ToolOutput::simple(format!("{output}{exit_suffix}"))
128                    });
129                };
130                // #707: a jail-accepted explicit `cwd` param is the client
131                // telling us where it now works (worktree switches arrive
132                // this way, not as `cd` commands) — persist it so path
133                // resolution's divergence check tracks the live checkout.
134                if had_explicit_cwd && cwd_jail_reason_was_none {
135                    session.note_explicit_cwd(&effective_cwd);
136                }
137                session.update_shell_cwd(&command);
138                let root_missing = session
139                    .project_root
140                    .as_deref()
141                    .is_none_or(|r| r.trim().is_empty());
142                if root_missing {
143                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
144                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
145                        && home.as_deref() != Some(root.as_str())
146                    {
147                        session.project_root = Some(root.clone());
148                        crate::core::index_orchestrator::ensure_all_background(&root);
149                    }
150                }
151            }
152
153            let arg_raw = get_bool(args, "raw").unwrap_or(false);
154            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
155            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
156            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
157            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
158
159            let crp_mode = ctx.crp_mode;
160            let cmd_clone = command.clone();
161            let cwd_clone = effective_cwd;
162
163            let extra_env: std::collections::HashMap<String, String> = args
164                .get("env")
165                .and_then(|v| v.as_object())
166                .map(|obj| {
167                    obj.iter()
168                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
169                        .filter(|(k, _)| !is_dangerous_env_key(k))
170                        .collect()
171                })
172                .unwrap_or_default();
173
174            let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
175                &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
176            );
177
178            // Structured diagnostics (#499) — same hook as the CLI path.
179            crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
180
181            let output = redact_shell_output_secrets(&raw_output);
182
183            let (result_out, original, saved, tee_hint) = if raw {
184                let tokens = crate::core::tokens::count_tokens(&output);
185                (output, tokens, 0, String::new())
186            } else {
187                let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
188                let result =
189                    crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
190                let original = crate::core::tokens::count_tokens(&output);
191                let sent = crate::core::tokens::count_tokens(&result);
192                let saved = original.saturating_sub(sent);
193
194                let cfg = crate::core::config::Config::load();
195                // Shared tee policy (#811): identical decision to the CLI path —
196                // `Failures` keys off the real exit code, not a substring match.
197                let tee_hint = if crate::shell::tee_policy::should_tee(
198                    &cfg.tee_mode,
199                    exit_code,
200                    output.trim().is_empty(),
201                    original,
202                    sent,
203                ) {
204                    crate::shell::save_tee(&cmd_clone, &output)
205                        .map(|p| {
206                            if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
207                            {
208                                let pct = crate::shell::tee_policy::savings_pct(original, sent);
209                                // Recovery grammar (path-first, MCP-optional): the raw
210                                // bytes are a real file the agent can read with any tool
211                                // (no MCP needed) — for orgs that forbid it — and the same
212                                // path doubles as the ctx_expand id for surgical slices
213                                // (head/search/json_path) without re-reading it all (#936).
214                                format!(
215                                    "\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]"
216                                )
217                            } else {
218                                format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
219                            }
220                        })
221                        .unwrap_or_default()
222                } else {
223                    String::new()
224                };
225
226                (result, original, saved, tee_hint)
227            };
228
229            let mode = if bypass {
230                Some("bypass".to_string())
231            } else if raw {
232                Some("raw".to_string())
233            } else {
234                None
235            };
236
237            let shell_mismatch = if cfg!(windows) && !raw {
238                shell_mismatch_hint(&command, &result_out)
239            } else {
240                String::new()
241            };
242
243            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
244            let exit_suffix = if exit_code != 0 {
245                format!("\n[exit:{exit_code}]")
246            } else {
247                String::new()
248            };
249            let final_out =
250                format!("{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{exit_suffix}");
251
252            Ok(ToolOutput {
253                text: final_out,
254                original_tokens: original,
255                saved_tokens: saved,
256                mode,
257                path: None,
258                changed: false,
259                shell_outcome: Some(ShellOutcome::Exit(exit_code)),
260            })
261        })
262    }
263}
264
265#[allow(clippy::fn_params_excessive_bools)]
266fn resolve_shell_raw_flags(
267    arg_raw: bool,
268    arg_bypass: bool,
269    env_disabled: bool,
270    env_raw: bool,
271) -> (bool, bool) {
272    let bypass = arg_bypass || env_raw;
273    let raw = arg_raw || bypass || env_disabled;
274    (raw, bypass)
275}
276
277fn shell_mismatch_hint(command: &str, output: &str) -> String {
278    let shell = crate::shell::shell_name();
279    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
280    let has_error = output.contains("is not recognized")
281        || output.contains("not found")
282        || output.contains("command not found");
283
284    if !has_error {
285        return String::new();
286    }
287
288    let powershell_cmds = [
289        "Get-Content",
290        "Select-Object",
291        "Get-ChildItem",
292        "Set-Location",
293        "Where-Object",
294        "ForEach-Object",
295        "Select-String",
296        "Invoke-Expression",
297        "Write-Output",
298    ];
299    let uses_powershell = powershell_cmds
300        .iter()
301        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
302
303    if is_posix && uses_powershell {
304        format!(
305            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
306        )
307    } else {
308        String::new()
309    }
310}
311
312fn is_dangerous_env_key(key: &str) -> bool {
313    const BLOCKED: &[&str] = &[
314        // Dynamic linker injection
315        "LD_PRELOAD",
316        "LD_LIBRARY_PATH",
317        "DYLD_INSERT_LIBRARIES",
318        "DYLD_LIBRARY_PATH",
319        "DYLD_FRAMEWORK_PATH",
320        // Shell re-entry / startup injection
321        "BASH_ENV",
322        "ENV",
323        "PROMPT_COMMAND",
324        "SHELL",
325        "IFS",
326        "CDPATH",
327        // Binary resolution hijacking
328        "PATH",
329        "GIT_EXEC_PATH",
330        "GIT_SSH",
331        "GIT_SSH_COMMAND",
332        // Identity / home directory manipulation
333        "HOME",
334        "USER",
335        "LOGNAME",
336        "XDG_CONFIG_HOME",
337        "XDG_DATA_HOME",
338        "XDG_STATE_HOME",
339        "XDG_CACHE_HOME",
340        // Language runtime search path hijacking
341        "PYTHONPATH",
342        "PYTHONSTARTUP",
343        "PYTHONHOME",
344        "NODE_PATH",
345        "NODE_OPTIONS",
346        "RUBYOPT",
347        "RUBYLIB",
348        "GEM_PATH",
349        "GEM_HOME",
350        "PERL5LIB",
351        "PERL5OPT",
352        "CLASSPATH",
353        "JAVA_HOME",
354        "CARGO_HOME",
355        "RUSTUP_HOME",
356        "GOPATH",
357        "GOROOT",
358    ];
359    let upper = key.to_uppercase();
360    if BLOCKED.contains(&upper.as_str()) {
361        return true;
362    }
363    if upper.starts_with("LD_") && upper.ends_with("_PATH") {
364        return true;
365    }
366    // Block all lean-ctx config overrides from env
367    if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
368        return true;
369    }
370    false
371}
372
373/// Warn when shell reads secret-like paths via cat/head/tail/less/more.
374/// WARN-ONLY: command still executes, this is purely observational.
375fn warn_shell_secret_paths(command: &str) {
376    const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
377    let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
378    for seg in &segments {
379        let trimmed = seg.trim();
380        let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
381        if tokens.is_empty() {
382            continue;
383        }
384        let base = tokens[0]
385            .rsplit('/')
386            .next()
387            .unwrap_or(&tokens[0])
388            .to_string();
389        if !READ_CMDS.contains(&base.as_str()) {
390            continue;
391        }
392        for tok in &tokens[1..] {
393            if tok.starts_with('-') {
394                continue;
395            }
396            let path = std::path::Path::new(tok.as_str());
397            if crate::core::io_boundary::is_secret_like(path).is_some() {
398                tracing::warn!(
399                    "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
400                );
401            }
402        }
403    }
404}
405
406fn redact_shell_output_secrets(output: &str) -> String {
407    let cfg = crate::core::config::Config::load();
408    if !cfg.secret_detection.enabled {
409        return output.to_string();
410    }
411    let (redacted, matches) =
412        crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
413    if !matches.is_empty() {
414        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
415        tracing::warn!(
416            "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
417            matches.len(),
418            names.join(", ")
419        );
420    }
421    redacted
422}