use crate::tools::CrpMode;
pub use lean_ctx::shell::command_text::{contains_auth_flow, normalize_command_for_shell};
const MAX_COMMAND_BYTES: usize = 8192;
pub fn validate_command(command: &str) -> Option<String> {
let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
let project_root = lean_ctx::core::config::Config::find_project_root();
validate_command_with_write_allow_paths(command, &write_allow_paths, project_root.as_deref())
}
pub(crate) fn validate_command_with_write_allow_paths(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
) -> Option<String> {
validate_command_in_cwd(command, write_allow_paths, project_root, None)
}
const CAPTURE_RULE: &str = "The rule: output capture (`>`, `>>`, `| tee`) may go to the \
session's project, a directory in `allow_paths` / `extra_roots`, a directory listed in \
`write_allow_paths` in config.toml, or a scratch path (/tmp, /var/tmp, $TMPDIR). Every \
other destination is refused. Commands that do not capture output are not restricted.";
pub(crate) fn capture_roots(
project_root: &str,
session_extra_roots: &[String],
cfg: &lean_ctx::core::config::Config,
) -> Vec<String> {
match lean_ctx::core::workspace_authority::current() {
lean_ctx::core::workspace_authority::Authority::Legacy => {}
lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
return snapshot
.granting_roots()
.filter(|(_, access)| {
*access == lean_ctx::core::workspace_authority::Access::ReadWrite
})
.map(|(root, _)| root.to_string_lossy().into_owned())
.collect();
}
lean_ctx::core::workspace_authority::Authority::Unavailable(_) => return Vec::new(),
}
std::iter::once(project_root.to_string())
.chain(session_extra_roots.iter().cloned())
.chain(cfg.allow_paths.iter().cloned())
.chain(cfg.extra_roots.iter().cloned())
.filter(|raw| !raw.trim().is_empty())
.map(|raw| lean_ctx::core::pathjail::expand_user_path(&raw))
.filter(|path| {
let contains_home = dirs::home_dir().is_some_and(|home| home.starts_with(path));
path.is_absolute()
&& !contains_home
&& !lean_ctx::core::pathutil::is_broad_or_unsafe_root(path)
&& !lean_ctx::core::pathjail::is_read_only_path(path)
})
.map(|path| path.to_string_lossy().into_owned())
.collect()
}
const WRITE_REASON: &str = "Files you keep are written with ctx_patch or the native Write/Edit \
tool, so the read-before-write gate and the edit checks apply to them; ctx_shell does not \
write them through output capture. This is unconditional: the redirect itself is not \
compressed, so a smaller command or `raw=true` does not change the verdict.";
pub(crate) fn validate_command_in_cwd(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
cwd: Option<&str>,
) -> Option<String> {
if command.len() > MAX_COMMAND_BYTES {
return Some(format!(
"ERROR: Command too large ({} bytes, limit {}). \
If you're writing file content, use the native Write/Edit tool instead. \
ctx_shell is for reading command output only (git, cargo, npm, etc.).",
command.len(),
MAX_COMMAND_BYTES
));
}
let cmd_no_heredoc = lean_ctx::core::shell_allowlist::strip_all_heredoc_bodies(command);
let segments = segments_in_cwd(&cmd_no_heredoc, cwd);
if let Some(target) = segments.iter().find_map(|(segment, here)| {
disallowed_write_redirect_target(segment, write_allow_paths, project_root, here.as_deref())
}) {
return Some(format!(
"ERROR: ctx_shell refuses the redirect into `{target}` — the destination decides, \
not the size or content of the output. {WRITE_REASON} {CAPTURE_RULE} \
Write the file with ctx_patch or the native Write tool, or capture to a scratch \
path.{}",
relative_target_note(&target)
));
}
if let Some(target) = segments.iter().find_map(|(segment, here)| {
disallowed_tee_target(segment, write_allow_paths, project_root, here.as_deref())
}) {
return Some(format!(
"ERROR: ctx_shell refuses `tee {target}`. Piping makes no difference: the \
destination decides. {WRITE_REASON} {CAPTURE_RULE} \
Write the file with ctx_patch or the native Write tool, or tee to a scratch \
path.{}",
relative_target_note(&target)
));
}
if is_heredoc_file_write(command, &segments, write_allow_paths, project_root) {
return Some(format!(
"ERROR: ctx_shell detected a heredoc writing to a file. {WRITE_REASON} \
Create the file with ctx_patch (op=create) or the native Write tool. \
Note: heredocs for input piping (e.g. psql <<EOF) are allowed."
));
}
if let Some(reason) = disallowed_download(&cmd_no_heredoc, write_allow_paths, project_root, cwd)
{
return Some(format!(
"ERROR: ctx_shell refuses the download ({reason}) — the destination decides. \
A download (curl -o/-O, wget, dd of=) may write where output capture may: the \
session's project, a directory in `allow_paths` / `extra_roots`, a directory \
listed in `write_allow_paths` in config.toml, or a scratch path (/tmp, \
/var/tmp, $TMPDIR). The target must be one that can be placed: an absolute \
path, $TMPDIR/…, a variable this same command set to a literal path \
(D=/x; curl -o $D/f), or a relative name in the directory its command runs in \
(the call's `cwd`, moved by a plain `cd`). Any other variable cannot be \
resolved here and is refused. A binary (image, PDF, archive) downloads to such \
a path directly, e.g. curl -sL -o /tmp/shot.png <url>; for text, fetch to \
stdout: curl <url> / wget -qO- <url>."
));
}
None
}
fn relative_target_note(target: &str) -> &'static str {
let t = target
.trim_start_matches(['>', '&', '|'])
.trim_matches(['"', '\'']);
if t.starts_with('/') || std::path::Path::new(t).is_absolute() {
return "";
}
" A relative target is placed in the directory its own command runs in: the call's \
`cwd`, moved by a plain `cd <dir> &&` before it. Where that directory cannot be \
known — a `cd` through a variable, `pushd`, a subshell, a `cd` that may not have \
run — a relative target is refused; give an absolute path."
}
fn segments_in_cwd(command: &str, cwd: Option<&str>) -> Vec<(String, Option<String>)> {
let base = cwd
.filter(|c| !c.trim().is_empty())
.map(std::path::Path::new);
lean_ctx::core::command_cwd::segments_with_cwd(command, base)
.into_iter()
.map(|s| {
let here = s.cwd.map(|p| p.to_string_lossy().into_owned());
(s.segment, here)
})
.collect()
}
fn is_unix_scratch_prefix(path: &str) -> bool {
for prefix in ["/tmp", "/var/tmp", "/private/tmp", "/dev/null"] {
if path == prefix || path.starts_with(&format!("{prefix}/")) {
return true;
}
}
false
}
fn is_scratch_path(path: &str) -> bool {
if is_unix_scratch_prefix(path) {
return true;
}
if let Ok(tmpdir) = std::env::var("TMPDIR")
&& !tmpdir.is_empty()
&& std::path::Path::new(path).starts_with(tmpdir.as_str())
{
return true;
}
let tmp = std::env::temp_dir();
if std::path::Path::new(path).starts_with(&tmp) {
return true;
}
false
}
#[cfg(test)]
fn download_to_file_reason(command: &str) -> Option<String> {
disallowed_download(command, &[], None, None)
}
fn disallowed_download(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
cwd: Option<&str>,
) -> Option<String> {
let base = cwd
.filter(|c| !c.trim().is_empty())
.map(std::path::Path::new);
let segments = lean_ctx::core::command_cwd::segments_with_cwd(command, base);
let known_vars = lean_ctx::core::command_vars::literal_assignments_before_each_segment(
command,
segments.len(),
);
for (index, seg_cwd) in segments.into_iter().enumerate() {
let seg = seg_cwd.segment;
let here = seg_cwd.cwd;
let vars = &known_vars[index];
let resolve = |path: &str| -> String {
let expanded = lean_ctx::core::command_vars::expand_leading_variable(path, vars);
let path = expanded.as_deref().unwrap_or(path);
let p = std::path::Path::new(path);
if p.is_absolute() || path.starts_with('/') {
return path.to_string();
}
match here.as_deref() {
Some(dir) => lean_ctx::core::command_cwd::join_in(dir, path)
.to_string_lossy()
.into_owned(),
None => path.to_string(),
}
};
let allowed = |path: &str| -> bool {
let target = resolve(path);
!target.contains(['$', '`'])
&& (is_scratch_path(&target)
|| is_write_allowed_redirect_target(
&target,
write_allow_paths,
project_root,
None,
))
};
let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(seg.trim());
let Some(first) = tokens.first() else {
continue;
};
let base = first.rsplit('/').next().unwrap_or(first);
match base {
"curl" => {
let tokens_slice = &tokens[1..];
for (i, tok) in tokens_slice.iter().enumerate() {
let target: Option<&str> = if tok == "--output" {
tokens_slice.get(i + 1).map(String::as_str)
} else if let Some(val) = tok.strip_prefix("--output=") {
Some(val)
} else if tok == "--output-dir" {
tokens_slice.get(i + 1).map(String::as_str)
} else if let Some(val) = tok.strip_prefix("--output-dir=") {
Some(val)
} else if tok.starts_with('-')
&& !tok.starts_with("--")
&& tok[1..].contains('o')
{
tokens_slice.get(i + 1).map(String::as_str)
} else if tok == "--remote-name"
|| tok == "--remote-name-all"
|| (tok.starts_with('-')
&& !tok.starts_with("--")
&& tok[1..].contains('O'))
{
Some(".")
} else {
None
};
if let Some(path) = target {
if allowed(path) {
continue;
}
return Some(format!("curl {tok} {path}"));
}
}
}
"wget" => {
if let Some(target) = wget_targets(&tokens[1..])
.into_iter()
.find(|target| !allowed(target))
{
return Some(format!("wget writes {target}"));
}
}
"dd" => {
for tok in &tokens[1..] {
if let Some(target) = tok.strip_prefix("of=")
&& target != "/dev/null"
&& !allowed(target)
{
return Some(format!("dd {tok}"));
}
}
}
_ => {}
}
}
None
}
fn wget_targets(args: &[String]) -> Vec<String> {
let mut document: Option<String> = None;
let mut prefix: Option<String> = None;
let mut logs = Vec::new();
let mut spider = false;
let mut i = 0;
while i < args.len() {
let tok = args[i].as_str();
let next = || args.get(i + 1).cloned();
let mut takes_next = false;
if tok == "--spider" {
spider = true;
} else if let Some((flag, value)) = tok.split_once('=').filter(|_| tok.starts_with("--")) {
match flag {
"--output-document" => document = Some(value.to_string()),
"--directory-prefix" => prefix = Some(value.to_string()),
"--output-file" | "--append-output" => logs.push(value.to_string()),
_ => {}
}
} else if matches!(
tok,
"--output-document" | "--directory-prefix" | "--output-file" | "--append-output"
) {
takes_next = true;
let value = next();
match tok {
"--output-document" => document = value,
"--directory-prefix" => prefix = value,
_ => logs.extend(value),
}
} else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.starts_with('-')) {
if let Some(pos) = cluster.find(['O', 'P', 'o', 'a']) {
let rest = &cluster[pos + 1..];
let value = if rest.is_empty() {
takes_next = true;
next()
} else {
Some(rest.to_string())
};
match &cluster[pos..=pos] {
"O" => document = value,
"P" => prefix = value,
_ => logs.extend(value),
}
}
}
i += if takes_next { 2 } else { 1 };
}
let mut targets = logs;
match document.as_deref() {
Some("-") => {}
Some(file) => targets.push(file.to_string()),
None if !spider => targets.push(prefix.unwrap_or_else(|| ".".to_string())),
None => {}
}
targets
}
fn is_heredoc_file_write(
command: &str,
segments: &[(String, Option<String>)],
write_allow_paths: &[String],
project_root: Option<&str>,
) -> bool {
let has_heredoc = command.contains("<<");
if !has_heredoc {
return false;
}
let cmd_lower = command.to_lowercase();
let heredoc_patterns = ["<<eof", "<<'eof'", "<<\"eof\"", "<<end", "<<'end'"];
let has_known_heredoc = heredoc_patterns.iter().any(|p| cmd_lower.contains(p));
if !has_known_heredoc {
return false;
}
segments.iter().any(|(segment, here)| {
disallowed_write_redirect_target(segment, write_allow_paths, project_root, here.as_deref())
.is_some()
})
}
pub fn is_temp_redirect_target(target: &str) -> bool {
let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
is_write_allowed_redirect_target(target, &write_allow_paths, None, None)
}
fn is_write_allowed_redirect_target(
target: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
cwd: Option<&str>,
) -> bool {
let t = target.trim_start_matches(['>', '&', '|']);
let t = t.trim_matches(['"', '\'']);
if t.starts_with('$') || t.starts_with("${") {
return true;
}
if is_unix_scratch_prefix(t) {
return true;
}
let path = std::path::Path::new(t);
let resolved_input;
let path = if path.is_absolute() {
path
} else if let Some(base) = cwd.filter(|c| !c.trim().is_empty()) {
resolved_input = std::path::Path::new(base).join(path);
resolved_input.as_path()
} else {
return false;
};
let resolved = resolve_path_for_comparison(path);
if project_root.is_some_and(|root| {
resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(root)))
}) {
return false;
}
write_allow_paths.iter().any(|allowed| {
resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(allowed)))
})
}
fn resolve_path_for_comparison(path: &std::path::Path) -> std::path::PathBuf {
use std::path::{Component, PathBuf};
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::ParentDir => {
normalized.pop();
}
other => normalized.push(other.as_os_str()),
}
}
let mut unresolved = Vec::new();
let mut existing = normalized.clone();
while !existing.exists() {
let Some(name) = existing.file_name() else {
break;
};
unresolved.push(name.to_os_string());
if !existing.pop() {
break;
}
}
let mut resolved = lean_ctx::core::pathutil::canonicalize_secure_or_self(&existing);
for component in unresolved.iter().rev() {
resolved.push(component);
}
resolved
}
fn tee_targets(command: &str) -> Vec<String> {
lean_ctx::core::shell_allowlist::extract_all_commands_pub(command)
.into_iter()
.filter_map(|segment| {
let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(segment.trim());
let first = tokens.first()?;
if first.rsplit('/').next().unwrap_or(first) != "tee" {
return None;
}
let mut after_separator = false;
Some(
tokens
.iter()
.skip(1)
.find(|token| {
if *token == "--" {
after_separator = true;
return false;
}
after_separator || !token.starts_with('-')
})
.cloned()
.unwrap_or_default(),
)
})
.collect()
}
fn disallowed_tee_target(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
cwd: Option<&str>,
) -> Option<String> {
tee_targets(command).into_iter().find(|target| {
!target.is_empty()
&& !is_write_allowed_redirect_target(target, write_allow_paths, project_root, cwd)
})
}
#[cfg(test)]
fn has_file_write_redirect(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
) -> bool {
disallowed_write_redirect_target(command, write_allow_paths, project_root, None).is_some()
}
fn disallowed_write_redirect_target(
command: &str,
write_allow_paths: &[String],
project_root: Option<&str>,
cwd: Option<&str>,
) -> Option<String> {
let bytes = command.as_bytes();
let len = bytes.len();
let mut i = 0;
let mut in_single_quote = false;
let mut in_double_quote = false;
while i < len {
let c = bytes[i];
if c == b'\\' && !in_single_quote {
i += 2;
continue;
}
if c == b'\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if c == b'"' && !in_single_quote {
in_double_quote = !in_double_quote;
} else if c == b'>' && !in_single_quote && !in_double_quote {
if i > 0 && bytes[i - 1] == b'2' {
i += 1;
continue;
}
let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
i + 2
} else {
i + 1
};
let rest = command[target_start..].trim_start();
let (rest, fd_dup) = match rest.strip_prefix('|') {
Some(r) => (r, false),
None => match rest.strip_prefix('&') {
Some(r) => (r, true),
None => (rest, false),
},
};
let word: String = rest
.chars()
.take_while(|c| !c.is_whitespace() && !matches!(c, ';' | '&' | '|' | ')' | '<'))
.collect();
let target = if fd_dup { format!("&{word}") } else { word };
if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
i += 1;
continue;
}
if let Some(fd) = target.strip_prefix('&')
&& !fd.is_empty()
&& (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
{
i += 1;
continue;
}
if is_write_allowed_redirect_target(&target, write_allow_paths, project_root, cwd) {
i += 1;
continue;
}
if !target.is_empty() {
return Some(target);
}
}
i += 1;
}
None
}
pub fn handle(command: &str, output: &str, exit_code: i32, _crp_mode: CrpMode) -> String {
lean_ctx::shell::compress::engine::compress_for_outcome(command, output, exit_code)
}
pub fn handle_with_context(
command: &str,
output: &str,
exit_code: i32,
crp_mode: CrpMode,
project_root: Option<&str>,
) -> String {
let mut result = handle(command, output, exit_code, crp_mode);
{
if let Some(root) = project_root {
let estimated_tokens = result.len() / 4;
if estimated_tokens > 500 {
let kernel_budget = 100;
if let Some(enrichment) = lean_ctx::core::context_kernel::bridge::kernel_enrich(
command,
root,
kernel_budget,
) && !enrichment.blocks.is_empty()
{
result.push_str("\n--- kernel context ---\n");
result.push_str(&enrichment.blocks);
}
}
}
}
result
}
#[cfg(test)]
mod kernel_tests {
use super::handle_with_context;
use crate::tools::CrpMode;
#[test]
fn handle_with_context_does_not_panic_on_short_output() {
let result = handle_with_context("ls", "file.txt", 0, CrpMode::Tdd, Some("/tmp"));
assert!(!result.is_empty(), "`result` is empty");
}
}
#[cfg(test)]
fn is_search_command(command: &str) -> bool {
let cmd = command.trim_start();
cmd.starts_with("grep ")
|| cmd.starts_with("rg ")
|| cmd.starts_with("find ")
|| cmd.starts_with("fd ")
|| cmd.starts_with("ag ")
|| cmd.starts_with("ack ")
}
#[cfg(test)]
fn generic_compress(output: &str) -> String {
let output = lean_ctx::core::compressor::strip_ansi(output);
let lines: Vec<&str> = output
.lines()
.filter(|l| {
let t = l.trim();
!t.is_empty()
})
.collect();
if lines.len() <= 20 {
return lines.join("\n");
}
let show_count = (lines.len() / 3).min(30);
let half = show_count / 2;
let first = &lines[..half];
let last = &lines[lines.len() - half..];
let omitted = lines.len() - (half * 2);
format!(
"{}\n[truncated: showing {}/{} lines, {} omitted. Use raw=true for full output.]\n{}",
first.join("\n"),
half * 2,
lines.len(),
omitted,
last.join("\n")
)
}
#[cfg(test)]
mod tests;