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        // pz21 §1.8: output from outside the repository is `tool_output`;
124        // a download also names the file it wrote.
125        note_tool_output(
126            &command,
127            resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
128            &ctx.project_root,
129        );
130
131        // The write-doctrine check (no `>`, `tee`, heredoc-to-file, curl -o, …)
132        // is an MCP-payload-safety convention, not a security boundary, so it is
133        // opt-out via `shell_allow_writes` (#523). The real command gating
134        // (`check_shell_allowlist`, below) is NOT affected by this flag.
135        let config = lean_ctx::core::config::Config::load();
136        let mut write_allow_paths = config.shell_write_allow_paths_effective();
137        // The session's project and the jail's allow entries are capture
138        // targets too, so no project root is passed as a refusal below.
139        write_allow_paths.extend(crate::tools::ctx_shell::capture_roots(
140            &ctx.project_root,
141            &ctx.extra_roots,
142            &config,
143        ));
144        if !lean_ctx::core::config::shell_policy::allow_writes()
145            && let Some(rejection) = crate::tools::ctx_shell::validate_command_in_cwd(
146                &command,
147                &write_allow_paths,
148                None,
149                resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
150            )
151        {
152            // The command never ran — report as a tool error so MCP clients
153            // (guards, retry logic) can detect it programmatically (#389).
154            return Ok(ToolOutput {
155                shell_outcome: Some(ShellOutcome::Blocked),
156                content_blocks: None,
157                ..ToolOutput::simple(rejection)
158            });
159        }
160
161        // The session's rights profile (process-v1 §9): every write the command
162        // line names stays in the role's write scope, never a locked path.
163        let rights_cwd = resolved_cwd
164            .as_ref()
165            .map_or_else(|| ctx.project_root.clone(), |(cwd, _)| cwd.clone());
166        // The `env` argument sets variables on the child, so the rights check
167        // sees its keys too (a namespace or command-valued variable there is
168        // refused just like the inline `KEY=value` form).
169        let env_keys: Vec<&str> = args
170            .get("env")
171            .and_then(Value::as_object)
172            .map(|obj| obj.keys().map(String::as_str).collect())
173            .unwrap_or_default();
174        if let Err(reason) = lean_ctx::core::session_rights::current().check_bash(
175            &command,
176            std::path::Path::new(&rights_cwd),
177            &env_keys,
178        ) {
179            return Ok(ToolOutput {
180                shell_outcome: Some(ShellOutcome::Blocked),
181                content_blocks: None,
182                ..ToolOutput::simple(reason)
183            });
184        }
185
186        if let Some((lang, code, remainder)) = detect_heredoc_reroute(&command) {
187            return tokio::task::block_in_place(|| {
188                handle_interpreter_heredoc_reroute(args, ctx, &lang, &code, remainder)
189            });
190        }
191
192        let grant = crate::server::shell_approval::current_grant();
193        // Relative paths in the command resolve where it runs, not where
194        // this server runs.
195        let run_cwd = resolved_cwd
196            .as_ref()
197            .map(|(cwd, _)| std::path::PathBuf::from(cwd));
198        if let Err(msg) = lean_ctx_shell_policy::with_run_cwd(run_cwd, || {
199            lean_ctx_shell_policy::check_shell_allowlist_granted(&command, &grant)
200        }) {
201            // Independent `;`/newline statements: run the allowed ones and
202            // name each skipped one, instead of refusing the whole line.
203            if let Some(plan) = lean_ctx_shell_policy::partial_run(&command, &grant) {
204                let mut sub = args.clone();
205                sub.insert("command".into(), Value::String(plan.run));
206                let mut output = self.handle(&sub, ctx)?;
207                for note in &plan.skipped {
208                    output.text.push('\n');
209                    output.text.push_str(note);
210                }
211                // Part of the line did not run: never report the call as a
212                // success. 126 is the shell's "found but cannot execute"; a
213                // failure of the part that ran keeps its own code. The
214                // ShellBlocked event was counted by the refusal above.
215                if matches!(output.shell_outcome, None | Some(ShellOutcome::Exit(0))) {
216                    output.shell_outcome = Some(ShellOutcome::Exit(126));
217                }
218                return Ok(output);
219            }
220            return Ok(ToolOutput {
221                shell_outcome: Some(ShellOutcome::Blocked),
222                content_blocks: None,
223                ..ToolOutput::simple(msg.to_string())
224            });
225        }
226
227        warn_shell_secret_paths(&command);
228
229        // #842: a bare `cat <file>` is better served by ctx_read — it delivers
230        // content inline instead of firewalling/archiving the output, avoiding
231        // a mandatory ctx_expand round-trip for agents with cat-muscle-memory.
232        if let Some(read_path) = detect_bare_cat_file(&command)
233            && let Some(cache_lock) = ctx.cache.as_ref()
234            && let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
235        {
236            let result = crate::tools::ctx_read::handle_with_task_resolved(
237                &mut cache,
238                &read_path,
239                "full",
240                crate::tools::CrpMode::Off,
241                None,
242            );
243            let note = format!(
244                "\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
245                         Use ctx_read(path=\"{read_path}\") directly next time.]"
246            );
247            let out = format!("{}{note}", result.content);
248            let sent = lean_ctx::core::tokens::count_tokens(&out);
249            return Ok(ToolOutput {
250                text: out,
251                original_tokens: sent,
252                saved_tokens: 0,
253                mode: Some("cat-redirect".to_string()),
254                path: Some(read_path),
255                changed: false,
256                shell_outcome: Some(ShellOutcome::Exit(0)),
257                content_blocks: None,
258            });
259        }
260
261        tokio::task::block_in_place(|| {
262            let session_lock = ctx
263                .session
264                .as_ref()
265                .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
266            let Some((effective_cwd, cwd_jail_reason)) = resolved_cwd else {
267                return Err(ErrorData::internal_error("session not available", None));
268            };
269            // A `cwd` rejected by the project-root jail is silently replaced with
270            // the root (deliberate sandboxing). Surface that swap as a one-line
271            // hint so the caller does not mistake the run dir for the requested
272            // one (#629); appended at the end of the output like the other hints.
273            let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
274            let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
275                format!(
276                    "\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
277                )
278            });
279
280            {
281                let Some(mut session) = crate::server::bounded_lock::write_for(
282                    session_lock,
283                    "ctx_shell_write",
284                    SESSION_LOCK_BUDGET,
285                ) else {
286                    tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
287                    let cmd_clone = command.clone();
288                    let cwd_clone = effective_cwd.clone();
289                    let extra_env = match collect_extra_env(args) {
290                        Ok(env) => env,
291                        Err(message) => {
292                            return Ok(ToolOutput {
293                                shell_outcome: Some(ShellOutcome::Blocked),
294                                content_blocks: None,
295                                ..ToolOutput::simple(message)
296                            });
297                        }
298                    };
299                    let execution = crate::server::execute::execute_command_with_env_captured(
300                        &cmd_clone, &cwd_clone, &extra_env, timeout_ms,
301                    );
302                    let admission = lean_ctx::core::context_admission::admit_tool_output(
303                        &execution.output,
304                        execution.completeness,
305                    );
306                    let output = redact_shell_output_secrets(&admission.rendered());
307                    // Keep failure reporting consistent on this degraded path:
308                    // same [exit:N] footer and the same structured outcome (#389).
309                    let exit_suffix =
310                        lean_ctx::shell::exit_status::exit_footer(execution.exit_code);
311                    let final_text = format!("{output}{exit_suffix}");
312                    let final_admission = lean_ctx::core::context_admission::admit_tool_output(
313                        &final_text,
314                        execution.completeness,
315                    );
316                    return Ok(ToolOutput {
317                        shell_outcome: Some(ShellOutcome::Exit(execution.exit_code)),
318                        content_blocks: None,
319                        // Q13: the call's notice goes in front last, after
320                        // admission; nothing above has seen it.
321                        ..ToolOutput::simple(crate::server::execute::with_notice(
322                            execution.notice,
323                            final_admission.rendered(),
324                        ))
325                    });
326                };
327                // #707: a jail-accepted explicit `cwd` param is the client
328                // telling us where it now works (worktree switches arrive
329                // this way, not as `cd` commands) — persist it so path
330                // resolution's divergence check tracks the live checkout.
331                if had_explicit_cwd && cwd_jail_reason_was_none {
332                    session.note_explicit_cwd(&effective_cwd);
333                }
334                session.update_shell_cwd(&command);
335                let root_missing = session
336                    .project_root
337                    .as_deref()
338                    .is_none_or(|r| r.trim().is_empty());
339                if root_missing {
340                    let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
341                    if let Some(root) =
342                        lean_ctx::core::protocol::detect_project_root(&effective_cwd)
343                        && home.as_deref() != Some(root.as_str())
344                    {
345                        session.project_root = Some(root.clone());
346                        lean_ctx::core::index_orchestrator::ensure_all_background(&root);
347                    }
348                }
349            }
350
351            let arg_raw = get_bool(args, "raw").unwrap_or(false);
352            let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
353            let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
354            let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
355            let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
356
357            let crp_mode = ctx.crp_mode;
358            let cmd_clone = command.clone();
359            let cwd_clone = effective_cwd;
360            let proactive_block = if raw
361                || !lean_ctx::core::profiles::active_profile()
362                    .output_hints
363                    .proactive_context()
364            {
365                None
366            } else {
367                lean_ctx::core::relevance_tracker::proactive_context(&format!(
368                    "ctx_shell command={cmd_clone} cwd={cwd_clone}"
369                ))
370            };
371
372            let extra_env = match collect_extra_env(args) {
373                Ok(env) => env,
374                Err(message) => {
375                    return Ok(ToolOutput {
376                        shell_outcome: Some(ShellOutcome::Blocked),
377                        content_blocks: None,
378                        ..ToolOutput::simple(message)
379                    });
380                }
381            };
382
383            let inline = get_bool(args, "inline").unwrap_or(false);
384
385            let auto_background = should_auto_background(&cmd_clone, timeout_ms);
386            let detached = get_bool(args, "run_in_background").unwrap_or(false);
387            if detached || auto_background {
388                // A job the agent detached on purpose is long work: without a
389                // stated lifetime it gets the ceiling, not the 2/10-minute
390                // foreground default (25.10.: a release install was killed
391                // after 10 of its ~35 minutes and nothing was installed).
392                let lifetime = if detached {
393                    timeout_ms.or(Some(lean_ctx::shell::exec::MAX_CALL_TIMEOUT_MS))
394                } else {
395                    timeout_ms
396                };
397                let job_id = crate::server::background_shell::start(
398                    cmd_clone, cwd_clone, extra_env, lifetime,
399                );
400                let mode = if auto_background {
401                    "auto-background"
402                } else {
403                    "background"
404                };
405                return Ok(ToolOutput {
406                    shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
407                        state: BackgroundJobState::Running,
408                        exit_code: None,
409                        job_id: job_id.clone(),
410                        archive_id: None,
411                        archive_truncated: None,
412                        captured_chars: None,
413                        archived_chars: None,
414                        summary: format!("{mode} job started"),
415                        is_error: false,
416                        display: None,
417                    })),
418                    content_blocks: None,
419                    ..ToolOutput::simple(format!(
420                        "[{mode}:{job_id} started — use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
421                    ))
422                });
423            }
424
425            // Foreground runs still detach onto a pollable job if they outlast
426            // the soft cap, so the MCP host's ~120s abort never strands the
427            // result behind an unresolvable task id (#1106).
428            //
429            // #1173: `timeout_ms` is the *job's* lifetime, never the foreground
430            // wait, so it must not raise this cap. Raising it bought no extra
431            // inline wait — the host aborts at ~120s regardless — it only
432            // suppressed our own detach, producing exactly the unresolvable
433            // task id the cap exists to prevent. Separate knobs, one direction:
434            // `LEAN_CTX_SHELL_FG_CAP_MS` moves the cap, `timeout_ms` does not.
435            let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
436            let progress_sender = ctx.progress_sender.clone();
437            let progress_label: String = cmd_clone.chars().take(60).collect();
438            let cap_secs = soft_cap.as_secs_f64();
439            let on_tick = |elapsed: std::time::Duration| {
440                #[allow(clippy::unwrap_or_default)]
441                if let Some(ref ps) = progress_sender
442                    && let Some(sender) = ps
443                        .lock()
444                        .unwrap_or_else(std::sync::PoisonError::into_inner)
445                        .as_ref()
446                {
447                    sender.send(
448                        elapsed.as_secs_f64(),
449                        Some(cap_secs),
450                        Some(format!(
451                            "ctx_shell: {}s elapsed — {progress_label}",
452                            elapsed.as_secs()
453                        )),
454                    );
455                }
456            };
457            let (process_output, exit_code, completeness, retained_withheld, containment_notice) =
458                match crate::server::background_shell::run_foreground_or_detach_captured(
459                    cmd_clone.clone(),
460                    cwd_clone.clone(),
461                    extra_env.clone(),
462                    timeout_ms,
463                    soft_cap,
464                    Some(&on_tick),
465                    ctx.cancel.as_ref(),
466                ) {
467                    crate::server::background_shell::CapturedForegroundResult::Finished {
468                        output,
469                        exit_code,
470                        completeness,
471                        retained_withheld,
472                        notice,
473                    } => (output, exit_code, completeness, retained_withheld, notice),
474                    crate::server::background_shell::CapturedForegroundResult::Detached {
475                        job_id,
476                    } => {
477                        return Ok(ToolOutput {
478                            shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
479                                state: BackgroundJobState::Running,
480                                exit_code: None,
481                                job_id: job_id.clone(),
482                                archive_id: None,
483                                archive_truncated: None,
484                                captured_chars: None,
485                                archived_chars: None,
486                                summary: "auto-detached job is still running".to_string(),
487                                is_error: false,
488                                display: None,
489                            })),
490                            content_blocks: None,
491                            // #1173: a detach is not a failure — the command is
492                            // alive and its output is recoverable, so say so
493                            // rather than leaving the caller to infer a hang.
494                            ..ToolOutput::simple(format!(
495                                "[auto-background:{job_id} still running — passed the {}s foreground cap, not an error; output is kept and delivered by ctx_shell(background_action=\"status\", job_id=\"{job_id}\"), or background_action=\"cancel\" to stop it]",
496                                soft_cap.as_secs()
497                            ))
498                        });
499                    }
500                };
501
502            let display_input = if retained_withheld {
503                "[lean-ctx gateway: retained output withheld by storage policy]".to_string()
504            } else {
505                process_output.clone()
506            };
507            let admission =
508                lean_ctx::core::context_admission::admit_tool_output(&display_input, completeness);
509            let output = redact_shell_output_secrets(&admission.rendered());
510            let output = if raw {
511                crate::server::walk_hint::strip_walk_hint(&output).to_string()
512            } else {
513                output
514            };
515
516            // Persistent diagnostics and derived stores require a separate,
517            // pathless storage decision under one current policy epoch.
518            let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
519            let stored_command = store.admit_shell_command(&cmd_clone);
520            let stored_output = (!retained_withheld)
521                .then(|| store.admit_shell_output(&process_output, completeness))
522                .flatten();
523            let storage_allowed = !retained_withheld
524                && store.is_current()
525                && stored_command.is_some()
526                && stored_output.is_some();
527            let command_for_store = stored_command.as_deref().unwrap_or_default();
528            let output_for_store = stored_output.as_deref().unwrap_or_default();
529            if storage_allowed {
530                lean_ctx::core::diagnostics_store::record_from_shell(
531                    command_for_store,
532                    output_for_store,
533                    exit_code,
534                );
535            }
536
537            let (result_out, original, saved, tee_hint) = if raw || inline {
538                let tokens = lean_ctx::core::tokens::count_tokens(&output);
539                (output, tokens, 0, String::new())
540            } else {
541                let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("shell");
542                let result =
543                    crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
544                let original = lean_ctx::core::tokens::count_tokens(&output);
545                let sent = lean_ctx::core::tokens::count_tokens(&result);
546                let saved = original.saturating_sub(sent);
547
548                let cfg = lean_ctx::core::config::Config::load();
549                // Shared tee policy (#811): identical decision to the CLI path —
550                // `Failures` keys off the real exit code, not a substring match.
551                let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
552                let tee_hint = if storage_allowed
553                    && lean_ctx::shell::tee_policy::should_tee(
554                        &cfg.tee_mode,
555                        exit_code,
556                        output.trim().is_empty() || timeout_notice_only,
557                        lean_ctx::shell::tee_policy::output_was_elided(&output, &result),
558                        original,
559                        sent,
560                    ) {
561                    lean_ctx::shell::save_tee(command_for_store, output_for_store)
562                        .map(|p| {
563                            if lean_ctx::core::protected_store::enforce_protected_store_path(std::path::Path::new(&p)).is_err() {
564                                format!("\n[full output: ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\")]")
565                            } else if matches!(cfg.tee_mode, lean_ctx::core::config::TeeMode::HighCompression)
566                            {
567                                let pct = lean_ctx::shell::tee_policy::savings_pct(original, sent);
568                                // Recovery grammar is path-first: agents without ctx_expand
569                                // can still read the saved artifact directly (#936).
570                                format!(
571                                    "\n[compressed {pct:.0}%: full output at {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\", search=\"…\"|head=N|json_path=\"…\") for a slice]"
572                                )
573                            } else {
574                                format!("\n[full output: {p} — read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
575                            }
576                        })
577                        .unwrap_or_default()
578                } else {
579                    String::new()
580                };
581
582                (result, original, saved, tee_hint)
583            };
584
585            let mode = if bypass {
586                Some("bypass".to_string())
587            } else if raw {
588                Some("raw".to_string())
589            } else {
590                None
591            };
592
593            let shell_mismatch = if cfg!(windows) && !raw {
594                shell_mismatch_hint(&command, &result_out)
595            } else {
596                String::new()
597            };
598
599            let result_out = lean_ctx::core::redaction::redact_text_if_enabled(&result_out);
600            // #815: exit 124 = timeout signal (from timeout(1) / lean-ctx
601            // shell timeout). Make it explicit so agents don't confuse a
602            // timed-out command with a successful empty result.
603            let exit_suffix = lean_ctx::shell::exit_status::exit_footer(exit_code);
604            let nudge = if raw { "" } else { search_tool_nudge(&command) };
605            let final_out = format!(
606                "{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
607            );
608            let final_out = if let Some(block) = proactive_block {
609                format!("{final_out}{block}")
610            } else {
611                final_out
612            };
613            let final_admission =
614                lean_ctx::core::context_admission::admit_tool_output(&final_out, completeness);
615            let final_out = final_admission.rendered();
616
617            Ok(ToolOutput {
618                // Q13: the call's notice goes in front last — after admission,
619                // compression and the hints above, none of which has seen it.
620                text: crate::server::execute::with_notice(containment_notice, final_out),
621                original_tokens: original,
622                saved_tokens: saved,
623                mode,
624                path: None,
625                changed: false,
626                shell_outcome: Some(ShellOutcome::Exit(exit_code)),
627                content_blocks: None,
628            })
629        })
630    }
631}
632
633/// Deny shell execution for explicitly restricted MCP clients. Missing client
634/// context remains allowed so existing integrations retain their current access.
635/// `ctx_execute` runs code too and applies the same gate (GHSA-rxv5-4c3x-g4q6 B).
636pub(crate) fn shell_access_denial(ctx: &ToolContext) -> Option<String> {
637    if let Some(role) = ctx.client_role.as_deref()
638        && (role.eq_ignore_ascii_case("untrusted") || role.eq_ignore_ascii_case("readonly"))
639    {
640        return Some(format!(
641            "[SHELL ACCESS DENIED] ctx_shell is unavailable to MCP clients with role '{role}'."
642        ));
643    }
644
645    if ctx.shell_access == Some(false) {
646        return Some(
647            "[SHELL ACCESS DENIED] ctx_shell requires shell_access=true in the MCP session/request context."
648                .to_string(),
649        );
650    }
651
652    None
653}
654
655fn resolve_effective_cwd(
656    session: Option<tokio::sync::OwnedRwLockReadGuard<lean_ctx::core::session::SessionState>>,
657    explicit_cwd: Option<&str>,
658) -> Result<(String, Option<String>), ErrorData> {
659    match session {
660        // A rejected directory fails here, before the write guard, the run,
661        // a background job or the cache key see a substituted one.
662        Some(session) => session
663            .execution_cwd(explicit_cwd)
664            .map(|cwd| (cwd, None))
665            .map_err(|reason| {
666                ErrorData::invalid_params(
667                    format!("working directory rejected, command not run: {reason}"),
668                    None,
669                )
670            }),
671        None => Err(ErrorData::internal_error(
672            "session lock timeout — cannot validate working directory",
673            None,
674        )),
675    }
676}
677
678#[allow(clippy::fn_params_excessive_bools)]
679fn resolve_shell_raw_flags(
680    arg_raw: bool,
681    arg_bypass: bool,
682    _env_disabled: bool,
683    env_raw: bool,
684) -> (bool, bool) {
685    let bypass = arg_bypass || env_raw;
686    let raw = arg_raw || bypass;
687    (raw, bypass)
688}
689
690/// A timeout notice is framework metadata, not recoverable command output. Do
691/// not archive it as a tee artifact: expanding it cannot recover any bytes (#995).
692///
693/// Keyed on what precedes the marker rather than on the notice's exact shape,
694/// so enriching it (the idle-timeout wording, the still-running segment list)
695/// cannot silently turn every timeout back into an archived artifact (#1173).
696fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
697    exit_code == 124
698        && crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
699}
700
701fn search_tool_nudge(command: &str) -> &'static str {
702    let cmd = command.trim();
703    let first_word = cmd.split_whitespace().next().unwrap_or("");
704    if !cmd.contains('|') {
705        match first_word {
706            "grep" | "rg" | "egrep" | "fgrep" | "ag" => {
707                return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
708            }
709            "find" => {
710                return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
711            }
712            "ls" | "exa" | "eza" => {
713                return "\n[hint: use ctx_tree for structured directory listing]";
714            }
715            _ => {}
716        }
717    }
718    ""
719}
720
721fn shell_mismatch_hint(command: &str, output: &str) -> String {
722    let shell = lean_ctx::shell::shell_name();
723    let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
724    let has_error = output.contains("is not recognized")
725        || output.contains("not found")
726        || output.contains("command not found");
727
728    if !has_error {
729        return String::new();
730    }
731
732    let powershell_cmds = [
733        "Get-Content",
734        "Select-Object",
735        "Get-ChildItem",
736        "Set-Location",
737        "Where-Object",
738        "ForEach-Object",
739        "Select-String",
740        "Invoke-Expression",
741        "Write-Output",
742    ];
743    let uses_powershell = powershell_cmds
744        .iter()
745        .any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
746
747    if is_posix && uses_powershell {
748        format!(
749            "\n[shell: {shell} — use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
750        )
751    } else {
752        String::new()
753    }
754}
755
756/// Collect the caller's `env` overrides, refusing protected keys by name instead
757/// of dropping them silently (#1771).
758///
759/// The inline-override refusal in `crates/lean-ctx-shell-policy/src/enforcement.rs` points the
760/// caller at this parameter and uses `PATH` as its worked example — yet five of the
761/// nine variables it blocks inline (`PATH`, `GIT_SSH`, `GIT_SSH_COMMAND`,
762/// `LD_PRELOAD`, `DYLD_INSERT_LIBRARIES`) are also refused here, by the shared override
763/// policy [`lean_ctx_shell_policy::env_override_refused`].
764/// Filtering them away meant the recommended recovery ran the command with the
765/// ambient value and reported success, which is worse than the refusal it replaced:
766/// a wrong result that looks right. Naming the key is the only honest outcome.
767fn collect_extra_env(
768    args: &Map<String, Value>,
769) -> Result<std::collections::HashMap<String, String>, String> {
770    let mut accepted = std::collections::HashMap::new();
771    let Some(obj) = args.get("env").and_then(|v| v.as_object()) else {
772        return Ok(accepted);
773    };
774    let mut rejected: Vec<&str> = Vec::new();
775    for (key, value) in obj {
776        let Some(text) = value.as_str() else { continue };
777        if lean_ctx_shell_policy::env_override_refused(key, text) {
778            rejected.push(key.as_str());
779            continue;
780        }
781        accepted.insert(key.clone(), text.to_string());
782    }
783    if rejected.is_empty() {
784        return Ok(accepted);
785    }
786    rejected.sort_unstable();
787    let names = rejected
788        .iter()
789        .map(|k| format!("`{k}`"))
790        .collect::<Vec<_>>()
791        .join(", ");
792    let plural = if rejected.len() == 1 { "key" } else { "keys" };
793    Err(format!(
794        "ERROR: ctx_shell refuses the `env` {plural} {names} — nothing was run. These keys can \
795         redirect which binary executes, so lean-ctx never forwards them, and the command would \
796         otherwise have used the inherited value while appearing to honor yours. This is the \
797         same rule that blocks the inline `KEY=value cmd` form; `env` is not a way around it. \
798         Invoke the binary by its full path instead, or drop the override."
799    ))
800}
801
802/// Warn when shell reads secret-like paths via cat/head/tail/less/more.
803/// WARN-ONLY: command still executes, this is purely observational.
804fn warn_shell_secret_paths(command: &str) {
805    const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
806    let segments = lean_ctx_shell_policy::extract_all_commands_pub(command);
807    for seg in &segments {
808        let trimmed = seg.trim();
809        let tokens = lean_ctx_shell_policy::shell_tokenize(trimmed);
810        if tokens.is_empty() {
811            continue;
812        }
813        let base = tokens[0]
814            .rsplit('/')
815            .next()
816            .unwrap_or(&tokens[0])
817            .to_string();
818        if !READ_CMDS.contains(&base.as_str()) {
819            continue;
820        }
821        for tok in &tokens[1..] {
822            if tok.starts_with('-') {
823                continue;
824            }
825            let path = std::path::Path::new(tok.as_str());
826            if lean_ctx::core::io_boundary::is_secret_like(path).is_some() {
827                tracing::warn!(
828                    "[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
829                );
830            }
831        }
832    }
833}
834
835/// The Codex MCP client abandons a tool call after five minutes. A Cargo test
836/// with an explicit five-minute-or-longer shell timeout cannot reliably finish
837/// inside that transport deadline (and can wait on Cargo's target lock), so
838/// detach it and return a pollable job instead.
839fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
840    timeout_ms.is_some_and(|timeout| timeout >= 300_000)
841        && command
842            .lines()
843            .any(|line| line.trim_start().starts_with("cargo test"))
844}
845
846/// Foreground wait budget before a still-running command is detached into a
847/// pollable background job. Kept below the MCP host's ~120s tool-call abort so
848/// the caller always receives a real `shell_*` job id instead of an
849/// unresolvable task id (#1106). Override with `LEAN_CTX_SHELL_FG_CAP_MS`.
850fn foreground_soft_cap_ms() -> u64 {
851    std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
852        .ok()
853        .and_then(|v| v.parse().ok())
854        .filter(|&ms| ms > 0)
855        .unwrap_or(110_000)
856}
857
858/// Map an interpreter binary to a `ctx_execute` language name.
859fn interpreter_to_execute_language(base: &str) -> Option<&'static str> {
860    match base {
861        "python" | "python2" | "python3" => Some("python"),
862        "node" => Some("javascript"),
863        "ruby" => Some("ruby"),
864        _ => None,
865    }
866}
867
868fn is_env_assignment_token(token: &str) -> bool {
869    let unquoted: String = token.chars().filter(|c| *c != '"' && *c != '\'').collect();
870    unquoted.contains('=')
871        && !unquoted.starts_with('-')
872        && !unquoted.starts_with('/')
873        && !unquoted.starts_with('.')
874}
875
876/// Quote-aware scan for compound operators that would invalidate a reroute.
877fn prelude_has_compound_operator(prelude: &str) -> bool {
878    let bytes = prelude.as_bytes();
879    let len = bytes.len();
880    let mut i = 0;
881    let mut in_single = false;
882    let mut in_double = false;
883    while i < len {
884        let ch = bytes[i];
885        if in_single {
886            if ch == b'\'' {
887                in_single = false;
888            }
889            i += 1;
890            continue;
891        }
892        if in_double {
893            if ch == b'\\' && i + 1 < len {
894                i += 2;
895            } else {
896                if ch == b'"' {
897                    in_double = false;
898                }
899                i += 1;
900            }
901            continue;
902        }
903        match ch {
904            b'\'' => {
905                in_single = true;
906                i += 1;
907            }
908            b'"' => {
909                in_double = true;
910                i += 1;
911            }
912            b';' | b'|' | b'&' | b'(' | b'{' => return true,
913            _ => i += 1,
914        }
915    }
916    false
917}
918
919/// Detect interpreter heredoc patterns and extract the language + code body.
920/// Returns `Some((language, code, remainder))` where remainder is any command
921/// after the heredoc terminator that still needs shell execution.
922fn detect_heredoc_reroute(command: &str) -> Option<(String, String, Option<String>)> {
923    if !command.contains("<<") {
924        return None;
925    }
926
927    let lines: Vec<&str> = command.lines().collect();
928    if lines.is_empty() {
929        return None;
930    }
931
932    let first_line = lines[0];
933    let delims = lean_ctx_shell_policy::heredoc_delims(first_line, false);
934    if delims.len() != 1 {
935        return None;
936    }
937    let delim = delims[0].clone();
938
939    let heredoc_pos = find_heredoc_operator(first_line)?;
940    let prelude = first_line[..heredoc_pos].trim();
941    if prelude_has_compound_operator(prelude) {
942        return None;
943    }
944
945    let language = parse_interpreter_heredoc_prelude(prelude)?.to_string();
946
947    if has_trailing_tokens_after_heredoc_delim(first_line, heredoc_pos) {
948        return None;
949    }
950
951    let mut body = String::new();
952    let mut remainder_start: Option<usize> = None;
953    for (idx, line) in lines.iter().enumerate().skip(1) {
954        if line.trim_start_matches('\t').trim() == delim {
955            remainder_start = Some(idx + 1);
956            break;
957        }
958        if !body.is_empty() {
959            body.push('\n');
960        }
961        body.push_str(line);
962    }
963    let remainder_start = remainder_start?;
964    let remainder = if remainder_start < lines.len() {
965        let rest = lines[remainder_start..].join("\n");
966        let rest = rest.trim();
967        if rest.is_empty() {
968            None
969        } else {
970            Some(rest.to_string())
971        }
972    } else {
973        None
974    };
975
976    Some((language, body, remainder))
977}
978
979fn find_heredoc_operator(line: &str) -> Option<usize> {
980    let bytes = line.as_bytes();
981    let len = bytes.len();
982    let mut i = 0;
983    let mut in_single = false;
984    let mut in_double = false;
985    while i < len {
986        let ch = bytes[i];
987        if in_single {
988            if ch == b'\'' {
989                in_single = false;
990            }
991            i += 1;
992            continue;
993        }
994        if in_double {
995            if ch == b'\\' && i + 1 < len {
996                i += 2;
997            } else {
998                if ch == b'"' {
999                    in_double = false;
1000                }
1001                i += 1;
1002            }
1003            continue;
1004        }
1005        match ch {
1006            b'\'' => {
1007                in_single = true;
1008                i += 1;
1009            }
1010            b'"' => {
1011                in_double = true;
1012                i += 1;
1013            }
1014            b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
1015                if i + 2 < len && bytes[i + 2] == b'<' {
1016                    i += 3;
1017                    continue;
1018                }
1019                return Some(i);
1020            }
1021            _ => i += 1,
1022        }
1023    }
1024    None
1025}
1026
1027fn has_trailing_tokens_after_heredoc_delim(line: &str, heredoc_pos: usize) -> bool {
1028    let bytes = line.as_bytes();
1029    let mut i = heredoc_pos + 2;
1030    if i < bytes.len() && bytes[i] == b'-' {
1031        i += 1;
1032    }
1033    while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
1034        i += 1;
1035    }
1036    let Some((_, _, next)) = lean_ctx_shell_policy::read_heredoc_delim(bytes, i) else {
1037        return true;
1038    };
1039    !line[next..].trim().is_empty()
1040}
1041
1042fn parse_interpreter_heredoc_prelude(prelude: &str) -> Option<&'static str> {
1043    let tokens = lean_ctx_shell_policy::shell_tokenize(prelude.trim());
1044    let mut idx = 0;
1045    while idx < tokens.len() && is_env_assignment_token(&tokens[idx]) {
1046        idx += 1;
1047    }
1048    if idx >= tokens.len() {
1049        return None;
1050    }
1051    let base = tokens[idx]
1052        .rsplit('/')
1053        .next()
1054        .unwrap_or(tokens[idx].as_str());
1055    let language = interpreter_to_execute_language(base)?;
1056    idx += 1;
1057    match tokens.get(idx) {
1058        None => Some(language),
1059        Some(dash) if dash == "-" => {
1060            if tokens.len() == idx + 1 {
1061                Some(language)
1062            } else {
1063                None
1064            }
1065        }
1066        Some(_) => None,
1067    }
1068}
1069
1070/// The one working directory both halves of a rerouted heredoc call run in.
1071///
1072/// Resolution goes through the session so an explicit `cwd` is validated
1073/// against the configured roots, exactly as the non-rerouted path does. A `cwd`
1074/// the caller named that cannot be validated is an error, never a silent
1075/// substitution (#1666).
1076///
1077/// `Ok(None)` means only that no directory could be established without a
1078/// session. The heredoc-only path never needed one and must keep working; the
1079/// shell remainder always required a session and keeps requiring it.
1080fn resolve_reroute_cwd(
1081    ctx: &ToolContext,
1082    explicit_cwd: Option<&str>,
1083) -> Result<Option<String>, ErrorData> {
1084    let Some(session_lock) = ctx.session.as_ref() else {
1085        if explicit_cwd.is_some() {
1086            return Err(ErrorData::internal_error(
1087                "session not available — cannot validate the requested working directory",
1088                None,
1089            ));
1090        }
1091        return Ok(None);
1092    };
1093    let guard =
1094        crate::server::bounded_lock::read_for(session_lock, "ctx_shell_cwd", SESSION_LOCK_BUDGET);
1095    let (cwd, _) = resolve_effective_cwd(guard, explicit_cwd)?;
1096    Ok(Some(cwd))
1097}
1098
1099fn handle_interpreter_heredoc_reroute(
1100    args: &Map<String, Value>,
1101    ctx: &ToolContext,
1102    language: &str,
1103    code: &str,
1104    remainder: Option<String>,
1105) -> Result<ToolOutput, ErrorData> {
1106    let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
1107    let timeout_secs = timeout_ms.map(|ms| ms.div_ceil(1000).max(1));
1108
1109    // #1666: resolve the working directory *before* the interpreter runs. The
1110    // shell remainder below always ran in the call's `cwd`; the rerouted
1111    // interpreter ran wherever the server process happened to sit. One tool
1112    // call, two directories — and a script using relative paths silently read
1113    // and wrote the wrong tree while reporting success.
1114    let explicit_cwd = get_str(args, "cwd");
1115    let exec_cwd = resolve_reroute_cwd(ctx, explicit_cwd.as_deref())?;
1116
1117    let (exec_text, exec_outcome) = crate::tools::ctx_execute::handle_in(
1118        language,
1119        code,
1120        None,
1121        timeout_secs,
1122        exec_cwd.as_deref().map(std::path::Path::new),
1123    );
1124    let reroute_note = format!(
1125        "\n[ctx_shell: interpreter heredoc auto-rerouted to ctx_execute(language=\"{language}\")]"
1126    );
1127    let exec_completeness = lean_ctx::core::context_admission::OutputCompleteness {
1128        reader_incomplete: true,
1129        ..lean_ctx::core::context_admission::OutputCompleteness::complete()
1130    };
1131    let exec_admission =
1132        lean_ctx::core::context_admission::admit_tool_output(&exec_text, exec_completeness);
1133    let exec_text = lean_ctx::core::redaction::redact_text_if_enabled(&exec_admission.rendered());
1134
1135    let Some(rest_cmd) = remainder else {
1136        let final_text = format!("{exec_text}{reroute_note}");
1137        let final_admission =
1138            lean_ctx::core::context_admission::admit_tool_output(&final_text, exec_completeness);
1139        let text = final_admission.rendered();
1140        return Ok(ToolOutput {
1141            original_tokens: lean_ctx::core::tokens::count_tokens(&text),
1142            text,
1143            saved_tokens: 0,
1144            mode: Some("heredoc-reroute".to_string()),
1145            path: None,
1146            changed: false,
1147            shell_outcome: Some(exec_outcome),
1148            content_blocks: None,
1149        });
1150    };
1151
1152    if let Err(msg) = lean_ctx_shell_policy::check_shell_allowlist_granted(
1153        &rest_cmd,
1154        &crate::server::shell_approval::current_grant(),
1155    ) {
1156        let blocked =
1157            format!("{exec_text}{reroute_note}\n\n[remainder blocked by shell allowlist]\n{msg}");
1158        return Ok(ToolOutput {
1159            shell_outcome: Some(ShellOutcome::Blocked),
1160            content_blocks: None,
1161            ..ToolOutput::simple(blocked)
1162        });
1163    }
1164
1165    // Unchanged from before #1666: running the shell remainder requires a
1166    // validated working directory.
1167    let effective_cwd =
1168        exec_cwd.ok_or_else(|| ErrorData::internal_error("session not available", None))?;
1169
1170    let extra_env = match collect_extra_env(args) {
1171        Ok(env) => env,
1172        Err(message) => {
1173            return Ok(ToolOutput {
1174                shell_outcome: Some(ShellOutcome::Blocked),
1175                content_blocks: None,
1176                ..ToolOutput::simple(message)
1177            });
1178        }
1179    };
1180
1181    let shell_execution = crate::server::execute::execute_command_with_env_captured(
1182        &rest_cmd,
1183        &effective_cwd,
1184        &extra_env,
1185        timeout_ms,
1186    );
1187    let shell_exit = shell_execution.exit_code;
1188    let shell_admission = lean_ctx::core::context_admission::admit_tool_output(
1189        &shell_execution.output,
1190        shell_execution.completeness,
1191    );
1192    let shell_output = redact_shell_output_secrets(&shell_admission.rendered());
1193    let arg_raw = get_bool(args, "raw").unwrap_or(false);
1194    let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
1195    let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
1196    let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
1197    let (raw, _) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
1198
1199    let shell_text = if raw {
1200        shell_output
1201    } else {
1202        crate::tools::ctx_shell::handle(&rest_cmd, &shell_output, shell_exit, ctx.crp_mode)
1203    };
1204    let shell_text = lean_ctx::core::redaction::redact_text_if_enabled(&shell_text);
1205    let exit_suffix = lean_ctx::shell::exit_status::exit_footer(shell_exit);
1206
1207    let combined = format!(
1208        "{exec_text}{reroute_note}\n\n[heredoc remainder via ctx_shell]\n{shell_text}{exit_suffix}"
1209    );
1210    let mut completeness = shell_execution.completeness;
1211    // ctx_execute currently exposes no raw-byte capture flags for the heredoc
1212    // half, so the combined output cannot claim full inspection coverage.
1213    completeness.reader_incomplete = true;
1214    let final_admission =
1215        lean_ctx::core::context_admission::admit_tool_output(&combined, completeness);
1216    // Q13: the shell half's notice goes in front last, after admission.
1217    let text =
1218        crate::server::execute::with_notice(shell_execution.notice, final_admission.rendered());
1219    let token_count = lean_ctx::core::tokens::count_tokens(&text);
1220    Ok(ToolOutput {
1221        text,
1222        original_tokens: token_count,
1223        saved_tokens: 0,
1224        mode: Some("heredoc-reroute".to_string()),
1225        path: None,
1226        changed: false,
1227        shell_outcome: Some(ShellOutcome::Exit(shell_exit)),
1228        content_blocks: None,
1229    })
1230}
1231
1232/// #842: detect a bare `cat <single_file>` command (no pipes, redirects, flags).
1233fn detect_bare_cat_file(command: &str) -> Option<String> {
1234    let trimmed = command.trim();
1235    let rest = trimmed.strip_prefix("cat ")?;
1236    let rest = rest.trim();
1237    if rest.is_empty()
1238        || rest.contains('|')
1239        || rest.contains('>')
1240        || rest.contains('<')
1241        || rest.contains(';')
1242        || rest.contains('&')
1243        || rest.contains('$')
1244        || rest.starts_with('-')
1245    {
1246        return None;
1247    }
1248    let parts: Vec<&str> = rest.split_whitespace().collect();
1249    if parts.len() != 1 {
1250        return None;
1251    }
1252    let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
1253    if file_path.is_empty() {
1254        return None;
1255    }
1256    Some(file_path.to_string())
1257}
1258
1259/// Classify `command` by the `tool_output` table (pz21 §1.8). A match makes
1260/// every command output this call admits `tool_output` (fenced, the session
1261/// tainted) and taints the session at once with each file it downloads, so
1262/// a later `ctx_read` of exactly that file is fenced too.
1263fn note_tool_output(command: &str, cwd: Option<&str>, project_root: &str) {
1264    use lean_ctx::core::context_admission::{capture, origin};
1265    let Some(found) = lean_ctx::core::patterns::tool_output::classify(command) else {
1266        // The project's own command: said explicitly, since an unclassified
1267        // output is admitted as of unknown origin.
1268        if let Some(capture) = capture::current() {
1269            capture.set_output_class(lean_ctx::core::patterns::output_class(command));
1270        }
1271        return;
1272    };
1273    let class = found.class();
1274    if let Some(capture) = capture::current() {
1275        capture.set_output_class(class.clone());
1276    }
1277    capture::note_origin(&class);
1278    let root = std::path::Path::new(project_root);
1279    let base = cwd.map_or(root, std::path::Path::new);
1280    for download in &found.downloads {
1281        let class = origin::download_origin(root, base, std::path::Path::new(download)).map_or(
1282            origin::SourceClass::Unclassified { tool: "download" },
1283            origin::SourceClass::External,
1284        );
1285        capture::note_origin(&class);
1286    }
1287}
1288
1289#[cfg(test)]
1290#[path = "ctx_shell_tests.rs"]
1291mod tests;