1use crate::tools::CrpMode;
3
4pub use lean_ctx::shell::command_text::{contains_auth_flow, normalize_command_for_shell};
5
6const MAX_COMMAND_BYTES: usize = 8192;
7
8pub fn validate_command(command: &str) -> Option<String> {
11 let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
12 let project_root = lean_ctx::core::config::Config::find_project_root();
13 validate_command_with_write_allow_paths(command, &write_allow_paths, project_root.as_deref())
14}
15
16pub(crate) fn validate_command_with_write_allow_paths(
22 command: &str,
23 write_allow_paths: &[String],
24 project_root: Option<&str>,
25) -> Option<String> {
26 validate_command_in_cwd(command, write_allow_paths, project_root, None)
27}
28
29const CAPTURE_RULE: &str = "The rule: output capture (`>`, `>>`, `| tee`) may go to the \
36 session's project, a directory in `allow_paths` / `extra_roots`, a directory listed in \
37 `write_allow_paths` in config.toml, or a scratch path (/tmp, /var/tmp, $TMPDIR). Every \
38 other destination is refused. Commands that do not capture output are not restricted.";
39
40pub(crate) fn capture_roots(
47 project_root: &str,
48 session_extra_roots: &[String],
49 cfg: &lean_ctx::core::config::Config,
50) -> Vec<String> {
51 match lean_ctx::core::workspace_authority::current() {
54 lean_ctx::core::workspace_authority::Authority::Legacy => {}
55 lean_ctx::core::workspace_authority::Authority::Workspace(snapshot) => {
56 return snapshot
57 .granting_roots()
58 .filter(|(_, access)| {
59 *access == lean_ctx::core::workspace_authority::Access::ReadWrite
60 })
61 .map(|(root, _)| root.to_string_lossy().into_owned())
62 .collect();
63 }
64 lean_ctx::core::workspace_authority::Authority::Unavailable(_) => return Vec::new(),
65 }
66 std::iter::once(project_root.to_string())
67 .chain(session_extra_roots.iter().cloned())
68 .chain(cfg.allow_paths.iter().cloned())
69 .chain(cfg.extra_roots.iter().cloned())
70 .filter(|raw| !raw.trim().is_empty())
71 .map(|raw| lean_ctx::core::pathjail::expand_user_path(&raw))
72 .filter(|path| {
73 let contains_home = dirs::home_dir().is_some_and(|home| home.starts_with(path));
74 path.is_absolute()
75 && !contains_home
76 && !lean_ctx::core::pathutil::is_broad_or_unsafe_root(path)
77 && !lean_ctx::core::pathjail::is_read_only_path(path)
78 })
79 .map(|path| path.to_string_lossy().into_owned())
80 .collect()
81}
82
83const WRITE_REASON: &str = "Files you keep are written with ctx_patch or the native Write/Edit \
92 tool, so the read-before-write gate and the edit checks apply to them; ctx_shell does not \
93 write them through output capture. This is unconditional: the redirect itself is not \
94 compressed, so a smaller command or `raw=true` does not change the verdict.";
95
96pub(crate) fn validate_command_in_cwd(
104 command: &str,
105 write_allow_paths: &[String],
106 project_root: Option<&str>,
107 cwd: Option<&str>,
108) -> Option<String> {
109 if command.len() > MAX_COMMAND_BYTES {
110 return Some(format!(
111 "ERROR: Command too large ({} bytes, limit {}). \
112 If you're writing file content, use the native Write/Edit tool instead. \
113 ctx_shell is for reading command output only (git, cargo, npm, etc.).",
114 command.len(),
115 MAX_COMMAND_BYTES
116 ));
117 }
118
119 let cmd_no_heredoc = lean_ctx::core::shell_allowlist::strip_all_heredoc_bodies(command);
122 let segments = segments_in_cwd(&cmd_no_heredoc, cwd);
129 if let Some(target) = segments.iter().find_map(|(segment, here)| {
138 disallowed_write_redirect_target(segment, write_allow_paths, project_root, here.as_deref())
139 }) {
140 return Some(format!(
141 "ERROR: ctx_shell refuses the redirect into `{target}` — the destination decides, \
142 not the size or content of the output. {WRITE_REASON} {CAPTURE_RULE} \
143 Write the file with ctx_patch or the native Write tool, or capture to a scratch \
144 path.{}",
145 relative_target_note(&target)
146 ));
147 }
148
149 if let Some(target) = segments.iter().find_map(|(segment, here)| {
160 disallowed_tee_target(segment, write_allow_paths, project_root, here.as_deref())
161 }) {
162 return Some(format!(
163 "ERROR: ctx_shell refuses `tee {target}`. Piping makes no difference: the \
164 destination decides. {WRITE_REASON} {CAPTURE_RULE} \
165 Write the file with ctx_patch or the native Write tool, or tee to a scratch \
166 path.{}",
167 relative_target_note(&target)
168 ));
169 }
170
171 if is_heredoc_file_write(command, &segments, write_allow_paths, project_root) {
172 return Some(format!(
173 "ERROR: ctx_shell detected a heredoc writing to a file. {WRITE_REASON} \
174 Create the file with ctx_patch (op=create) or the native Write tool. \
175 Note: heredocs for input piping (e.g. psql <<EOF) are allowed."
176 ));
177 }
178
179 if let Some(reason) = disallowed_download(&cmd_no_heredoc, write_allow_paths, project_root, cwd)
196 {
197 return Some(format!(
198 "ERROR: ctx_shell refuses the download ({reason}) — the destination decides. \
199 A download (curl -o/-O, wget, dd of=) may write where output capture may: the \
200 session's project, a directory in `allow_paths` / `extra_roots`, a directory \
201 listed in `write_allow_paths` in config.toml, or a scratch path (/tmp, \
202 /var/tmp, $TMPDIR). The target must be one that can be placed: an absolute \
203 path, $TMPDIR/…, a variable this same command set to a literal path \
204 (D=/x; curl -o $D/f), or a relative name in the directory its command runs in \
205 (the call's `cwd`, moved by a plain `cd`). Any other variable cannot be \
206 resolved here and is refused. A binary (image, PDF, archive) downloads to such \
207 a path directly, e.g. curl -sL -o /tmp/shot.png <url>; for text, fetch to \
208 stdout: curl <url> / wget -qO- <url>."
209 ));
210 }
211
212 None
213}
214
215fn relative_target_note(target: &str) -> &'static str {
218 let t = target
219 .trim_start_matches(['>', '&', '|'])
220 .trim_matches(['"', '\'']);
221 if t.starts_with('/') || std::path::Path::new(t).is_absolute() {
222 return "";
223 }
224 " A relative target is placed in the directory its own command runs in: the call's \
225 `cwd`, moved by a plain `cd <dir> &&` before it. Where that directory cannot be \
226 known — a `cd` through a variable, `pushd`, a subshell, a `cd` that may not have \
227 run — a relative target is refused; give an absolute path."
228}
229
230fn segments_in_cwd(command: &str, cwd: Option<&str>) -> Vec<(String, Option<String>)> {
233 let base = cwd
234 .filter(|c| !c.trim().is_empty())
235 .map(std::path::Path::new);
236 lean_ctx::core::command_cwd::segments_with_cwd(command, base)
237 .into_iter()
238 .map(|s| {
239 let here = s.cwd.map(|p| p.to_string_lossy().into_owned());
240 (s.segment, here)
241 })
242 .collect()
243}
244
245fn is_unix_scratch_prefix(path: &str) -> bool {
249 for prefix in ["/tmp", "/var/tmp", "/private/tmp", "/dev/null"] {
250 if path == prefix || path.starts_with(&format!("{prefix}/")) {
251 return true;
252 }
253 }
254 false
255}
256
257fn is_scratch_path(path: &str) -> bool {
261 if is_unix_scratch_prefix(path) {
262 return true;
263 }
264 if let Ok(tmpdir) = std::env::var("TMPDIR")
265 && !tmpdir.is_empty()
266 && std::path::Path::new(path).starts_with(tmpdir.as_str())
267 {
268 return true;
269 }
270 let tmp = std::env::temp_dir();
272 if std::path::Path::new(path).starts_with(&tmp) {
273 return true;
274 }
275 false
276}
277
278#[cfg(test)]
283fn download_to_file_reason(command: &str) -> Option<String> {
284 disallowed_download(command, &[], None, None)
285}
286
287fn disallowed_download(
293 command: &str,
294 write_allow_paths: &[String],
295 project_root: Option<&str>,
296 cwd: Option<&str>,
297) -> Option<String> {
298 let base = cwd
310 .filter(|c| !c.trim().is_empty())
311 .map(std::path::Path::new);
312 let segments = lean_ctx::core::command_cwd::segments_with_cwd(command, base);
313 let known_vars = lean_ctx::core::command_vars::literal_assignments_before_each_segment(
314 command,
315 segments.len(),
316 );
317 for (index, seg_cwd) in segments.into_iter().enumerate() {
318 let seg = seg_cwd.segment;
319 let here = seg_cwd.cwd;
320 let vars = &known_vars[index];
321 let resolve = |path: &str| -> String {
322 let expanded = lean_ctx::core::command_vars::expand_leading_variable(path, vars);
323 let path = expanded.as_deref().unwrap_or(path);
324 let p = std::path::Path::new(path);
325 if p.is_absolute() || path.starts_with('/') {
328 return path.to_string();
329 }
330 match here.as_deref() {
333 Some(dir) => lean_ctx::core::command_cwd::join_in(dir, path)
334 .to_string_lossy()
335 .into_owned(),
336 None => path.to_string(),
337 }
338 };
339 let allowed = |path: &str| -> bool {
344 let target = resolve(path);
345 !target.contains(['$', '`'])
346 && (is_scratch_path(&target)
347 || is_write_allowed_redirect_target(
348 &target,
349 write_allow_paths,
350 project_root,
351 None,
352 ))
353 };
354 let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(seg.trim());
355 let Some(first) = tokens.first() else {
356 continue;
357 };
358 let base = first.rsplit('/').next().unwrap_or(first);
359 match base {
360 "curl" => {
361 let tokens_slice = &tokens[1..];
362 for (i, tok) in tokens_slice.iter().enumerate() {
363 let target: Option<&str> = if tok == "--output" {
364 tokens_slice.get(i + 1).map(String::as_str)
365 } else if let Some(val) = tok.strip_prefix("--output=") {
366 Some(val)
367 } else if tok == "--output-dir" {
368 tokens_slice.get(i + 1).map(String::as_str)
369 } else if let Some(val) = tok.strip_prefix("--output-dir=") {
370 Some(val)
371 } else if tok.starts_with('-')
372 && !tok.starts_with("--")
373 && tok[1..].contains('o')
374 {
375 tokens_slice.get(i + 1).map(String::as_str)
377 } else if tok == "--remote-name"
378 || tok == "--remote-name-all"
379 || (tok.starts_with('-')
380 && !tok.starts_with("--")
381 && tok[1..].contains('O'))
382 {
383 Some(".")
384 } else {
385 None
386 };
387 if let Some(path) = target {
388 if allowed(path) {
389 continue;
390 }
391 return Some(format!("curl {tok} {path}"));
392 }
393 }
394 }
395 "wget" => {
396 if let Some(target) = wget_targets(&tokens[1..])
397 .into_iter()
398 .find(|target| !allowed(target))
399 {
400 return Some(format!("wget writes {target}"));
401 }
402 }
403 "dd" => {
404 for tok in &tokens[1..] {
405 if let Some(target) = tok.strip_prefix("of=")
406 && target != "/dev/null"
407 && !allowed(target)
408 {
409 return Some(format!("dd {tok}"));
410 }
411 }
412 }
413 _ => {}
414 }
415 }
416 None
417}
418
419fn wget_targets(args: &[String]) -> Vec<String> {
423 let mut document: Option<String> = None;
424 let mut prefix: Option<String> = None;
425 let mut logs = Vec::new();
426 let mut spider = false;
427 let mut i = 0;
428 while i < args.len() {
429 let tok = args[i].as_str();
430 let next = || args.get(i + 1).cloned();
431 let mut takes_next = false;
432 if tok == "--spider" {
433 spider = true;
434 } else if let Some((flag, value)) = tok.split_once('=').filter(|_| tok.starts_with("--")) {
435 match flag {
436 "--output-document" => document = Some(value.to_string()),
437 "--directory-prefix" => prefix = Some(value.to_string()),
438 "--output-file" | "--append-output" => logs.push(value.to_string()),
439 _ => {}
440 }
441 } else if matches!(
442 tok,
443 "--output-document" | "--directory-prefix" | "--output-file" | "--append-output"
444 ) {
445 takes_next = true;
446 let value = next();
447 match tok {
448 "--output-document" => document = value,
449 "--directory-prefix" => prefix = value,
450 _ => logs.extend(value),
451 }
452 } else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.starts_with('-')) {
453 if let Some(pos) = cluster.find(['O', 'P', 'o', 'a']) {
456 let rest = &cluster[pos + 1..];
457 let value = if rest.is_empty() {
458 takes_next = true;
459 next()
460 } else {
461 Some(rest.to_string())
462 };
463 match &cluster[pos..=pos] {
464 "O" => document = value,
465 "P" => prefix = value,
466 _ => logs.extend(value),
467 }
468 }
469 }
470 i += if takes_next { 2 } else { 1 };
471 }
472 let mut targets = logs;
473 match document.as_deref() {
474 Some("-") => {}
475 Some(file) => targets.push(file.to_string()),
476 None if !spider => targets.push(prefix.unwrap_or_else(|| ".".to_string())),
477 None => {}
478 }
479 targets
480}
481
482fn is_heredoc_file_write(
486 command: &str,
487 segments: &[(String, Option<String>)],
488 write_allow_paths: &[String],
489 project_root: Option<&str>,
490) -> bool {
491 let has_heredoc = command.contains("<<");
492 if !has_heredoc {
493 return false;
494 }
495 let cmd_lower = command.to_lowercase();
496 let heredoc_patterns = ["<<eof", "<<'eof'", "<<\"eof\"", "<<end", "<<'end'"];
497 let has_known_heredoc = heredoc_patterns.iter().any(|p| cmd_lower.contains(p));
498 if !has_known_heredoc {
499 return false;
500 }
501 segments.iter().any(|(segment, here)| {
504 disallowed_write_redirect_target(segment, write_allow_paths, project_root, here.as_deref())
505 .is_some()
506 })
507}
508
509pub fn is_temp_redirect_target(target: &str) -> bool {
519 let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
520 is_write_allowed_redirect_target(target, &write_allow_paths, None, None)
523}
524
525fn is_write_allowed_redirect_target(
526 target: &str,
527 write_allow_paths: &[String],
528 project_root: Option<&str>,
529 cwd: Option<&str>,
530) -> bool {
531 let t = target.trim_start_matches(['>', '&', '|']);
533 let t = t.trim_matches(['"', '\'']);
536 if t.starts_with('$') || t.starts_with("${") {
537 return true;
539 }
540
541 if is_unix_scratch_prefix(t) {
546 return true;
547 }
548
549 let path = std::path::Path::new(t);
563 let resolved_input;
564 let path = if path.is_absolute() {
565 path
566 } else if let Some(base) = cwd.filter(|c| !c.trim().is_empty()) {
567 resolved_input = std::path::Path::new(base).join(path);
568 resolved_input.as_path()
569 } else {
570 return false;
571 };
572 let resolved = resolve_path_for_comparison(path);
573 if project_root.is_some_and(|root| {
574 resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(root)))
575 }) {
576 return false;
577 }
578 write_allow_paths.iter().any(|allowed| {
579 resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(allowed)))
580 })
581}
582
583fn resolve_path_for_comparison(path: &std::path::Path) -> std::path::PathBuf {
584 use std::path::{Component, PathBuf};
585
586 let mut normalized = PathBuf::new();
587 for component in path.components() {
588 match component {
589 Component::CurDir => {}
590 Component::ParentDir => {
591 normalized.pop();
592 }
593 other => normalized.push(other.as_os_str()),
594 }
595 }
596
597 let mut unresolved = Vec::new();
598 let mut existing = normalized.clone();
599 while !existing.exists() {
600 let Some(name) = existing.file_name() else {
601 break;
602 };
603 unresolved.push(name.to_os_string());
604 if !existing.pop() {
605 break;
606 }
607 }
608 let mut resolved = lean_ctx::core::pathutil::canonicalize_secure_or_self(&existing);
609 for component in unresolved.iter().rev() {
610 resolved.push(component);
611 }
612 resolved
613}
614
615fn tee_targets(command: &str) -> Vec<String> {
616 lean_ctx::core::shell_allowlist::extract_all_commands_pub(command)
617 .into_iter()
618 .filter_map(|segment| {
619 let tokens = lean_ctx::core::shell_allowlist::shell_tokenize(segment.trim());
620 let first = tokens.first()?;
621 if first.rsplit('/').next().unwrap_or(first) != "tee" {
622 return None;
623 }
624 let mut after_separator = false;
625 Some(
626 tokens
627 .iter()
628 .skip(1)
629 .find(|token| {
630 if *token == "--" {
631 after_separator = true;
632 return false;
633 }
634 after_separator || !token.starts_with('-')
635 })
636 .cloned()
637 .unwrap_or_default(),
638 )
639 })
640 .collect()
641}
642
643fn disallowed_tee_target(
650 command: &str,
651 write_allow_paths: &[String],
652 project_root: Option<&str>,
653 cwd: Option<&str>,
654) -> Option<String> {
655 tee_targets(command).into_iter().find(|target| {
656 !target.is_empty()
657 && !is_write_allowed_redirect_target(target, write_allow_paths, project_root, cwd)
658 })
659}
660
661#[cfg(test)]
668fn has_file_write_redirect(
669 command: &str,
670 write_allow_paths: &[String],
671 project_root: Option<&str>,
672) -> bool {
673 disallowed_write_redirect_target(command, write_allow_paths, project_root, None).is_some()
677}
678
679fn disallowed_write_redirect_target(
688 command: &str,
689 write_allow_paths: &[String],
690 project_root: Option<&str>,
691 cwd: Option<&str>,
692) -> Option<String> {
693 let bytes = command.as_bytes();
694 let len = bytes.len();
695 let mut i = 0;
696 let mut in_single_quote = false;
697 let mut in_double_quote = false;
698
699 while i < len {
700 let c = bytes[i];
701 if c == b'\\' && !in_single_quote {
702 i += 2;
708 continue;
709 }
710 if c == b'\'' && !in_double_quote {
711 in_single_quote = !in_single_quote;
712 } else if c == b'"' && !in_single_quote {
713 in_double_quote = !in_double_quote;
714 } else if c == b'>' && !in_single_quote && !in_double_quote {
715 if i > 0 && bytes[i - 1] == b'2' {
716 i += 1;
717 continue;
718 }
719 let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
720 i + 2
721 } else {
722 i + 1
723 };
724 let rest = command[target_start..].trim_start();
729 let (rest, fd_dup) = match rest.strip_prefix('|') {
730 Some(r) => (r, false),
731 None => match rest.strip_prefix('&') {
732 Some(r) => (r, true),
733 None => (rest, false),
734 },
735 };
736 let word: String = rest
749 .chars()
750 .take_while(|c| !c.is_whitespace() && !matches!(c, ';' | '&' | '|' | ')' | '<'))
751 .collect();
752 let target = if fd_dup { format!("&{word}") } else { word };
753 if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
754 i += 1;
755 continue;
756 }
757 if let Some(fd) = target.strip_prefix('&')
760 && !fd.is_empty()
761 && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
762 {
763 i += 1;
764 continue;
765 }
766 if is_write_allowed_redirect_target(&target, write_allow_paths, project_root, cwd) {
769 i += 1;
770 continue;
771 }
772 if !target.is_empty() {
773 return Some(target);
774 }
775 }
776 i += 1;
777 }
778 None
779}
780
781pub fn handle(command: &str, output: &str, exit_code: i32, _crp_mode: CrpMode) -> String {
786 lean_ctx::shell::compress::engine::compress_for_outcome(command, output, exit_code)
787}
788
789pub fn handle_with_context(
790 command: &str,
791 output: &str,
792 exit_code: i32,
793 crp_mode: CrpMode,
794 project_root: Option<&str>,
795) -> String {
796 let mut result = handle(command, output, exit_code, crp_mode);
797
798 {
799 if let Some(root) = project_root {
800 let estimated_tokens = result.len() / 4;
801 if estimated_tokens > 500 {
802 let kernel_budget = 100;
803 if let Some(enrichment) = lean_ctx::core::context_kernel::bridge::kernel_enrich(
804 command,
805 root,
806 kernel_budget,
807 ) && !enrichment.blocks.is_empty()
808 {
809 result.push_str("\n--- kernel context ---\n");
810 result.push_str(&enrichment.blocks);
811 }
812 }
813 }
814 }
815
816 result
817}
818
819#[cfg(test)]
820mod kernel_tests {
821 use super::handle_with_context;
822 use crate::tools::CrpMode;
823
824 #[test]
825 fn handle_with_context_does_not_panic_on_short_output() {
826 let result = handle_with_context("ls", "file.txt", 0, CrpMode::Tdd, Some("/tmp"));
827 assert!(!result.is_empty(), "`result` is empty");
828 }
829}
830
831#[cfg(test)]
832fn is_search_command(command: &str) -> bool {
833 let cmd = command.trim_start();
834 cmd.starts_with("grep ")
835 || cmd.starts_with("rg ")
836 || cmd.starts_with("find ")
837 || cmd.starts_with("fd ")
838 || cmd.starts_with("ag ")
839 || cmd.starts_with("ack ")
840}
841
842#[cfg(test)]
843fn generic_compress(output: &str) -> String {
844 let output = lean_ctx::core::compressor::strip_ansi(output);
845 let lines: Vec<&str> = output
846 .lines()
847 .filter(|l| {
848 let t = l.trim();
849 !t.is_empty()
850 })
851 .collect();
852
853 if lines.len() <= 20 {
854 return lines.join("\n");
855 }
856
857 let show_count = (lines.len() / 3).min(30);
858 let half = show_count / 2;
859 let first = &lines[..half];
860 let last = &lines[lines.len() - half..];
861 let omitted = lines.len() - (half * 2);
862 format!(
863 "{}\n[truncated: showing {}/{} lines, {} omitted. Use raw=true for full output.]\n{}",
864 first.join("\n"),
865 half * 2,
866 lines.len(),
867 omitted,
868 last.join("\n")
869 )
870}
871
872#[cfg(test)]
873mod tests;