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