Skip to main content

lean_ctx/tools/registered/
ctx_shell.rs

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