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 tokio::task::block_in_place(|| {
49 let session_lock = ctx
50 .session
51 .as_ref()
52 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
53
54 let explicit_cwd = get_str(args, "cwd");
55 let effective_cwd = {
56 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
57 match guard {
58 Some(session) => session.effective_cwd(explicit_cwd.as_deref()),
59 None => explicit_cwd.unwrap_or_else(|| ".".to_string()),
60 }
61 };
62
63 {
64 let Some(mut session) =
65 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
66 else {
67 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
68 let cmd_clone = command.clone();
69 let cwd_clone = effective_cwd.clone();
70 let extra_env: std::collections::HashMap<String, String> = args
71 .get("env")
72 .and_then(|v| v.as_object())
73 .map(|obj| {
74 obj.iter()
75 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
76 .filter(|(k, _)| !is_dangerous_env_key(k))
77 .collect()
78 })
79 .unwrap_or_default();
80 let (output, _exit_code) = crate::server::execute::execute_command_with_env(
81 &cmd_clone, &cwd_clone, &extra_env,
82 );
83 return Ok(ToolOutput::simple(output));
84 };
85 session.update_shell_cwd(&command);
86 let root_missing = session
87 .project_root
88 .as_deref()
89 .is_none_or(|r| r.trim().is_empty());
90 if root_missing {
91 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
92 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd) {
93 if home.as_deref() != Some(root.as_str()) {
94 session.project_root = Some(root.clone());
95 crate::core::index_orchestrator::ensure_all_background(&root);
96 }
97 }
98 }
99 }
100
101 let arg_raw = get_bool(args, "raw").unwrap_or(false);
102 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
103 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
104 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
105 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
106
107 let crp_mode = ctx.crp_mode;
108 let cmd_clone = command.clone();
109 let cwd_clone = effective_cwd;
110
111 let extra_env: std::collections::HashMap<String, String> = args
112 .get("env")
113 .and_then(|v| v.as_object())
114 .map(|obj| {
115 obj.iter()
116 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
117 .filter(|(k, _)| !is_dangerous_env_key(k))
118 .collect()
119 })
120 .unwrap_or_default();
121
122 let (output, exit_code) = crate::server::execute::execute_command_with_env(
123 &cmd_clone, &cwd_clone, &extra_env,
124 );
125
126 let (result_out, original, saved, tee_hint) = if raw {
127 let tokens = crate::core::tokens::count_tokens(&output);
128 (output, tokens, 0, String::new())
129 } else {
130 let result = crate::tools::ctx_shell::handle(&cmd_clone, &output, crp_mode);
131 let original = crate::core::tokens::count_tokens(&output);
132 let sent = crate::core::tokens::count_tokens(&result);
133 let saved = original.saturating_sub(sent);
134
135 let cfg = crate::core::config::Config::load();
136 let savings_pct = if original > 0 {
137 ((original.saturating_sub(sent)) as f64 / original as f64) * 100.0
138 } else {
139 0.0
140 };
141 let tee_hint = match cfg.tee_mode {
142 crate::core::config::TeeMode::Always => {
143 crate::shell::save_tee(&cmd_clone, &output)
144 .map(|p| format!("\n[full output: {p}]"))
145 .unwrap_or_default()
146 }
147 crate::core::config::TeeMode::Failures
148 if !output.trim().is_empty()
149 && (output.contains("error")
150 || output.contains("Error")
151 || output.contains("ERROR")) =>
152 {
153 crate::shell::save_tee(&cmd_clone, &output)
154 .map(|p| format!("\n[full output: {p}]"))
155 .unwrap_or_default()
156 }
157 crate::core::config::TeeMode::HighCompression
158 if savings_pct > 70.0 && original > 100 =>
159 {
160 crate::shell::save_tee(&cmd_clone, &output)
161 .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
162 .unwrap_or_default()
163 }
164 _ => {
165 if savings_pct > 70.0
166 && original > 100
167 && matches!(cfg.tee_mode, crate::core::config::TeeMode::Failures)
168 {
169 crate::shell::save_tee(&cmd_clone, &output)
170 .map(|p| format!("\n[compressed {savings_pct:.0}%: full output at {p} if needed]"))
171 .unwrap_or_default()
172 } else {
173 String::new()
174 }
175 }
176 };
177
178 (result, original, saved, tee_hint)
179 };
180
181 let mode = if bypass {
182 Some("bypass".to_string())
183 } else if raw {
184 Some("raw".to_string())
185 } else {
186 None
187 };
188
189 let shell_mismatch = if cfg!(windows) && !raw {
190 shell_mismatch_hint(&command, &result_out)
191 } else {
192 String::new()
193 };
194
195 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
196 let exit_suffix = if exit_code != 0 {
197 format!("\n[exit:{exit_code}]")
198 } else {
199 String::new()
200 };
201 let final_out = format!("{result_out}{tee_hint}{shell_mismatch}{exit_suffix}");
202
203 Ok(ToolOutput {
204 text: final_out,
205 original_tokens: original,
206 saved_tokens: saved,
207 mode,
208 path: None,
209 changed: false,
210 })
211 })
212 }
213}
214
215#[allow(clippy::fn_params_excessive_bools)]
216fn resolve_shell_raw_flags(
217 arg_raw: bool,
218 arg_bypass: bool,
219 env_disabled: bool,
220 env_raw: bool,
221) -> (bool, bool) {
222 let bypass = arg_bypass || env_raw;
223 let raw = arg_raw || bypass || env_disabled;
224 (raw, bypass)
225}
226
227fn shell_mismatch_hint(command: &str, output: &str) -> String {
228 let shell = crate::shell::shell_name();
229 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
230 let has_error = output.contains("is not recognized")
231 || output.contains("not found")
232 || output.contains("command not found");
233
234 if !has_error {
235 return String::new();
236 }
237
238 let powershell_cmds = [
239 "Get-Content",
240 "Select-Object",
241 "Get-ChildItem",
242 "Set-Location",
243 "Where-Object",
244 "ForEach-Object",
245 "Select-String",
246 "Invoke-Expression",
247 "Write-Output",
248 ];
249 let uses_powershell = powershell_cmds
250 .iter()
251 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
252
253 if is_posix && uses_powershell {
254 format!(
255 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
256 )
257 } else {
258 String::new()
259 }
260}
261
262fn is_dangerous_env_key(key: &str) -> bool {
263 const BLOCKED: &[&str] = &[
264 "LD_PRELOAD",
265 "LD_LIBRARY_PATH",
266 "DYLD_INSERT_LIBRARIES",
267 "DYLD_LIBRARY_PATH",
268 "DYLD_FRAMEWORK_PATH",
269 "BASH_ENV",
270 "ENV",
271 "PROMPT_COMMAND",
272 "SHELL",
273 "IFS",
274 "CDPATH",
275 ];
276 let upper = key.to_uppercase();
277 BLOCKED.contains(&upper.as_str()) || upper.starts_with("LD_") && upper.ends_with("_PATH")
278}