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