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 ..ToolOutput::simple(rejection)
59 });
60 }
61
62 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
63 return Ok(ToolOutput {
64 shell_outcome: Some(ShellOutcome::Blocked),
65 ..ToolOutput::simple(msg.to_string())
66 });
67 }
68
69 warn_shell_secret_paths(&command);
70
71 tokio::task::block_in_place(|| {
72 let session_lock = ctx
73 .session
74 .as_ref()
75 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
76
77 let explicit_cwd = get_str(args, "cwd");
78 let had_explicit_cwd = explicit_cwd.is_some();
79 let (effective_cwd, cwd_jail_reason) = {
80 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
81 match guard {
82 Some(session) => session.effective_cwd_checked(explicit_cwd.as_deref()),
83 None => (explicit_cwd.unwrap_or_else(|| ".".to_string()), None),
84 }
85 };
86 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
91 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
92 format!(
93 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
94 )
95 });
96
97 {
98 let Some(mut session) =
99 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
100 else {
101 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
102 let cmd_clone = command.clone();
103 let cwd_clone = effective_cwd.clone();
104 let extra_env: std::collections::HashMap<String, String> = args
105 .get("env")
106 .and_then(|v| v.as_object())
107 .map(|obj| {
108 obj.iter()
109 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
110 .filter(|(k, _)| !is_dangerous_env_key(k))
111 .collect()
112 })
113 .unwrap_or_default();
114 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
115 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
116 );
117 let output = redact_shell_output_secrets(&raw_output);
118 let exit_suffix = match exit_code {
121 0 => String::new(),
122 124 => "\n[exit:124 — command timed out]".to_string(),
123 _ => format!("\n[exit:{exit_code}]"),
124 };
125 return Ok(ToolOutput {
126 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
127 ..ToolOutput::simple(format!("{output}{exit_suffix}"))
128 });
129 };
130 if had_explicit_cwd && cwd_jail_reason_was_none {
135 session.note_explicit_cwd(&effective_cwd);
136 }
137 session.update_shell_cwd(&command);
138 let root_missing = session
139 .project_root
140 .as_deref()
141 .is_none_or(|r| r.trim().is_empty());
142 if root_missing {
143 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
144 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
145 && home.as_deref() != Some(root.as_str())
146 {
147 session.project_root = Some(root.clone());
148 crate::core::index_orchestrator::ensure_all_background(&root);
149 }
150 }
151 }
152
153 let arg_raw = get_bool(args, "raw").unwrap_or(false);
154 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
155 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
156 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
157 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
158
159 let crp_mode = ctx.crp_mode;
160 let cmd_clone = command.clone();
161 let cwd_clone = effective_cwd;
162
163 let extra_env: std::collections::HashMap<String, String> = args
164 .get("env")
165 .and_then(|v| v.as_object())
166 .map(|obj| {
167 obj.iter()
168 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
169 .filter(|(k, _)| !is_dangerous_env_key(k))
170 .collect()
171 })
172 .unwrap_or_default();
173
174 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
175 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
176 );
177
178 crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
180
181 let output = redact_shell_output_secrets(&raw_output);
182
183 let (result_out, original, saved, tee_hint) = if raw {
184 let tokens = crate::core::tokens::count_tokens(&output);
185 (output, tokens, 0, String::new())
186 } else {
187 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
188 let result =
189 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
190 let original = crate::core::tokens::count_tokens(&output);
191 let sent = crate::core::tokens::count_tokens(&result);
192 let saved = original.saturating_sub(sent);
193
194 let cfg = crate::core::config::Config::load();
195 let tee_hint = if crate::shell::tee_policy::should_tee(
198 &cfg.tee_mode,
199 exit_code,
200 output.trim().is_empty(),
201 original,
202 sent,
203 ) {
204 crate::shell::save_tee(&cmd_clone, &output)
205 .map(|p| {
206 if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
207 {
208 let pct = crate::shell::tee_policy::savings_pct(original, sent);
209 format!(
215 "\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]"
216 )
217 } else {
218 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
219 }
220 })
221 .unwrap_or_default()
222 } else {
223 String::new()
224 };
225
226 (result, original, saved, tee_hint)
227 };
228
229 let mode = if bypass {
230 Some("bypass".to_string())
231 } else if raw {
232 Some("raw".to_string())
233 } else {
234 None
235 };
236
237 let shell_mismatch = if cfg!(windows) && !raw {
238 shell_mismatch_hint(&command, &result_out)
239 } else {
240 String::new()
241 };
242
243 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
244 let exit_suffix = match exit_code {
248 0 => String::new(),
249 124 => "\n[exit:124 — command timed out]".to_string(),
250 _ => format!("\n[exit:{exit_code}]"),
251 };
252 let final_out =
253 format!("{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{exit_suffix}");
254
255 Ok(ToolOutput {
256 text: final_out,
257 original_tokens: original,
258 saved_tokens: saved,
259 mode,
260 path: None,
261 changed: false,
262 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
263 })
264 })
265 }
266}
267
268#[allow(clippy::fn_params_excessive_bools)]
269fn resolve_shell_raw_flags(
270 arg_raw: bool,
271 arg_bypass: bool,
272 env_disabled: bool,
273 env_raw: bool,
274) -> (bool, bool) {
275 let bypass = arg_bypass || env_raw;
276 let raw = arg_raw || bypass || env_disabled;
277 (raw, bypass)
278}
279
280fn shell_mismatch_hint(command: &str, output: &str) -> String {
281 let shell = crate::shell::shell_name();
282 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
283 let has_error = output.contains("is not recognized")
284 || output.contains("not found")
285 || output.contains("command not found");
286
287 if !has_error {
288 return String::new();
289 }
290
291 let powershell_cmds = [
292 "Get-Content",
293 "Select-Object",
294 "Get-ChildItem",
295 "Set-Location",
296 "Where-Object",
297 "ForEach-Object",
298 "Select-String",
299 "Invoke-Expression",
300 "Write-Output",
301 ];
302 let uses_powershell = powershell_cmds
303 .iter()
304 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
305
306 if is_posix && uses_powershell {
307 format!(
308 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
309 )
310 } else {
311 String::new()
312 }
313}
314
315fn is_dangerous_env_key(key: &str) -> bool {
316 const BLOCKED: &[&str] = &[
317 "LD_PRELOAD",
319 "LD_LIBRARY_PATH",
320 "DYLD_INSERT_LIBRARIES",
321 "DYLD_LIBRARY_PATH",
322 "DYLD_FRAMEWORK_PATH",
323 "BASH_ENV",
325 "ENV",
326 "PROMPT_COMMAND",
327 "SHELL",
328 "IFS",
329 "CDPATH",
330 "PATH",
332 "GIT_EXEC_PATH",
333 "GIT_SSH",
334 "GIT_SSH_COMMAND",
335 "HOME",
337 "USER",
338 "LOGNAME",
339 "XDG_CONFIG_HOME",
340 "XDG_DATA_HOME",
341 "XDG_STATE_HOME",
342 "XDG_CACHE_HOME",
343 "PYTHONPATH",
345 "PYTHONSTARTUP",
346 "PYTHONHOME",
347 "NODE_PATH",
348 "NODE_OPTIONS",
349 "RUBYOPT",
350 "RUBYLIB",
351 "GEM_PATH",
352 "GEM_HOME",
353 "PERL5LIB",
354 "PERL5OPT",
355 "CLASSPATH",
356 "JAVA_HOME",
357 "CARGO_HOME",
358 "RUSTUP_HOME",
359 "GOPATH",
360 "GOROOT",
361 ];
362 let upper = key.to_uppercase();
363 if BLOCKED.contains(&upper.as_str()) {
364 return true;
365 }
366 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
367 return true;
368 }
369 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
371 return true;
372 }
373 false
374}
375
376fn warn_shell_secret_paths(command: &str) {
379 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
380 let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
381 for seg in &segments {
382 let trimmed = seg.trim();
383 let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
384 if tokens.is_empty() {
385 continue;
386 }
387 let base = tokens[0]
388 .rsplit('/')
389 .next()
390 .unwrap_or(&tokens[0])
391 .to_string();
392 if !READ_CMDS.contains(&base.as_str()) {
393 continue;
394 }
395 for tok in &tokens[1..] {
396 if tok.starts_with('-') {
397 continue;
398 }
399 let path = std::path::Path::new(tok.as_str());
400 if crate::core::io_boundary::is_secret_like(path).is_some() {
401 tracing::warn!(
402 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
403 );
404 }
405 }
406 }
407}
408
409fn redact_shell_output_secrets(output: &str) -> String {
410 let cfg = crate::core::config::Config::load();
411 if !cfg.secret_detection.enabled {
412 return output.to_string();
413 }
414 let (redacted, matches) =
415 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
416 if !matches.is_empty() {
417 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
418 tracing::warn!(
419 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
420 matches.len(),
421 names.join(", ")
422 );
423 }
424 redacted
425}