Skip to main content

lean_ctx_server/tools/registered/
ctx_shell.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
16/// Shell execution must never wait through the general-purpose session-lock
17/// recovery window. The command can run without bookkeeping; cwd validation
18/// cannot, so it fails promptly and lets the client retry.
19const 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                // Looking after a running job is no new command: the session
87                // stats leave it out (`server_metrics::counts_in_session`).
88                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        // #1811: the write-doctrine guard classifies the *destination*, so a
97        // relative redirect target only means something once it is placed
98        // against the directory the command runs in. That directory is not the
99        // `cwd` argument: `effective_cwd_checked` replaces a jail-rejected one
100        // with the project root, and falls back to the session cwd when none
101        // was passed. So it is resolved once, here, and the same value feeds
102        // both the guard and the run below — resolving it twice would let the
103        // two answers drift, and a guard that judged a directory the command
104        // never ran in is exactly the bug #1811 reports.
105        let explicit_cwd = get_str(args, "cwd");
106        let had_explicit_cwd = explicit_cwd.is_some();
107        // Without a session (one-shot CLI contexts) the run directory cannot be
108        // resolved here. That is not an error yet — the cat-redirect below never
109        // needs one — so the guard is simply told nothing and keeps its stricter
110        // pre-#1811 refusal, and the session requirement is raised further down,
111        // where running the command actually depends on it.
112        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        // The write-doctrine check (no `>`, `tee`, heredoc-to-file, curl -o, …)
125        // is an MCP-payload-safety convention, not a security boundary, so it is
126        // opt-out via `shell_allow_writes` (#523). The real command gating
127        // (`check_shell_allowlist`, below) is NOT affected by this flag.
128        let config = lean_ctx::core::config::Config::load();
129        let mut write_allow_paths = config.shell_write_allow_paths_effective();
130        // The session's project and the jail's allow entries are capture
131        // targets too, so no project root is passed as a refusal below.
132        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            // The command never ran — report as a tool error so MCP clients
146            // (guards, retry logic) can detect it programmatically (#389).
147            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        // Relative paths in the command resolve where it runs, not where
162        // this server runs.
163        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            // Independent `;`/newline statements: run the allowed ones and
170            // name each skipped one, instead of refusing the whole line.
171            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                // Part of the line did not run: never report the call as a
180                // success. 126 is the shell's "found but cannot execute"; a
181                // failure of the part that ran keeps its own code. The
182                // ShellBlocked event was counted by the refusal above.
183                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        // #842: a bare `cat <file>` is better served by ctx_read — it delivers
198        // content inline instead of firewalling/archiving the output, avoiding
199        // a mandatory ctx_expand round-trip for agents with cat-muscle-memory.
200        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            // A `cwd` rejected by the project-root jail is silently replaced with
238            // the root (deliberate sandboxing). Surface that swap as a one-line
239            // hint so the caller does not mistake the run dir for the requested
240            // one (#629); appended at the end of the output like the other hints.
241            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                    // Keep failure reporting consistent on this degraded path:
276                    // same [exit:N] footer and the same structured outcome (#389).
277                    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                        // Q13: the call's notice goes in front last, after
288                        // admission; nothing above has seen it.
289                        ..ToolOutput::simple(crate::server::execute::with_notice(
290                            execution.notice,
291                            final_admission.rendered(),
292                        ))
293                    });
294                };
295                // #707: a jail-accepted explicit `cwd` param is the client
296                // telling us where it now works (worktree switches arrive
297                // this way, not as `cd` commands) — persist it so path
298                // resolution's divergence check tracks the live checkout.
299                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                // A job the agent detached on purpose is long work: without a
357                // stated lifetime it gets the ceiling, not the 2/10-minute
358                // foreground default (25.10.: a release install was killed
359                // after 10 of its ~35 minutes and nothing was installed).
360                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            // Foreground runs still detach onto a pollable job if they outlast
394            // the soft cap, so the MCP host's ~120s abort never strands the
395            // result behind an unresolvable task id (#1106).
396            //
397            // #1173: `timeout_ms` is the *job's* lifetime, never the foreground
398            // wait, so it must not raise this cap. Raising it bought no extra
399            // inline wait — the host aborts at ~120s regardless — it only
400            // suppressed our own detach, producing exactly the unresolvable
401            // task id the cap exists to prevent. Separate knobs, one direction:
402            // `LEAN_CTX_SHELL_FG_CAP_MS` moves the cap, `timeout_ms` does not.
403            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                            // #1173: a detach is not a failure — the command is
460                            // alive and its output is recoverable, so say so
461                            // rather than leaving the caller to infer a hang.
462                            ..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            // Persistent diagnostics and derived stores require a separate,
485            // pathless storage decision under one current policy epoch.
486            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                // Shared tee policy (#811): identical decision to the CLI path —
518                // `Failures` keys off the real exit code, not a substring match.
519                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                                // Recovery grammar is path-first: agents without ctx_expand
537                                // can still read the saved artifact directly (#936).
538                                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            // #815: exit 124 = timeout signal (from timeout(1) / lean-ctx
569            // shell timeout). Make it explicit so agents don't confuse a
570            // timed-out command with a successful empty result.
571            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                // Q13: the call's notice goes in front last — after admission,
587                // compression and the hints above, none of which has seen it.
588                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
601/// Deny shell execution for explicitly restricted MCP clients. Missing client
602/// context remains allowed so existing integrations retain their current access.
603/// `ctx_execute` runs code too and applies the same gate (GHSA-rxv5-4c3x-g4q6 B).
604pub(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        // A rejected directory fails here, before the write guard, the run,
629        // a background job or the cache key see a substituted one.
630        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
658/// A timeout notice is framework metadata, not recoverable command output. Do
659/// not archive it as a tee artifact: expanding it cannot recover any bytes (#995).
660///
661/// Keyed on what precedes the marker rather than on the notice's exact shape,
662/// so enriching it (the idle-timeout wording, the still-running segment list)
663/// cannot silently turn every timeout back into an archived artifact (#1173).
664fn 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
724/// Collect the caller's `env` overrides, refusing protected keys by name instead
725/// of dropping them silently (#1771).
726///
727/// The inline-override refusal in `core/shell_allowlist/enforcement.rs` points the
728/// caller at this parameter and uses `PATH` as its worked example — yet five of the
729/// nine variables it blocks inline (`PATH`, `GIT_SSH`, `GIT_SSH_COMMAND`,
730/// `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`) are also in [`is_dangerous_env_key`].
731/// Filtering them away meant the recommended recovery ran the command with the
732/// ambient value and reported success, which is worse than the refusal it replaced:
733/// a wrong result that looks right. Naming the key is the only honest outcome.
734fn 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        // Dynamic linker injection
772        "LD_PRELOAD",
773        "LD_LIBRARY_PATH",
774        "DYLD_INSERT_LIBRARIES",
775        "DYLD_LIBRARY_PATH",
776        "DYLD_FRAMEWORK_PATH",
777        // Shell re-entry / startup injection
778        "BASH_ENV",
779        "ENV",
780        "PROMPT_COMMAND",
781        "SHELL",
782        "IFS",
783        "CDPATH",
784        // Binary resolution hijacking
785        "PATH",
786        "GIT_EXEC_PATH",
787        "GIT_SSH",
788        "GIT_SSH_COMMAND",
789        // Identity / home directory manipulation
790        "HOME",
791        "USER",
792        "LOGNAME",
793        "XDG_CONFIG_HOME",
794        "XDG_DATA_HOME",
795        "XDG_STATE_HOME",
796        "XDG_CACHE_HOME",
797        // Language runtime search path hijacking
798        "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    // Block all lean-ctx config overrides from env
824    if upper.starts_with("LEAN_CTX_") || upper.starts_with("LCTX_") {
825        return true;
826    }
827    false
828}
829
830/// Warn when shell reads secret-like paths via cat/head/tail/less/more.
831/// WARN-ONLY: command still executes, this is purely observational.
832fn 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
863/// The Codex MCP client abandons a tool call after five minutes. A Cargo test
864/// with an explicit five-minute-or-longer shell timeout cannot reliably finish
865/// inside that transport deadline (and can wait on Cargo's target lock), so
866/// detach it and return a pollable job instead.
867fn 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
874/// Foreground wait budget before a still-running command is detached into a
875/// pollable background job. Kept below the MCP host's ~120s tool-call abort so
876/// the caller always receives a real `shell_*` job id instead of an
877/// unresolvable task id (#1106). Override with `LEAN_CTX_SHELL_FG_CAP_MS`.
878fn 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
886/// Map an interpreter binary to a `ctx_execute` language name.
887fn 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
904/// Quote-aware scan for compound operators that would invalidate a reroute.
905fn 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
947/// Detect interpreter heredoc patterns and extract the language + code body.
948/// Returns `Some((language, code, remainder))` where remainder is any command
949/// after the heredoc terminator that still needs shell execution.
950fn 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
1098/// The one working directory both halves of a rerouted heredoc call run in.
1099///
1100/// Resolution goes through the session so an explicit `cwd` is validated
1101/// against the configured roots, exactly as the non-rerouted path does. A `cwd`
1102/// the caller named that cannot be validated is an error, never a silent
1103/// substitution (#1666).
1104///
1105/// `Ok(None)` means only that no directory could be established without a
1106/// session. The heredoc-only path never needed one and must keep working; the
1107/// shell remainder always required a session and keeps requiring it.
1108fn 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    // #1666: resolve the working directory *before* the interpreter runs. The
1138    // shell remainder below always ran in the call's `cwd`; the rerouted
1139    // interpreter ran wherever the server process happened to sit. One tool
1140    // call, two directories — and a script using relative paths silently read
1141    // and wrote the wrong tree while reporting success.
1142    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    // Unchanged from before #1666: running the shell remainder requires a
1194    // validated working directory.
1195    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    // ctx_execute currently exposes no raw-byte capture flags for the heredoc
1240    // half, so the combined output cannot claim full inspection coverage.
1241    completeness.reader_incomplete = true;
1242    let final_admission =
1243        lean_ctx::core::context_admission::admit_tool_output(&combined, completeness);
1244    // Q13: the shell half's notice goes in front last, after admission.
1245    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
1260/// #842: detect a bare `cat <single_file>` command (no pipes, redirects, flags).
1261fn 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;