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
124 let config = lean_ctx::core::config::Config::load();
129 let mut write_allow_paths = config.shell_write_allow_paths_effective();
130 write_allow_paths.extend(crate::tools::ctx_shell::capture_roots(
133 &ctx.project_root,
134 &ctx.extra_roots,
135 &config,
136 ));
137 if !lean_ctx::core::config::shell_policy::allow_writes()
138 && let Some(rejection) = crate::tools::ctx_shell::validate_command_in_cwd(
139 &command,
140 &write_allow_paths,
141 None,
142 resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
143 )
144 {
145 return Ok(ToolOutput {
148 shell_outcome: Some(ShellOutcome::Blocked),
149 content_blocks: None,
150 ..ToolOutput::simple(rejection)
151 });
152 }
153
154 if let Some((lang, code, remainder)) = detect_heredoc_reroute(&command) {
155 return tokio::task::block_in_place(|| {
156 handle_interpreter_heredoc_reroute(args, ctx, &lang, &code, remainder)
157 });
158 }
159
160 let grant = crate::server::shell_approval::current_grant();
161 let run_cwd = resolved_cwd
164 .as_ref()
165 .map(|(cwd, _)| std::path::PathBuf::from(cwd));
166 if let Err(msg) = lean_ctx::core::shell_allowlist::with_run_cwd(run_cwd, || {
167 lean_ctx::core::shell_allowlist::check_shell_allowlist_granted(&command, &grant)
168 }) {
169 if let Some(plan) = lean_ctx::core::shell_allowlist::partial_run(&command, &grant) {
172 let mut sub = args.clone();
173 sub.insert("command".into(), Value::String(plan.run));
174 let mut output = self.handle(&sub, ctx)?;
175 for note in &plan.skipped {
176 output.text.push('\n');
177 output.text.push_str(note);
178 }
179 if matches!(output.shell_outcome, None | Some(ShellOutcome::Exit(0))) {
184 output.shell_outcome = Some(ShellOutcome::Exit(126));
185 }
186 return Ok(output);
187 }
188 return Ok(ToolOutput {
189 shell_outcome: Some(ShellOutcome::Blocked),
190 content_blocks: None,
191 ..ToolOutput::simple(msg.to_string())
192 });
193 }
194
195 warn_shell_secret_paths(&command);
196
197 if let Some(read_path) = detect_bare_cat_file(&command)
201 && let Some(cache_lock) = ctx.cache.as_ref()
202 && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
203 {
204 let result = crate::tools::ctx_read::handle_with_task_resolved(
205 &mut cache,
206 &read_path,
207 "full",
208 crate::tools::CrpMode::Off,
209 None,
210 );
211 let note = format!(
212 "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
213 Use ctx_read(path=\"{read_path}\") directly next time.]"
214 );
215 let out = format!("{}{note}", result.content);
216 let sent = lean_ctx::core::tokens::count_tokens(&out);
217 return Ok(ToolOutput {
218 text: out,
219 original_tokens: sent,
220 saved_tokens: 0,
221 mode: Some("cat-redirect".to_string()),
222 path: Some(read_path),
223 changed: false,
224 shell_outcome: Some(ShellOutcome::Exit(0)),
225 content_blocks: None,
226 });
227 }
228
229 tokio::task::block_in_place(|| {
230 let session_lock = ctx
231 .session
232 .as_ref()
233 .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
234 let Some((effective_cwd, cwd_jail_reason)) = resolved_cwd else {
235 return Err(ErrorData::internal_error("session not available", None));
236 };
237 let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
242 let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
243 format!(
244 "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
245 )
246 });
247
248 {
249 let Some(mut session) = crate::server::bounded_lock::write_for(
250 session_lock,
251 "ctx_shell_write",
252 SESSION_LOCK_BUDGET,
253 ) else {
254 tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
255 let cmd_clone = command.clone();
256 let cwd_clone = effective_cwd.clone();
257 let extra_env = match collect_extra_env(args) {
258 Ok(env) => env,
259 Err(message) => {
260 return Ok(ToolOutput {
261 shell_outcome: Some(ShellOutcome::Blocked),
262 content_blocks: None,
263 ..ToolOutput::simple(message)
264 });
265 }
266 };
267 let execution = crate::server::execute::execute_command_with_env_captured(
268 &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
269 );
270 let admission = lean_ctx::core::context_admission::admit_tool_output(
271 &execution.output,
272 execution.completeness,
273 );
274 let output = redact_shell_output_secrets(&admission.rendered());
275 let exit_suffix =
278 lean_ctx::shell::exit_status::exit_footer(execution.exit_code);
279 let final_text = format!("{output}{exit_suffix}");
280 let final_admission = lean_ctx::core::context_admission::admit_tool_output(
281 &final_text,
282 execution.completeness,
283 );
284 return Ok(ToolOutput {
285 shell_outcome: Some(ShellOutcome::Exit(execution.exit_code)),
286 content_blocks: None,
287 ..ToolOutput::simple(crate::server::execute::with_notice(
290 execution.notice,
291 final_admission.rendered(),
292 ))
293 });
294 };
295 if had_explicit_cwd && cwd_jail_reason_was_none {
300 session.note_explicit_cwd(&effective_cwd);
301 }
302 session.update_shell_cwd(&command);
303 let root_missing = session
304 .project_root
305 .as_deref()
306 .is_none_or(|r| r.trim().is_empty());
307 if root_missing {
308 let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
309 if let Some(root) =
310 lean_ctx::core::protocol::detect_project_root(&effective_cwd)
311 && home.as_deref() != Some(root.as_str())
312 {
313 session.project_root = Some(root.clone());
314 lean_ctx::core::index_orchestrator::ensure_all_background(&root);
315 }
316 }
317 }
318
319 let arg_raw = get_bool(args, "raw").unwrap_or(false);
320 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
321 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
322 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
323 let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
324
325 let crp_mode = ctx.crp_mode;
326 let cmd_clone = command.clone();
327 let cwd_clone = effective_cwd;
328 let proactive_block = if raw
329 || !lean_ctx::core::profiles::active_profile()
330 .output_hints
331 .proactive_context()
332 {
333 None
334 } else {
335 lean_ctx::core::relevance_tracker::proactive_context(&format!(
336 "ctx_shell command={cmd_clone} cwd={cwd_clone}"
337 ))
338 };
339
340 let extra_env = match collect_extra_env(args) {
341 Ok(env) => env,
342 Err(message) => {
343 return Ok(ToolOutput {
344 shell_outcome: Some(ShellOutcome::Blocked),
345 content_blocks: None,
346 ..ToolOutput::simple(message)
347 });
348 }
349 };
350
351 let inline = get_bool(args, "inline").unwrap_or(false);
352
353 let auto_background = should_auto_background(&cmd_clone, timeout_ms);
354 let detached = get_bool(args, "run_in_background").unwrap_or(false);
355 if detached || auto_background {
356 let lifetime = if detached {
361 timeout_ms.or(Some(lean_ctx::shell::exec::MAX_CALL_TIMEOUT_MS))
362 } else {
363 timeout_ms
364 };
365 let job_id = crate::server::background_shell::start(
366 cmd_clone, cwd_clone, extra_env, lifetime,
367 );
368 let mode = if auto_background {
369 "auto-background"
370 } else {
371 "background"
372 };
373 return Ok(ToolOutput {
374 shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
375 state: BackgroundJobState::Running,
376 exit_code: None,
377 job_id: job_id.clone(),
378 archive_id: None,
379 archive_truncated: None,
380 captured_chars: None,
381 archived_chars: None,
382 summary: format!("{mode} job started"),
383 is_error: false,
384 display: None,
385 })),
386 content_blocks: None,
387 ..ToolOutput::simple(format!(
388 "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
389 ))
390 });
391 }
392
393 let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
404 let progress_sender = ctx.progress_sender.clone();
405 let progress_label: String = cmd_clone.chars().take(60).collect();
406 let cap_secs = soft_cap.as_secs_f64();
407 let on_tick = |elapsed: std::time::Duration| {
408 #[allow(clippy::unwrap_or_default)]
409 if let Some(ref ps) = progress_sender
410 && let Some(sender) = ps
411 .lock()
412 .unwrap_or_else(std::sync::PoisonError::into_inner)
413 .as_ref()
414 {
415 sender.send(
416 elapsed.as_secs_f64(),
417 Some(cap_secs),
418 Some(format!(
419 "ctx_shell: {}s elapsed — {progress_label}",
420 elapsed.as_secs()
421 )),
422 );
423 }
424 };
425 let (process_output, exit_code, completeness, retained_withheld, containment_notice) =
426 match crate::server::background_shell::run_foreground_or_detach_captured(
427 cmd_clone.clone(),
428 cwd_clone.clone(),
429 extra_env.clone(),
430 timeout_ms,
431 soft_cap,
432 Some(&on_tick),
433 ctx.cancel.as_ref(),
434 ) {
435 crate::server::background_shell::CapturedForegroundResult::Finished {
436 output,
437 exit_code,
438 completeness,
439 retained_withheld,
440 notice,
441 } => (output, exit_code, completeness, retained_withheld, notice),
442 crate::server::background_shell::CapturedForegroundResult::Detached {
443 job_id,
444 } => {
445 return Ok(ToolOutput {
446 shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
447 state: BackgroundJobState::Running,
448 exit_code: None,
449 job_id: job_id.clone(),
450 archive_id: None,
451 archive_truncated: None,
452 captured_chars: None,
453 archived_chars: None,
454 summary: "auto-detached job is still running".to_string(),
455 is_error: false,
456 display: None,
457 })),
458 content_blocks: None,
459 ..ToolOutput::simple(format!(
463 "[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]",
464 soft_cap.as_secs()
465 ))
466 });
467 }
468 };
469
470 let display_input = if retained_withheld {
471 "[lean-ctx gateway: retained output withheld by storage policy]".to_string()
472 } else {
473 process_output.clone()
474 };
475 let admission =
476 lean_ctx::core::context_admission::admit_tool_output(&display_input, completeness);
477 let output = redact_shell_output_secrets(&admission.rendered());
478 let output = if raw {
479 crate::server::walk_hint::strip_walk_hint(&output).to_string()
480 } else {
481 output
482 };
483
484 let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
487 let stored_command = store.admit_shell_command(&cmd_clone);
488 let stored_output = (!retained_withheld)
489 .then(|| store.admit_shell_output(&process_output, completeness))
490 .flatten();
491 let storage_allowed = !retained_withheld
492 && store.is_current()
493 && stored_command.is_some()
494 && stored_output.is_some();
495 let command_for_store = stored_command.as_deref().unwrap_or_default();
496 let output_for_store = stored_output.as_deref().unwrap_or_default();
497 if storage_allowed {
498 lean_ctx::core::diagnostics_store::record_from_shell(
499 command_for_store,
500 output_for_store,
501 exit_code,
502 );
503 }
504
505 let (result_out, original, saved, tee_hint) = if raw || inline {
506 let tokens = lean_ctx::core::tokens::count_tokens(&output);
507 (output, tokens, 0, String::new())
508 } else {
509 let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("shell");
510 let result =
511 crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
512 let original = lean_ctx::core::tokens::count_tokens(&output);
513 let sent = lean_ctx::core::tokens::count_tokens(&result);
514 let saved = original.saturating_sub(sent);
515
516 let cfg = lean_ctx::core::config::Config::load();
517 let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
520 let tee_hint = if storage_allowed
521 && lean_ctx::shell::tee_policy::should_tee(
522 &cfg.tee_mode,
523 exit_code,
524 output.trim().is_empty() || timeout_notice_only,
525 lean_ctx::shell::tee_policy::output_was_elided(&output, &result),
526 original,
527 sent,
528 ) {
529 lean_ctx::shell::save_tee(command_for_store, output_for_store)
530 .map(|p| {
531 if lean_ctx::core::protected_store::enforce_protected_store_path(std::path::Path::new(&p)).is_err() {
532 format!("\n[full output: ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\")]")
533 } else if matches!(cfg.tee_mode, lean_ctx::core::config::TeeMode::HighCompression)
534 {
535 let pct = lean_ctx::shell::tee_policy::savings_pct(original, sent);
536 format!(
539 "\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]"
540 )
541 } else {
542 format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
543 }
544 })
545 .unwrap_or_default()
546 } else {
547 String::new()
548 };
549
550 (result, original, saved, tee_hint)
551 };
552
553 let mode = if bypass {
554 Some("bypass".to_string())
555 } else if raw {
556 Some("raw".to_string())
557 } else {
558 None
559 };
560
561 let shell_mismatch = if cfg!(windows) && !raw {
562 shell_mismatch_hint(&command, &result_out)
563 } else {
564 String::new()
565 };
566
567 let result_out = lean_ctx::core::redaction::redact_text_if_enabled(&result_out);
568 let exit_suffix = lean_ctx::shell::exit_status::exit_footer(exit_code);
572 let nudge = if raw { "" } else { search_tool_nudge(&command) };
573 let final_out = format!(
574 "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
575 );
576 let final_out = if let Some(block) = proactive_block {
577 format!("{final_out}{block}")
578 } else {
579 final_out
580 };
581 let final_admission =
582 lean_ctx::core::context_admission::admit_tool_output(&final_out, completeness);
583 let final_out = final_admission.rendered();
584
585 Ok(ToolOutput {
586 text: crate::server::execute::with_notice(containment_notice, final_out),
589 original_tokens: original,
590 saved_tokens: saved,
591 mode,
592 path: None,
593 changed: false,
594 shell_outcome: Some(ShellOutcome::Exit(exit_code)),
595 content_blocks: None,
596 })
597 })
598 }
599}
600
601pub(crate) fn shell_access_denial(ctx: &ToolContext) -> Option<String> {
605 if let Some(role) = ctx.client_role.as_deref()
606 && (role.eq_ignore_ascii_case("untrusted") || role.eq_ignore_ascii_case("readonly"))
607 {
608 return Some(format!(
609 "[SHELL ACCESS DENIED] ctx_shell is unavailable to MCP clients with role '{role}'."
610 ));
611 }
612
613 if ctx.shell_access == Some(false) {
614 return Some(
615 "[SHELL ACCESS DENIED] ctx_shell requires shell_access=true in the MCP session/request context."
616 .to_string(),
617 );
618 }
619
620 None
621}
622
623fn resolve_effective_cwd(
624 session: Option<tokio::sync::OwnedRwLockReadGuard<lean_ctx::core::session::SessionState>>,
625 explicit_cwd: Option<&str>,
626) -> Result<(String, Option<String>), ErrorData> {
627 match session {
628 Some(session) => session
631 .execution_cwd(explicit_cwd)
632 .map(|cwd| (cwd, None))
633 .map_err(|reason| {
634 ErrorData::invalid_params(
635 format!("working directory rejected, command not run: {reason}"),
636 None,
637 )
638 }),
639 None => Err(ErrorData::internal_error(
640 "session lock timeout — cannot validate working directory",
641 None,
642 )),
643 }
644}
645
646#[allow(clippy::fn_params_excessive_bools)]
647fn resolve_shell_raw_flags(
648 arg_raw: bool,
649 arg_bypass: bool,
650 _env_disabled: bool,
651 env_raw: bool,
652) -> (bool, bool) {
653 let bypass = arg_bypass || env_raw;
654 let raw = arg_raw || bypass;
655 (raw, bypass)
656}
657
658fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
665 exit_code == 124
666 && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
667}
668
669fn search_tool_nudge(command: &str) -> &'static str {
670 let cmd = command.trim();
671 let first_word = cmd.split_whitespace().next().unwrap_or("");
672 if !cmd.contains('|') {
673 match first_word {
674 "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
675 return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
676 }
677 "find" => {
678 return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
679 }
680 "ls" | "exa" | "eza" => {
681 return "\n[hint: use ctx_tree for structured directory listing]";
682 }
683 _ => {}
684 }
685 }
686 ""
687}
688
689fn shell_mismatch_hint(command: &str, output: &str) -> String {
690 let shell = lean_ctx::shell::shell_name();
691 let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
692 let has_error = output.contains("is not recognized")
693 || output.contains("not found")
694 || output.contains("command not found");
695
696 if !has_error {
697 return String::new();
698 }
699
700 let powershell_cmds = [
701 "Get-Content",
702 "Select-Object",
703 "Get-ChildItem",
704 "Set-Location",
705 "Where-Object",
706 "ForEach-Object",
707 "Select-String",
708 "Invoke-Expression",
709 "Write-Output",
710 ];
711 let uses_powershell = powershell_cmds
712 .iter()
713 .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
714
715 if is_posix && uses_powershell {
716 format!(
717 "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
718 )
719 } else {
720 String::new()
721 }
722}
723
724fn collect_extra_env(
735 args: &Map<String, Value>,
736) -> Result<std::collections::HashMap<String, String>, String> {
737 let mut accepted = std::collections::HashMap::new();
738 let Some(obj) = args.get("env").and_then(|v| v.as_object()) else {
739 return Ok(accepted);
740 };
741 let mut rejected: Vec<&str> = Vec::new();
742 for (key, value) in obj {
743 let Some(text) = value.as_str() else { continue };
744 if is_dangerous_env_key(key) {
745 rejected.push(key.as_str());
746 continue;
747 }
748 accepted.insert(key.clone(), text.to_string());
749 }
750 if rejected.is_empty() {
751 return Ok(accepted);
752 }
753 rejected.sort_unstable();
754 let names = rejected
755 .iter()
756 .map(|k| format!("`{k}`"))
757 .collect::<Vec<_>>()
758 .join(", ");
759 let plural = if rejected.len() == 1 { "key" } else { "keys" };
760 Err(format!(
761 "ERROR: ctx_shell refuses the `env` {plural} {names} — nothing was run. These keys can \
762 redirect which binary executes, so lean-ctx never forwards them, and the command would \
763 otherwise have used the inherited value while appearing to honor yours. This is the \
764 same rule that blocks the inline `KEY=value cmd` form; `env` is not a way around it. \
765 Invoke the binary by its full path instead, or drop the override."
766 ))
767}
768
769fn is_dangerous_env_key(key: &str) -> bool {
770 const BLOCKED: &[&str] = &[
771 "LD_PRELOAD",
773 "LD_LIBRARY_PATH",
774 "DYLD_INSERT_LIBRARIES",
775 "DYLD_LIBRARY_PATH",
776 "DYLD_FRAMEWORK_PATH",
777 "BASH_ENV",
779 "ENV",
780 "PROMPT_COMMAND",
781 "SHELL",
782 "IFS",
783 "CDPATH",
784 "PATH",
786 "GIT_EXEC_PATH",
787 "GIT_SSH",
788 "GIT_SSH_COMMAND",
789 "HOME",
791 "USER",
792 "LOGNAME",
793 "XDG_CONFIG_HOME",
794 "XDG_DATA_HOME",
795 "XDG_STATE_HOME",
796 "XDG_CACHE_HOME",
797 "PYTHONPATH",
799 "PYTHONSTARTUP",
800 "PYTHONHOME",
801 "NODE_PATH",
802 "NODE_OPTIONS",
803 "RUBYOPT",
804 "RUBYLIB",
805 "GEM_PATH",
806 "GEM_HOME",
807 "PERL5LIB",
808 "PERL5OPT",
809 "CLASSPATH",
810 "JAVA_HOME",
811 "CARGO_HOME",
812 "RUSTUP_HOME",
813 "GOPATH",
814 "GOROOT",
815 ];
816 let upper = key.to_uppercase();
817 if BLOCKED.contains(&upper.as_str()) {
818 return true;
819 }
820 if upper.starts_with("LD_") && upper.ends_with("_PATH") {
821 return true;
822 }
823 if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
825 return true;
826 }
827 false
828}
829
830fn warn_shell_secret_paths(command: &str) {
833 const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
834 let segments = lean_ctx::core::shell_allowlist::extract_all_commands_pub(command);
835 for seg in &segments {
836 let trimmed = seg.trim();
837 let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(trimmed);
838 if tokens.is_empty() {
839 continue;
840 }
841 let base = tokens[0]
842 .rsplit('/')
843 .next()
844 .unwrap_or(&tokens[0])
845 .to_string();
846 if !READ_CMDS.contains(&base.as_str()) {
847 continue;
848 }
849 for tok in &tokens[1..] {
850 if tok.starts_with('-') {
851 continue;
852 }
853 let path = std::path::Path::new(tok.as_str());
854 if lean_ctx::core::io_boundary::is_secret_like(path).is_some() {
855 tracing::warn!(
856 "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
857 );
858 }
859 }
860 }
861}
862
863fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
868 timeout_ms.is_some_and(|timeout| timeout >= 300_000)
869 && command
870 .lines()
871 .any(|line| line.trim_start().starts_with("cargo test"))
872}
873
874fn foreground_soft_cap_ms() -> u64 {
879 std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
880 .ok()
881 .and_then(|v| v.parse().ok())
882 .filter(|&ms| ms > 0)
883 .unwrap_or(110_000)
884}
885
886fn interpreter_to_execute_language(base: &str) -> Option<&'static str> {
888 match base {
889 "python" | "python2" | "python3" => Some("python"),
890 "node" => Some("javascript"),
891 "ruby" => Some("ruby"),
892 _ => None,
893 }
894}
895
896fn is_env_assignment_token(token: &str) -> bool {
897 let unquoted: String = token.chars().filter(|c| *c != '"' && *c != '\'').collect();
898 unquoted.contains('=')
899 && !unquoted.starts_with('-')
900 && !unquoted.starts_with('/')
901 && !unquoted.starts_with('.')
902}
903
904fn prelude_has_compound_operator(prelude: &str) -> bool {
906 let bytes = prelude.as_bytes();
907 let len = bytes.len();
908 let mut i = 0;
909 let mut in_single = false;
910 let mut in_double = false;
911 while i < len {
912 let ch = bytes[i];
913 if in_single {
914 if ch == b'\'' {
915 in_single = false;
916 }
917 i += 1;
918 continue;
919 }
920 if in_double {
921 if ch == b'\\' && i + 1 < len {
922 i += 2;
923 } else {
924 if ch == b'"' {
925 in_double = false;
926 }
927 i += 1;
928 }
929 continue;
930 }
931 match ch {
932 b'\'' => {
933 in_single = true;
934 i += 1;
935 }
936 b'"' => {
937 in_double = true;
938 i += 1;
939 }
940 b';' | b'|' | b'&' | b'(' | b'{' => return true,
941 _ => i += 1,
942 }
943 }
944 false
945}
946
947fn detect_heredoc_reroute(command: &str) -> Option<(String, String, Option<String>)> {
951 if !command.contains("<<") {
952 return None;
953 }
954
955 let lines: Vec<&str> = command.lines().collect();
956 if lines.is_empty() {
957 return None;
958 }
959
960 let first_line = lines[0];
961 let delims = lean_ctx::core::shell_allowlist::heredoc_delims(first_line, false);
962 if delims.len() != 1 {
963 return None;
964 }
965 let delim = delims[0].clone();
966
967 let heredoc_pos = find_heredoc_operator(first_line)?;
968 let prelude = first_line[..heredoc_pos].trim();
969 if prelude_has_compound_operator(prelude) {
970 return None;
971 }
972
973 let language = parse_interpreter_heredoc_prelude(prelude)?.to_string();
974
975 if has_trailing_tokens_after_heredoc_delim(first_line, heredoc_pos) {
976 return None;
977 }
978
979 let mut body = String::new();
980 let mut remainder_start: Option<usize> = None;
981 for (idx, line) in lines.iter().enumerate().skip(1) {
982 if line.trim_start_matches('\t').trim() == delim {
983 remainder_start = Some(idx + 1);
984 break;
985 }
986 if !body.is_empty() {
987 body.push('\n');
988 }
989 body.push_str(line);
990 }
991 let remainder_start = remainder_start?;
992 let remainder = if remainder_start < lines.len() {
993 let rest = lines[remainder_start..].join("\n");
994 let rest = rest.trim();
995 if rest.is_empty() {
996 None
997 } else {
998 Some(rest.to_string())
999 }
1000 } else {
1001 None
1002 };
1003
1004 Some((language, body, remainder))
1005}
1006
1007fn find_heredoc_operator(line: &str) -> Option<usize> {
1008 let bytes = line.as_bytes();
1009 let len = bytes.len();
1010 let mut i = 0;
1011 let mut in_single = false;
1012 let mut in_double = false;
1013 while i < len {
1014 let ch = bytes[i];
1015 if in_single {
1016 if ch == b'\'' {
1017 in_single = false;
1018 }
1019 i += 1;
1020 continue;
1021 }
1022 if in_double {
1023 if ch == b'\\' && i + 1 < len {
1024 i += 2;
1025 } else {
1026 if ch == b'"' {
1027 in_double = false;
1028 }
1029 i += 1;
1030 }
1031 continue;
1032 }
1033 match ch {
1034 b'\'' => {
1035 in_single = true;
1036 i += 1;
1037 }
1038 b'"' => {
1039 in_double = true;
1040 i += 1;
1041 }
1042 b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
1043 if i + 2 < len && bytes[i + 2] == b'<' {
1044 i += 3;
1045 continue;
1046 }
1047 return Some(i);
1048 }
1049 _ => i += 1,
1050 }
1051 }
1052 None
1053}
1054
1055fn has_trailing_tokens_after_heredoc_delim(line: &str, heredoc_pos: usize) -> bool {
1056 let bytes = line.as_bytes();
1057 let mut i = heredoc_pos + 2;
1058 if i < bytes.len() && bytes[i] == b'-' {
1059 i += 1;
1060 }
1061 while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1062 i += 1;
1063 }
1064 let Some((_, _, next)) = lean_ctx::core::shell_allowlist::read_heredoc_delim(bytes, i) else {
1065 return true;
1066 };
1067 !line[next..].trim().is_empty()
1068}
1069
1070fn parse_interpreter_heredoc_prelude(prelude: &str) -> Option<&'static str> {
1071 let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(prelude.trim());
1072 let mut idx = 0;
1073 while idx < tokens.len() && is_env_assignment_token(&tokens[idx]) {
1074 idx += 1;
1075 }
1076 if idx >= tokens.len() {
1077 return None;
1078 }
1079 let base = tokens[idx]
1080 .rsplit('/')
1081 .next()
1082 .unwrap_or(tokens[idx].as_str());
1083 let language = interpreter_to_execute_language(base)?;
1084 idx += 1;
1085 match tokens.get(idx) {
1086 None => Some(language),
1087 Some(dash) if dash == "-" => {
1088 if tokens.len() == idx + 1 {
1089 Some(language)
1090 } else {
1091 None
1092 }
1093 }
1094 Some(_) => None,
1095 }
1096}
1097
1098fn resolve_reroute_cwd(
1109 ctx: &ToolContext,
1110 explicit_cwd: Option<&str>,
1111) -> Result<Option<String>, ErrorData> {
1112 let Some(session_lock) = ctx.session.as_ref() else {
1113 if explicit_cwd.is_some() {
1114 return Err(ErrorData::internal_error(
1115 "session not available — cannot validate the requested working directory",
1116 None,
1117 ));
1118 }
1119 return Ok(None);
1120 };
1121 let guard =
1122 crate::server::bounded_lock::read_for(session_lock, "ctx_shell_cwd", SESSION_LOCK_BUDGET);
1123 let (cwd, _) = resolve_effective_cwd(guard, explicit_cwd)?;
1124 Ok(Some(cwd))
1125}
1126
1127fn handle_interpreter_heredoc_reroute(
1128 args: &Map<String, Value>,
1129 ctx: &ToolContext,
1130 language: &str,
1131 code: &str,
1132 remainder: Option<String>,
1133) -> Result<ToolOutput, ErrorData> {
1134 let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
1135 let timeout_secs = timeout_ms.map(|ms| ms.div_ceil(1000).max(1));
1136
1137 let explicit_cwd = get_str(args, "cwd");
1143 let exec_cwd = resolve_reroute_cwd(ctx, explicit_cwd.as_deref())?;
1144
1145 let (exec_text, exec_outcome) = crate::tools::ctx_execute::handle_in(
1146 language,
1147 code,
1148 None,
1149 timeout_secs,
1150 exec_cwd.as_deref().map(std::path::Path::new),
1151 );
1152 let reroute_note = format!(
1153 "\n[ctx_shell: interpreter heredoc auto-rerouted to ctx_execute(language=\"{language}\")]"
1154 );
1155 let exec_completeness = lean_ctx::core::context_admission::OutputCompleteness {
1156 reader_incomplete: true,
1157 ..lean_ctx::core::context_admission::OutputCompleteness::complete()
1158 };
1159 let exec_admission =
1160 lean_ctx::core::context_admission::admit_tool_output(&exec_text, exec_completeness);
1161 let exec_text = lean_ctx::core::redaction::redact_text_if_enabled(&exec_admission.rendered());
1162
1163 let Some(rest_cmd) = remainder else {
1164 let final_text = format!("{exec_text}{reroute_note}");
1165 let final_admission =
1166 lean_ctx::core::context_admission::admit_tool_output(&final_text, exec_completeness);
1167 let text = final_admission.rendered();
1168 return Ok(ToolOutput {
1169 original_tokens: lean_ctx::core::tokens::count_tokens(&text),
1170 text,
1171 saved_tokens: 0,
1172 mode: Some("heredoc-reroute".to_string()),
1173 path: None,
1174 changed: false,
1175 shell_outcome: Some(exec_outcome),
1176 content_blocks: None,
1177 });
1178 };
1179
1180 if let Err(msg) = lean_ctx::core::shell_allowlist::check_shell_allowlist_granted(
1181 &rest_cmd,
1182 &crate::server::shell_approval::current_grant(),
1183 ) {
1184 let blocked =
1185 format!("{exec_text}{reroute_note}\n\n[remainder blocked by shell allowlist]\n{msg}");
1186 return Ok(ToolOutput {
1187 shell_outcome: Some(ShellOutcome::Blocked),
1188 content_blocks: None,
1189 ..ToolOutput::simple(blocked)
1190 });
1191 }
1192
1193 let effective_cwd =
1196 exec_cwd.ok_or_else(|| ErrorData::internal_error("session not available", None))?;
1197
1198 let extra_env = match collect_extra_env(args) {
1199 Ok(env) => env,
1200 Err(message) => {
1201 return Ok(ToolOutput {
1202 shell_outcome: Some(ShellOutcome::Blocked),
1203 content_blocks: None,
1204 ..ToolOutput::simple(message)
1205 });
1206 }
1207 };
1208
1209 let shell_execution = crate::server::execute::execute_command_with_env_captured(
1210 &rest_cmd,
1211 &effective_cwd,
1212 &extra_env,
1213 timeout_ms,
1214 );
1215 let shell_exit = shell_execution.exit_code;
1216 let shell_admission = lean_ctx::core::context_admission::admit_tool_output(
1217 &shell_execution.output,
1218 shell_execution.completeness,
1219 );
1220 let shell_output = redact_shell_output_secrets(&shell_admission.rendered());
1221 let arg_raw = get_bool(args, "raw").unwrap_or(false);
1222 let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
1223 let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
1224 let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
1225 let (raw, _) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
1226
1227 let shell_text = if raw {
1228 shell_output
1229 } else {
1230 crate::tools::ctx_shell::handle(&rest_cmd, &shell_output, shell_exit, ctx.crp_mode)
1231 };
1232 let shell_text = lean_ctx::core::redaction::redact_text_if_enabled(&shell_text);
1233 let exit_suffix = lean_ctx::shell::exit_status::exit_footer(shell_exit);
1234
1235 let combined = format!(
1236 "{exec_text}{reroute_note}\n\n[heredoc remainder via ctx_shell]\n{shell_text}{exit_suffix}"
1237 );
1238 let mut completeness = shell_execution.completeness;
1239 completeness.reader_incomplete = true;
1242 let final_admission =
1243 lean_ctx::core::context_admission::admit_tool_output(&combined, completeness);
1244 let text =
1246 crate::server::execute::with_notice(shell_execution.notice, final_admission.rendered());
1247 let token_count = lean_ctx::core::tokens::count_tokens(&text);
1248 Ok(ToolOutput {
1249 text,
1250 original_tokens: token_count,
1251 saved_tokens: 0,
1252 mode: Some("heredoc-reroute".to_string()),
1253 path: None,
1254 changed: false,
1255 shell_outcome: Some(ShellOutcome::Exit(shell_exit)),
1256 content_blocks: None,
1257 })
1258}
1259
1260fn detect_bare_cat_file(command: &str) -> Option<String> {
1262 let trimmed = command.trim();
1263 let rest = trimmed.strip_prefix("cat ")?;
1264 let rest = rest.trim();
1265 if rest.is_empty()
1266 || rest.contains('|')
1267 || rest.contains('>')
1268 || rest.contains('<')
1269 || rest.contains(';')
1270 || rest.contains('&')
1271 || rest.contains('$')
1272 || rest.starts_with('-')
1273 {
1274 return None;
1275 }
1276 let parts: Vec<&str> = rest.split_whitespace().collect();
1277 if parts.len() != 1 {
1278 return None;
1279 }
1280 let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
1281 if file_path.is_empty() {
1282 return None;
1283 }
1284 Some(file_path.to_string())
1285}
1286
1287#[cfg(test)]
1288#[path = "ctx_shell_tests.rs"]
1289mod tests;