Skip to main content

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::core::shell_allowlist::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    if std::path::Path::new(path).starts_with(&tmp) {
273        return true;
274    }
275    false
276}
277
278/// (`curl -o`, `wget` default mode, `dd of=`) — the redirect-free equivalent of
279/// `> file`, reported as a `validate_command` bypass in GH #391.
280///
281/// Test convenience: no capture roots and no `cwd`, so only scratch passes.
282#[cfg(test)]
283fn download_to_file_reason(command: &str) -> Option<String> {
284    disallowed_download(command, &[], None, None)
285}
286
287/// The first download whose target may not be written, as the refusal's
288/// reason. A target passes where `>` output capture may write (scratch, the
289/// capture roots in `write_allow_paths`), judged where it lands: relative to
290/// the directory its own command runs in, through variables the command set
291/// to a literal. A variable that cannot be followed is refused.
292fn disallowed_download(
293    command: &str,
294    write_allow_paths: &[String],
295    project_root: Option<&str>,
296    cwd: Option<&str>,
297) -> Option<String> {
298    // #1661: the scratch carve-out is written in absolute paths, but agents
299    // reach a scratch directory the way a person would — `cd <scratchpad> &&
300    // curl -o shot.png …`. Judged as the bare string `shot.png`, a target that
301    // lands squarely inside the sanctioned directory looked like a project
302    // write and was blocked, leaving no in-tool way to fetch a binary at all.
303    // Segments are paired with the directory they actually run in so a relative
304    // target is judged where it lands.
305    //
306    // #1953: likewise a target spelled through a variable the same command set
307    // to a literal (`D=/tmp/x; curl -o $D/f`) is judged where it lands; any
308    // other variable stays unresolved and is refused as before.
309    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            // `starts_with('/')`: shell text, so a Unix-absolute target is
326            // absolute on Windows too, where `is_absolute` disagrees (#1467).
327            if p.is_absolute() || path.starts_with('/') {
328                return path.to_string();
329            }
330            // Without a known directory the target stays unresolved, which the
331            // scratch check then rejects — the guard errs closed.
332            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        // Where `>` capture may write. A target still holding an expansion
340        // after `resolve` is refused wherever it sits — `$UNSET/f` under a
341        // project cwd would otherwise read as a project path — so the capture
342        // guard's `$…` escape hatch (#989) never reaches downloads.
343        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                        // -o <file>: next token is the path
376                        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
419/// Every file a wget call writes: the document (`-O`/`--output-document`,
420/// else the URL's name in `-P`/`--directory-prefix` or the current directory)
421/// and a log (`-o`/`-a`). `-O -` and `--spider` write no document.
422fn 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            // A short-option cluster: the first value-taking letter takes the
454            // rest of the token, or the next token when nothing follows it.
455            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
482/// Returns true only for heredocs that redirect to files (the dangerous pattern).
483/// Legitimate heredoc uses (input piping, inline scripts) are allowed through.
484/// `segments` is the heredoc-stripped command with each segment's directory.
485fn 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    // #931: the segments come from the heredoc-stripped text, so `>` / `>>`
502    // inside the body are not mistaken for file-write redirects.
503    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
509/// Detects shell redirect operators (`>` or `>>`) that write to files.
510/// Ignores `>` inside quotes, after a backslash escape (`\"` must not toggle
511/// quote state, `\>` is a literal), `2>` (stderr), `/dev/null`, and
512/// comparison operators.
513/// #848: temp directory targets are read-back, not persistent writes.
514/// #848/#989: targets that are NOT persistent project-file writes.
515/// Redirecting to temp dirs, /dev/* devices, or paths containing shell
516/// variables (which we cannot resolve at parse time) is output capture,
517/// not file authoring.
518pub fn is_temp_redirect_target(target: &str) -> bool {
519    let write_allow_paths = lean_ctx::core::config::default_shell_write_allow_paths();
520    // No cwd here: this public helper judges a target in isolation, so a
521    // relative one stays unplaceable and therefore not allowed (#1811).
522    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    // `>|` is the noclobber-override form of `>`; the `|` is not part of the path.
532    let t = target.trim_start_matches(['>', '&', '|']);
533    // #1142: agents quote scratch paths (`> "$TMPDIR/x.log"`, `> "/private/tmp/x"`);
534    // strip quotes so quoted and unquoted targets are judged identically.
535    let t = t.trim_matches(['"', '\'']);
536    if t.starts_with('$') || t.starts_with("${") {
537        // Preserve #989's escape hatch for harness-provided scratch paths.
538        return true;
539    }
540
541    // On Windows, agents often use Unix-style /tmp paths (Git Bash, WSL,
542    // or Claude generating Unix commands).  `/tmp/foo` is not absolute per
543    // std::path on Windows, so check well-known scratch prefixes before
544    // the is_absolute gate.  (#1467)
545    if is_unix_scratch_prefix(t) {
546        return true;
547    }
548
549    // #1811: a relative target is a destination like any other — it just needs
550    // placing first. The guard used to refuse every relative target outright,
551    // so `cwd=<scratch> … > probe.txt` was blocked while the identical
552    // `> <scratch>/probe.txt` was allowed, and the refusal said "the
553    // destination decides" about a destination it had not resolved.
554    //
555    // Resolving narrows as often as it widens: a relative target under a
556    // project cwd now resolves *into* the project and is refused on the same
557    // rule as an absolute one, instead of by accident of its spelling.
558    //
559    // `cwd` is the directory the target's own command runs in — the call's
560    // `cwd` moved by any `cd` before it (#1850, `command_cwd`), or `None` when
561    // that cannot be known, which refuses a relative target below.
562    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
643/// The first `tee` destination that is not a permitted write target.
644///
645/// Returns the path so the refusal can name it (#1671). The rule is entirely
646/// about the destination — whether the `tee` is piped makes no difference — and
647/// a message that cannot name the destination ends up describing some other
648/// rule instead.
649fn 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/// True when the command redirects into a path it may not write.
662///
663/// Test-only convenience: every production caller needs the offending target
664/// for its message and goes to [`disallowed_write_redirect_target`] directly
665/// (#1768). The assertions here only care about the verdict, and reading
666/// `.is_some()` into every one of them would obscure what they pin.
667#[cfg(test)]
668fn has_file_write_redirect(
669    command: &str,
670    write_allow_paths: &[String],
671    project_root: Option<&str>,
672) -> bool {
673    // No cwd: these assertions pin the target-spelling rules, where a relative
674    // target is unplaceable and therefore refused (#1811). The cwd-resolution
675    // behaviour has its own tests that call the public entry point.
676    disallowed_write_redirect_target(command, write_allow_paths, project_root, None).is_some()
677}
678
679/// The first redirect target that is **not** write-allowed, or `None` when the
680/// command writes nowhere it may not.
681///
682/// Returns the target rather than a bare verdict so the refusal can name what
683/// tripped it (#1768) — the same reason [`disallowed_tee_target`] exists. A
684/// guard that states a rule the caller cannot map onto their own command gets
685/// read as something else (there, "piping"; here, "large payloads"), and every
686/// such misreading sends the caller somewhere that cannot help.
687fn 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            // A backslash escapes the next byte (POSIX: outside quotes and
703            // inside double quotes; inside single quotes it is literal).
704            // Without this, an escaped quote like `\"` toggled the quote
705            // state and literal `>` in quoted prose (e.g. `(root: <root>)`
706            // in a gh --body string) read as a redirect (#903).
707            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            // The redirect *operator* can carry one more character: `>|`
725            // overrides noclobber and `>&` duplicates a descriptor. Neither
726            // belongs to the target word, so strip it before reading the word
727            // and re-attach `&` for the fd check below.
728            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            // The word ends at whitespace *or* at a shell metacharacter
737            // (#1659). Stopping only at whitespace made the verdict depend on
738            // where the redirect sat: `echo a 1>/dev/null` read the target as
739            // `/dev/null` and was allowed, while `echo a 1>/dev/null; echo b`
740            // read `/dev/null;` — semicolon included, since it is not
741            // whitespace — missed the /dev/null exemption, and was blocked as a
742            // file write. `&&` happened to work only because a space precedes
743            // it. Same redirect, same shell semantics, opposite verdicts.
744            //
745            // `(` is deliberately not a terminator: it would reduce the
746            // process-substitution target `>(cmd)` to an empty word, and an
747            // empty word falls through unblocked.
748            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            // #1142: `>&1` / `>&2` duplicate a file descriptor — no file involved.
758            // (`2>&1` is already skipped by the `2>` case above.)
759            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            // #848: allow redirects to temp directories — agents capture
767            // build output for grepping, not writing persistent files.
768            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
781/// Compresses shell command output using the unified compression pipeline.
782/// Delegates to the same exit-code-aware logic used by the CLI, so a failed
783/// command (`exit_code != 0`) is preserved verbatim and successful output is
784/// compressed consistently (excluded_commands, structural routing, terse). #810.
785pub 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;