lean_ctx/tools/registered/
ctx_shell.rs1use 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 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 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 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 "LD_PRELOAD",
297 "LD_LIBRARY_PATH",
298 "DYLD_INSERT_LIBRARIES",
299 "DYLD_LIBRARY_PATH",
300 "DYLD_FRAMEWORK_PATH",
301 "BASH_ENV",
303 "ENV",
304 "PROMPT_COMMAND",
305 "SHELL",
306 "IFS",
307 "CDPATH",
308 "PATH",
310 "GIT_EXEC_PATH",
311 "GIT_SSH",
312 "GIT_SSH_COMMAND",
313 "HOME",
315 "USER",
316 "LOGNAME",
317 "XDG_CONFIG_HOME",
318 "XDG_DATA_HOME",
319 "XDG_STATE_HOME",
320 "XDG_CACHE_HOME",
321 "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 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
349 return true;
350 }
351 false
352}
353
354fn 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
387fn 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}