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