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            "WORKFLOW: preferred — auto-compresses output (build/test/log).\n\
21             raw=true for verbatim output.\n\
22             [exit:N] on errors (lossless).\n\
23             ANTIPATTERN: multi-line scripts → ctx_execute.",
24            json!({
25                "type": "object",
26                "properties": {
27                    "command": { "type": "string", "description": "Shell command" },
28                    "raw": { "type": "boolean", "description": "Skip compression (verbatim)" },
29                    "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
30                    "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } }
31                },
32                "required": ["command"]
33            }),
34        )
35    }
36
37    fn handle(
38        &self,
39        args: &Map<String, Value>,
40        ctx: &ToolContext,
41    ) -> Result<ToolOutput, ErrorData> {
42        let command = get_str(args, "command")
43            .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
44
45        // The write-doctrine check (no `>`, `tee`, heredoc-to-file, curl -o, …)
46        // is an MCP-payload-safety convention, not a security boundary, so it is
47        // opt-out via `shell_allow_writes` (#523). The real command gating
48        // (`check_shell_allowlist`, below) is NOT affected by this flag.
49        if !crate::core::config::Config::load().shell_allow_writes_effective()
50            && let Some(rejection) = crate::tools::ctx_shell::validate_command(&command)
51        {
52            // The command never ran — report as a tool error so MCP clients
53            // (guards, retry logic) can detect it programmatically (#389).
54            return Ok(ToolOutput {
55                shell_outcome: Some(ShellOutcome::Blocked),
56                ..ToolOutput::simple(rejection)
57            });
58        }
59
60        if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
61            return Ok(ToolOutput {
62                shell_outcome: Some(ShellOutcome::Blocked),
63                ..ToolOutput::simple(msg)
64            });
65        }
66
67        warn_shell_secret_paths(&command);
68
69        tokio::task::block_in_place(|| {
70            let session_lock = ctx
71                .session
72                .as_ref()
73                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
74
75            let explicit_cwd = get_str(args, "cwd");
76            let (effective_cwd, cwd_jail_reason) = {
77                let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
78                match guard {
79                    Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
80                    None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
81                }
82            };
83            // A `cwd` rejected by the project-root jail is silently replaced with
84            // the root (deliberate sandboxing). Surface that swap as a one-line
85            // hint so the caller does not mistake the run dir for the requested
86            // one (#629); appended at the end of the output like the other hints.
87            let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
88                format!(
89                    "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
90                )
91            });
92
93            {
94                let Some(mut session) =
95                    crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
96                else {
97                    tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
98                    let cmd_clone = command.clone();
99                    let cwd_clone = effective_cwd.clone();
100                    let extra_env: std::collections::HashMap<String, String> = args
101                        .get("env")
102                        .and_then(|v| v.as_object())
103                        .map(|obj| {
104                            obj.iter()
105                                .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
106                                .filter(|(k, _)| !is_dangerous_env_key(k))
107                                .collect()
108                        })
109                        .unwrap_or_default();
110                    let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
111                        &cmd_clone, &cwd_clone, &extra_env,
112                    );
113                    let output = redact_shell_output_secrets(&raw_output);
114                    // Keep failure reporting consistent on this degraded path:
115                    // same [exit:N] footer and the same structured outcome (#389).
116                    let exit_suffix = if exit_code != 0 {
117                        format!("\n[exit:{exit_code}]")
118                    } else {
119                        String::new()
120                    };
121                    return Ok(ToolOutput {
122                        shell_outcome: Some(ShellOutcome::Exit(exit_code)),
123                        ..ToolOutput::simple(format!("{output}{exit_suffix}"))
124                    });
125                };
126                session.update_shell_cwd(&command);
127                let root_missing = session
128                    .project_root
129                    .as_deref()
130                    .is_none_or(|r| r.trim().is_empty());
131                if root_missing {
132                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
133                    if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
134                        && home.as_deref() != Some(root.as_str())
135                    {
136                        session.project_root = Some(root.clone());
137                        crate::core::index_orchestrator::ensure_all_background(&root);
138                    }
139                }
140            }
141
142            let arg_raw = get_bool(args, "raw").unwrap_or(false);
143            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
144            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
145            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
146            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
147
148            let crp_mode = ctx.crp_mode;
149            let cmd_clone = command.clone();
150            let cwd_clone = effective_cwd;
151
152            let extra_env: std::collections::HashMap<String, String> = args
153                .get("env")
154                .and_then(|v| v.as_object())
155                .map(|obj| {
156                    obj.iter()
157                        .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
158                        .filter(|(k, _)| !is_dangerous_env_key(k))
159                        .collect()
160                })
161                .unwrap_or_default();
162
163            let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
164                &cmd_clone, &cwd_clone, &extra_env,
165            );
166
167            // Structured diagnostics (#499) — same hook as the CLI path.
168            crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
169
170            let output = redact_shell_output_secrets(&raw_output);
171
172            let (result_out, original, saved, tee_hint) = if raw {
173                let tokens = crate::core::tokens::count_tokens(&output);
174                (output, tokens, 0, String::new())
175            } else {
176                let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
177                let result =
178                    crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
179                let original = crate::core::tokens::count_tokens(&output);
180                let sent = crate::core::tokens::count_tokens(&result);
181                let saved = original.saturating_sub(sent);
182
183                let cfg = crate::core::config::Config::load();
184                // Shared tee policy (#811): identical decision to the CLI path —
185                // `Failures` keys off the real exit code, not a substring match.
186                let tee_hint = if crate::shell::tee_policy::should_tee(
187                    &cfg.tee_mode,
188                    exit_code,
189                    output.trim().is_empty(),
190                    original,
191                    sent,
192                ) {
193                    crate::shell::save_tee(&cmd_clone, &output)
194                        .map(|p| {
195                            if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
196                            {
197                                let pct = crate::shell::tee_policy::savings_pct(original, sent);
198                                // Recovery grammar (path-first, MCP-optional): the raw
199                                // bytes are a real file the agent can read with any tool
200                                // (no MCP needed) — for orgs that forbid it — and the same
201                                // path doubles as the ctx_expand id for surgical slices
202                                // (head/search/json_path) without re-reading it all (#936).
203                                format!(
204                                    "\n[compressed {pct:.0}%: full output at {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\") for a slice]"
205                                )
206                            } else {
207                                format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
208                            }
209                        })
210                        .unwrap_or_default()
211                } else {
212                    String::new()
213                };
214
215                (result, original, saved, tee_hint)
216            };
217
218            let mode = if bypass {
219                Some("bypass".to_string())
220            } else if raw {
221                Some("raw".to_string())
222            } else {
223                None
224            };
225
226            let shell_mismatch = if cfg!(windows) && !raw {
227                shell_mismatch_hint(&command, &result_out)
228            } else {
229                String::new()
230            };
231
232            let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
233            let exit_suffix = if exit_code != 0 {
234                format!("\n[exit:{exit_code}]")
235            } else {
236                String::new()
237            };
238            let final_out =
239                format!("{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{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
395fn redact_shell_output_secrets(output: &str) -> String {
396    let cfg = crate::core::config::Config::load();
397    if !cfg.secret_detection.enabled {
398        return output.to_string();
399    }
400    let (redacted, matches) =
401        crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
402    if !matches.is_empty() {
403        let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
404        tracing::warn!(
405            "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
406            matches.len(),
407            names.join(", ")
408        );
409    }
410    redacted
411}