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