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