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