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 Uses the system shell ($SHELL) profile-free — no rc/profile files sourced. \
22 Especially for build/test/log commands (cargo, make, npm, pytest, \
23 go test, …), the heaviest output in a session. Compression is \
24 lossless for signal: compiler errors, test results and panics are \
25 kept verbatim. cwd persists across calls.",
26 json!({
27 "type": "object",
28 "properties": {
29 "command": { "type": "string", "description": "Shell command" },
30 "raw": { "type": "boolean", "description": "Skip compression" },
31 "cwd": { "type": "string", "description": "Working directory (default: last cd or project root)" },
32 "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } }
33 },
34 "required": ["command"]
35 }),
36 )
37 }
38
39 fn handle(
40 &self,
41 args: &Map<String, Value>,
42 ctx: &ToolContext,
43 ) -> Result<ToolOutput, ErrorData> {
44 let command = get_str(args, "command")
45 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
46
47 if let Some(rejection) = crate::tools::ctx_shell::validate_command(&command) {
48 return Ok(ToolOutput {
51 shell_outcome: Some(ShellOutcome::Blocked),
52 ..ToolOutput::simple(rejection)
53 });
54 }
55
56 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
57 return Ok(ToolOutput {
58 shell_outcome: Some(ShellOutcome::Blocked),
59 ..ToolOutput::simple(msg)
60 });
61 }
62
63 warn_shell_secret_paths(&command);
64
65 tokio::task::block_in_place(|| {
66 let session_lock = ctx
67 .session
68 .as_ref()
69 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
70
71 let explicit_cwd = get_str(args, "cwd");
72 let effective_cwd = {
73 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
74 match guard {
75 Some(session) => session.effective_cwd(explicit_cwd.as_deref()),
76 None => explicit_cwd.unwrap_or_else(|| ".".to_string()),
77 }
78 };
79
80 {
81 let Some(mut session) =
82 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
83 else {
84 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
85 let cmd_clone = command.clone();
86 let cwd_clone = effective_cwd.clone();
87 let extra_env: std::collections::HashMap<String, String> = args
88 .get("env")
89 .and_then(|v| v.as_object())
90 .map(|obj| {
91 obj.iter()
92 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
93 .filter(|(k, _)| !is_dangerous_env_key(k))
94 .collect()
95 })
96 .unwrap_or_default();
97 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
98 &cmd_clone, &cwd_clone, &extra_env,
99 );
100 let output = redact_shell_output_secrets(&raw_output);
101 let exit_suffix = if exit_code != 0 {
104 format!("\n[exit:{exit_code}]")
105 } else {
106 String::new()
107 };
108 return Ok(ToolOutput {
109 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
110 ..ToolOutput::simple(format!("{output}{exit_suffix}"))
111 });
112 };
113 session.update_shell_cwd(&command);
114 let root_missing = session
115 .project_root
116 .as_deref()
117 .is_none_or(|r| r.trim().is_empty());
118 if root_missing {
119 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
120 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
121 && home.as_deref() != Some(root.as_str())
122 {
123 session.project_root = Some(root.clone());
124 crate::core::index_orchestrator::ensure_all_background(&root);
125 }
126 }
127 }
128
129 let arg_raw = get_bool(args, "raw").unwrap_or(false);
130 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
131 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
132 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
133 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
134
135 let crp_mode = ctx.crp_mode;
136 let cmd_clone = command.clone();
137 let cwd_clone = effective_cwd;
138
139 let extra_env: std::collections::HashMap<String, String> = args
140 .get("env")
141 .and_then(|v| v.as_object())
142 .map(|obj| {
143 obj.iter()
144 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
145 .filter(|(k, _)| !is_dangerous_env_key(k))
146 .collect()
147 })
148 .unwrap_or_default();
149
150 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
151 &cmd_clone, &cwd_clone, &extra_env,
152 );
153
154 crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
156
157 let output = redact_shell_output_secrets(&raw_output);
158
159 let (result_out, original, saved, tee_hint) = if raw {
160 let tokens = crate::core::tokens::count_tokens(&output);
161 (output, tokens, 0, String::new())
162 } else {
163 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
164 let result = crate::tools::ctx_shell::handle(&cmd_clone, &output, crp_mode);
165 let original = crate::core::tokens::count_tokens(&output);
166 let sent = crate::core::tokens::count_tokens(&result);
167 let saved = original.saturating_sub(sent);
168
169 let cfg = crate::core::config::Config::load();
170 let savings_pct = if original > 0 {
171 ((original.saturating_sub(sent)) as f64 / original as f64) * 100.0
172 } else {
173 0.0
174 };
175 let tee_hint = match cfg.tee_mode {
176 crate::core::config::TeeMode::Always => {
177 crate::shell::save_tee(&cmd_clone, &output)
178 .map(|p| format!("\n[full output: {p}]"))
179 .unwrap_or_default()
180 }
181 crate::core::config::TeeMode::Failures
182 if !output.trim().is_empty()
183 && (output.contains("error")
184 || output.contains("Error")
185 || output.contains("ERROR")) =>
186 {
187 crate::shell::save_tee(&cmd_clone, &output)
188 .map(|p| format!("\n[full output: {p}]"))
189 .unwrap_or_default()
190 }
191 crate::core::config::TeeMode::HighCompression
192 if savings_pct > 70.0 && original > 100 =>
193 {
194 crate::shell::save_tee(&cmd_clone, &output)
195 .map(|p| {
196 format!(
197 "\n[compressed {savings_pct:.0}%: full output at {p} if needed]"
198 )
199 })
200 .unwrap_or_default()
201 }
202 _ => {
203 if savings_pct > 70.0
204 && original > 100
205 && matches!(cfg.tee_mode, crate::core::config::TeeMode::Failures)
206 {
207 crate::shell::save_tee(&cmd_clone, &output)
208 .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
209 .unwrap_or_default()
210 } else {
211 String::new()
212 }
213 }
214 };
215
216 (result, original, saved, tee_hint)
217 };
218
219 let mode = if bypass {
220 Some("bypass".to_string())
221 } else if raw {
222 Some("raw".to_string())
223 } else {
224 None
225 };
226
227 let shell_mismatch = if cfg!(windows) && !raw {
228 shell_mismatch_hint(&command, &result_out)
229 } else {
230 String::new()
231 };
232
233 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
234 let exit_suffix = if exit_code != 0 {
235 format!("\n[exit:{exit_code}]")
236 } else {
237 String::new()
238 };
239 let final_out = format!("{result_out}{tee_hint}{shell_mismatch}{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 "LD_PRELOAD",
305 "LD_LIBRARY_PATH",
306 "DYLD_INSERT_LIBRARIES",
307 "DYLD_LIBRARY_PATH",
308 "DYLD_FRAMEWORK_PATH",
309 "BASH_ENV",
311 "ENV",
312 "PROMPT_COMMAND",
313 "SHELL",
314 "IFS",
315 "CDPATH",
316 "PATH",
318 "GIT_EXEC_PATH",
319 "GIT_SSH",
320 "GIT_SSH_COMMAND",
321 "HOME",
323 "USER",
324 "LOGNAME",
325 "XDG_CONFIG_HOME",
326 "XDG_DATA_HOME",
327 "XDG_STATE_HOME",
328 "XDG_CACHE_HOME",
329 "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 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
357 return true;
358 }
359 false
360}
361
362fn 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 {
397 let cfg = crate::core::config::Config::load();
398 if !cfg.secret_detection.enabled {
399 return output.to_string();
400 }
401 let (redacted, matches) =
402 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
403 if !matches.is_empty() {
404 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
405 tracing::warn!(
406 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
407 matches.len(),
408 names.join(", ")
409 );
410 }
411 redacted
412}