1use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7 BackgroundJobState, BackgroundShellOutcome, McpTool, ShellOutcome, ToolContext, ToolOutput,
8 get_bool, get_int, get_str,
9};
10use lean_ctx::tool_defs::tool_def;
11
12use super::ctx_shell_background::{format_background_snapshot, redact_shell_output_secrets};
13
14pub struct CtxShellTool;
15
16const SESSION_LOCK_BUDGET: std::time::Duration = std::time::Duration::from_millis(250);
20
21impl McpTool for CtxShellTool {
22 fn name(&self) -> &'static str {
23 "ctx_shell"
24 }
25
26 fn tool_def(&self) -> Tool {
27 tool_def(
28 "ctx_shell",
29 "WORKFLOW: preferred — auto-compresses output (build/test/log).\n\
30 Big logs: narrow the command (grep, sed -n, head/tail).\n\
31 [exit:N] on errors (lossless).\n\
32 POLICY: allowlisted read-only path.\n\
33 A [BLOCKED] command is permanent; ctx_execute shares the same allowlist — fix with \
34 `lean-ctx allow <cmd>`.\n\
35 ANTIPATTERN: multi-line scripts, sh/bash script.sh, $var-as-command → ctx_execute \
36 (when exposed).",
37 json!({
38 "type": "object",
39 "properties": {
40 "command": { "type": "string", "description": "Shell command" },
41 "raw": { "type": "boolean", "description": "Exact bytes; only for diffs to apply, hashes, encoded output" },
42 "inline": { "type": "boolean", "description": "Verbatim up to archive.inline_max_bytes, else archived" },
43 "cwd": { "type": "string", "description": "Working dir (persists across calls)" },
44 "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." },
45 "env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } },
46 "run_in_background": { "type": "boolean", "description": "Detach immediately and return a job id. The command keeps timeout_ms (default here: the 1 h ceiling); poll or cancel with background_action and job_id." },
47 "background_action": { "type": "string", "enum": ["status", "cancel"], "description": "Inspect or cancel a background ctx_shell job; terminal status may include archiveId. Evicted/expired jobs return a structured lookup error." },
48 "job_id": { "type": "string", "description": "Job id returned by run_in_background." }
49 }
50 }),
51 )
52 }
53
54 fn handle(
55 &self,
56 args: &Map<String, Value>,
57 ctx: &ToolContext,
58 ) -> Result<ToolOutput, ErrorData> {
59 if let Some(message) = shell_access_denial(ctx) {
60 return Ok(ToolOutput {
61 shell_outcome: Some(ShellOutcome::Blocked),
62 content_blocks: None,
63 ..ToolOutput::simple(message)
64 });
65 }
66
67 if let Some(action) = get_str(args, "background_action") {
68 let id = get_str(args, "job_id").ok_or_else(|| {
69 ErrorData::invalid_params("job_id is required with background_action", None)
70 })?;
71 let is_cancel = action == "cancel";
72 let state = match action.as_str() {
73 "status" => crate::server::background_shell::snapshot(&id),
74 "cancel" => crate::server::background_shell::cancel_snapshot(&id),
75 _ => {
76 return Err(ErrorData::invalid_params(
77 "background_action must be status or cancel",
78 None,
79 ));
80 }
81 };
82 let (text, outcome) = format_background_snapshot(&id, is_cancel, state);
83 return Ok(ToolOutput {
84 shell_outcome: Some(outcome),
85 content_blocks: None,
86 mode: Some(crate::tools::server_metrics::BACKGROUND_POLL_MODE.to_string()),
89 ..ToolOutput::simple(text)
90 });
91 }
92 let command = get_str(args, "command")
93 .ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
94 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
95
96 let explicit_cwd = get_str(args, "cwd");
106 let had_explicit_cwd = explicit_cwd.is_some();
107 let resolved_cwd = match ctx.session.as_ref() {
113 Some(session_lock) => Some(tokio::task::block_in_place(|| {
114 let guard = crate::server::bounded_lock::read_for(
115 session_lock,
116 "ctx_shell_cwd",
117 SESSION_LOCK_BUDGET,
118 );
119 resolve_effective_cwd(guard, explicit_cwd.as_deref())
120 })?),
121 None => None,
122 };
123 note_tool_output(
126 &command,
127 resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
128 &ctx.project_root,
129 );
130
131 let config = lean_ctx::core::config::Config::load();
136 let mut write_allow_paths = config.shell_write_allow_paths_effective();
137 write_allow_paths.extend(crate::tools::ctx_shell::capture_roots(
140 &ctx.project_root,
141 &ctx.extra_roots,
142 &config,
143 ));
144 if !lean_ctx::core::config::shell_policy::allow_writes()
145 && let Some(rejection) = crate::tools::ctx_shell::validate_command_in_cwd(
146 &command,
147 &write_allow_paths,
148 None,
149 resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
150 )
151 {
152 return Ok(ToolOutput {
155 shell_outcome: Some(ShellOutcome::Blocked),
156 content_blocks: None,
157 ..ToolOutput::simple(rejection)
158 });
159 }
160
161 let rights_cwd = resolved_cwd
164 .as_ref()
165 .map_or_else(|| ctx.project_root.clone(), |(cwd, _)| cwd.clone());
166 let env_keys: Vec<&str> = args
170 .get("env")
171 .and_then(Value::as_object)
172 .map(|obj| obj.keys().map(String::as_str).collect())
173 .unwrap_or_default();
174 if let Err(reason) = lean_ctx::core::session_rights::current().check_bash(
175 &command,
176 std::path::Path::new(&rights_cwd),
177 &env_keys,
178 ) {
179 return Ok(ToolOutput {
180 shell_outcome: Some(ShellOutcome::Blocked),
181 content_blocks: None,
182 ..ToolOutput::simple(reason)
183 });
184 }
185
186 if let Some((lang, code, remainder)) = detect_heredoc_reroute(&command) {
187 return tokio::task::block_in_place(|| {
188 handle_interpreter_heredoc_reroute(args, ctx, &lang, &code, remainder)
189 });
190 }
191
192 let grant = crate::server::shell_approval::current_grant();
193 let run_cwd = resolved_cwd
196 .as_ref()
197 .map(|(cwd, _)| std::path::PathBuf::from(cwd));
198 if let Err(msg) = lean_ctx_shell_policy::with_run_cwd(run_cwd, || {
199 lean_ctx_shell_policy::check_shell_allowlist_granted(&command, &grant)
200 }) {
201 if let Some(plan) = lean_ctx_shell_policy::partial_run(&command, &grant) {
204 let mut sub = args.clone();
205 sub.insert("command".into(), Value::String(plan.run));
206 let mut output = self.handle(&sub, ctx)?;
207 for note in &plan.skipped {
208 output.text.push('\n');
209 output.text.push_str(note);
210 }
211 if matches!(output.shell_outcome, None | Some(ShellOutcome::Exit(0))) {
216 output.shell_outcome = Some(ShellOutcome::Exit(126));
217 }
218 return Ok(output);
219 }
220 return Ok(ToolOutput {
221 shell_outcome: Some(ShellOutcome::Blocked),
222 content_blocks: None,
223 ..ToolOutput::simple(msg.to_string())
224 });
225 }
226
227 warn_shell_secret_paths(&command);
228
229 if let Some(read_path) = detect_bare_cat_file(&command)
233 && let Some(cache_lock) = ctx.cache.as_ref()
234 && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
235 {
236 let result = crate::tools::ctx_read::handle_with_task_resolved(
237 &mut cache,
238 &read_path,
239 "full",
240 crate::tools::CrpMode::Off,
241 None,
242 );
243 let note = format!(
244 "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
245 Use ctx_read(path=\"{read_path}\") directly next time.]"
246 );
247 let out = format!("{}{note}", result.content);
248 let sent = lean_ctx::core::tokens::count_tokens(&out);
249 return Ok(ToolOutput {
250 text: out,
251 original_tokens: sent,
252 saved_tokens: 0,
253 mode: Some("cat-redirect".to_string()),
254 path: Some(read_path),
255 changed: false,
256 shell_outcome: Some(ShellOutcome::Exit(0)),
257 content_blocks: None,
258 });
259 }
260
261 tokio::task::block_in_place(|| {
262 let session_lock = ctx
263 .session
264 .as_ref()
265 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
266 let Some((effective_cwd, cwd_jail_reason)) = resolved_cwd else {
267 return Err(ErrorData::internal_error("session not available", None));
268 };
269 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
274 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
275 format!(
276 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
277 )
278 });
279
280 {
281 let Some(mut session) = crate::server::bounded_lock::write_for(
282 session_lock,
283 "ctx_shell_write",
284 SESSION_LOCK_BUDGET,
285 ) else {
286 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
287 let cmd_clone = command.clone();
288 let cwd_clone = effective_cwd.clone();
289 let extra_env = match collect_extra_env(args) {
290 Ok(env) => env,
291 Err(message) => {
292 return Ok(ToolOutput {
293 shell_outcome: Some(ShellOutcome::Blocked),
294 content_blocks: None,
295 ..ToolOutput::simple(message)
296 });
297 }
298 };
299 let execution = crate::server::execute::execute_command_with_env_captured(
300 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
301 );
302 let admission = lean_ctx::core::context_admission::admit_tool_output(
303 &execution.output,
304 execution.completeness,
305 );
306 let output = redact_shell_output_secrets(&admission.rendered());
307 let exit_suffix =
310 lean_ctx::shell::exit_status::exit_footer(execution.exit_code);
311 let final_text = format!("{output}{exit_suffix}");
312 let final_admission = lean_ctx::core::context_admission::admit_tool_output(
313 &final_text,
314 execution.completeness,
315 );
316 return Ok(ToolOutput {
317 shell_outcome: Some(ShellOutcome::Exit(execution.exit_code)),
318 content_blocks: None,
319 ..ToolOutput::simple(crate::server::execute::with_notice(
322 execution.notice,
323 final_admission.rendered(),
324 ))
325 });
326 };
327 if had_explicit_cwd && cwd_jail_reason_was_none {
332 session.note_explicit_cwd(&effective_cwd);
333 }
334 session.update_shell_cwd(&command);
335 let root_missing = session
336 .project_root
337 .as_deref()
338 .is_none_or(|r| r.trim().is_empty());
339 if root_missing {
340 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
341 if let Some(root) =
342 lean_ctx::core::protocol::detect_project_root(&effective_cwd)
343 && home.as_deref() != Some(root.as_str())
344 {
345 session.project_root = Some(root.clone());
346 lean_ctx::core::index_orchestrator::ensure_all_background(&root);
347 }
348 }
349 }
350
351 let arg_raw = get_bool(args, "raw").unwrap_or(false);
352 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
353 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
354 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
355 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
356
357 let crp_mode = ctx.crp_mode;
358 let cmd_clone = command.clone();
359 let cwd_clone = effective_cwd;
360 let proactive_block = if raw
361 || !lean_ctx::core::profiles::active_profile()
362 .output_hints
363 .proactive_context()
364 {
365 None
366 } else {
367 lean_ctx::core::relevance_tracker::proactive_context(&format!(
368 "ctx_shell command={cmd_clone} cwd={cwd_clone}"
369 ))
370 };
371
372 let extra_env = match collect_extra_env(args) {
373 Ok(env) => env,
374 Err(message) => {
375 return Ok(ToolOutput {
376 shell_outcome: Some(ShellOutcome::Blocked),
377 content_blocks: None,
378 ..ToolOutput::simple(message)
379 });
380 }
381 };
382
383 let inline = get_bool(args, "inline").unwrap_or(false);
384
385 let auto_background = should_auto_background(&cmd_clone, timeout_ms);
386 let detached = get_bool(args, "run_in_background").unwrap_or(false);
387 if detached || auto_background {
388 let lifetime = if detached {
393 timeout_ms.or(Some(lean_ctx::shell::exec::MAX_CALL_TIMEOUT_MS))
394 } else {
395 timeout_ms
396 };
397 let job_id = crate::server::background_shell::start(
398 cmd_clone, cwd_clone, extra_env, lifetime,
399 );
400 let mode = if auto_background {
401 "auto-background"
402 } else {
403 "background"
404 };
405 return Ok(ToolOutput {
406 shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
407 state: BackgroundJobState::Running,
408 exit_code: None,
409 job_id: job_id.clone(),
410 archive_id: None,
411 archive_truncated: None,
412 captured_chars: None,
413 archived_chars: None,
414 summary: format!("{mode} job started"),
415 is_error: false,
416 display: None,
417 })),
418 content_blocks: None,
419 ..ToolOutput::simple(format!(
420 "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
421 ))
422 });
423 }
424
425 let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
436 let progress_sender = ctx.progress_sender.clone();
437 let progress_label: String = cmd_clone.chars().take(60).collect();
438 let cap_secs = soft_cap.as_secs_f64();
439 let on_tick = |elapsed: std::time::Duration| {
440 #[allow(clippy::unwrap_or_default)]
441 if let Some(ref ps) = progress_sender
442 && let Some(sender) = ps
443 .lock()
444 .unwrap_or_else(std::sync::PoisonError::into_inner)
445 .as_ref()
446 {
447 sender.send(
448 elapsed.as_secs_f64(),
449 Some(cap_secs),
450 Some(format!(
451 "ctx_shell: {}s elapsed — {progress_label}",
452 elapsed.as_secs()
453 )),
454 );
455 }
456 };
457 let (process_output, exit_code, completeness, retained_withheld, containment_notice) =
458 match crate::server::background_shell::run_foreground_or_detach_captured(
459 cmd_clone.clone(),
460 cwd_clone.clone(),
461 extra_env.clone(),
462 timeout_ms,
463 soft_cap,
464 Some(&on_tick),
465 ctx.cancel.as_ref(),
466 ) {
467 crate::server::background_shell::CapturedForegroundResult::Finished {
468 output,
469 exit_code,
470 completeness,
471 retained_withheld,
472 notice,
473 } => (output, exit_code, completeness, retained_withheld, notice),
474 crate::server::background_shell::CapturedForegroundResult::Detached {
475 job_id,
476 } => {
477 return Ok(ToolOutput {
478 shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
479 state: BackgroundJobState::Running,
480 exit_code: None,
481 job_id: job_id.clone(),
482 archive_id: None,
483 archive_truncated: None,
484 captured_chars: None,
485 archived_chars: None,
486 summary: "auto-detached job is still running".to_string(),
487 is_error: false,
488 display: None,
489 })),
490 content_blocks: None,
491 ..ToolOutput::simple(format!(
495 "[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]",
496 soft_cap.as_secs()
497 ))
498 });
499 }
500 };
501
502 let display_input = if retained_withheld {
503 "[lean-ctx gateway: retained output withheld by storage policy]".to_string()
504 } else {
505 process_output.clone()
506 };
507 let admission =
508 lean_ctx::core::context_admission::admit_tool_output(&display_input, completeness);
509 let output = redact_shell_output_secrets(&admission.rendered());
510 let output = if raw {
511 crate::server::walk_hint::strip_walk_hint(&output).to_string()
512 } else {
513 output
514 };
515
516 let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
519 let stored_command = store.admit_shell_command(&cmd_clone);
520 let stored_output = (!retained_withheld)
521 .then(|| store.admit_shell_output(&process_output, completeness))
522 .flatten();
523 let storage_allowed = !retained_withheld
524 && store.is_current()
525 && stored_command.is_some()
526 && stored_output.is_some();
527 let command_for_store = stored_command.as_deref().unwrap_or_default();
528 let output_for_store = stored_output.as_deref().unwrap_or_default();
529 if storage_allowed {
530 lean_ctx::core::diagnostics_store::record_from_shell(
531 command_for_store,
532 output_for_store,
533 exit_code,
534 );
535 }
536
537 let (result_out, original, saved, tee_hint) = if raw || inline {
538 let tokens = lean_ctx::core::tokens::count_tokens(&output);
539 (output, tokens, 0, String::new())
540 } else {
541 let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("shell");
542 let result =
543 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
544 let original = lean_ctx::core::tokens::count_tokens(&output);
545 let sent = lean_ctx::core::tokens::count_tokens(&result);
546 let saved = original.saturating_sub(sent);
547
548 let cfg = lean_ctx::core::config::Config::load();
549 let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
552 let tee_hint = if storage_allowed
553 && lean_ctx::shell::tee_policy::should_tee(
554 &cfg.tee_mode,
555 exit_code,
556 output.trim().is_empty() || timeout_notice_only,
557 lean_ctx::shell::tee_policy::output_was_elided(&output, &result),
558 original,
559 sent,
560 ) {
561 lean_ctx::shell::save_tee(command_for_store, output_for_store)
562 .map(|p| {
563 if lean_ctx::core::protected_store::enforce_protected_store_path(std::path::Path::new(&p)).is_err() {
564 format!("\n[full output: ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\")]")
565 } else if matches!(cfg.tee_mode, lean_ctx::core::config::TeeMode::HighCompression)
566 {
567 let pct = lean_ctx::shell::tee_policy::savings_pct(original, sent);
568 format!(
571 "\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]"
572 )
573 } else {
574 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
575 }
576 })
577 .unwrap_or_default()
578 } else {
579 String::new()
580 };
581
582 (result, original, saved, tee_hint)
583 };
584
585 let mode = if bypass {
586 Some("bypass".to_string())
587 } else if raw {
588 Some("raw".to_string())
589 } else {
590 None
591 };
592
593 let shell_mismatch = if cfg!(windows) && !raw {
594 shell_mismatch_hint(&command, &result_out)
595 } else {
596 String::new()
597 };
598
599 let result_out = lean_ctx::core::redaction::redact_text_if_enabled(&result_out);
600 let exit_suffix = lean_ctx::shell::exit_status::exit_footer(exit_code);
604 let nudge = if raw { "" } else { search_tool_nudge(&command) };
605 let final_out = format!(
606 "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
607 );
608 let final_out = if let Some(block) = proactive_block {
609 format!("{final_out}{block}")
610 } else {
611 final_out
612 };
613 let final_admission =
614 lean_ctx::core::context_admission::admit_tool_output(&final_out, completeness);
615 let final_out = final_admission.rendered();
616
617 Ok(ToolOutput {
618 text: crate::server::execute::with_notice(containment_notice, final_out),
621 original_tokens: original,
622 saved_tokens: saved,
623 mode,
624 path: None,
625 changed: false,
626 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
627 content_blocks: None,
628 })
629 })
630 }
631}
632
633pub(crate) fn shell_access_denial(ctx: &ToolContext) -> Option<String> {
637 if let Some(role) = ctx.client_role.as_deref()
638 && (role.eq_ignore_ascii_case("untrusted") || role.eq_ignore_ascii_case("readonly"))
639 {
640 return Some(format!(
641 "[SHELL ACCESS DENIED] ctx_shell is unavailable to MCP clients with role '{role}'."
642 ));
643 }
644
645 if ctx.shell_access == Some(false) {
646 return Some(
647 "[SHELL ACCESS DENIED] ctx_shell requires shell_access=true in the MCP session/request context."
648 .to_string(),
649 );
650 }
651
652 None
653}
654
655fn resolve_effective_cwd(
656 session: Option<tokio::sync::OwnedRwLockReadGuard<lean_ctx::core::session::SessionState>>,
657 explicit_cwd: Option<&str>,
658) -> Result<(String, Option<String>), ErrorData> {
659 match session {
660 Some(session) => session
663 .execution_cwd(explicit_cwd)
664 .map(|cwd| (cwd, None))
665 .map_err(|reason| {
666 ErrorData::invalid_params(
667 format!("working directory rejected, command not run: {reason}"),
668 None,
669 )
670 }),
671 None => Err(ErrorData::internal_error(
672 "session lock timeout — cannot validate working directory",
673 None,
674 )),
675 }
676}
677
678#[allow(clippy::fn_params_excessive_bools)]
679fn resolve_shell_raw_flags(
680 arg_raw: bool,
681 arg_bypass: bool,
682 _env_disabled: bool,
683 env_raw: bool,
684) -> (bool, bool) {
685 let bypass = arg_bypass || env_raw;
686 let raw = arg_raw || bypass;
687 (raw, bypass)
688}
689
690fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
697 exit_code == 124
698 && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
699}
700
701fn search_tool_nudge(command: &str) -> &'static str {
702 let cmd = command.trim();
703 let first_word = cmd.split_whitespace().next().unwrap_or("");
704 if !cmd.contains('|') {
705 match first_word {
706 "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
707 return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
708 }
709 "find" => {
710 return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
711 }
712 "ls" | "exa" | "eza" => {
713 return "\n[hint: use ctx_tree for structured directory listing]";
714 }
715 _ => {}
716 }
717 }
718 ""
719}
720
721fn shell_mismatch_hint(command: &str, output: &str) -> String {
722 let shell = lean_ctx::shell::shell_name();
723 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
724 let has_error = output.contains("is not recognized")
725 || output.contains("not found")
726 || output.contains("command not found");
727
728 if !has_error {
729 return String::new();
730 }
731
732 let powershell_cmds = [
733 "Get-Content",
734 "Select-Object",
735 "Get-ChildItem",
736 "Set-Location",
737 "Where-Object",
738 "ForEach-Object",
739 "Select-String",
740 "Invoke-Expression",
741 "Write-Output",
742 ];
743 let uses_powershell = powershell_cmds
744 .iter()
745 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
746
747 if is_posix && uses_powershell {
748 format!(
749 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
750 )
751 } else {
752 String::new()
753 }
754}
755
756fn collect_extra_env(
768 args: &Map<String, Value>,
769) -> Result<std::collections::HashMap<String, String>, String> {
770 let mut accepted = std::collections::HashMap::new();
771 let Some(obj) = args.get("env").and_then(|v| v.as_object()) else {
772 return Ok(accepted);
773 };
774 let mut rejected: Vec<&str> = Vec::new();
775 for (key, value) in obj {
776 let Some(text) = value.as_str() else { continue };
777 if lean_ctx_shell_policy::env_override_refused(key, text) {
778 rejected.push(key.as_str());
779 continue;
780 }
781 accepted.insert(key.clone(), text.to_string());
782 }
783 if rejected.is_empty() {
784 return Ok(accepted);
785 }
786 rejected.sort_unstable();
787 let names = rejected
788 .iter()
789 .map(|k| format!("`{k}`"))
790 .collect::<Vec<_>>()
791 .join(", ");
792 let plural = if rejected.len() == 1 { "key" } else { "keys" };
793 Err(format!(
794 "ERROR: ctx_shell refuses the `env` {plural} {names} — nothing was run. These keys can \
795 redirect which binary executes, so lean-ctx never forwards them, and the command would \
796 otherwise have used the inherited value while appearing to honor yours. This is the \
797 same rule that blocks the inline `KEY=value cmd` form; `env` is not a way around it. \
798 Invoke the binary by its full path instead, or drop the override."
799 ))
800}
801
802fn warn_shell_secret_paths(command: &str) {
805 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
806 let segments = lean_ctx_shell_policy::extract_all_commands_pub(command);
807 for seg in &segments {
808 let trimmed = seg.trim();
809 let tokens = lean_ctx_shell_policy::shell_tokenize(trimmed);
810 if tokens.is_empty() {
811 continue;
812 }
813 let base = tokens[0]
814 .rsplit('/')
815 .next()
816 .unwrap_or(&tokens[0])
817 .to_string();
818 if !READ_CMDS.contains(&base.as_str()) {
819 continue;
820 }
821 for tok in &tokens[1..] {
822 if tok.starts_with('-') {
823 continue;
824 }
825 let path = std::path::Path::new(tok.as_str());
826 if lean_ctx::core::io_boundary::is_secret_like(path).is_some() {
827 tracing::warn!(
828 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
829 );
830 }
831 }
832 }
833}
834
835fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
840 timeout_ms.is_some_and(|timeout| timeout >= 300_000)
841 && command
842 .lines()
843 .any(|line| line.trim_start().starts_with("cargo test"))
844}
845
846fn foreground_soft_cap_ms() -> u64 {
851 std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
852 .ok()
853 .and_then(|v| v.parse().ok())
854 .filter(|&ms| ms > 0)
855 .unwrap_or(110_000)
856}
857
858fn interpreter_to_execute_language(base: &str) -> Option<&'static str> {
860 match base {
861 "python" | "python2" | "python3" => Some("python"),
862 "node" => Some("javascript"),
863 "ruby" => Some("ruby"),
864 _ => None,
865 }
866}
867
868fn is_env_assignment_token(token: &str) -> bool {
869 let unquoted: String = token.chars().filter(|c| *c != '"' && *c != '\'').collect();
870 unquoted.contains('=')
871 && !unquoted.starts_with('-')
872 && !unquoted.starts_with('/')
873 && !unquoted.starts_with('.')
874}
875
876fn prelude_has_compound_operator(prelude: &str) -> bool {
878 let bytes = prelude.as_bytes();
879 let len = bytes.len();
880 let mut i = 0;
881 let mut in_single = false;
882 let mut in_double = false;
883 while i < len {
884 let ch = bytes[i];
885 if in_single {
886 if ch == b'\'' {
887 in_single = false;
888 }
889 i += 1;
890 continue;
891 }
892 if in_double {
893 if ch == b'\\' && i + 1 < len {
894 i += 2;
895 } else {
896 if ch == b'"' {
897 in_double = false;
898 }
899 i += 1;
900 }
901 continue;
902 }
903 match ch {
904 b'\'' => {
905 in_single = true;
906 i += 1;
907 }
908 b'"' => {
909 in_double = true;
910 i += 1;
911 }
912 b';' | b'|' | b'&' | b'(' | b'{' => return true,
913 _ => i += 1,
914 }
915 }
916 false
917}
918
919fn detect_heredoc_reroute(command: &str) -> Option<(String, String, Option<String>)> {
923 if !command.contains("<<") {
924 return None;
925 }
926
927 let lines: Vec<&str> = command.lines().collect();
928 if lines.is_empty() {
929 return None;
930 }
931
932 let first_line = lines[0];
933 let delims = lean_ctx_shell_policy::heredoc_delims(first_line, false);
934 if delims.len() != 1 {
935 return None;
936 }
937 let delim = delims[0].clone();
938
939 let heredoc_pos = find_heredoc_operator(first_line)?;
940 let prelude = first_line[..heredoc_pos].trim();
941 if prelude_has_compound_operator(prelude) {
942 return None;
943 }
944
945 let language = parse_interpreter_heredoc_prelude(prelude)?.to_string();
946
947 if has_trailing_tokens_after_heredoc_delim(first_line, heredoc_pos) {
948 return None;
949 }
950
951 let mut body = String::new();
952 let mut remainder_start: Option<usize> = None;
953 for (idx, line) in lines.iter().enumerate().skip(1) {
954 if line.trim_start_matches('\t').trim() == delim {
955 remainder_start = Some(idx + 1);
956 break;
957 }
958 if !body.is_empty() {
959 body.push('\n');
960 }
961 body.push_str(line);
962 }
963 let remainder_start = remainder_start?;
964 let remainder = if remainder_start < lines.len() {
965 let rest = lines[remainder_start..].join("\n");
966 let rest = rest.trim();
967 if rest.is_empty() {
968 None
969 } else {
970 Some(rest.to_string())
971 }
972 } else {
973 None
974 };
975
976 Some((language, body, remainder))
977}
978
979fn find_heredoc_operator(line: &str) -> Option<usize> {
980 let bytes = line.as_bytes();
981 let len = bytes.len();
982 let mut i = 0;
983 let mut in_single = false;
984 let mut in_double = false;
985 while i < len {
986 let ch = bytes[i];
987 if in_single {
988 if ch == b'\'' {
989 in_single = false;
990 }
991 i += 1;
992 continue;
993 }
994 if in_double {
995 if ch == b'\\' && i + 1 < len {
996 i += 2;
997 } else {
998 if ch == b'"' {
999 in_double = false;
1000 }
1001 i += 1;
1002 }
1003 continue;
1004 }
1005 match ch {
1006 b'\'' => {
1007 in_single = true;
1008 i += 1;
1009 }
1010 b'"' => {
1011 in_double = true;
1012 i += 1;
1013 }
1014 b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
1015 if i + 2 < len && bytes[i + 2] == b'<' {
1016 i += 3;
1017 continue;
1018 }
1019 return Some(i);
1020 }
1021 _ => i += 1,
1022 }
1023 }
1024 None
1025}
1026
1027fn has_trailing_tokens_after_heredoc_delim(line: &str, heredoc_pos: usize) -> bool {
1028 let bytes = line.as_bytes();
1029 let mut i = heredoc_pos + 2;
1030 if i < bytes.len() && bytes[i] == b'-' {
1031 i += 1;
1032 }
1033 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1034 i += 1;
1035 }
1036 let Some((_, _, next)) = lean_ctx_shell_policy::read_heredoc_delim(bytes, i) else {
1037 return true;
1038 };
1039 !line[next..].trim().is_empty()
1040}
1041
1042fn parse_interpreter_heredoc_prelude(prelude: &str) -> Option<&'static str> {
1043 let tokens = lean_ctx_shell_policy::shell_tokenize(prelude.trim());
1044 let mut idx = 0;
1045 while idx < tokens.len() && is_env_assignment_token(&tokens[idx]) {
1046 idx += 1;
1047 }
1048 if idx >= tokens.len() {
1049 return None;
1050 }
1051 let base = tokens[idx]
1052 .rsplit('/')
1053 .next()
1054 .unwrap_or(tokens[idx].as_str());
1055 let language = interpreter_to_execute_language(base)?;
1056 idx += 1;
1057 match tokens.get(idx) {
1058 None => Some(language),
1059 Some(dash) if dash == "-" => {
1060 if tokens.len() == idx + 1 {
1061 Some(language)
1062 } else {
1063 None
1064 }
1065 }
1066 Some(_) => None,
1067 }
1068}
1069
1070fn resolve_reroute_cwd(
1081 ctx: &ToolContext,
1082 explicit_cwd: Option<&str>,
1083) -> Result<Option<String>, ErrorData> {
1084 let Some(session_lock) = ctx.session.as_ref() else {
1085 if explicit_cwd.is_some() {
1086 return Err(ErrorData::internal_error(
1087 "session not available — cannot validate the requested working directory",
1088 None,
1089 ));
1090 }
1091 return Ok(None);
1092 };
1093 let guard =
1094 crate::server::bounded_lock::read_for(session_lock, "ctx_shell_cwd", SESSION_LOCK_BUDGET);
1095 let (cwd, _) = resolve_effective_cwd(guard, explicit_cwd)?;
1096 Ok(Some(cwd))
1097}
1098
1099fn handle_interpreter_heredoc_reroute(
1100 args: &Map<String, Value>,
1101 ctx: &ToolContext,
1102 language: &str,
1103 code: &str,
1104 remainder: Option<String>,
1105) -> Result<ToolOutput, ErrorData> {
1106 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
1107 let timeout_secs = timeout_ms.map(|ms| ms.div_ceil(1000).max(1));
1108
1109 let explicit_cwd = get_str(args, "cwd");
1115 let exec_cwd = resolve_reroute_cwd(ctx, explicit_cwd.as_deref())?;
1116
1117 let (exec_text, exec_outcome) = crate::tools::ctx_execute::handle_in(
1118 language,
1119 code,
1120 None,
1121 timeout_secs,
1122 exec_cwd.as_deref().map(std::path::Path::new),
1123 );
1124 let reroute_note = format!(
1125 "\n[ctx_shell: interpreter heredoc auto-rerouted to ctx_execute(language=\"{language}\")]"
1126 );
1127 let exec_completeness = lean_ctx::core::context_admission::OutputCompleteness {
1128 reader_incomplete: true,
1129 ..lean_ctx::core::context_admission::OutputCompleteness::complete()
1130 };
1131 let exec_admission =
1132 lean_ctx::core::context_admission::admit_tool_output(&exec_text, exec_completeness);
1133 let exec_text = lean_ctx::core::redaction::redact_text_if_enabled(&exec_admission.rendered());
1134
1135 let Some(rest_cmd) = remainder else {
1136 let final_text = format!("{exec_text}{reroute_note}");
1137 let final_admission =
1138 lean_ctx::core::context_admission::admit_tool_output(&final_text, exec_completeness);
1139 let text = final_admission.rendered();
1140 return Ok(ToolOutput {
1141 original_tokens: lean_ctx::core::tokens::count_tokens(&text),
1142 text,
1143 saved_tokens: 0,
1144 mode: Some("heredoc-reroute".to_string()),
1145 path: None,
1146 changed: false,
1147 shell_outcome: Some(exec_outcome),
1148 content_blocks: None,
1149 });
1150 };
1151
1152 if let Err(msg) = lean_ctx_shell_policy::check_shell_allowlist_granted(
1153 &rest_cmd,
1154 &crate::server::shell_approval::current_grant(),
1155 ) {
1156 let blocked =
1157 format!("{exec_text}{reroute_note}\n\n[remainder blocked by shell allowlist]\n{msg}");
1158 return Ok(ToolOutput {
1159 shell_outcome: Some(ShellOutcome::Blocked),
1160 content_blocks: None,
1161 ..ToolOutput::simple(blocked)
1162 });
1163 }
1164
1165 let effective_cwd =
1168 exec_cwd.ok_or_else(|| ErrorData::internal_error("session not available", None))?;
1169
1170 let extra_env = match collect_extra_env(args) {
1171 Ok(env) => env,
1172 Err(message) => {
1173 return Ok(ToolOutput {
1174 shell_outcome: Some(ShellOutcome::Blocked),
1175 content_blocks: None,
1176 ..ToolOutput::simple(message)
1177 });
1178 }
1179 };
1180
1181 let shell_execution = crate::server::execute::execute_command_with_env_captured(
1182 &rest_cmd,
1183 &effective_cwd,
1184 &extra_env,
1185 timeout_ms,
1186 );
1187 let shell_exit = shell_execution.exit_code;
1188 let shell_admission = lean_ctx::core::context_admission::admit_tool_output(
1189 &shell_execution.output,
1190 shell_execution.completeness,
1191 );
1192 let shell_output = redact_shell_output_secrets(&shell_admission.rendered());
1193 let arg_raw = get_bool(args, "raw").unwrap_or(false);
1194 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
1195 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
1196 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
1197 let (raw, _) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
1198
1199 let shell_text = if raw {
1200 shell_output
1201 } else {
1202 crate::tools::ctx_shell::handle(&rest_cmd, &shell_output, shell_exit, ctx.crp_mode)
1203 };
1204 let shell_text = lean_ctx::core::redaction::redact_text_if_enabled(&shell_text);
1205 let exit_suffix = lean_ctx::shell::exit_status::exit_footer(shell_exit);
1206
1207 let combined = format!(
1208 "{exec_text}{reroute_note}\n\n[heredoc remainder via ctx_shell]\n{shell_text}{exit_suffix}"
1209 );
1210 let mut completeness = shell_execution.completeness;
1211 completeness.reader_incomplete = true;
1214 let final_admission =
1215 lean_ctx::core::context_admission::admit_tool_output(&combined, completeness);
1216 let text =
1218 crate::server::execute::with_notice(shell_execution.notice, final_admission.rendered());
1219 let token_count = lean_ctx::core::tokens::count_tokens(&text);
1220 Ok(ToolOutput {
1221 text,
1222 original_tokens: token_count,
1223 saved_tokens: 0,
1224 mode: Some("heredoc-reroute".to_string()),
1225 path: None,
1226 changed: false,
1227 shell_outcome: Some(ShellOutcome::Exit(shell_exit)),
1228 content_blocks: None,
1229 })
1230}
1231
1232fn detect_bare_cat_file(command: &str) -> Option<String> {
1234 let trimmed = command.trim();
1235 let rest = trimmed.strip_prefix("cat ")?;
1236 let rest = rest.trim();
1237 if rest.is_empty()
1238 || rest.contains('|')
1239 || rest.contains('>')
1240 || rest.contains('<')
1241 || rest.contains(';')
1242 || rest.contains('&')
1243 || rest.contains('$')
1244 || rest.starts_with('-')
1245 {
1246 return None;
1247 }
1248 let parts: Vec<&str> = rest.split_whitespace().collect();
1249 if parts.len() != 1 {
1250 return None;
1251 }
1252 let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
1253 if file_path.is_empty() {
1254 return None;
1255 }
1256 Some(file_path.to_string())
1257}
1258
1259fn note_tool_output(command: &str, cwd: Option<&str>, project_root: &str) {
1264 use lean_ctx::core::context_admission::{capture, origin};
1265 let Some(found) = lean_ctx::core::patterns::tool_output::classify(command) else {
1266 if let Some(capture) = capture::current() {
1269 capture.set_output_class(lean_ctx::core::patterns::output_class(command));
1270 }
1271 return;
1272 };
1273 let class = found.class();
1274 if let Some(capture) = capture::current() {
1275 capture.set_output_class(class.clone());
1276 }
1277 capture::note_origin(&class);
1278 let root = std::path::Path::new(project_root);
1279 let base = cwd.map_or(root, std::path::Path::new);
1280 for download in &found.downloads {
1281 let class = origin::download_origin(root, base, std::path::Path::new(download)).map_or(
1282 origin::SourceClass::Unclassified { tool: "download" },
1283 origin::SourceClass::External,
1284 );
1285 capture::note_origin(&class);
1286 }
1287}
1288
1289#[cfg(test)]
1290#[path = "ctx_shell_tests.rs"]
1291mod tests;