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