use rmcp::ErrorData;
use rmcp::model::Tool;
use serde_json::{Map, Value, json};
use crate::server::tool_trait::{
BackgroundJobState, BackgroundShellOutcome, McpTool, ShellOutcome, ToolContext, ToolOutput,
get_bool, get_int, get_str,
};
use lean_ctx::tool_defs::tool_def;
use super::ctx_shell_background::{format_background_snapshot, redact_shell_output_secrets};
pub struct CtxShellTool;
const SESSION_LOCK_BUDGET: std::time::Duration = std::time::Duration::from_millis(250);
impl McpTool for CtxShellTool {
fn name(&self) -> &'static str {
"ctx_shell"
}
fn tool_def(&self) -> Tool {
tool_def(
"ctx_shell",
"WORKFLOW: preferred โ auto-compresses output (build/test/log).\n\
Big logs: narrow the command (grep, sed -n, head/tail).\n\
[exit:N] on errors (lossless).\n\
POLICY: allowlisted read-only path.\n\
A [BLOCKED] command is permanent; ctx_execute shares the same allowlist โ fix with \
`lean-ctx allow <cmd>`.\n\
ANTIPATTERN: multi-line scripts, sh/bash script.sh, $var-as-command โ ctx_execute \
(when exposed).",
json!({
"type": "object",
"properties": {
"command": { "type": "string", "description": "Shell command" },
"raw": { "type": "boolean", "description": "Exact bytes; only for diffs to apply, hashes, encoded output" },
"inline": { "type": "boolean", "description": "Verbatim up to archive.inline_max_bytes, else archived" },
"cwd": { "type": "string", "description": "Working dir (persists across calls)" },
"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." },
"env": { "type": "object", "description": "Extra env vars", "additionalProperties": { "type": "string" } },
"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." },
"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." },
"job_id": { "type": "string", "description": "Job id returned by run_in_background." }
}
}),
)
}
fn handle(
&self,
args: &Map<String, Value>,
ctx: &ToolContext,
) -> Result<ToolOutput, ErrorData> {
if let Some(message) = shell_access_denial(ctx) {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(message)
});
}
if let Some(action) = get_str(args, "background_action") {
let id = get_str(args, "job_id").ok_or_else(|| {
ErrorData::invalid_params("job_id is required with background_action", None)
})?;
let is_cancel = action == "cancel";
let state = match action.as_str() {
"status" => crate::server::background_shell::snapshot(&id),
"cancel" => crate::server::background_shell::cancel_snapshot(&id),
_ => {
return Err(ErrorData::invalid_params(
"background_action must be status or cancel",
None,
));
}
};
let (text, outcome) = format_background_snapshot(&id, is_cancel, state);
return Ok(ToolOutput {
shell_outcome: Some(outcome),
content_blocks: None,
mode: Some(crate::tools::server_metrics::BACKGROUND_POLL_MODE.to_string()),
..ToolOutput::simple(text)
});
}
let command = get_str(args, "command")
.ok_or_else(|| ErrorData::invalid_params("command is required", None))?;
let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
let explicit_cwd = get_str(args, "cwd");
let had_explicit_cwd = explicit_cwd.is_some();
let resolved_cwd = match ctx.session.as_ref() {
Some(session_lock) => Some(tokio::task::block_in_place(|| {
let guard = crate::server::bounded_lock::read_for(
session_lock,
"ctx_shell_cwd",
SESSION_LOCK_BUDGET,
);
resolve_effective_cwd(guard, explicit_cwd.as_deref())
})?),
None => None,
};
note_tool_output(
&command,
resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
&ctx.project_root,
);
let config = lean_ctx::core::config::Config::load();
let mut write_allow_paths = config.shell_write_allow_paths_effective();
write_allow_paths.extend(crate::tools::ctx_shell::capture_roots(
&ctx.project_root,
&ctx.extra_roots,
&config,
));
if !lean_ctx::core::config::shell_policy::allow_writes()
&& let Some(rejection) = crate::tools::ctx_shell::validate_command_in_cwd(
&command,
&write_allow_paths,
None,
resolved_cwd.as_ref().map(|(cwd, _)| cwd.as_str()),
)
{
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(rejection)
});
}
let rights_cwd = resolved_cwd
.as_ref()
.map_or_else(|| ctx.project_root.clone(), |(cwd, _)| cwd.clone());
let env_keys: Vec<&str> = args
.get("env")
.and_then(Value::as_object)
.map(|obj| obj.keys().map(String::as_str).collect())
.unwrap_or_default();
if let Err(reason) = lean_ctx::core::session_rights::current().check_bash(
&command,
std::path::Path::new(&rights_cwd),
&env_keys,
) {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(reason)
});
}
if let Some((lang, code, remainder)) = detect_heredoc_reroute(&command) {
return tokio::task::block_in_place(|| {
handle_interpreter_heredoc_reroute(args, ctx, &lang, &code, remainder)
});
}
let grant = crate::server::shell_approval::current_grant();
let run_cwd = resolved_cwd
.as_ref()
.map(|(cwd, _)| std::path::PathBuf::from(cwd));
if let Err(msg) = lean_ctx_shell_policy::with_run_cwd(run_cwd, || {
lean_ctx_shell_policy::check_shell_allowlist_granted(&command, &grant)
}) {
if let Some(plan) = lean_ctx_shell_policy::partial_run(&command, &grant) {
let mut sub = args.clone();
sub.insert("command".into(), Value::String(plan.run));
let mut output = self.handle(&sub, ctx)?;
for note in &plan.skipped {
output.text.push('\n');
output.text.push_str(note);
}
if matches!(output.shell_outcome, None | Some(ShellOutcome::Exit(0))) {
output.shell_outcome = Some(ShellOutcome::Exit(126));
}
return Ok(output);
}
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(msg.to_string())
});
}
warn_shell_secret_paths(&command);
if let Some(read_path) = detect_bare_cat_file(&command)
&& let Some(cache_lock) = ctx.cache.as_ref()
&& let Some(mut cache) = crate::server::bounded_lock::write(cache_lock, "cat_redirect")
{
let result = crate::tools::ctx_read::handle_with_task_resolved(
&mut cache,
&read_path,
"full",
crate::tools::CrpMode::Off,
None,
);
let note = format!(
"\n[ctx_shell: bare `cat` redirected to ctx_read for inline delivery. \
Use ctx_read(path=\"{read_path}\") directly next time.]"
);
let out = format!("{}{note}", result.content);
let sent = lean_ctx::core::tokens::count_tokens(&out);
return Ok(ToolOutput {
text: out,
original_tokens: sent,
saved_tokens: 0,
mode: Some("cat-redirect".to_string()),
path: Some(read_path),
changed: false,
shell_outcome: Some(ShellOutcome::Exit(0)),
content_blocks: None,
});
}
tokio::task::block_in_place(|| {
let session_lock = ctx
.session
.as_ref()
.ok_or_else(|| ErrorData::internal_error("session not available", None))?;
let Some((effective_cwd, cwd_jail_reason)) = resolved_cwd else {
return Err(ErrorData::internal_error("session not available", None));
};
let cwd_jail_reason_was_none = cwd_jail_reason.is_none();
let cwd_jail_hint = cwd_jail_reason.map_or_else(String::new, |reason| {
format!(
"\n[cwd: requested path rejected by project-root jail ({reason}) \u{2014} ran in {effective_cwd} instead]"
)
});
{
let Some(mut session) = crate::server::bounded_lock::write_for(
session_lock,
"ctx_shell_write",
SESSION_LOCK_BUDGET,
) else {
tracing::debug!("[ctx_shell: session lock timeout, proceeding without update]");
let cmd_clone = command.clone();
let cwd_clone = effective_cwd.clone();
let extra_env = match collect_extra_env(args) {
Ok(env) => env,
Err(message) => {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(message)
});
}
};
let execution = crate::server::execute::execute_command_with_env_captured(
&cmd_clone, &cwd_clone, &extra_env, timeout_ms,
);
let admission = lean_ctx::core::context_admission::admit_tool_output(
&execution.output,
execution.completeness,
);
let output = redact_shell_output_secrets(&admission.rendered());
let exit_suffix =
lean_ctx::shell::exit_status::exit_footer(execution.exit_code);
let final_text = format!("{output}{exit_suffix}");
let final_admission = lean_ctx::core::context_admission::admit_tool_output(
&final_text,
execution.completeness,
);
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Exit(execution.exit_code)),
content_blocks: None,
..ToolOutput::simple(crate::server::execute::with_notice(
execution.notice,
final_admission.rendered(),
))
});
};
if had_explicit_cwd && cwd_jail_reason_was_none {
session.note_explicit_cwd(&effective_cwd);
}
session.update_shell_cwd(&command);
let root_missing = session
.project_root
.as_deref()
.is_none_or(|r| r.trim().is_empty());
if root_missing {
let home = dirs::home_dir().map(|h| h.to_string_lossy().to_string());
if let Some(root) =
lean_ctx::core::protocol::detect_project_root(&effective_cwd)
&& home.as_deref() != Some(root.as_str())
{
session.project_root = Some(root.clone());
lean_ctx::core::index_orchestrator::ensure_all_background(&root);
}
}
}
let arg_raw = get_bool(args, "raw").unwrap_or(false);
let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
let (raw, bypass) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
let crp_mode = ctx.crp_mode;
let cmd_clone = command.clone();
let cwd_clone = effective_cwd;
let proactive_block = if raw
|| !lean_ctx::core::profiles::active_profile()
.output_hints
.proactive_context()
{
None
} else {
lean_ctx::core::relevance_tracker::proactive_context(&format!(
"ctx_shell command={cmd_clone} cwd={cwd_clone}"
))
};
let extra_env = match collect_extra_env(args) {
Ok(env) => env,
Err(message) => {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(message)
});
}
};
let inline = get_bool(args, "inline").unwrap_or(false);
let auto_background = should_auto_background(&cmd_clone, timeout_ms);
let detached = get_bool(args, "run_in_background").unwrap_or(false);
if detached || auto_background {
let lifetime = if detached {
timeout_ms.or(Some(lean_ctx::shell::exec::MAX_CALL_TIMEOUT_MS))
} else {
timeout_ms
};
let job_id = crate::server::background_shell::start(
cmd_clone, cwd_clone, extra_env, lifetime,
);
let mode = if auto_background {
"auto-background"
} else {
"background"
};
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
state: BackgroundJobState::Running,
exit_code: None,
job_id: job_id.clone(),
archive_id: None,
archive_truncated: None,
captured_chars: None,
archived_chars: None,
summary: format!("{mode} job started"),
is_error: false,
display: None,
})),
content_blocks: None,
..ToolOutput::simple(format!(
"[{mode}:{job_id} started โ use ctx_shell(background_action=\"status\", job_id=\"{job_id}\") to poll or background_action=\"cancel\" to stop it]"
))
});
}
let soft_cap = std::time::Duration::from_millis(foreground_soft_cap_ms());
let progress_sender = ctx.progress_sender.clone();
let progress_label: String = cmd_clone.chars().take(60).collect();
let cap_secs = soft_cap.as_secs_f64();
let on_tick = |elapsed: std::time::Duration| {
#[allow(clippy::unwrap_or_default)]
if let Some(ref ps) = progress_sender
&& let Some(sender) = ps
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
{
sender.send(
elapsed.as_secs_f64(),
Some(cap_secs),
Some(format!(
"ctx_shell: {}s elapsed โ {progress_label}",
elapsed.as_secs()
)),
);
}
};
let (process_output, exit_code, completeness, retained_withheld, containment_notice) =
match crate::server::background_shell::run_foreground_or_detach_captured(
cmd_clone.clone(),
cwd_clone.clone(),
extra_env.clone(),
timeout_ms,
soft_cap,
Some(&on_tick),
ctx.cancel.as_ref(),
) {
crate::server::background_shell::CapturedForegroundResult::Finished {
output,
exit_code,
completeness,
retained_withheld,
notice,
} => (output, exit_code, completeness, retained_withheld, notice),
crate::server::background_shell::CapturedForegroundResult::Detached {
job_id,
} => {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Background(BackgroundShellOutcome {
state: BackgroundJobState::Running,
exit_code: None,
job_id: job_id.clone(),
archive_id: None,
archive_truncated: None,
captured_chars: None,
archived_chars: None,
summary: "auto-detached job is still running".to_string(),
is_error: false,
display: None,
})),
content_blocks: None,
..ToolOutput::simple(format!(
"[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]",
soft_cap.as_secs()
))
});
}
};
let display_input = if retained_withheld {
"[lean-ctx gateway: retained output withheld by storage policy]".to_string()
} else {
process_output.clone()
};
let admission =
lean_ctx::core::context_admission::admit_tool_output(&display_input, completeness);
let output = redact_shell_output_secrets(&admission.rendered());
let output = if raw {
crate::server::walk_hint::strip_walk_hint(&output).to_string()
} else {
output
};
let store = lean_ctx::core::context_admission::stores::StoreAdmission::current();
let stored_command = store.admit_shell_command(&cmd_clone);
let stored_output = (!retained_withheld)
.then(|| store.admit_shell_output(&process_output, completeness))
.flatten();
let storage_allowed = !retained_withheld
&& store.is_current()
&& stored_command.is_some()
&& stored_output.is_some();
let command_for_store = stored_command.as_deref().unwrap_or_default();
let output_for_store = stored_output.as_deref().unwrap_or_default();
if storage_allowed {
lean_ctx::core::diagnostics_store::record_from_shell(
command_for_store,
output_for_store,
exit_code,
);
}
let (result_out, original, saved, tee_hint) = if raw || inline {
let tokens = lean_ctx::core::tokens::count_tokens(&output);
(output, tokens, 0, String::new())
} else {
let _mode_guard = lean_ctx::core::savings_footer::ModeGuard::new("shell");
let result =
crate::tools::ctx_shell::handle(&cmd_clone, &output, exit_code, crp_mode);
let original = lean_ctx::core::tokens::count_tokens(&output);
let sent = lean_ctx::core::tokens::count_tokens(&result);
let saved = original.saturating_sub(sent);
let cfg = lean_ctx::core::config::Config::load();
let timeout_notice_only = is_timeout_notice_only(&output, exit_code);
let tee_hint = if storage_allowed
&& lean_ctx::shell::tee_policy::should_tee(
&cfg.tee_mode,
exit_code,
output.trim().is_empty() || timeout_notice_only,
lean_ctx::shell::tee_policy::output_was_elided(&output, &result),
original,
sent,
) {
lean_ctx::shell::save_tee(command_for_store, output_for_store)
.map(|p| {
if lean_ctx::core::protected_store::enforce_protected_store_path(std::path::Path::new(&p)).is_err() {
format!("\n[full output: ctx_expand(id=\"{p}\", search=\"โฆ\"|head=N|json_path=\"โฆ\")]")
} else if matches!(cfg.tee_mode, lean_ctx::core::config::TeeMode::HighCompression)
{
let pct = lean_ctx::shell::tee_policy::savings_pct(original, sent);
format!(
"\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]"
)
} else {
format!("\n[full output: {p} โ read it directly (no MCP), or ctx_expand(id=\"{p}\")]")
}
})
.unwrap_or_default()
} else {
String::new()
};
(result, original, saved, tee_hint)
};
let mode = if bypass {
Some("bypass".to_string())
} else if raw {
Some("raw".to_string())
} else {
None
};
let shell_mismatch = if cfg!(windows) && !raw {
shell_mismatch_hint(&command, &result_out)
} else {
String::new()
};
let result_out = lean_ctx::core::redaction::redact_text_if_enabled(&result_out);
let exit_suffix = lean_ctx::shell::exit_status::exit_footer(exit_code);
let nudge = if raw { "" } else { search_tool_nudge(&command) };
let final_out = format!(
"{result_out}{tee_hint}{shell_mismatch}{cwd_jail_hint}{nudge}{exit_suffix}"
);
let final_out = if let Some(block) = proactive_block {
format!("{final_out}{block}")
} else {
final_out
};
let final_admission =
lean_ctx::core::context_admission::admit_tool_output(&final_out, completeness);
let final_out = final_admission.rendered();
Ok(ToolOutput {
text: crate::server::execute::with_notice(containment_notice, final_out),
original_tokens: original,
saved_tokens: saved,
mode,
path: None,
changed: false,
shell_outcome: Some(ShellOutcome::Exit(exit_code)),
content_blocks: None,
})
})
}
}
pub(crate) fn shell_access_denial(ctx: &ToolContext) -> Option<String> {
if let Some(role) = ctx.client_role.as_deref()
&& (role.eq_ignore_ascii_case("untrusted") || role.eq_ignore_ascii_case("readonly"))
{
return Some(format!(
"[SHELL ACCESS DENIED] ctx_shell is unavailable to MCP clients with role '{role}'."
));
}
if ctx.shell_access == Some(false) {
return Some(
"[SHELL ACCESS DENIED] ctx_shell requires shell_access=true in the MCP session/request context."
.to_string(),
);
}
None
}
fn resolve_effective_cwd(
session: Option<tokio::sync::OwnedRwLockReadGuard<lean_ctx::core::session::SessionState>>,
explicit_cwd: Option<&str>,
) -> Result<(String, Option<String>), ErrorData> {
match session {
Some(session) => session
.execution_cwd(explicit_cwd)
.map(|cwd| (cwd, None))
.map_err(|reason| {
ErrorData::invalid_params(
format!("working directory rejected, command not run: {reason}"),
None,
)
}),
None => Err(ErrorData::internal_error(
"session lock timeout โ cannot validate working directory",
None,
)),
}
}
#[allow(clippy::fn_params_excessive_bools)]
fn resolve_shell_raw_flags(
arg_raw: bool,
arg_bypass: bool,
_env_disabled: bool,
env_raw: bool,
) -> (bool, bool) {
let bypass = arg_bypass || env_raw;
let raw = arg_raw || bypass;
(raw, bypass)
}
fn is_timeout_notice_only(output: &str, exit_code: i32) -> bool {
exit_code == 124
&& crate::server::execute::output_before_timeout_marker(output).is_some_and(str::is_empty)
}
fn search_tool_nudge(command: &str) -> &'static str {
let cmd = command.trim();
let first_word = cmd.split_whitespace().next().unwrap_or("");
if !cmd.contains('|') {
match first_word {
"grep" | "rg" | "egrep" | "fgrep" | "ag" => {
return "\n[hint: use ctx_search for structured, cached results with symbol/semantic modes]";
}
"find" => {
return "\n[hint: use ctx_glob or ctx_tree for structured file discovery]";
}
"ls" | "exa" | "eza" => {
return "\n[hint: use ctx_tree for structured directory listing]";
}
_ => {}
}
}
""
}
fn shell_mismatch_hint(command: &str, output: &str) -> String {
let shell = lean_ctx::shell::shell_name();
let is_posix = matches!(shell.as_str(), "bash" | "sh" | "zsh" | "fish");
let has_error = output.contains("is not recognized")
|| output.contains("not found")
|| output.contains("command not found");
if !has_error {
return String::new();
}
let powershell_cmds = [
"Get-Content",
"Select-Object",
"Get-ChildItem",
"Set-Location",
"Where-Object",
"ForEach-Object",
"Select-String",
"Invoke-Expression",
"Write-Output",
];
let uses_powershell = powershell_cmds
.iter()
.any(|c| command.contains(c) || command.contains(&c.to_lowercase()));
if is_posix && uses_powershell {
format!(
"\n[shell: {shell} โ use POSIX commands (cat, head, grep, find, ls) not PowerShell cmdlets]"
)
} else {
String::new()
}
}
fn collect_extra_env(
args: &Map<String, Value>,
) -> Result<std::collections::HashMap<String, String>, String> {
let mut accepted = std::collections::HashMap::new();
let Some(obj) = args.get("env").and_then(|v| v.as_object()) else {
return Ok(accepted);
};
let mut rejected: Vec<&str> = Vec::new();
for (key, value) in obj {
let Some(text) = value.as_str() else { continue };
if lean_ctx_shell_policy::env_override_refused(key, text) {
rejected.push(key.as_str());
continue;
}
accepted.insert(key.clone(), text.to_string());
}
if rejected.is_empty() {
return Ok(accepted);
}
rejected.sort_unstable();
let names = rejected
.iter()
.map(|k| format!("`{k}`"))
.collect::<Vec<_>>()
.join(", ");
let plural = if rejected.len() == 1 { "key" } else { "keys" };
Err(format!(
"ERROR: ctx_shell refuses the `env` {plural} {names} โ nothing was run. These keys can \
redirect which binary executes, so lean-ctx never forwards them, and the command would \
otherwise have used the inherited value while appearing to honor yours. This is the \
same rule that blocks the inline `KEY=value cmd` form; `env` is not a way around it. \
Invoke the binary by its full path instead, or drop the override."
))
}
fn warn_shell_secret_paths(command: &str) {
const READ_CMDS: &[&str] = &["cat", "head", "tail", "less", "more", "bat"];
let segments = lean_ctx_shell_policy::extract_all_commands_pub(command);
for seg in &segments {
let trimmed = seg.trim();
let tokens = lean_ctx_shell_policy::shell_tokenize(trimmed);
if tokens.is_empty() {
continue;
}
let base = tokens[0]
.rsplit('/')
.next()
.unwrap_or(&tokens[0])
.to_string();
if !READ_CMDS.contains(&base.as_str()) {
continue;
}
for tok in &tokens[1..] {
if tok.starts_with('-') {
continue;
}
let path = std::path::Path::new(tok.as_str());
if lean_ctx::core::io_boundary::is_secret_like(path).is_some() {
tracing::warn!(
"[SECURITY] Shell reading secret-like path: {tok} (command: {base})"
);
}
}
}
}
fn should_auto_background(command: &str, timeout_ms: Option<u64>) -> bool {
timeout_ms.is_some_and(|timeout| timeout >= 300_000)
&& command
.lines()
.any(|line| line.trim_start().starts_with("cargo test"))
}
fn foreground_soft_cap_ms() -> u64 {
std::env::var("LEAN_CTX_SHELL_FG_CAP_MS")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&ms| ms > 0)
.unwrap_or(110_000)
}
fn interpreter_to_execute_language(base: &str) -> Option<&'static str> {
match base {
"python" | "python2" | "python3" => Some("python"),
"node" => Some("javascript"),
"ruby" => Some("ruby"),
_ => None,
}
}
fn is_env_assignment_token(token: &str) -> bool {
let unquoted: String = token.chars().filter(|c| *c != '"' && *c != '\'').collect();
unquoted.contains('=')
&& !unquoted.starts_with('-')
&& !unquoted.starts_with('/')
&& !unquoted.starts_with('.')
}
fn prelude_has_compound_operator(prelude: &str) -> bool {
let bytes = prelude.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
while i < len {
let ch = bytes[i];
if in_single {
if ch == b'\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if ch == b'\\' && i + 1 < len {
i += 2;
} else {
if ch == b'"' {
in_double = false;
}
i += 1;
}
continue;
}
match ch {
b'\'' => {
in_single = true;
i += 1;
}
b'"' => {
in_double = true;
i += 1;
}
b';' | b'|' | b'&' | b'(' | b'{' => return true,
_ => i += 1,
}
}
false
}
fn detect_heredoc_reroute(command: &str) -> Option<(String, String, Option<String>)> {
if !command.contains("<<") {
return None;
}
let lines: Vec<&str> = command.lines().collect();
if lines.is_empty() {
return None;
}
let first_line = lines[0];
let delims = lean_ctx_shell_policy::heredoc_delims(first_line, false);
if delims.len() != 1 {
return None;
}
let delim = delims[0].clone();
let heredoc_pos = find_heredoc_operator(first_line)?;
let prelude = first_line[..heredoc_pos].trim();
if prelude_has_compound_operator(prelude) {
return None;
}
let language = parse_interpreter_heredoc_prelude(prelude)?.to_string();
if has_trailing_tokens_after_heredoc_delim(first_line, heredoc_pos) {
return None;
}
let mut body = String::new();
let mut remainder_start: Option<usize> = None;
for (idx, line) in lines.iter().enumerate().skip(1) {
if line.trim_start_matches('\t').trim() == delim {
remainder_start = Some(idx + 1);
break;
}
if !body.is_empty() {
body.push('\n');
}
body.push_str(line);
}
let remainder_start = remainder_start?;
let remainder = if remainder_start < lines.len() {
let rest = lines[remainder_start..].join("\n");
let rest = rest.trim();
if rest.is_empty() {
None
} else {
Some(rest.to_string())
}
} else {
None
};
Some((language, body, remainder))
}
fn find_heredoc_operator(line: &str) -> Option<usize> {
let bytes = line.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single = false;
let mut in_double = false;
while i < len {
let ch = bytes[i];
if in_single {
if ch == b'\'' {
in_single = false;
}
i += 1;
continue;
}
if in_double {
if ch == b'\\' && i + 1 < len {
i += 2;
} else {
if ch == b'"' {
in_double = false;
}
i += 1;
}
continue;
}
match ch {
b'\'' => {
in_single = true;
i += 1;
}
b'"' => {
in_double = true;
i += 1;
}
b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
if i + 2 < len && bytes[i + 2] == b'<' {
i += 3;
continue;
}
return Some(i);
}
_ => i += 1,
}
}
None
}
fn has_trailing_tokens_after_heredoc_delim(line: &str, heredoc_pos: usize) -> bool {
let bytes = line.as_bytes();
let mut i = heredoc_pos + 2;
if i < bytes.len() && bytes[i] == b'-' {
i += 1;
}
while i < bytes.len() && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let Some((_, _, next)) = lean_ctx_shell_policy::read_heredoc_delim(bytes, i) else {
return true;
};
!line[next..].trim().is_empty()
}
fn parse_interpreter_heredoc_prelude(prelude: &str) -> Option<&'static str> {
let tokens = lean_ctx_shell_policy::shell_tokenize(prelude.trim());
let mut idx = 0;
while idx < tokens.len() && is_env_assignment_token(&tokens[idx]) {
idx += 1;
}
if idx >= tokens.len() {
return None;
}
let base = tokens[idx]
.rsplit('/')
.next()
.unwrap_or(tokens[idx].as_str());
let language = interpreter_to_execute_language(base)?;
idx += 1;
match tokens.get(idx) {
None => Some(language),
Some(dash) if dash == "-" => {
if tokens.len() == idx + 1 {
Some(language)
} else {
None
}
}
Some(_) => None,
}
}
fn resolve_reroute_cwd(
ctx: &ToolContext,
explicit_cwd: Option<&str>,
) -> Result<Option<String>, ErrorData> {
let Some(session_lock) = ctx.session.as_ref() else {
if explicit_cwd.is_some() {
return Err(ErrorData::internal_error(
"session not available โ cannot validate the requested working directory",
None,
));
}
return Ok(None);
};
let guard =
crate::server::bounded_lock::read_for(session_lock, "ctx_shell_cwd", SESSION_LOCK_BUDGET);
let (cwd, _) = resolve_effective_cwd(guard, explicit_cwd)?;
Ok(Some(cwd))
}
fn handle_interpreter_heredoc_reroute(
args: &Map<String, Value>,
ctx: &ToolContext,
language: &str,
code: &str,
remainder: Option<String>,
) -> Result<ToolOutput, ErrorData> {
let timeout_ms = get_int(args, "timeout_ms").and_then(|n| u64::try_from(n).ok());
let timeout_secs = timeout_ms.map(|ms| ms.div_ceil(1000).max(1));
let explicit_cwd = get_str(args, "cwd");
let exec_cwd = resolve_reroute_cwd(ctx, explicit_cwd.as_deref())?;
let (exec_text, exec_outcome) = crate::tools::ctx_execute::handle_in(
language,
code,
None,
timeout_secs,
exec_cwd.as_deref().map(std::path::Path::new),
);
let reroute_note = format!(
"\n[ctx_shell: interpreter heredoc auto-rerouted to ctx_execute(language=\"{language}\")]"
);
let exec_completeness = lean_ctx::core::context_admission::OutputCompleteness {
reader_incomplete: true,
..lean_ctx::core::context_admission::OutputCompleteness::complete()
};
let exec_admission =
lean_ctx::core::context_admission::admit_tool_output(&exec_text, exec_completeness);
let exec_text = lean_ctx::core::redaction::redact_text_if_enabled(&exec_admission.rendered());
let Some(rest_cmd) = remainder else {
let final_text = format!("{exec_text}{reroute_note}");
let final_admission =
lean_ctx::core::context_admission::admit_tool_output(&final_text, exec_completeness);
let text = final_admission.rendered();
return Ok(ToolOutput {
original_tokens: lean_ctx::core::tokens::count_tokens(&text),
text,
saved_tokens: 0,
mode: Some("heredoc-reroute".to_string()),
path: None,
changed: false,
shell_outcome: Some(exec_outcome),
content_blocks: None,
});
};
if let Err(msg) = lean_ctx_shell_policy::check_shell_allowlist_granted(
&rest_cmd,
&crate::server::shell_approval::current_grant(),
) {
let blocked =
format!("{exec_text}{reroute_note}\n\n[remainder blocked by shell allowlist]\n{msg}");
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(blocked)
});
}
let effective_cwd =
exec_cwd.ok_or_else(|| ErrorData::internal_error("session not available", None))?;
let extra_env = match collect_extra_env(args) {
Ok(env) => env,
Err(message) => {
return Ok(ToolOutput {
shell_outcome: Some(ShellOutcome::Blocked),
content_blocks: None,
..ToolOutput::simple(message)
});
}
};
let shell_execution = crate::server::execute::execute_command_with_env_captured(
&rest_cmd,
&effective_cwd,
&extra_env,
timeout_ms,
);
let shell_exit = shell_execution.exit_code;
let shell_admission = lean_ctx::core::context_admission::admit_tool_output(
&shell_execution.output,
shell_execution.completeness,
);
let shell_output = redact_shell_output_secrets(&shell_admission.rendered());
let arg_raw = get_bool(args, "raw").unwrap_or(false);
let arg_bypass = get_bool(args, "bypass").unwrap_or(false);
let env_disabled = std::env::var("LEAN_CTX_DISABLED").is_ok();
let env_raw = std::env::var("LEAN_CTX_RAW").is_ok();
let (raw, _) = resolve_shell_raw_flags(arg_raw, arg_bypass, env_disabled, env_raw);
let shell_text = if raw {
shell_output
} else {
crate::tools::ctx_shell::handle(&rest_cmd, &shell_output, shell_exit, ctx.crp_mode)
};
let shell_text = lean_ctx::core::redaction::redact_text_if_enabled(&shell_text);
let exit_suffix = lean_ctx::shell::exit_status::exit_footer(shell_exit);
let combined = format!(
"{exec_text}{reroute_note}\n\n[heredoc remainder via ctx_shell]\n{shell_text}{exit_suffix}"
);
let mut completeness = shell_execution.completeness;
completeness.reader_incomplete = true;
let final_admission =
lean_ctx::core::context_admission::admit_tool_output(&combined, completeness);
let text =
crate::server::execute::with_notice(shell_execution.notice, final_admission.rendered());
let token_count = lean_ctx::core::tokens::count_tokens(&text);
Ok(ToolOutput {
text,
original_tokens: token_count,
saved_tokens: 0,
mode: Some("heredoc-reroute".to_string()),
path: None,
changed: false,
shell_outcome: Some(ShellOutcome::Exit(shell_exit)),
content_blocks: None,
})
}
fn detect_bare_cat_file(command: &str) -> Option<String> {
let trimmed = command.trim();
let rest = trimmed.strip_prefix("cat ")?;
let rest = rest.trim();
if rest.is_empty()
|| rest.contains('|')
|| rest.contains('>')
|| rest.contains('<')
|| rest.contains(';')
|| rest.contains('&')
|| rest.contains('$')
|| rest.starts_with('-')
{
return None;
}
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() != 1 {
return None;
}
let file_path = parts[0].trim_matches(|c: char| c == '\'' || c == '"');
if file_path.is_empty() {
return None;
}
Some(file_path.to_string())
}
fn note_tool_output(command: &str, cwd: Option<&str>, project_root: &str) {
use lean_ctx::core::context_admission::{capture, origin};
let Some(found) = lean_ctx::core::patterns::tool_output::classify(command) else {
if let Some(capture) = capture::current() {
capture.set_output_class(lean_ctx::core::patterns::output_class(command));
}
return;
};
let class = found.class();
if let Some(capture) = capture::current() {
capture.set_output_class(class.clone());
}
capture::note_origin(&class);
let root = std::path::Path::new(project_root);
let base = cwd.map_or(root, std::path::Path::new);
for download in &found.downloads {
let class = origin::download_origin(root, base, std::path::Path::new(download)).map_or(
origin::SourceClass::Unclassified { tool: "download" },
origin::SourceClass::External,
);
capture::note_origin(&class);
}
}
#[cfg(test)]
#[path = "ctx_shell_tests.rs"]
mod tests;