lean_ctx/tools/registered/
ctx_shell.rs1use rmcp::model::Tool;
2use rmcp::ErrorData;
3use serde_json::{json, Map, Value};
4
5use crate::server::tool_trait::{get_bool, get_str, McpTool, ToolContext, ToolOutput};
6use crate::tool_defs::tool_def;
7
8pub struct CtxShellTool;
9
10impl McpTool for CtxShellTool {
11 fn name(&self) -> &'static str {
12 "ctx_shell"
13 }
14
15 fn tool_def(&self) -> Tool {
16 tool_def(
17 "ctx_shell",
18 "Run shell command (compressed output, 95+ patterns). Use raw=true to skip compression. cwd sets working directory (persists across calls via cd tracking). Output redaction is on by default for non-admin roles (admin can disable).",
19 json!({
20 "type": "object",
21 "properties": {
22 "command": { "type": "string", "description": "Shell command to execute" },
23 "raw": { "type": "boolean", "description": "Skip compression, return full uncompressed output. Redaction still applies by default for non-admin roles." },
24 "cwd": { "type": "string", "description": "Working directory for the command. If omitted, uses last cd target or project root." },
25 "env": { "type": "object", "description": "Additional environment variables to set for the command. Use to pass agent runtime vars (e.g. CODEX_THREAD_ID).", "additionalProperties": { "type": "string" } }
26 },
27 "required": ["command"]
28 }),
29 )
30 }
31
32 fn handle(
33 &self,
34 args: &Map<String, Value>,
35 ctx: &ToolContext,
36 ) -> Result<ToolOutput, ErrorData> {
37 let command = get_str(args, "command")
38 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
39
40 if let Some(rejection) = crate::tools::ctx_shell::validate_command(&command) {
41 return Ok(ToolOutput::simple(rejection));
42 }
43
44 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
45 return Ok(ToolOutput::simple(msg));
46 }
47
48 warn_shell_secret_paths(&command);
49
50 tokio::task::block_in_place(|| {
51 let session_lock = ctx
52 .session
53 .as_ref()
54 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
55
56 let explicit_cwd = get_str(args, "cwd");
57 let effective_cwd = {
58 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
59 match guard {
60 Some(session) => session.effective_cwd(explicit_cwd.as_deref()),
61 None => explicit_cwd.unwrap_or_else(|| ".".to_string()),
62 }
63 };
64
65 {
66 let Some(mut session) =
67 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
68 else {
69 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
70 let cmd_clone = command.clone();
71 let cwd_clone = effective_cwd.clone();
72 let extra_env: std::collections::HashMap<String, String> = args
73 .get("env")
74 .and_then(|v| v.as_object())
75 .map(|obj| {
76 obj.iter()
77 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
78 .filter(|(k, _)| !is_dangerous_env_key(k))
79 .collect()
80 })
81 .unwrap_or_default();
82 let (raw_output, _exit_code) = crate::server::execute::execute_command_with_env(
83 &cmd_clone, &cwd_clone, &extra_env,
84 );
85 let output = redact_shell_output_secrets(&raw_output);
86 return Ok(ToolOutput::simple(output));
87 };
88 session.update_shell_cwd(&command);
89 let root_missing = session
90 .project_root
91 .as_deref()
92 .is_none_or(|r| r.trim().is_empty());
93 if root_missing {
94 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
95 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd) {
96 if home.as_deref() != Some(root.as_str()) {
97 session.project_root = Some(root.clone());
98 crate::core::index_orchestrator::ensure_all_background(&root);
99 }
100 }
101 }
102 }
103
104 let arg_raw = get_bool(args, "raw").unwrap_or(false);
105 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
106 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
107 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
108 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
109
110 let crp_mode = ctx.crp_mode;
111 let cmd_clone = command.clone();
112 let cwd_clone = effective_cwd;
113
114 let extra_env: std::collections::HashMap<String, String> = args
115 .get("env")
116 .and_then(|v| v.as_object())
117 .map(|obj| {
118 obj.iter()
119 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
120 .filter(|(k, _)| !is_dangerous_env_key(k))
121 .collect()
122 })
123 .unwrap_or_default();
124
125 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
126 &cmd_clone, &cwd_clone, &extra_env,
127 );
128
129 let output = redact_shell_output_secrets(&raw_output);
130
131 let (result_out, original, saved, tee_hint) = if raw {
132 let tokens = crate::core::tokens::count_tokens(&output);
133 (output, tokens, 0, String::new())
134 } else {
135 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
136 let result = crate::tools::ctx_shell::handle(&cmd_clone, &output, crp_mode);
137 let original = crate::core::tokens::count_tokens(&output);
138 let sent = crate::core::tokens::count_tokens(&result);
139 let saved = original.saturating_sub(sent);
140
141 let cfg = crate::core::config::Config::load();
142 let savings_pct = if original > 0 {
143 ((original.saturating_sub(sent)) as f64 / original as f64) * 100.0
144 } else {
145 0.0
146 };
147 let tee_hint = match cfg.tee_mode {
148 crate::core::config::TeeMode::Always => {
149 crate::shell::save_tee(&cmd_clone, &output)
150 .map(|p| format!("\n[full output: {p}]"))
151 .unwrap_or_default()
152 }
153 crate::core::config::TeeMode::Failures
154 if !output.trim().is_empty()
155 && (output.contains("error")
156 || output.contains("Error")
157 || output.contains("ERROR")) =>
158 {
159 crate::shell::save_tee(&cmd_clone, &output)
160 .map(|p| format!("\n[full output: {p}]"))
161 .unwrap_or_default()
162 }
163 crate::core::config::TeeMode::HighCompression
164 if savings_pct > 70.0 && original > 100 =>
165 {
166 crate::shell::save_tee(&cmd_clone, &output)
167 .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
168 .unwrap_or_default()
169 }
170 _ => {
171 if savings_pct > 70.0
172 && original > 100
173 && matches!(cfg.tee_mode, crate::core::config::TeeMode::Failures)
174 {
175 crate::shell::save_tee(&cmd_clone, &output)
176 .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
177 .unwrap_or_default()
178 } else {
179 String::new()
180 }
181 }
182 };
183
184 (result, original, saved, tee_hint)
185 };
186
187 let mode = if bypass {
188 Some("bypass".to_string())
189 } else if raw {
190 Some("raw".to_string())
191 } else {
192 None
193 };
194
195 let shell_mismatch = if cfg!(windows) && !raw {
196 shell_mismatch_hint(&command, &result_out)
197 } else {
198 String::new()
199 };
200
201 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
202 let exit_suffix = if exit_code != 0 {
203 format!("\n[exit:{exit_code}]")
204 } else {
205 String::new()
206 };
207 let final_out = format!("{result_out}{tee_hint}{shell_mismatch}{exit_suffix}");
208
209 Ok(ToolOutput {
210 text: final_out,
211 original_tokens: original,
212 saved_tokens: saved,
213 mode,
214 path: None,
215 changed: false,
216 })
217 })
218 }
219}
220
221#[allow(clippy::fn_params_excessive_bools)]
222fn resolve_shell_raw_flags(
223 arg_raw: bool,
224 arg_bypass: bool,
225 env_disabled: bool,
226 env_raw: bool,
227) -> (bool, bool) {
228 let bypass = arg_bypass || env_raw;
229 let raw = arg_raw || bypass || env_disabled;
230 (raw, bypass)
231}
232
233fn shell_mismatch_hint(command: &str, output: &str) -> String {
234 let shell = crate::shell::shell_name();
235 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
236 let has_error = output.contains("is not recognized")
237 || output.contains("not found")
238 || output.contains("command not found");
239
240 if !has_error {
241 return String::new();
242 }
243
244 let powershell_cmds = [
245 "Get-Content",
246 "Select-Object",
247 "Get-ChildItem",
248 "Set-Location",
249 "Where-Object",
250 "ForEach-Object",
251 "Select-String",
252 "Invoke-Expression",
253 "Write-Output",
254 ];
255 let uses_powershell = powershell_cmds
256 .iter()
257 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
258
259 if is_posix && uses_powershell {
260 format!(
261 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
262 )
263 } else {
264 String::new()
265 }
266}
267
268fn is_dangerous_env_key(key: &str) -> bool {
269 const BLOCKED: &[&str] = &[
270 "LD_PRELOAD",
272 "LD_LIBRARY_PATH",
273 "DYLD_INSERT_LIBRARIES",
274 "DYLD_LIBRARY_PATH",
275 "DYLD_FRAMEWORK_PATH",
276 "BASH_ENV",
278 "ENV",
279 "PROMPT_COMMAND",
280 "SHELL",
281 "IFS",
282 "CDPATH",
283 "PATH",
285 "GIT_EXEC_PATH",
286 "GIT_SSH",
287 "GIT_SSH_COMMAND",
288 "HOME",
290 "USER",
291 "LOGNAME",
292 "XDG_CONFIG_HOME",
293 "XDG_DATA_HOME",
294 "XDG_STATE_HOME",
295 "XDG_CACHE_HOME",
296 "PYTHONPATH",
298 "PYTHONSTARTUP",
299 "PYTHONHOME",
300 "NODE_PATH",
301 "NODE_OPTIONS",
302 "RUBYOPT",
303 "RUBYLIB",
304 "GEM_PATH",
305 "GEM_HOME",
306 "PERL5LIB",
307 "PERL5OPT",
308 "CLASSPATH",
309 "JAVA_HOME",
310 "CARGO_HOME",
311 "RUSTUP_HOME",
312 "GOPATH",
313 "GOROOT",
314 ];
315 let upper = key.to_uppercase();
316 if BLOCKED.contains(&upper.as_str()) {
317 return true;
318 }
319 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
320 return true;
321 }
322 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
324 return true;
325 }
326 false
327}
328
329fn warn_shell_secret_paths(command: &str) {
332 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
333 let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
334 for seg in &segments {
335 let trimmed = seg.trim();
336 let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
337 if tokens.is_empty() {
338 continue;
339 }
340 let base = tokens[0]
341 .rsplit('/')
342 .next()
343 .unwrap_or(&tokens[0])
344 .to_string();
345 if !READ_CMDS.contains(&base.as_str()) {
346 continue;
347 }
348 for tok in &tokens[1..] {
349 if tok.starts_with('-') {
350 continue;
351 }
352 let path = std::path::Path::new(tok.as_str());
353 if crate::core::io_boundary::is_secret_like(path).is_some() {
354 tracing::warn!(
355 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
356 );
357 }
358 }
359 }
360}
361
362fn redact_shell_output_secrets(output: &str) -> String {
364 let cfg = crate::core::config::Config::load();
365 if !cfg.secret_detection.enabled {
366 return output.to_string();
367 }
368 let (redacted, matches) =
369 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
370 if !matches.is_empty() {
371 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
372 tracing::warn!(
373 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
374 matches.len(),
375 names.join(", ")
376 );
377 }
378 redacted
379}