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