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_int, 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 "timeout_ms": { "type": "integer", "description": "Per-call timeout in ms (max 3600000). Overridden by LEAN_CTX_SHELL_TIMEOUT_MS." },
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 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
46
47 if !crate::core::config::Config::load().shell_allow_writes_effective()
52 && let Some(rejection) = crate::tools::ctx_shell::validate_command(&command)
53 {
54 return Ok(ToolOutput {
57 shell_outcome: Some(ShellOutcome::Blocked),
58 content_blocks: None,
59 ..ToolOutput::simple(rejection)
60 });
61 }
62
63 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
64 return Ok(ToolOutput {
65 shell_outcome: Some(ShellOutcome::Blocked),
66 content_blocks: None,
67 ..ToolOutput::simple(msg.to_string())
68 });
69 }
70
71 warn_shell_secret_paths(&command);
72
73 if let Some(read_path) = detect_bare_cat_file(&command)
77 && let Some(cache_lock) = ctx.cache.as_ref()
78 && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
79 {
80 let result = crate::tools::ctx_read::handle_with_task_resolved(
81 &mut cache,
82 &read_path,
83 "full",
84 crate::tools::CrpMode::Off,
85 None,
86 );
87 let note = format!(
88 "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
89 Use ctx_read(path=\"{read_path}\") directly next time.]"
90 );
91 let out = format!("{}{note}", result.content);
92 let sent = crate::core::tokens::count_tokens(&out);
93 return Ok(ToolOutput {
94 text: out,
95 original_tokens: sent,
96 saved_tokens: 0,
97 mode: Some("cat-redirect".to_string()),
98 path: Some(read_path),
99 changed: false,
100 shell_outcome: Some(ShellOutcome::Exit(0)),
101 content_blocks: None,
102 });
103 }
104
105 tokio::task::block_in_place(|| {
106 let session_lock = ctx
107 .session
108 .as_ref()
109 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
110
111 let explicit_cwd = get_str(args, "cwd");
112 let had_explicit_cwd = explicit_cwd.is_some();
113 let (effective_cwd, cwd_jail_reason) = {
114 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
115 match guard {
116 Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
117 None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
118 }
119 };
120 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
125 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
126 format!(
127 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
128 )
129 });
130
131 {
132 let Some(mut session) =
133 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
134 else {
135 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
136 let cmd_clone = command.clone();
137 let cwd_clone = effective_cwd.clone();
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 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
149 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
150 );
151 let output = redact_shell_output_secrets(&raw_output);
152 let exit_suffix = match exit_code {
155 0 => String::new(),
156 124 => "\n[exit:124 — command timed out]".to_string(),
157 _ => format!("\n[exit:{exit_code}]"),
158 };
159 return Ok(ToolOutput {
160 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
161 content_blocks: None,
162 ..ToolOutput::simple(format!("{output}{exit_suffix}"))
163 });
164 };
165 if had_explicit_cwd && cwd_jail_reason_was_none {
170 session.note_explicit_cwd(&effective_cwd);
171 }
172 session.update_shell_cwd(&command);
173 let root_missing = session
174 .project_root
175 .as_deref()
176 .is_none_or(|r| r.trim().is_empty());
177 if root_missing {
178 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
179 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
180 && home.as_deref() != Some(root.as_str())
181 {
182 session.project_root = Some(root.clone());
183 crate::core::index_orchestrator::ensure_all_background(&root);
184 }
185 }
186 }
187
188 let arg_raw = get_bool(args, "raw").unwrap_or(false);
189 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
190 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
191 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
192 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
193
194 let crp_mode = ctx.crp_mode;
195 let cmd_clone = command.clone();
196 let cwd_clone = effective_cwd;
197
198 let extra_env: std::collections::HashMap<String, String> = args
199 .get("env")
200 .and_then(|v| v.as_object())
201 .map(|obj| {
202 obj.iter()
203 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
204 .filter(|(k, _)| !is_dangerous_env_key(k))
205 .collect()
206 })
207 .unwrap_or_default();
208
209 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
210 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
211 );
212
213 crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
215
216 let output = redact_shell_output_secrets(&raw_output);
217
218 let (result_out, original, saved, tee_hint) = if raw {
219 let tokens = crate::core::tokens::count_tokens(&output);
220 (output, tokens, 0, String::new())
221 } else {
222 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
223 let result =
224 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
225 let original = crate::core::tokens::count_tokens(&output);
226 let sent = crate::core::tokens::count_tokens(&result);
227 let saved = original.saturating_sub(sent);
228
229 let cfg = crate::core::config::Config::load();
230 let tee_hint = if crate::shell::tee_policy::should_tee(
233 &cfg.tee_mode,
234 exit_code,
235 output.trim().is_empty(),
236 original,
237 sent,
238 ) {
239 crate::shell::save_tee(&cmd_clone, &output)
240 .map(|p| {
241 if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
242 {
243 let pct = crate::shell::tee_policy::savings_pct(original, sent);
244 format!(
250 "\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]"
251 )
252 } else {
253 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
254 }
255 })
256 .unwrap_or_default()
257 } else {
258 String::new()
259 };
260
261 (result, original, saved, tee_hint)
262 };
263
264 let mode = if bypass {
265 Some("bypass".to_string())
266 } else if raw {
267 Some("raw".to_string())
268 } else {
269 None
270 };
271
272 let shell_mismatch = if cfg!(windows) && !raw {
273 shell_mismatch_hint(&command, &result_out)
274 } else {
275 String::new()
276 };
277
278 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
279 let exit_suffix = match exit_code {
283 0 => String::new(),
284 124 => "\n[exit:124 — command timed out]".to_string(),
285 _ => format!("\n[exit:{exit_code}]"),
286 };
287 let nudge = if raw { "" } else { search_tool_nudge(&command) };
288 let final_out = format!(
289 "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
290 );
291
292 Ok(ToolOutput {
293 text: final_out,
294 original_tokens: original,
295 saved_tokens: saved,
296 mode,
297 path: None,
298 changed: false,
299 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
300 content_blocks: None,
301 })
302 })
303 }
304}
305
306#[allow(clippy::fn_params_excessive_bools)]
307fn resolve_shell_raw_flags(
308 arg_raw: bool,
309 arg_bypass: bool,
310 env_disabled: bool,
311 env_raw: bool,
312) -> (bool, bool) {
313 let bypass = arg_bypass || env_raw;
314 let raw = arg_raw || bypass || env_disabled;
315 (raw, bypass)
316}
317
318fn search_tool_nudge(command: &str) -> &'static str {
319 let cmd = command.trim();
320 let first_word = cmd.split_whitespace().next().unwrap_or("");
321 if !cmd.contains('|') {
322 match first_word {
323 "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
324 return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
325 }
326 "find" => {
327 return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
328 }
329 "ls" | "exa" | "eza" => {
330 return "\n[hint: use ctx_tree for structured directory listing]";
331 }
332 _ => {}
333 }
334 }
335 ""
336}
337
338fn shell_mismatch_hint(command: &str, output: &str) -> String {
339 let shell = crate::shell::shell_name();
340 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
341 let has_error = output.contains("is not recognized")
342 || output.contains("not found")
343 || output.contains("command not found");
344
345 if !has_error {
346 return String::new();
347 }
348
349 let powershell_cmds = [
350 "Get-Content",
351 "Select-Object",
352 "Get-ChildItem",
353 "Set-Location",
354 "Where-Object",
355 "ForEach-Object",
356 "Select-String",
357 "Invoke-Expression",
358 "Write-Output",
359 ];
360 let uses_powershell = powershell_cmds
361 .iter()
362 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
363
364 if is_posix && uses_powershell {
365 format!(
366 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
367 )
368 } else {
369 String::new()
370 }
371}
372
373fn is_dangerous_env_key(key: &str) -> bool {
374 const BLOCKED: &[&str] = &[
375 "LD_PRELOAD",
377 "LD_LIBRARY_PATH",
378 "DYLD_INSERT_LIBRARIES",
379 "DYLD_LIBRARY_PATH",
380 "DYLD_FRAMEWORK_PATH",
381 "BASH_ENV",
383 "ENV",
384 "PROMPT_COMMAND",
385 "SHELL",
386 "IFS",
387 "CDPATH",
388 "PATH",
390 "GIT_EXEC_PATH",
391 "GIT_SSH",
392 "GIT_SSH_COMMAND",
393 "HOME",
395 "USER",
396 "LOGNAME",
397 "XDG_CONFIG_HOME",
398 "XDG_DATA_HOME",
399 "XDG_STATE_HOME",
400 "XDG_CACHE_HOME",
401 "PYTHONPATH",
403 "PYTHONSTARTUP",
404 "PYTHONHOME",
405 "NODE_PATH",
406 "NODE_OPTIONS",
407 "RUBYOPT",
408 "RUBYLIB",
409 "GEM_PATH",
410 "GEM_HOME",
411 "PERL5LIB",
412 "PERL5OPT",
413 "CLASSPATH",
414 "JAVA_HOME",
415 "CARGO_HOME",
416 "RUSTUP_HOME",
417 "GOPATH",
418 "GOROOT",
419 ];
420 let upper = key.to_uppercase();
421 if BLOCKED.contains(&upper.as_str()) {
422 return true;
423 }
424 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
425 return true;
426 }
427 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
429 return true;
430 }
431 false
432}
433
434fn warn_shell_secret_paths(command: &str) {
437 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
438 let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
439 for seg in &segments {
440 let trimmed = seg.trim();
441 let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
442 if tokens.is_empty() {
443 continue;
444 }
445 let base = tokens[0]
446 .rsplit('/')
447 .next()
448 .unwrap_or(&tokens[0])
449 .to_string();
450 if !READ_CMDS.contains(&base.as_str()) {
451 continue;
452 }
453 for tok in &tokens[1..] {
454 if tok.starts_with('-') {
455 continue;
456 }
457 let path = std::path::Path::new(tok.as_str());
458 if crate::core::io_boundary::is_secret_like(path).is_some() {
459 tracing::warn!(
460 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
461 );
462 }
463 }
464 }
465}
466
467fn redact_shell_output_secrets(output: &str) -> String {
468 let cfg = crate::core::config::Config::load();
469 if !cfg.secret_detection.enabled {
470 return output.to_string();
471 }
472 let (redacted, matches) =
473 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
474 if !matches.is_empty() {
475 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
476 tracing::warn!(
477 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
478 matches.len(),
479 names.join(", ")
480 );
481 }
482 redacted
483}
484
485fn detect_bare_cat_file(command: &str) -> Option<String> {
487 let trimmed = command.trim();
488 let rest = trimmed.strip_prefix("cat ")?;
489 let rest = rest.trim();
490 if rest.is_empty()
491 || rest.contains('|')
492 || rest.contains('>')
493 || rest.contains('<')
494 || rest.contains(';')
495 || rest.contains('&')
496 || rest.contains('$')
497 || rest.starts_with('-')
498 {
499 return None;
500 }
501 let parts: Vec<&str> = rest.split_whitespace().collect();
502 if parts.len() != 1 {
503 return None;
504 }
505 let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
506 if file_path.is_empty() {
507 return None;
508 }
509 Some(file_path.to_string())
510}