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