lean_ctx_server/tools/ctx_shell.rs
1// SPDX-License-Identifier: Apache-2.0
2use 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
8/// Validates a shell command before execution. Returns Some(error_message) if
9/// the command should be rejected, None if it's safe to run.
10pub 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
16/// Validate without knowing where the command will run.
17///
18/// A relative redirect target cannot be placed, so it is refused — the
19/// conservative reading. Callers that know the working directory should use
20/// [`validate_command_in_cwd`] instead, which resolves the destination first.
21pub(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
29/// The capture rule as the guard applies it, shared by the redirect and `tee`
30/// refusals so the two cannot drift apart. #1946: both used to call the rule
31/// "output capture into a project path", but a destination outside the
32/// project — and outside every root — is refused just the same. A caller who
33/// took that wording at its word tried another non-project path and was
34/// refused again with the identical text.
35const 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
40/// Directories ctx_shell output capture may write besides `write_allow_paths`
41/// and the scratch paths: the session's project, its host-declared roots and
42/// the jail's explicit allow entries — the places lean-ctx's own edit tools may
43/// write (owner decision 2026-10-08: refusing `> out.txt` inside the project
44/// while `allow_paths` was ignored read as a bug). Broad roots (`/`, `~`, temp
45/// parents) and anything under `read_only_roots` are never included.
46pub(crate) fn capture_roots(
47 project_root: &str,
48 session_extra_roots: &[String],
49 cfg: &lean_ctx::core::config::Config,
50) -> Vec<String> {
51 // Under a Workspace (#2120) capture goes only to its read-write roots;
52 // configured allow entries and extra roots grant nothing.
53 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
83/// Why the capture rule exists, shared by every write refusal (#1992). The
84/// refusals used to say ctx_shell's compression could land in the file as
85/// markers. That stopped being true with #1303 (v3.9.14): the child shell
86/// performs the redirect, so the file receives the command's own bytes and
87/// only the MCP return channel is compressed. A caller who knew their output
88/// was tiny plain text read the old rationale as a risk that did not apply to
89/// them and retried with `raw=true` or a smaller command — neither changes
90/// the verdict, because the reason is the write path, not the bytes.
91const 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
96/// Validate against the directory the command will actually run in (#1811).
97///
98/// The write guard classifies the *destination*, so a relative redirect target
99/// has to be placed before it can be judged. Without a `cwd` it cannot be, and
100/// every relative target was refused — which made `cwd=<scratch> … > probe.txt`
101/// fail while the identical absolute path succeeded, under a message promising
102/// that the destination decides.
103pub(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 // #931: strip heredoc bodies before the redirect scanner — a `>` inside a
120 // heredoc body is opaque data, not a file-write redirect.
121 let cmd_no_heredoc = lean_ctx_shell_policy::strip_all_heredoc_bodies(command);
122 // #1850: a relative target lands where its own command runs, and a `cd`
123 // earlier in the line moves that. Judging every target against the call's
124 // `cwd` blocked `cd /tmp && echo x > out.txt` and let `cd <project> && echo
125 // x > f` from a scratch cwd write straight into the project. Each segment
126 // is judged against its own directory; where that cannot be known, a
127 // relative target is refused.
128 let segments = segments_in_cwd(&cmd_no_heredoc, cwd);
129 // #1768: the rule is the destination, not the size of the payload. The
130 // refusal used to justify itself with "MCP protocol corruption on large
131 // payloads" while the guard allows a megabyte into /tmp and blocks two
132 // bytes into a project file — so the stated reason argued *against* the
133 // verdict in both directions, and steered callers to retry smaller (no
134 // effect) or to avoid the sanctioned scratch capture. #1992: the
135 // compression-marker rationale that replaced it was stale too — see
136 // WRITE_REASON for the reason that holds.
137 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 // #989: tee detection must run on heredoc-stripped text to avoid false
150 // positives when the word "tee" appears in heredoc/quoted payloads.
151 //
152 // #1671: the rule is the destination, not the pipe. The message used to say
153 // "tee without pipe" and then state that piped tee is allowed — so it
154 // rejected a command and, in the next sentence, described that exact
155 // command as permitted. A reviewer read it, concluded the block was correct
156 // because an alternative was offered, and closed a review with no findings.
157 // Naming the pipe also sent callers off to restructure their pipeline,
158 // which cannot help: `… | tee FILE | wc -l` is judged identically.
159 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 // #1672: on the heredoc-stripped text, for the same reason as #931 and
180 // #989 above. A download flag inside a heredoc *body* is opaque data — a
181 // commit message, a doc, a fixture — and refusing the call for it blocks
182 // writing about the guard at all. This detector was simply never switched
183 // over when the other two were.
184 //
185 // #2080: the refusal used to say "the capture rule applies to them as
186 // well" and that "a literal path" would do. Neither was true — `>` may
187 // write into the project and the configured roots, a download only into
188 // a scratch path — so a caller followed it to a project path and was
189 // refused again with the identical text. It now states the rule it applies.
190 //
191 // Owner decision 2026-10-15: a download may write wherever `>` output
192 // capture may. Scratch-only made agents fetch a model or an asset into
193 // /tmp and `mv` it, and they retried project paths in a loop before
194 // finding that route; the rule is a payload convention, not a boundary.
195 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
215/// How a relative target is placed — said when one is refused, because the
216/// answer is usually to say where it should go (#1850).
217fn 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
230/// Each segment of the heredoc-stripped command with the directory it runs in,
231/// as a string for the path checks. An empty `cwd` is no `cwd`.
232fn 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
245/// Well-known Unix scratch prefixes that agents use on every platform.
246/// On Windows, `/tmp/foo` is not a real absolute path, but Git Bash, WSL,
247/// and agent-generated commands routinely target it. (#1467)
248fn 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
257/// Detects download/copy tools writing directly to files via their own flags.
258/// Returns true when a path targets a scratch/temp location outside the
259/// project, where file downloads are safe (#1021).
260fn 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 // Windows: %TEMP% / std::env::temp_dir()
271 let tmp = std::env::temp_dir();
272 std::path::Path::new(path).starts_with(&tmp)
273}
274
275/// (`curl -o`, `wget` default mode, `dd of=`) — the redirect-free equivalent of
276/// `> file`, reported as a `validate_command` bypass in GH #391.
277///
278/// Test convenience: no capture roots and no `cwd`, so only scratch passes.
279#[cfg(test)]
280fn download_to_file_reason(command: &str) -> Option<String> {
281 disallowed_download(command, &[], None, None)
282}
283
284/// The first download whose target may not be written, as the refusal's
285/// reason. A target passes where `>` output capture may write (scratch, the
286/// capture roots in `write_allow_paths`), judged where it lands: relative to
287/// the directory its own command runs in, through variables the command set
288/// to a literal. A variable that cannot be followed is refused.
289fn disallowed_download(
290 command: &str,
291 write_allow_paths: &[String],
292 project_root: Option<&str>,
293 cwd: Option<&str>,
294) -> Option<String> {
295 // #1661: the scratch carve-out is written in absolute paths, but agents
296 // reach a scratch directory the way a person would — `cd <scratchpad> &&
297 // curl -o shot.png …`. Judged as the bare string `shot.png`, a target that
298 // lands squarely inside the sanctioned directory looked like a project
299 // write and was blocked, leaving no in-tool way to fetch a binary at all.
300 // Segments are paired with the directory they actually run in so a relative
301 // target is judged where it lands.
302 //
303 // #1953: likewise a target spelled through a variable the same command set
304 // to a literal (`D=/tmp/x; curl -o $D/f`) is judged where it lands; any
305 // other variable stays unresolved and is refused as before.
306 let base = cwd
307 .filter(|c| !c.trim().is_empty())
308 .map(std::path::Path::new);
309 let segments = lean_ctx::core::command_cwd::segments_with_cwd(command, base);
310 let known_vars = lean_ctx::core::command_vars::literal_assignments_before_each_segment(
311 command,
312 segments.len(),
313 );
314 for (index, seg_cwd) in segments.into_iter().enumerate() {
315 let seg = seg_cwd.segment;
316 let here = seg_cwd.cwd;
317 let vars = &known_vars[index];
318 let resolve = |path: &str| -> String {
319 let expanded = lean_ctx::core::command_vars::expand_leading_variable(path, vars);
320 let path = expanded.as_deref().unwrap_or(path);
321 let p = std::path::Path::new(path);
322 // `starts_with('/')`: shell text, so a Unix-absolute target is
323 // absolute on Windows too, where `is_absolute` disagrees (#1467).
324 if p.is_absolute() || path.starts_with('/') {
325 return path.to_string();
326 }
327 // Without a known directory the target stays unresolved, which the
328 // scratch check then rejects — the guard errs closed.
329 match here.as_deref() {
330 Some(dir) => lean_ctx::core::command_cwd::join_in(dir, path)
331 .to_string_lossy()
332 .into_owned(),
333 None => path.to_string(),
334 }
335 };
336 // Where `>` capture may write. A target still holding an expansion
337 // after `resolve` is refused wherever it sits — `$UNSET/f` under a
338 // project cwd would otherwise read as a project path — so the capture
339 // guard's `$…` escape hatch (#989) never reaches downloads.
340 let allowed = |path: &str| -> bool {
341 let target = resolve(path);
342 !target.contains(['$', '`'])
343 && (is_scratch_path(&target)
344 || is_write_allowed_redirect_target(
345 &target,
346 write_allow_paths,
347 project_root,
348 None,
349 ))
350 };
351 let tokens = lean_ctx_shell_policy::shell_tokenize(seg.trim());
352 let Some(first) = tokens.first() else {
353 continue;
354 };
355 let base = first.rsplit('/').next().unwrap_or(first);
356 match base {
357 // The same list of written files as the shell gate (#2212): every
358 // curl output option (`-o`, `-O`, `-D`, `-c`, `--trace`, `--hsts`,
359 // `-w '%output{…}'`, …) and every wget file (document, logs,
360 // `--save-cookies`, `-e logfile=…`, …). A config file the call
361 // reads (`curl -K`, `wget --config`) may name any file: refused
362 // wherever the call runs.
363 "curl" | "wget" => {
364 if let Some(target) = lean_ctx_shell_policy::download_targets(base, &tokens[1..])
365 .into_iter()
366 .find(|target| {
367 target == lean_ctx_shell_policy::UNKNOWN_DOWNLOAD_TARGET || !allowed(target)
368 })
369 {
370 return Some(format!("{base} writes {target}"));
371 }
372 }
373 "dd" => {
374 for tok in &tokens[1..] {
375 if let Some(target) = tok.strip_prefix("of=")
376 && target != "/dev/null"
377 && !allowed(target)
378 {
379 return Some(format!("dd {tok}"));
380 }
381 }
382 }
383 _ => {}
384 }
385 }
386 None
387}
388
389/// Returns true only for heredocs that redirect to files (the dangerous pattern).
390/// Legitimate heredoc uses (input piping, inline scripts) are allowed through.
391/// `segments` is the heredoc-stripped command with each segment's directory.
392fn is_heredoc_file_write(
393 command: &str,
394 segments: &[(String, Option<String>)],
395 write_allow_paths: &[String],
396 project_root: Option<&str>,
397) -> bool {
398 let has_heredoc = command.contains("<<");
399 if !has_heredoc {
400 return false;
401 }
402 let cmd_lower = command.to_lowercase();
403 let heredoc_patterns = ["<<eof", "<<'eof'", "<<\"eof\"", "<<end", "<<'end'"];
404 let has_known_heredoc = heredoc_patterns.iter().any(|p| cmd_lower.contains(p));
405 if !has_known_heredoc {
406 return false;
407 }
408 // #931: the segments come from the heredoc-stripped text, so `>` / `>>`
409 // inside the body are not mistaken for file-write redirects.
410 segments.iter().any(|(segment, here)| {
411 disallowed_write_redirect_target(segment, write_allow_paths, project_root, here.as_deref())
412 .is_some()
413 })
414}
415
416/// Detects shell redirect operators (`>` or `>>`) that write to files.
417/// Ignores `>` inside quotes, after a backslash escape (`\"` must not toggle
418/// quote state, `\>` is a literal), `2>` (stderr), `/dev/null`, and
419/// comparison operators.
420/// #848: temp directory targets are read-back, not persistent writes.
421/// #848/#989: targets that are NOT persistent project-file writes.
422/// Redirecting to temp dirs, /dev/* devices, or paths containing shell
423/// variables (which we cannot resolve at parse time) is output capture,
424/// not file authoring.
425pub fn is_temp_redirect_target(target: &str) -> bool {
426 let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
427 // No cwd here: this public helper judges a target in isolation, so a
428 // relative one stays unplaceable and therefore not allowed (#1811).
429 is_write_allowed_redirect_target(target, &write_allow_paths, None, None)
430}
431
432fn is_write_allowed_redirect_target(
433 target: &str,
434 write_allow_paths: &[String],
435 project_root: Option<&str>,
436 cwd: Option<&str>,
437) -> bool {
438 // `>|` is the noclobber-override form of `>`; the `|` is not part of the path.
439 let t = target.trim_start_matches(['>', '&', '|']);
440 // #1142: agents quote scratch paths (`> "$TMPDIR/x.log"`, `> "/private/tmp/x"`);
441 // strip quotes so quoted and unquoted targets are judged identically.
442 let t = t.trim_matches(['"', '\'']);
443 if t.starts_with('$') || t.starts_with("${") {
444 // Preserve #989's escape hatch for harness-provided scratch paths.
445 return true;
446 }
447
448 // On Windows, agents often use Unix-style /tmp paths (Git Bash, WSL,
449 // or Claude generating Unix commands). `/tmp/foo` is not absolute per
450 // std::path on Windows, so check well-known scratch prefixes before
451 // the is_absolute gate. (#1467)
452 if is_unix_scratch_prefix(t) {
453 return true;
454 }
455
456 // #1811: a relative target is a destination like any other — it just needs
457 // placing first. The guard used to refuse every relative target outright,
458 // so `cwd=<scratch> … > probe.txt` was blocked while the identical
459 // `> <scratch>/probe.txt` was allowed, and the refusal said "the
460 // destination decides" about a destination it had not resolved.
461 //
462 // Resolving narrows as often as it widens: a relative target under a
463 // project cwd now resolves *into* the project and is refused on the same
464 // rule as an absolute one, instead of by accident of its spelling.
465 //
466 // `cwd` is the directory the target's own command runs in — the call's
467 // `cwd` moved by any `cd` before it (#1850, `command_cwd`), or `None` when
468 // that cannot be known, which refuses a relative target below.
469 let path = std::path::Path::new(t);
470 let resolved_input;
471 let path = if path.is_absolute() {
472 path
473 } else if let Some(base) = cwd.filter(|c| !c.trim().is_empty()) {
474 resolved_input = std::path::Path::new(base).join(path);
475 resolved_input.as_path()
476 } else {
477 return false;
478 };
479 let resolved = resolve_path_for_comparison(path);
480 if project_root.is_some_and(|root| {
481 resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(root)))
482 }) {
483 return false;
484 }
485 write_allow_paths.iter().any(|allowed| {
486 resolved.starts_with(resolve_path_for_comparison(std::path::Path::new(allowed)))
487 })
488}
489
490fn resolve_path_for_comparison(path: &std::path::Path) -> std::path::PathBuf {
491 use std::path::{Component, PathBuf};
492
493 let mut normalized = PathBuf::new();
494 for component in path.components() {
495 match component {
496 Component::CurDir => {}
497 Component::ParentDir => {
498 normalized.pop();
499 }
500 other => normalized.push(other.as_os_str()),
501 }
502 }
503
504 let mut unresolved = Vec::new();
505 let mut existing = normalized.clone();
506 while !existing.exists() {
507 let Some(name) = existing.file_name() else {
508 break;
509 };
510 unresolved.push(name.to_os_string());
511 if !existing.pop() {
512 break;
513 }
514 }
515 let mut resolved = lean_ctx::core::pathutil::canonicalize_secure_or_self(&existing);
516 for component in unresolved.iter().rev() {
517 resolved.push(component);
518 }
519 resolved
520}
521
522fn tee_targets(command: &str) -> Vec<String> {
523 lean_ctx_shell_policy::extract_all_commands_pub(command)
524 .into_iter()
525 .filter_map(|segment| {
526 let tokens = lean_ctx_shell_policy::shell_tokenize(segment.trim());
527 let first = tokens.first()?;
528 if first.rsplit('/').next().unwrap_or(first) != "tee" {
529 return None;
530 }
531 let mut after_separator = false;
532 Some(
533 tokens
534 .iter()
535 .skip(1)
536 .find(|token| {
537 if *token == "--" {
538 after_separator = true;
539 return false;
540 }
541 after_separator || !token.starts_with('-')
542 })
543 .cloned()
544 .unwrap_or_default(),
545 )
546 })
547 .collect()
548}
549
550/// The first `tee` destination that is not a permitted write target.
551///
552/// Returns the path so the refusal can name it (#1671). The rule is entirely
553/// about the destination — whether the `tee` is piped makes no difference — and
554/// a message that cannot name the destination ends up describing some other
555/// rule instead.
556fn disallowed_tee_target(
557 command: &str,
558 write_allow_paths: &[String],
559 project_root: Option<&str>,
560 cwd: Option<&str>,
561) -> Option<String> {
562 tee_targets(command).into_iter().find(|target| {
563 !target.is_empty()
564 && !is_write_allowed_redirect_target(target, write_allow_paths, project_root, cwd)
565 })
566}
567
568/// True when the command redirects into a path it may not write.
569///
570/// Test-only convenience: every production caller needs the offending target
571/// for its message and goes to [`disallowed_write_redirect_target`] directly
572/// (#1768). The assertions here only care about the verdict, and reading
573/// `.is_some()` into every one of them would obscure what they pin.
574#[cfg(test)]
575fn has_file_write_redirect(
576 command: &str,
577 write_allow_paths: &[String],
578 project_root: Option<&str>,
579) -> bool {
580 // No cwd: these assertions pin the target-spelling rules, where a relative
581 // target is unplaceable and therefore refused (#1811). The cwd-resolution
582 // behaviour has its own tests that call the public entry point.
583 disallowed_write_redirect_target(command, write_allow_paths, project_root, None).is_some()
584}
585
586/// The first redirect target that is **not** write-allowed, or `None` when the
587/// command writes nowhere it may not.
588///
589/// Returns the target rather than a bare verdict so the refusal can name what
590/// tripped it (#1768) — the same reason [`disallowed_tee_target`] exists. A
591/// guard that states a rule the caller cannot map onto their own command gets
592/// read as something else (there, "piping"; here, "large payloads"), and every
593/// such misreading sends the caller somewhere that cannot help.
594fn disallowed_write_redirect_target(
595 command: &str,
596 write_allow_paths: &[String],
597 project_root: Option<&str>,
598 cwd: Option<&str>,
599) -> Option<String> {
600 let bytes = command.as_bytes();
601 let len = bytes.len();
602 let mut i = 0;
603 let mut in_single_quote = false;
604 let mut in_double_quote = false;
605
606 while i < len {
607 let c = bytes[i];
608 if c == b'\\' && !in_single_quote {
609 // A backslash escapes the next byte (POSIX: outside quotes and
610 // inside double quotes; inside single quotes it is literal).
611 // Without this, an escaped quote like `\"` toggled the quote
612 // state and literal `>` in quoted prose (e.g. `(root: <root>)`
613 // in a gh --body string) read as a redirect (#903).
614 i += 2;
615 continue;
616 }
617 if c == b'\'' && !in_double_quote {
618 in_single_quote = !in_single_quote;
619 } else if c == b'"' && !in_single_quote {
620 in_double_quote = !in_double_quote;
621 } else if c == b'>' && !in_single_quote && !in_double_quote {
622 if i > 0 && bytes[i - 1] == b'2' {
623 i += 1;
624 continue;
625 }
626 let target_start = if i + 1 < len && bytes[i + 1] == b'>' {
627 i + 2
628 } else {
629 i + 1
630 };
631 // The redirect *operator* can carry one more character: `>|`
632 // overrides noclobber and `>&` duplicates a descriptor. Neither
633 // belongs to the target word, so strip it before reading the word
634 // and re-attach `&` for the fd check below.
635 let rest = command[target_start..].trim_start();
636 let (rest, fd_dup) = match rest.strip_prefix('|') {
637 Some(r) => (r, false),
638 None => match rest.strip_prefix('&') {
639 Some(r) => (r, true),
640 None => (rest, false),
641 },
642 };
643 // The word ends at whitespace *or* at a shell metacharacter
644 // (#1659). Stopping only at whitespace made the verdict depend on
645 // where the redirect sat: `echo a 1>/dev/null` read the target as
646 // `/dev/null` and was allowed, while `echo a 1>/dev/null; echo b`
647 // read `/dev/null;` — semicolon included, since it is not
648 // whitespace — missed the /dev/null exemption, and was blocked as a
649 // file write. `&&` happened to work only because a space precedes
650 // it. Same redirect, same shell semantics, opposite verdicts.
651 //
652 // `(` is deliberately not a terminator: it would reduce the
653 // process-substitution target `>(cmd)` to an empty word, and an
654 // empty word falls through unblocked.
655 let word: String = rest
656 .chars()
657 .take_while(|c| !c.is_whitespace() && !matches!(c, ';' | '&' | '|' | ')' | '<'))
658 .collect();
659 let target = if fd_dup { format!("&{word}") } else { word };
660 if target == "/dev/null" || target == "/dev/stdout" || target == "/dev/stderr" {
661 i += 1;
662 continue;
663 }
664 // #1142: `>&1` / `>&2` duplicate a file descriptor — no file involved.
665 // (`2>&1` is already skipped by the `2>` case above.)
666 if let Some(fd) = target.strip_prefix('&')
667 && !fd.is_empty()
668 && (fd == "-" || fd.chars().all(|c| c.is_ascii_digit()))
669 {
670 i += 1;
671 continue;
672 }
673 // #848: allow redirects to temp directories — agents capture
674 // build output for grepping, not writing persistent files.
675 if is_write_allowed_redirect_target(&target, write_allow_paths, project_root, cwd) {
676 i += 1;
677 continue;
678 }
679 if !target.is_empty() {
680 return Some(target);
681 }
682 }
683 i += 1;
684 }
685 None
686}
687
688/// Compresses shell command output using the unified compression pipeline.
689/// Delegates to the same exit-code-aware logic used by the CLI, so a failed
690/// command (`exit_code != 0`) is preserved verbatim and successful output is
691/// compressed consistently (excluded_commands, structural routing, terse). #810.
692pub fn handle(command: &str, output: &str, exit_code: i32, _crp_mode: CrpMode) -> String {
693 lean_ctx::shell::compress::engine::compress_for_outcome(command, output, exit_code)
694}
695
696pub fn handle_with_context(
697 command: &str,
698 output: &str,
699 exit_code: i32,
700 crp_mode: CrpMode,
701 project_root: Option<&str>,
702) -> String {
703 let mut result = handle(command, output, exit_code, crp_mode);
704
705 {
706 if let Some(root) = project_root {
707 let estimated_tokens = result.len() / 4;
708 if estimated_tokens > 500 {
709 let kernel_budget = 100;
710 if let Some(enrichment) = lean_ctx::core::context_kernel::bridge::kernel_enrich(
711 command,
712 root,
713 kernel_budget,
714 ) && !enrichment.blocks.is_empty()
715 {
716 result.push_str("\n--- kernel context ---\n");
717 result.push_str(&enrichment.blocks);
718 }
719 }
720 }
721 }
722
723 result
724}
725
726#[cfg(test)]
727mod kernel_tests {
728 use super::handle_with_context;
729 use crate::tools::CrpMode;
730
731 #[test]
732 fn handle_with_context_does_not_panic_on_short_output() {
733 let result = handle_with_context("ls", "file.txt", 0, CrpMode::Tdd, Some("/tmp"));
734 assert!(!result.is_empty(), "`result` is empty");
735 }
736}
737
738#[cfg(test)]
739fn is_search_command(command: &str) -> bool {
740 let cmd = command.trim_start();
741 cmd.starts_with("grep ")
742 || cmd.starts_with("rg ")
743 || cmd.starts_with("find ")
744 || cmd.starts_with("fd ")
745 || cmd.starts_with("ag ")
746 || cmd.starts_with("ack ")
747}
748
749#[cfg(test)]
750fn generic_compress(output: &str) -> String {
751 let output = lean_ctx::core::compressor::strip_ansi(output);
752 let lines: Vec<&str> = output
753 .lines()
754 .filter(|l| {
755 let t = l.trim();
756 !t.is_empty()
757 })
758 .collect();
759
760 if lines.len() <= 20 {
761 return lines.join("\n");
762 }
763
764 let show_count = (lines.len() / 3).min(30);
765 let half = show_count / 2;
766 let first = &lines[..half];
767 let last = &lines[lines.len() - half..];
768 let omitted = lines.len() - (half * 2);
769 format!(
770 "{}\n[truncated: showing {}/{} lines, {} omitted. Use raw=true for full output.]\n{}",
771 first.join("\n"),
772 half * 2,
773 lines.len(),
774 omitted,
775 last.join("\n")
776 )
777}
778
779#[cfg(test)]
780mod tests;