Skip to main content

lean_ctx/tools/registered/
ctx_shell.rs

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