1use rmcp::ErrorData;
2use rmcp::model::Tool;
3use serde_json::{Map, Value, json};
4
5use crate::core::ocla::cache_types::{CacheKeyBuilder, ShellCommandKey};
6
7use crate::server::tool_trait::{
8 McpTool, ShellOutcome, ToolContext, ToolOutput, get_bool, get_int, get_str,
9};
10use crate::tool_defs::tool_def;
11
12pub struct CtxShellTool;
13
14impl McpTool for CtxShellTool {
15 fn name(&self) -> &'static str {
16 "ctx_shell"
17 }
18
19 fn tool_def(&self) -> Tool {
20 tool_def(
21 "ctx_shell",
22 "WORKFLOW: preferred — auto-compresses output (build/test/log).\n\
23 raw=true for verbatim output; inline=true for moderately-sized verbatim output.\n\
24 [exit:N] on errors (lossless).\n\
25 POLICY (by design): allowlisted read-only path; ctx_execute is the trusted script path.\n\
26 A [BLOCKED] command is permanent — escalate to ctx_execute(language=\"shell\"), do not retry here.\n\
27 ANTIPATTERN: multi-line scripts, sh/bash script.sh, $var-as-command → ctx_execute.",
28 json!({
29 "type": "object",
30 "properties": {
31 "command": { "type": "string", "description": "Shell command" },
32 "raw": { "type": "boolean", "description": "Skip compression (verbatim)" },
33 "inline": { "type": "boolean", "description": "Return verbatim output inline up to archive.inline_max_bytes; larger output uses the archive/firewall" },
34 "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
35 "timeout_ms": { "type": "integer", "description": "Job lifetime in ms (max 3600000) — NOT the inline wait. A command still running at the ~110s foreground cap detaches to a pollable shell_* job and keeps running up to timeout_ms. Overridden by LEAN_CTX_SHELL_TIMEOUT_MS." },
36 "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } },
37 "run_in_background": { "type": "boolean", "description": "Detach immediately and return a job id. The command keeps timeout_ms; poll or cancel with background_action and job_id." },
38 "background_action": { "type": "string", "enum": ["status", "cancel"], "description": "Inspect or cancel a background ctx_shell job." },
39 "job_id": { "type": "string", "description": "Job id returned by run_in_background." }
40 }
41 }),
42 )
43 }
44
45 fn handle(
46 &self,
47 args: &Map<String, Value>,
48 ctx: &ToolContext,
49 ) -> Result<ToolOutput, ErrorData> {
50 if let Some(message) = shell_access_denial(ctx) {
51 return Ok(ToolOutput {
52 shell_outcome: Some(ShellOutcome::Blocked),
53 content_blocks: None,
54 ..ToolOutput::simple(message)
55 });
56 }
57
58 if let Some(action) = get_str(args, "background_action") {
59 let id = get_str(args, "job_id").ok_or_else(|| {
60 ErrorData::invalid_params("job_id is required with background_action", None)
61 })?;
62 let is_cancel = action == "cancel";
63 let state = match action.as_str() {
64 "status" => crate::server::background_shell::status(&id),
65 "cancel" => crate::server::background_shell::cancel(&id),
66 _ => {
67 return Err(ErrorData::invalid_params(
68 "background_action must be status or cancel",
69 None,
70 ));
71 }
72 };
73 let (text, exit_code) = format_background_state(&id, is_cancel, state);
74 return Ok(ToolOutput {
75 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
76 content_blocks: None,
77 ..ToolOutput::simple(text)
78 });
79 }
80 let command = get_str(args, "command")
81 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
82 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
83
84 let config = crate::core::config::Config::load();
89 let write_allow_paths = config.shell_write_allow_paths_effective();
90 let project_root = crate::core::config::Config::find_project_root();
91 if !config.shell_allow_writes_effective()
92 && let Some(rejection) =
93 crate::tools::ctx_shell::validate_command_with_write_allow_paths(
94 &command,
95 &write_allow_paths,
96 project_root.as_deref(),
97 )
98 {
99 return Ok(ToolOutput {
102 shell_outcome: Some(ShellOutcome::Blocked),
103 content_blocks: None,
104 ..ToolOutput::simple(rejection)
105 });
106 }
107
108 if let Some((lang, code, remainder)) = detect_heredoc_reroute(&command) {
109 return tokio::task::block_in_place(|| {
110 handle_interpreter_heredoc_reroute(args, ctx, &lang, &code, remainder)
111 });
112 }
113
114 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&command) {
115 return Ok(ToolOutput {
116 shell_outcome: Some(ShellOutcome::Blocked),
117 content_blocks: None,
118 ..ToolOutput::simple(msg.to_string())
119 });
120 }
121
122 warn_shell_secret_paths(&command);
123
124 if let Some(read_path) = detect_bare_cat_file(&command)
128 && let Some(cache_lock) = ctx.cache.as_ref()
129 && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
130 {
131 let result = crate::tools::ctx_read::handle_with_task_resolved(
132 &mut cache,
133 &read_path,
134 "full",
135 crate::tools::CrpMode::Off,
136 None,
137 );
138 let note = format!(
139 "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
140 Use ctx_read(path=\"{read_path}\") directly next time.]"
141 );
142 let out = format!("{}{note}", result.content);
143 let sent = crate::core::tokens::count_tokens(&out);
144 return Ok(ToolOutput {
145 text: out,
146 original_tokens: sent,
147 saved_tokens: 0,
148 mode: Some("cat-redirect".to_string()),
149 path: Some(read_path),
150 changed: false,
151 shell_outcome: Some(ShellOutcome::Exit(0)),
152 content_blocks: None,
153 });
154 }
155
156 tokio::task::block_in_place(|| {
157 let session_lock = ctx
158 .session
159 .as_ref()
160 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
161
162 let explicit_cwd = get_str(args, "cwd");
163 let had_explicit_cwd = explicit_cwd.is_some();
164 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
165 let (effective_cwd, cwd_jail_reason) =
166 resolve_effective_cwd(guard, explicit_cwd.as_deref())?;
167 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
172 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
173 format!(
174 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
175 )
176 });
177
178 {
179 let Some(mut session) =
180 crate::server::bounded_lock::write(session_lock, "ctx_shell_write")
181 else {
182 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
183 let cmd_clone = command.clone();
184 let cwd_clone = effective_cwd.clone();
185 let extra_env: std::collections::HashMap<String, String> = args
186 .get("env")
187 .and_then(|v| v.as_object())
188 .map(|obj| {
189 obj.iter()
190 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
191 .filter(|(k, _)| !is_dangerous_env_key(k))
192 .collect()
193 })
194 .unwrap_or_default();
195 let (raw_output, exit_code) = crate::server::execute::execute_command_with_env(
196 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
197 );
198 let output = redact_shell_output_secrets(&raw_output);
199 let exit_suffix = match exit_code {
202 0 => String::new(),
203 124 => "\n[exit:124 — command timed out]".to_string(),
204 _ => format!("\n[exit:{exit_code}]"),
205 };
206 return Ok(ToolOutput {
207 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
208 content_blocks: None,
209 ..ToolOutput::simple(format!("{output}{exit_suffix}"))
210 });
211 };
212 if had_explicit_cwd && cwd_jail_reason_was_none {
217 session.note_explicit_cwd(&effective_cwd);
218 }
219 session.update_shell_cwd(&command);
220 let root_missing = session
221 .project_root
222 .as_deref()
223 .is_none_or(|r| r.trim().is_empty());
224 if root_missing {
225 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
226 if let Some(root) = crate::core::protocol::detect_project_root(&effective_cwd)
227 && home.as_deref() != Some(root.as_str())
228 {
229 session.project_root = Some(root.clone());
230 crate::core::index_orchestrator::ensure_all_background(&root);
231 }
232 }
233 }
234
235 let arg_raw = get_bool(args, "raw").unwrap_or(false);
236 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
237 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
238 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
239 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
240
241 let crp_mode = ctx.crp_mode;
242 let cmd_clone = command.clone();
243 let cwd_clone = effective_cwd;
244 let proactive_block = if raw
245 || !crate::core::profiles::active_profile()
246 .output_hints
247 .proactive_context()
248 {
249 None
250 } else {
251 crate::core::relevance_tracker::proactive_context(&format!(
252 "ctx_shell command={cmd_clone} cwd={cwd_clone}"
253 ))
254 };
255
256 let extra_env: std::collections::HashMap<String, String> = args
257 .get("env")
258 .and_then(|v| v.as_object())
259 .map(|obj| {
260 obj.iter()
261 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
262 .filter(|(k, _)| !is_dangerous_env_key(k))
263 .collect()
264 })
265 .unwrap_or_default();
266
267 let inline = get_bool(args, "inline").unwrap_or(false);
268 let shell_cache_key = shell_cache_key(&cmd_clone, &cwd_clone, &extra_env);
269 if !raw
270 && !inline
271 && crate::core::config::Config::load()
272 .cache
273 .shell_cache_enabled
274 && let Some(key) = shell_cache_key.as_ref()
275 && let Some(cached) = crate::core::ocla::shell_cache_allowlist::SHELL_RESULT_CACHE
276 .get(&key.cache_key())
277 .map(|r| r.clone())
278 && let Ok(value) = serde_json::from_str::<Value>(&cached)
279 && let (Some(text), Some(exit_code)) = (
280 value.get("text").and_then(Value::as_str),
281 value.get("exit_code").and_then(Value::as_i64),
282 )
283 {
284 return Ok(ToolOutput {
285 text: text.to_string(),
286 original_tokens: crate::core::tokens::count_tokens(text),
287 saved_tokens: 0,
288 mode: Some("cross-agent-cache".to_string()),
289 path: None,
290 changed: false,
291 shell_outcome: Some(ShellOutcome::Exit(exit_code as i32)),
292 content_blocks: None,
293 });
294 }
295
296 if let Some(ref key) = shell_cache_key {
298 let ck = key.cache_key();
299 let validator = key.validator();
300 if let Some(entry) =
301 crate::core::ocla::cache_delivery::check(&ck, &validator, "ctx_shell")
302 {
303 let stub = crate::core::ocla::cache_delivery::stub(&entry, "shell command");
304 return Ok(ToolOutput {
305 text: stub,
306 original_tokens: entry.token_count as usize,
307 saved_tokens: entry.token_count as usize,
308 mode: Some("cross-agent-cache".to_string()),
309 path: None,
310 changed: false,
311 shell_outcome: None,
312 content_blocks: None,
313 });
314 }
315 }
316
317 let auto_background = should_auto_background(&cmd_clone, timeout_ms);
318 if get_bool(args, "run_in_background").unwrap_or(false) || auto_background {
319 let job_id = crate::server::background_shell::start(
320 cmd_clone, cwd_clone, extra_env, timeout_ms,
321 );
322 let mode = if auto_background {
323 "auto-background"
324 } else {
325 "background"
326 };
327 return Ok(ToolOutput {
328 shell_outcome: Some(ShellOutcome::Exit(0)),
329 content_blocks: None,
330 ..ToolOutput::simple(format!(
331 "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
332 ))
333 });
334 }
335
336 let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
347 let progress_sender = ctx.progress_sender.clone();
348 let progress_label: String = cmd_clone.chars().take(60).collect();
349 let cap_secs = soft_cap.as_secs_f64();
350 let on_tick = |elapsed: std::time::Duration| {
351 #[allow(clippy::unwrap_or_default)]
352 if let Some(ref ps) = progress_sender
353 && let Some(sender) = ps
354 .lock()
355 .unwrap_or_else(std::sync::PoisonError::into_inner)
356 .as_ref()
357 {
358 sender.send(
359 elapsed.as_secs_f64(),
360 Some(cap_secs),
361 Some(format!(
362 "ctx_shell: {}s elapsed — {progress_label}",
363 elapsed.as_secs()
364 )),
365 );
366 }
367 };
368 let (raw_output, exit_code) =
369 match crate::server::background_shell::run_foreground_or_detach(
370 cmd_clone.clone(),
371 cwd_clone.clone(),
372 extra_env.clone(),
373 timeout_ms,
374 soft_cap,
375 Some(&on_tick),
376 ) {
377 crate::server::background_shell::ForegroundResult::Finished {
378 output,
379 exit_code,
380 } => (output, exit_code),
381 crate::server::background_shell::ForegroundResult::Detached { job_id } => {
382 return Ok(ToolOutput {
383 shell_outcome: Some(ShellOutcome::Exit(0)),
384 content_blocks: None,
385 ..ToolOutput::simple(format!(
389 "[auto-background:{job_id} still running — passed the {}s foreground cap, not an error; output is kept and delivered by ctx_shell(background_action=\"status\", job_id=\"{job_id}\"), or background_action=\"cancel\" to stop it]",
390 soft_cap.as_secs()
391 ))
392 });
393 }
394 };
395
396 crate::core::diagnostics_store::record_from_shell(&cmd_clone, &raw_output, exit_code);
398
399 let output = redact_shell_output_secrets(&raw_output);
400
401 let (result_out, original, saved, tee_hint) = if raw || inline {
402 let tokens = crate::core::tokens::count_tokens(&output);
403 (output, tokens, 0, String::new())
404 } else {
405 let _mode_guard = crate::core::savings_footer::ModeGuard::new("shell");
406 let result =
407 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
408 let original = crate::core::tokens::count_tokens(&output);
409 let sent = crate::core::tokens::count_tokens(&result);
410 let saved = original.saturating_sub(sent);
411
412 let cfg = crate::core::config::Config::load();
413 let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
416 let tee_hint = if crate::shell::tee_policy::should_tee(
417 &cfg.tee_mode,
418 exit_code,
419 output.trim().is_empty() || timeout_notice_only,
420 crate::shell::tee_policy::output_was_elided(&output, &result),
421 original,
422 sent,
423 ) {
424 crate::shell::save_tee(&cmd_clone, &output)
425 .map(|p| {
426 if matches!(cfg.tee_mode, crate::core::config::TeeMode::HighCompression)
427 {
428 let pct = crate::shell::tee_policy::savings_pct(original, sent);
429 format!(
432 "\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]"
433 )
434 } else {
435 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
436 }
437 })
438 .unwrap_or_default()
439 } else {
440 String::new()
441 };
442
443 (result, original, saved, tee_hint)
444 };
445
446 let mode = if bypass {
447 Some("bypass".to_string())
448 } else if raw {
449 Some("raw".to_string())
450 } else {
451 None
452 };
453
454 let shell_mismatch = if cfg!(windows) && !raw {
455 shell_mismatch_hint(&command, &result_out)
456 } else {
457 String::new()
458 };
459
460 let result_out = crate::core::redaction::redact_text_if_enabled(&result_out);
461 let exit_suffix = match exit_code {
465 0 => String::new(),
466 124 => "\n[exit:124 — command timed out]".to_string(),
467 _ => format!("\n[exit:{exit_code}]"),
468 };
469 let nudge = if raw { "" } else { search_tool_nudge(&command) };
470 let final_out = format!(
471 "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
472 );
473 let final_out = if let Some(block) = proactive_block {
474 format!("{final_out}{block}")
475 } else {
476 final_out
477 };
478
479 if !raw
480 && !inline
481 && crate::core::config::Config::load()
482 .cache
483 .shell_cache_enabled
484 && let Some(key) = shell_cache_key
485 {
486 let cached = json!({ "text": final_out, "exit_code": exit_code }).to_string();
487 crate::core::ocla::shell_cache_allowlist::SHELL_RESULT_CACHE
488 .insert(key.cache_key(), cached);
489 crate::core::ocla::cache_delivery::record(
491 key.cache_key(),
492 crate::core::ocla::cache_types::DeliveryKind::ShellCommand,
493 key.validator(),
494 None,
495 &final_out,
496 "ctx_shell",
497 );
498 }
499
500 Ok(ToolOutput {
501 text: final_out,
502 original_tokens: original,
503 saved_tokens: saved,
504 mode,
505 path: None,
506 changed: false,
507 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
508 content_blocks: None,
509 })
510 })
511 }
512}
513
514fn shell_cache_key(
515 command: &str,
516 cwd: &str,
517 env: &std::collections::HashMap<String, String>,
518) -> Option<ShellCommandKey> {
519 if !crate::core::ocla::shell_cache_allowlist::is_cacheable_command(command) {
520 return None;
521 }
522 let mut env_pairs = env.iter().collect::<Vec<_>>();
523 env_pairs.sort_unstable_by(|left, right| left.0.cmp(right.0));
524 let mut canonical_env = String::new();
525 for (name, value) in env_pairs {
526 canonical_env.push_str(name);
527 canonical_env.push('=');
528 canonical_env.push_str(value);
529 canonical_env.push('\n');
530 }
531 Some(ShellCommandKey {
532 command_normalized: crate::core::ocla::shell_cache_allowlist::normalize_command(command),
533 cwd: if std::path::Path::new(cwd).is_absolute() {
534 "$PROJECT_ROOT".to_string()
535 } else {
536 cwd.to_string()
537 },
538 env_hash: blake3::hash(canonical_env.as_bytes()).to_hex().to_string(),
539 })
540}
541
542fn shell_access_denial(ctx: &ToolContext) -> Option<String> {
545 if let Some(role) = ctx.client_role.as_deref()
546 && (role.eq_ignore_ascii_case("untrusted") || role.eq_ignore_ascii_case("readonly"))
547 {
548 return Some(format!(
549 "[SHELL ACCESS DENIED] ctx_shell is unavailable to MCP clients with role '{role}'."
550 ));
551 }
552
553 if ctx.shell_access == Some(false) {
554 return Some(
555 "[SHELL ACCESS DENIED] ctx_shell requires shell_access=true in the MCP session/request context."
556 .to_string(),
557 );
558 }
559
560 None
561}
562
563fn resolve_effective_cwd(
564 session: Option<tokio::sync::OwnedRwLockReadGuard<crate::core::session::SessionState>>,
565 explicit_cwd: Option<&str>,
566) -> Result<(String, Option<String>), ErrorData> {
567 match session {
568 Some(session) => Ok(session.effective_cwd_checked(explicit_cwd)),
569 None => Err(ErrorData::internal_error(
570 "session lock timeout — cannot validate working directory",
571 None,
572 )),
573 }
574}
575
576#[allow(clippy::fn_params_excessive_bools)]
577fn resolve_shell_raw_flags(
578 arg_raw: bool,
579 arg_bypass: bool,
580 env_disabled: bool,
581 env_raw: bool,
582) -> (bool, bool) {
583 let bypass = arg_bypass || env_raw;
584 let raw = arg_raw || bypass || env_disabled;
585 (raw, bypass)
586}
587
588fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
595 exit_code == 124
596 && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
597}
598
599fn search_tool_nudge(command: &str) -> &'static str {
600 let cmd = command.trim();
601 let first_word = cmd.split_whitespace().next().unwrap_or("");
602 if !cmd.contains('|') {
603 match first_word {
604 "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
605 return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
606 }
607 "find" => {
608 return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
609 }
610 "ls" | "exa" | "eza" => {
611 return "\n[hint: use ctx_tree for structured directory listing]";
612 }
613 _ => {}
614 }
615 }
616 ""
617}
618
619fn shell_mismatch_hint(command: &str, output: &str) -> String {
620 let shell = crate::shell::shell_name();
621 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
622 let has_error = output.contains("is not recognized")
623 || output.contains("not found")
624 || output.contains("command not found");
625
626 if !has_error {
627 return String::new();
628 }
629
630 let powershell_cmds = [
631 "Get-Content",
632 "Select-Object",
633 "Get-ChildItem",
634 "Set-Location",
635 "Where-Object",
636 "ForEach-Object",
637 "Select-String",
638 "Invoke-Expression",
639 "Write-Output",
640 ];
641 let uses_powershell = powershell_cmds
642 .iter()
643 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
644
645 if is_posix && uses_powershell {
646 format!(
647 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
648 )
649 } else {
650 String::new()
651 }
652}
653
654fn is_dangerous_env_key(key: &str) -> bool {
655 const BLOCKED: &[&str] = &[
656 "LD_PRELOAD",
658 "LD_LIBRARY_PATH",
659 "DYLD_INSERT_LIBRARIES",
660 "DYLD_LIBRARY_PATH",
661 "DYLD_FRAMEWORK_PATH",
662 "BASH_ENV",
664 "ENV",
665 "PROMPT_COMMAND",
666 "SHELL",
667 "IFS",
668 "CDPATH",
669 "PATH",
671 "GIT_EXEC_PATH",
672 "GIT_SSH",
673 "GIT_SSH_COMMAND",
674 "HOME",
676 "USER",
677 "LOGNAME",
678 "XDG_CONFIG_HOME",
679 "XDG_DATA_HOME",
680 "XDG_STATE_HOME",
681 "XDG_CACHE_HOME",
682 "PYTHONPATH",
684 "PYTHONSTARTUP",
685 "PYTHONHOME",
686 "NODE_PATH",
687 "NODE_OPTIONS",
688 "RUBYOPT",
689 "RUBYLIB",
690 "GEM_PATH",
691 "GEM_HOME",
692 "PERL5LIB",
693 "PERL5OPT",
694 "CLASSPATH",
695 "JAVA_HOME",
696 "CARGO_HOME",
697 "RUSTUP_HOME",
698 "GOPATH",
699 "GOROOT",
700 ];
701 let upper = key.to_uppercase();
702 if BLOCKED.contains(&upper.as_str()) {
703 return true;
704 }
705 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
706 return true;
707 }
708 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
710 return true;
711 }
712 false
713}
714
715fn warn_shell_secret_paths(command: &str) {
718 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
719 let segments = crate::core::shell_allowlist::extract_all_commands_pub(command);
720 for seg in &segments {
721 let trimmed = seg.trim();
722 let tokens = crate::core::shell_allowlist::shell_tokenize(trimmed);
723 if tokens.is_empty() {
724 continue;
725 }
726 let base = tokens[0]
727 .rsplit('/')
728 .next()
729 .unwrap_or(&tokens[0])
730 .to_string();
731 if !READ_CMDS.contains(&base.as_str()) {
732 continue;
733 }
734 for tok in &tokens[1..] {
735 if tok.starts_with('-') {
736 continue;
737 }
738 let path = std::path::Path::new(tok.as_str());
739 if crate::core::io_boundary::is_secret_like(path).is_some() {
740 tracing::warn!(
741 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
742 );
743 }
744 }
745 }
746}
747
748fn format_background_state(
758 id: &str,
759 is_cancel: bool,
760 state: Option<crate::server::background_shell::JobState>,
761) -> (String, i32) {
762 use crate::server::background_shell::JobState;
763 let Some(state) = state else {
764 return if is_cancel {
765 (
766 format!("[background:{id} not found — already finished or cancelled]"),
767 0,
768 )
769 } else {
770 (format!("[background:{id} not found]"), 1)
771 };
772 };
773 match state {
774 JobState::Running { output } => {
775 let body = redact_shell_output_secrets(&output);
779 let head = if is_cancel {
780 format!(
781 "[background:{id} cancel requested — job is stopping; poll status for the final output]"
782 )
783 } else {
784 format!("[background:{id} running]")
785 };
786 if body.trim().is_empty() {
787 (head, 0)
788 } else {
789 (format!("{head}\n{body}"), 0)
790 }
791 }
792 JobState::Completed { output, exit_code } => (
793 format!(
794 "[background:{id} completed]\n{}{}",
795 redact_shell_output_secrets(&output),
796 if exit_code == 0 {
797 String::new()
798 } else {
799 format!("\n[exit:{exit_code}]")
800 }
801 ),
802 if is_cancel { 0 } else { exit_code },
803 ),
804 JobState::Cancelled { output } => (
805 format!(
806 "[background:{id} cancelled]\n{}\n[cancelled: {id}, exit 130]",
807 redact_shell_output_secrets(&output)
808 ),
809 0,
810 ),
811 }
812}
813
814fn redact_shell_output_secrets(output: &str) -> String {
815 let cfg = crate::core::config::Config::load();
816 if !cfg.secret_detection.enabled {
817 return output.to_string();
818 }
819 let (redacted, matches) =
820 crate::core::secret_detection::scan_and_redact(output, &cfg.secret_detection);
821 if !matches.is_empty() {
822 let names: Vec<&str> = matches.iter().map(|m| m.pattern_name).collect();
823 tracing::warn!(
824 "[SHELL SECRET REDACTION] {} secret(s) redacted from shell output: {}",
825 matches.len(),
826 names.join(", ")
827 );
828 }
829 redacted
830}
831
832fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
837 timeout_ms.is_some_and(|timeout| timeout >= 300_000)
838 && command
839 .lines()
840 .any(|line| line.trim_start().starts_with("cargo test"))
841}
842
843fn foreground_soft_cap_ms() -> u64 {
848 std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
849 .ok()
850 .and_then(|v| v.parse().ok())
851 .filter(|&ms| ms > 0)
852 .unwrap_or(110_000)
853}
854
855fn interpreter_to_execute_language(base: &str) -> Option<&'static str> {
857 match base {
858 "python" | "python2" | "python3" => Some("python"),
859 "node" => Some("javascript"),
860 "ruby" => Some("ruby"),
861 _ => None,
862 }
863}
864
865fn is_env_assignment_token(token: &str) -> bool {
866 let unquoted: String = token.chars().filter(|c| *c != '"' && *c != '\'').collect();
867 unquoted.contains('=')
868 && !unquoted.starts_with('-')
869 && !unquoted.starts_with('/')
870 && !unquoted.starts_with('.')
871}
872
873fn prelude_has_compound_operator(prelude: &str) -> bool {
875 let bytes = prelude.as_bytes();
876 let len = bytes.len();
877 let mut i = 0;
878 let mut in_single = false;
879 let mut in_double = false;
880 while i < len {
881 let ch = bytes[i];
882 if in_single {
883 if ch == b'\'' {
884 in_single = false;
885 }
886 i += 1;
887 continue;
888 }
889 if in_double {
890 if ch == b'\\' && i + 1 < len {
891 i += 2;
892 } else {
893 if ch == b'"' {
894 in_double = false;
895 }
896 i += 1;
897 }
898 continue;
899 }
900 match ch {
901 b'\'' => {
902 in_single = true;
903 i += 1;
904 }
905 b'"' => {
906 in_double = true;
907 i += 1;
908 }
909 b';' | b'|' | b'&' | b'(' | b'{' => return true,
910 _ => i += 1,
911 }
912 }
913 false
914}
915
916fn detect_heredoc_reroute(command: &str) -> Option<(String, String, Option<String>)> {
920 if !command.contains("<<") {
921 return None;
922 }
923
924 let lines: Vec<&str> = command.lines().collect();
925 if lines.is_empty() {
926 return None;
927 }
928
929 let first_line = lines[0];
930 let delims = crate::core::shell_allowlist::heredoc_delims(first_line, false);
931 if delims.len() != 1 {
932 return None;
933 }
934 let delim = delims[0].clone();
935
936 let heredoc_pos = find_heredoc_operator(first_line)?;
937 let prelude = first_line[..heredoc_pos].trim();
938 if prelude_has_compound_operator(prelude) {
939 return None;
940 }
941
942 let language = parse_interpreter_heredoc_prelude(prelude)?.to_string();
943
944 if has_trailing_tokens_after_heredoc_delim(first_line, heredoc_pos) {
945 return None;
946 }
947
948 let mut body = String::new();
949 let mut remainder_start: Option<usize> = None;
950 for (idx, line) in lines.iter().enumerate().skip(1) {
951 if line.trim_start_matches('\t').trim() == delim {
952 remainder_start = Some(idx + 1);
953 break;
954 }
955 if !body.is_empty() {
956 body.push('\n');
957 }
958 body.push_str(line);
959 }
960 let remainder_start = remainder_start?;
961 let remainder = if remainder_start < lines.len() {
962 let rest = lines[remainder_start..].join("\n");
963 let rest = rest.trim();
964 if rest.is_empty() {
965 None
966 } else {
967 Some(rest.to_string())
968 }
969 } else {
970 None
971 };
972
973 Some((language, body, remainder))
974}
975
976fn find_heredoc_operator(line: &str) -> Option<usize> {
977 let bytes = line.as_bytes();
978 let len = bytes.len();
979 let mut i = 0;
980 let mut in_single = false;
981 let mut in_double = false;
982 while i < len {
983 let ch = bytes[i];
984 if in_single {
985 if ch == b'\'' {
986 in_single = false;
987 }
988 i += 1;
989 continue;
990 }
991 if in_double {
992 if ch == b'\\' && i + 1 < len {
993 i += 2;
994 } else {
995 if ch == b'"' {
996 in_double = false;
997 }
998 i += 1;
999 }
1000 continue;
1001 }
1002 match ch {
1003 b'\'' => {
1004 in_single = true;
1005 i += 1;
1006 }
1007 b'"' => {
1008 in_double = true;
1009 i += 1;
1010 }
1011 b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
1012 if i + 2 < len && bytes[i + 2] == b'<' {
1013 i += 3;
1014 continue;
1015 }
1016 return Some(i);
1017 }
1018 _ => i += 1,
1019 }
1020 }
1021 None
1022}
1023
1024fn has_trailing_tokens_after_heredoc_delim(line: &str, heredoc_pos: usize) -> bool {
1025 let bytes = line.as_bytes();
1026 let mut i = heredoc_pos + 2;
1027 if i < bytes.len() && bytes[i] == b'-' {
1028 i += 1;
1029 }
1030 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1031 i += 1;
1032 }
1033 let Some((_, _, next)) = crate::core::shell_allowlist::read_heredoc_delim(bytes, i) else {
1034 return true;
1035 };
1036 !line[next..].trim().is_empty()
1037}
1038
1039fn parse_interpreter_heredoc_prelude(prelude: &str) -> Option<&'static str> {
1040 let tokens = crate::core::shell_allowlist::shell_tokenize(prelude.trim());
1041 let mut idx = 0;
1042 while idx < tokens.len() && is_env_assignment_token(&tokens[idx]) {
1043 idx += 1;
1044 }
1045 if idx >= tokens.len() {
1046 return None;
1047 }
1048 let base = tokens[idx]
1049 .rsplit('/')
1050 .next()
1051 .unwrap_or(tokens[idx].as_str());
1052 let language = interpreter_to_execute_language(base)?;
1053 idx += 1;
1054 match tokens.get(idx) {
1055 None => Some(language),
1056 Some(dash) if dash == "-" => {
1057 if tokens.len() == idx + 1 {
1058 Some(language)
1059 } else {
1060 None
1061 }
1062 }
1063 Some(_) => None,
1064 }
1065}
1066
1067fn handle_interpreter_heredoc_reroute(
1068 args: &Map<String, Value>,
1069 ctx: &ToolContext,
1070 language: &str,
1071 code: &str,
1072 remainder: Option<String>,
1073) -> Result<ToolOutput, ErrorData> {
1074 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
1075 let timeout_secs = timeout_ms.map(|ms| ms.div_ceil(1000).max(1));
1076
1077 let (exec_text, exec_outcome) =
1078 crate::tools::ctx_execute::handle(language, code, None, timeout_secs);
1079 let reroute_note = format!(
1080 "\n[ctx_shell: interpreter heredoc auto-rerouted to ctx_execute(language=\"{language}\")]"
1081 );
1082 let exec_text = crate::core::redaction::redact_text_if_enabled(&exec_text);
1083
1084 let Some(rest_cmd) = remainder else {
1085 return Ok(ToolOutput {
1086 text: format!("{exec_text}{reroute_note}"),
1087 original_tokens: crate::core::tokens::count_tokens(&exec_text),
1088 saved_tokens: 0,
1089 mode: Some("heredoc-reroute".to_string()),
1090 path: None,
1091 changed: false,
1092 shell_outcome: Some(exec_outcome),
1093 content_blocks: None,
1094 });
1095 };
1096
1097 if let Err(msg) = crate::core::shell_allowlist::check_shell_allowlist(&rest_cmd) {
1098 let blocked =
1099 format!("{exec_text}{reroute_note}\n\n[remainder blocked by shell allowlist]\n{msg}");
1100 return Ok(ToolOutput {
1101 shell_outcome: Some(ShellOutcome::Blocked),
1102 content_blocks: None,
1103 ..ToolOutput::simple(blocked)
1104 });
1105 }
1106
1107 let session_lock = ctx
1108 .session
1109 .as_ref()
1110 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
1111 let explicit_cwd = get_str(args, "cwd");
1112 let guard = crate::server::bounded_lock::read(session_lock, "ctx_shell_cwd");
1113 let (effective_cwd, _) = resolve_effective_cwd(guard, explicit_cwd.as_deref())?;
1114
1115 let extra_env: std::collections::HashMap<String, String> = args
1116 .get("env")
1117 .and_then(|v| v.as_object())
1118 .map(|obj| {
1119 obj.iter()
1120 .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
1121 .filter(|(k, _)| !is_dangerous_env_key(k))
1122 .collect()
1123 })
1124 .unwrap_or_default();
1125
1126 let (shell_raw, shell_exit) = crate::server::execute::execute_command_with_env(
1127 &rest_cmd,
1128 &effective_cwd,
1129 &extra_env,
1130 timeout_ms,
1131 );
1132 let shell_output = redact_shell_output_secrets(&shell_raw);
1133 let arg_raw = get_bool(args, "raw").unwrap_or(false);
1134 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
1135 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
1136 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
1137 let (raw, _) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
1138
1139 let shell_text = if raw {
1140 shell_output
1141 } else {
1142 crate::tools::ctx_shell::handle(&rest_cmd, &shell_output, shell_exit, ctx.crp_mode)
1143 };
1144 let shell_text = crate::core::redaction::redact_text_if_enabled(&shell_text);
1145 let exit_suffix = match shell_exit {
1146 0 => String::new(),
1147 124 => "\n[exit:124 — command timed out]".to_string(),
1148 _ => format!("\n[exit:{shell_exit}]"),
1149 };
1150
1151 let combined = format!(
1152 "{exec_text}{reroute_note}\n\n[heredoc remainder via ctx_shell]\n{shell_text}{exit_suffix}"
1153 );
1154 let token_count = crate::core::tokens::count_tokens(&combined);
1155 Ok(ToolOutput {
1156 text: combined,
1157 original_tokens: token_count,
1158 saved_tokens: 0,
1159 mode: Some("heredoc-reroute".to_string()),
1160 path: None,
1161 changed: false,
1162 shell_outcome: Some(ShellOutcome::Exit(shell_exit)),
1163 content_blocks: None,
1164 })
1165}
1166
1167fn detect_bare_cat_file(command: &str) -> Option<String> {
1169 let trimmed = command.trim();
1170 let rest = trimmed.strip_prefix("cat ")?;
1171 let rest = rest.trim();
1172 if rest.is_empty()
1173 || rest.contains('|')
1174 || rest.contains('>')
1175 || rest.contains('<')
1176 || rest.contains(';')
1177 || rest.contains('&')
1178 || rest.contains('$')
1179 || rest.starts_with('-')
1180 {
1181 return None;
1182 }
1183 let parts: Vec<&str> = rest.split_whitespace().collect();
1184 if parts.len() != 1 {
1185 return None;
1186 }
1187 let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
1188 if file_path.is_empty() {
1189 return None;
1190 }
1191 Some(file_path.to_string())
1192}
1193
1194#[cfg(test)]
1195mod tests {
1196 use super::{
1197 CtxShellTool, detect_heredoc_reroute, format_background_state, is_timeout_notice_only,
1198 resolve_effective_cwd, shell_access_denial, should_auto_background,
1199 };
1200 use crate::server::background_shell::JobState;
1201 use crate::server::tool_trait::{McpTool, ShellOutcome, ToolContext};
1202
1203 #[test]
1206 fn cancel_is_acknowledged_and_never_reports_a_failure() {
1207 let running = JobState::Running {
1208 output: String::new(),
1209 };
1210 let (text, exit) = format_background_state("shell_x", true, Some(running.clone()));
1211 assert_eq!(exit, 0);
1212 assert!(text.contains("cancel requested"), "{text}");
1213
1214 let (text, exit) = format_background_state("shell_x", false, Some(running));
1216 assert_eq!(exit, 0);
1217 assert!(text.contains("[background:shell_x running]"), "{text}");
1218
1219 let (text, exit) = format_background_state(
1221 "shell_x",
1222 true,
1223 Some(JobState::Cancelled {
1224 output: "[cancelled: command stopped on request]".to_string(),
1225 }),
1226 );
1227 assert_eq!(exit, 0);
1228 assert!(text.contains("[cancelled: shell_x, exit 130]"), "{text}");
1229
1230 let finished = JobState::Completed {
1232 output: "boom".to_string(),
1233 exit_code: 1,
1234 };
1235 assert_eq!(
1236 format_background_state("shell_x", true, Some(finished.clone())).1,
1237 0
1238 );
1239 assert_eq!(
1240 format_background_state("shell_x", false, Some(finished)).1,
1241 1
1242 );
1243 assert_eq!(format_background_state("shell_x", true, None).1, 0);
1244 assert_eq!(format_background_state("shell_x", false, None).1, 1);
1245 }
1246
1247 #[test]
1248 fn long_cargo_test_is_auto_backgrounded() {
1249 assert!(should_auto_background(
1250 "cargo test --lib a\ncargo test --lib b",
1251 Some(3_600_000)
1252 ));
1253 assert!(should_auto_background("cargo test --lib a", Some(300_000)));
1254 assert!(!should_auto_background("cargo test --lib a", Some(299_999)));
1255 }
1256
1257 #[test]
1258 fn timeout_notice_without_child_output_is_not_recoverable() {
1259 assert!(is_timeout_notice_only(
1260 "ERROR: command timed out after 200ms",
1261 124
1262 ));
1263 assert!(is_timeout_notice_only(
1264 " ERROR: command timed out after 200ms\n",
1265 124
1266 ));
1267 assert!(!is_timeout_notice_only(
1268 "useful output\nERROR: command timed out after 200ms",
1269 124
1270 ));
1271 assert!(!is_timeout_notice_only(
1272 "ERROR: command timed out after 200ms",
1273 1
1274 ));
1275 assert!(is_timeout_notice_only(
1279 "ERROR: command timed out after 200ms without new output\n\
1280 [still running at timeout: sleep 300]",
1281 124
1282 ));
1283 assert!(!is_timeout_notice_only(
1284 "useful output\nERROR: command timed out after 200ms\n\
1285 [still running at timeout: sleep 300]",
1286 124
1287 ));
1288 assert!(!is_timeout_notice_only("some tool output", 124));
1290 }
1291
1292 #[test]
1293 fn unavailable_session_lock_rejects_explicit_cwd() {
1294 let error = resolve_effective_cwd(None, Some("/tmp/unvalidated"))
1295 .expect_err("an unavailable session lock must reject an unvalidated cwd");
1296 assert!(
1297 error.message.contains("cannot validate working directory"),
1298 "{error:?}"
1299 );
1300 }
1301
1302 #[test]
1303 fn untrusted_and_readonly_clients_cannot_run_shell_commands() {
1304 for role in ["untrusted", "readonly"] {
1305 let ctx = ToolContext {
1306 client_role: Some(role.to_string()),
1307 ..ToolContext::default()
1308 };
1309 let output = CtxShellTool
1310 .handle(&serde_json::Map::new(), &ctx)
1311 .expect("role denial must be a tool result");
1312
1313 assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
1314 assert!(
1315 output.text.contains("SHELL ACCESS DENIED"),
1316 "{role}: {}",
1317 output.text
1318 );
1319 assert!(output.text.contains(role), "{role}: {}", output.text);
1320 }
1321 }
1322
1323 #[test]
1324 fn absent_shell_context_preserves_default_access() {
1325 assert!(shell_access_denial(&ToolContext::default()).is_none());
1326 }
1327
1328 #[test]
1329 fn explicitly_disabled_shell_access_blocks_the_request() {
1330 let ctx = ToolContext {
1331 shell_access: Some(false),
1332 ..ToolContext::default()
1333 };
1334 let output = CtxShellTool
1335 .handle(&serde_json::Map::new(), &ctx)
1336 .expect("shell-access denial must be a tool result");
1337
1338 assert_eq!(output.shell_outcome, Some(ShellOutcome::Blocked));
1339 assert!(output.text.contains("shell_access=true"), "{}", output.text);
1340 }
1341
1342 #[test]
1343 fn detect_heredoc_reroute_python_quoted() {
1344 let cmd = "python3 - <<'PY'\nprint(1)\nPY";
1345 let (lang, code, rest) = detect_heredoc_reroute(cmd).expect("must detect python heredoc");
1346 assert_eq!(lang, "python");
1347 assert_eq!(code, "print(1)");
1348 assert!(rest.is_none());
1349 }
1350
1351 #[test]
1352 fn detect_heredoc_reroute_python_with_remainder() {
1353 let cmd = "python3 <<'PY'\nprint(1)\nPY\nnode --test file.js";
1354 let (lang, code, rest) = detect_heredoc_reroute(cmd).expect("must detect split heredoc");
1355 assert_eq!(lang, "python");
1356 assert_eq!(code, "print(1)");
1357 assert_eq!(rest.as_deref(), Some("node --test file.js"));
1358 }
1359
1360 #[test]
1361 fn detect_heredoc_reroute_unquoted_and_tab_stripped() {
1362 let unquoted = "ruby <<EOF\nputs 1\nEOF";
1363 let (lang, code, rest) = detect_heredoc_reroute(unquoted).unwrap();
1364 assert_eq!(lang, "ruby");
1365 assert_eq!(code, "puts 1");
1366 assert!(rest.is_none());
1367
1368 let tabbed = "python3 <<-\tSCRIPT\n\tprint('ok')\nSCRIPT";
1369 let (lang, code, rest) = detect_heredoc_reroute(tabbed).unwrap();
1370 assert_eq!(lang, "python");
1371 assert_eq!(code, "\tprint('ok')");
1372 assert!(rest.is_none());
1373 }
1374
1375 #[test]
1376 fn detect_heredoc_reroute_rejects_compound_prefix() {
1377 assert!(detect_heredoc_reroute("echo hi; python3 <<'PY'\nx\nPY").is_none());
1378 assert!(detect_heredoc_reroute("python3 -c 'print(1)'").is_none());
1379 assert!(detect_heredoc_reroute("python3 <<'PY' | cat\nx\nPY").is_none());
1380 }
1381}