Skip to main content

aft/bash_rewrite/
rules.rs

1use serde_json::{json, Value};
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime};
4
5const REGEX_SIZE_LIMIT: usize = 10 * 1024 * 1024;
6const GREP_FOOTER_FRESHNESS_WINDOW: Duration = Duration::from_secs(60);
7
8use crate::bash_rewrite::footer::{add_footer, add_grep_footer};
9use crate::bash_rewrite::parser::parse;
10use crate::bash_rewrite::{RewriteRequest, RewriteRule};
11use crate::context::AppContext;
12use crate::protocol::{RawRequest, Response};
13
14pub struct GrepRule;
15pub struct RgRule;
16pub struct FindRule;
17pub struct CatRule;
18pub struct HeadRule;
19pub struct TailRule;
20pub struct CatAppendRule;
21pub struct SedRule;
22pub struct LsRule;
23
24impl RewriteRule for GrepRule {
25    fn name(&self) -> &'static str {
26        "grep"
27    }
28
29    fn decide(
30        &self,
31        command: &str,
32        request_id: &str,
33        session_id: Option<&str>,
34        ctx: &AppContext,
35    ) -> crate::bash_rewrite::RewriteDecision {
36        let Some(params) = grep_request(command, "grep") else {
37            return decline("grep", "grep.decline", "unsupported grep shape");
38        };
39        if let Some(path) = params.get("path").and_then(Value::as_str) {
40            if !path_is_safe(ctx, path, true) {
41                return decline(
42                    "grep",
43                    "grep.decline",
44                    "grep path is outside the project root or missing",
45                );
46            }
47        }
48        accept(
49            "grep",
50            "grep.accept",
51            "dc.grep.accept.v1",
52            command,
53            request_id,
54            session_id,
55            params,
56        )
57    }
58
59    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
60        let path = request.params.get("path").and_then(Value::as_str);
61        let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
62        grep_footer_response(response, ctx, path)
63    }
64}
65
66impl RewriteRule for RgRule {
67    fn name(&self) -> &'static str {
68        "rg"
69    }
70
71    fn decide(
72        &self,
73        command: &str,
74        request_id: &str,
75        session_id: Option<&str>,
76        ctx: &AppContext,
77    ) -> crate::bash_rewrite::RewriteDecision {
78        let Some(params) = grep_request(command, "rg") else {
79            return decline("rg", "rg.decline", "unsupported rg shape");
80        };
81        if let Some(path) = params.get("path").and_then(Value::as_str) {
82            if !path_is_safe(ctx, path, true) {
83                return decline(
84                    "rg",
85                    "rg.decline",
86                    "rg path is outside the project root or missing",
87                );
88            }
89        }
90        accept(
91            "rg",
92            "rg.accept",
93            "dc.rg.accept.v1",
94            command,
95            request_id,
96            session_id,
97            params,
98        )
99    }
100
101    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
102        let path = request.params.get("path").and_then(Value::as_str);
103        let response = crate::commands::grep::handle_grep(&tool_request("grep", request, ctx), ctx);
104        grep_footer_response(response, ctx, path)
105    }
106}
107
108impl RewriteRule for FindRule {
109    fn name(&self) -> &'static str {
110        "find"
111    }
112
113    fn decide(
114        &self,
115        command: &str,
116        request_id: &str,
117        session_id: Option<&str>,
118        ctx: &AppContext,
119    ) -> crate::bash_rewrite::RewriteDecision {
120        let Some(params) = find_request(command) else {
121            return decline("find", "find.decline", "unsupported find shape");
122        };
123        if let Some(path) = params.get("path").and_then(Value::as_str) {
124            if !path_is_safe(ctx, path, true) {
125                return decline(
126                    "find",
127                    "find.decline",
128                    "find path is outside the project root or missing",
129                );
130            }
131        }
132        accept(
133            "find",
134            "find.accept",
135            "dc.find.accept.v1",
136            command,
137            request_id,
138            session_id,
139            params,
140        )
141    }
142
143    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
144        call_and_footer(
145            crate::commands::glob::handle_glob(&tool_request("glob", request, ctx), ctx),
146            "glob",
147        )
148    }
149}
150
151impl RewriteRule for CatRule {
152    fn name(&self) -> &'static str {
153        "cat"
154    }
155
156    fn decide(
157        &self,
158        command: &str,
159        request_id: &str,
160        session_id: Option<&str>,
161        ctx: &AppContext,
162    ) -> crate::bash_rewrite::RewriteDecision {
163        let Some(params) = cat_read_request(command) else {
164            return decline("cat", "cat.decline", "unsupported cat shape");
165        };
166        let path = params
167            .get("file")
168            .and_then(Value::as_str)
169            .unwrap_or_default();
170        if !path_is_safe(ctx, path, true) {
171            return decline_cat_read(ctx, path, "path_is_safe");
172        }
173        if !read_shape_is_faithful(ctx, path) {
174            return decline_cat_read(ctx, path, "read_shape_is_faithful");
175        }
176        accept(
177            "cat",
178            "cat.accept",
179            "dc.cat.accept.v1",
180            command,
181            request_id,
182            session_id,
183            params,
184        )
185    }
186
187    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
188        call_and_footer(
189            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
190            "read",
191        )
192    }
193}
194
195impl RewriteRule for HeadRule {
196    fn name(&self) -> &'static str {
197        "head"
198    }
199
200    fn decide(
201        &self,
202        command: &str,
203        request_id: &str,
204        session_id: Option<&str>,
205        ctx: &AppContext,
206    ) -> crate::bash_rewrite::RewriteDecision {
207        let Some(params) = head_tail_read_request(command, "head") else {
208            return decline("head", "head.decline", "unsupported head shape");
209        };
210        let path = params
211            .get("file")
212            .and_then(Value::as_str)
213            .unwrap_or_default();
214        let lines = params.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
215        if !effective_hashline_session(ctx, session_id)
216            || !path_is_safe(ctx, path, true)
217            || !head_tail_shape_is_faithful(ctx, path, lines, false)
218        {
219            return decline(
220                "head",
221                "head.decline",
222                "head requires an effective hashline session and a faithful text-read shape",
223            );
224        }
225        accept(
226            "head",
227            "head.accept",
228            "dc.head.accept.v1",
229            command,
230            request_id,
231            session_id,
232            params,
233        )
234    }
235
236    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
237        call_and_footer(
238            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
239            "read",
240        )
241    }
242}
243
244impl RewriteRule for TailRule {
245    fn name(&self) -> &'static str {
246        "tail"
247    }
248
249    fn decide(
250        &self,
251        command: &str,
252        request_id: &str,
253        session_id: Option<&str>,
254        ctx: &AppContext,
255    ) -> crate::bash_rewrite::RewriteDecision {
256        let Some(params) = head_tail_read_request(command, "tail") else {
257            return decline("tail", "tail.decline", "unsupported tail shape");
258        };
259        let path = params
260            .get("file")
261            .and_then(Value::as_str)
262            .unwrap_or_default();
263        let lines = params.get("limit").and_then(Value::as_u64).unwrap_or(10) as usize;
264        if !effective_hashline_session(ctx, session_id)
265            || !path_is_safe(ctx, path, true)
266            || !head_tail_shape_is_faithful(ctx, path, lines, true)
267        {
268            return decline(
269                "tail",
270                "tail.decline",
271                "tail requires an effective hashline session and a faithful text-read shape",
272            );
273        }
274        accept(
275            "tail",
276            "tail.accept",
277            "dc.tail.accept.v1",
278            command,
279            request_id,
280            session_id,
281            params,
282        )
283    }
284
285    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
286        call_and_footer(
287            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
288            "read",
289        )
290    }
291}
292
293impl RewriteRule for CatAppendRule {
294    fn name(&self) -> &'static str {
295        "cat_append"
296    }
297
298    fn decide(
299        &self,
300        command: &str,
301        request_id: &str,
302        session_id: Option<&str>,
303        ctx: &AppContext,
304    ) -> crate::bash_rewrite::RewriteDecision {
305        let Some(params) = append_request(command) else {
306            return decline(
307                "cat_append",
308                "cat_append.decline",
309                "unsupported append shape",
310            );
311        };
312        let path = params
313            .get("file")
314            .and_then(Value::as_str)
315            .unwrap_or_default();
316        if !append_path_is_safe(ctx, path) {
317            return decline(
318                "cat_append",
319                "cat_append.decline",
320                "append path is outside the project root or has no existing parent",
321            );
322        }
323        accept(
324            "cat_append",
325            "cat_append.accept",
326            "dc.cat_append.accept.v1",
327            command,
328            request_id,
329            session_id,
330            params,
331        )
332    }
333
334    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
335        call_and_footer(
336            crate::commands::edit_match::handle_edit_match(
337                &tool_request("edit_match", request, ctx),
338                ctx,
339            ),
340            "edit",
341        )
342    }
343}
344
345impl RewriteRule for SedRule {
346    fn name(&self) -> &'static str {
347        "sed"
348    }
349
350    fn decide(
351        &self,
352        command: &str,
353        request_id: &str,
354        session_id: Option<&str>,
355        ctx: &AppContext,
356    ) -> crate::bash_rewrite::RewriteDecision {
357        let Some(params) = sed_request(command) else {
358            return decline("sed", "sed.decline", "unsupported sed shape");
359        };
360        let path = params
361            .get("file")
362            .and_then(Value::as_str)
363            .unwrap_or_default();
364        if !path_is_safe(ctx, path, true) || !read_shape_is_faithful(ctx, path) {
365            return decline(
366                "sed",
367                "sed.decline",
368                "sed path is outside the project root or exceeds the read contract",
369            );
370        }
371        accept(
372            "sed",
373            "sed.accept",
374            "dc.sed.accept.v1",
375            command,
376            request_id,
377            session_id,
378            params,
379        )
380    }
381
382    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
383        call_and_footer(
384            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
385            "read",
386        )
387    }
388}
389
390impl RewriteRule for LsRule {
391    fn name(&self) -> &'static str {
392        "ls"
393    }
394
395    fn decide(
396        &self,
397        command: &str,
398        request_id: &str,
399        session_id: Option<&str>,
400        ctx: &AppContext,
401    ) -> crate::bash_rewrite::RewriteDecision {
402        let Some(params) = ls_request(command, ctx) else {
403            return decline("ls", "ls.decline", "unsupported ls shape or target");
404        };
405        accept(
406            "ls",
407            "ls.accept",
408            "dc.ls.accept.v1",
409            command,
410            request_id,
411            session_id,
412            params,
413        )
414    }
415
416    fn execute(&self, request: &RewriteRequest, ctx: &AppContext) -> Response {
417        call_and_footer(
418            crate::commands::read::handle_read(&tool_request("read", request, ctx), ctx),
419            "read",
420        )
421    }
422}
423
424fn accept(
425    rule_id: &'static str,
426    branch_id: &'static str,
427    decision_class_id: &'static str,
428    command: &str,
429    request_id: &str,
430    session_id: Option<&str>,
431    params: Value,
432) -> crate::bash_rewrite::RewriteDecision {
433    crate::bash_rewrite::RewriteDecision::Accept(RewriteRequest {
434        request_id: request_id.to_string(),
435        command: command.to_string(),
436        session_id: session_id.map(str::to_owned),
437        rule_id,
438        branch_id,
439        decision_class_id,
440        params,
441    })
442}
443
444fn decline(
445    rule_id: &'static str,
446    branch_id: &'static str,
447    reason: &str,
448) -> crate::bash_rewrite::RewriteDecision {
449    let decision_class_id = match rule_id {
450        "grep" => "dc.grep.decline.v1",
451        "rg" => "dc.rg.decline.v1",
452        "find" => "dc.find.decline.v1",
453        "cat" => "dc.cat.decline.v1",
454        "head" => "dc.head.decline.v1",
455        "tail" => "dc.tail.decline.v1",
456        "cat_append" => "dc.cat_append.decline.v1",
457        "sed" => "dc.sed.decline.v1",
458        "ls" => "dc.ls.decline.v1",
459        _ => "dc.native.decline.v1",
460    };
461    crate::bash_rewrite::RewriteDecision::Decline(crate::bash_rewrite::DeclineReason {
462        rule_id: Some(rule_id),
463        branch_id,
464        decision_class_id,
465        reason: reason.to_string(),
466    })
467}
468
469fn tool_request(tool: &str, request: &RewriteRequest, ctx: &AppContext) -> RawRequest {
470    let mut params = request.params.clone();
471    let root = grep_project_root(ctx);
472    if matches!(tool, "read" | "edit_match") {
473        if let Some(file) = params
474            .get("file")
475            .and_then(Value::as_str)
476            .map(str::to_owned)
477        {
478            if tool == "read" && matches!(request.rule_id, "cat" | "head" | "tail") {
479                params["_hashline_requested_path"] = Value::String(file.clone());
480                params["_hashline_bash_read_kind"] = Value::String(request.rule_id.to_string());
481                if let Some(lines) = request.params.get("limit").and_then(Value::as_u64) {
482                    params["_hashline_bash_read_lines"] = Value::from(lines);
483                }
484            }
485            let path = Path::new(&file);
486            if path.is_relative() {
487                params["file"] = Value::String(root.join(path).display().to_string());
488            }
489            if tool == "read" && request.rule_id == "tail" {
490                if let Some(path) = params.get("file").and_then(Value::as_str) {
491                    if let Some(total_lines) = text_file_line_count(Path::new(path)) {
492                        if total_lines > 0 {
493                            let lines = request
494                                .params
495                                .get("limit")
496                                .and_then(Value::as_u64)
497                                .unwrap_or(10) as usize;
498                            params["start_line"] =
499                                Value::from(total_lines.saturating_sub(lines).saturating_add(1));
500                            params["end_line"] = Value::from(total_lines);
501                        }
502                    }
503                }
504            }
505        }
506    }
507    if tool == "glob" && params.get("path").is_none() {
508        params["path"] = Value::String(root.display().to_string());
509    }
510    RawRequest {
511        id: request.request_id.clone(),
512        command: tool.to_string(),
513        lsp_hints: None,
514        session_id: request.session_id.clone(),
515        params,
516    }
517}
518
519/// Add the normal tool footer while preserving a handler error as the final
520/// response. A handler error is not a permission to execute native bash: the
521/// request has already entered the internal handler.
522fn call_and_footer(response: Response, replacement_tool: &str) -> Response {
523    let output = response_output(&response.data);
524    let footered = add_footer(&output, replacement_tool);
525    apply_footer(response, footered)
526}
527
528fn grep_footer_response(response: Response, ctx: &AppContext, path: Option<&str>) -> Response {
529    let output = response_output(&response.data);
530    let footered = if should_suppress_grep_footer(path, &grep_project_root(ctx)) {
531        output
532    } else {
533        add_grep_footer(&output, ctx.config().aft_search_registered)
534    };
535    apply_footer(response, footered)
536}
537
538fn grep_project_root(ctx: &AppContext) -> std::path::PathBuf {
539    let configured = ctx
540        .config()
541        .project_root
542        .clone()
543        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
544    std::fs::canonicalize(&configured).unwrap_or(configured)
545}
546
547fn should_suppress_grep_footer(path: Option<&str>, project_root: &Path) -> bool {
548    let Some(path) = path else {
549        return false;
550    };
551    // Canonicalize the root here rather than trusting callers: the target
552    // below is canonicalized, and comparing a canonical target against a
553    // non-canonical root breaks on alias spellings (macOS /var vs
554    // /private/var), misreading in-root paths as external.
555    let project_root =
556        std::fs::canonicalize(project_root).unwrap_or_else(|_| project_root.to_path_buf());
557    let project_root = project_root.as_path();
558    let target = Path::new(path);
559    let target = if target.is_absolute() {
560        target.to_path_buf()
561    } else {
562        project_root.join(target)
563    };
564    let Ok(target) = std::fs::canonicalize(target) else {
565        return false;
566    };
567    if !target.starts_with(project_root) {
568        return true;
569    }
570    let Ok(metadata) = std::fs::metadata(&target) else {
571        return false;
572    };
573    if metadata.is_file() {
574        return true;
575    }
576    let Ok(modified) = metadata.modified() else {
577        return false;
578    };
579    SystemTime::now()
580        .duration_since(modified)
581        .is_ok_and(|age| age < GREP_FOOTER_FRESHNESS_WINDOW)
582}
583
584fn apply_footer(mut response: Response, output: String) -> Response {
585    if let Some(object) = response.data.as_object_mut() {
586        object.insert("output".to_string(), Value::String(output.clone()));
587
588        for key in ["text", "content", "message"] {
589            if object.get(key).is_some_and(Value::is_string) {
590                object.insert(key.to_string(), Value::String(output.clone()));
591                break;
592            }
593        }
594    } else {
595        response.data = json!({ "output": output });
596    }
597
598    response
599}
600
601fn response_output(data: &Value) -> String {
602    if let Some(output) = data.get("output").and_then(Value::as_str) {
603        return output.to_string();
604    }
605    if let Some(text) = data.get("text").and_then(Value::as_str) {
606        return text.to_string();
607    }
608    if let Some(content) = data.get("content").and_then(Value::as_str) {
609        return content.to_string();
610    }
611    if let Some(message) = data.get("message").and_then(Value::as_str) {
612        return message.to_string();
613    }
614    if let Some(entries) = data.get("entries").and_then(Value::as_array) {
615        return entries
616            .iter()
617            .filter_map(Value::as_str)
618            .collect::<Vec<_>>()
619            .join("\n");
620    }
621    serde_json::to_string_pretty(data).unwrap_or_else(|_| data.to_string())
622}
623
624fn path_candidate(ctx: &AppContext, path: &str) -> PathBuf {
625    let candidate = Path::new(path);
626    if candidate.is_absolute() {
627        candidate.to_path_buf()
628    } else {
629        grep_project_root(ctx).join(candidate)
630    }
631}
632
633fn resolved_path_candidate(ctx: &AppContext, path: &str) -> PathBuf {
634    let candidate = path_candidate(ctx, path);
635    std::fs::canonicalize(&candidate).unwrap_or(candidate)
636}
637
638fn decline_cat_read(
639    ctx: &AppContext,
640    path: &str,
641    predicate: &'static str,
642) -> crate::bash_rewrite::RewriteDecision {
643    let candidate = resolved_path_candidate(ctx, path);
644    crate::slog_warn!(
645        "bash rewrite rule cat declined: read declined: predicate={} resolved_candidate={:?}",
646        predicate,
647        candidate
648    );
649    decline(
650        "cat",
651        "cat.decline",
652        &format!(
653            "read declined: {predicate} failed for {}",
654            candidate.display()
655        ),
656    )
657}
658
659fn path_is_safe(ctx: &AppContext, path: &str, require_existing: bool) -> bool {
660    let root = grep_project_root(ctx);
661    let candidate = path_candidate(ctx, path);
662    if require_existing && !candidate.exists() {
663        return false;
664    }
665    let resolved = std::fs::canonicalize(&candidate).unwrap_or(candidate);
666    resolved.starts_with(&root)
667}
668
669fn read_shape_is_faithful(ctx: &AppContext, path: &str) -> bool {
670    let candidate = path_candidate(ctx, path);
671    let Ok(metadata) = std::fs::metadata(&candidate) else {
672        return false;
673    };
674    if !metadata.is_file() || metadata.len() > 50 * 1024 {
675        return false;
676    }
677    let Ok(bytes) = std::fs::read(candidate) else {
678        return false;
679    };
680    let Ok(text) = std::str::from_utf8(&bytes) else {
681        return false;
682    };
683    text.lines().all(|line| line.len() <= 2_000)
684}
685
686fn effective_hashline_session(ctx: &AppContext, session_id: Option<&str>) -> bool {
687    let root = ctx
688        .canonical_cache_root_opt()
689        .unwrap_or_else(|| grep_project_root(ctx));
690    ctx.hashline_bindings()
691        .capture(
692            root,
693            session_id.unwrap_or(crate::protocol::DEFAULT_SESSION_ID),
694        )
695        .is_some_and(|binding| binding.effective())
696}
697
698fn head_tail_shape_is_faithful(ctx: &AppContext, path: &str, lines: usize, tail: bool) -> bool {
699    let root = grep_project_root(ctx);
700    let candidate = if Path::new(path).is_absolute() {
701        Path::new(path).to_path_buf()
702    } else {
703        root.join(path)
704    };
705    let Ok(metadata) = std::fs::metadata(&candidate) else {
706        return false;
707    };
708    if !metadata.is_file() {
709        return false;
710    }
711    let Ok(bytes) = std::fs::read(candidate) else {
712        return false;
713    };
714    let Ok(text) = std::str::from_utf8(&bytes) else {
715        return false;
716    };
717    let records = text.lines().collect::<Vec<_>>();
718    let selected: Box<dyn Iterator<Item = &&str>> = if tail {
719        Box::new(records.iter().skip(records.len().saturating_sub(lines)))
720    } else {
721        Box::new(records.iter().take(lines))
722    };
723    let mut displayed_bytes = 0_usize;
724    for record in selected {
725        if record.len() > 2_000 {
726            return false;
727        }
728        displayed_bytes = displayed_bytes
729            .saturating_add(record.len())
730            .saturating_add(16);
731        if displayed_bytes > 50 * 1024 {
732            return false;
733        }
734    }
735    true
736}
737
738fn text_file_line_count(path: &Path) -> Option<usize> {
739    let bytes = std::fs::read(path).ok()?;
740    std::str::from_utf8(&bytes).ok()?;
741    if bytes.is_empty() {
742        return Some(0);
743    }
744    Some(
745        bytes.iter().filter(|byte| **byte == b'\n').count()
746            + usize::from(bytes.last() != Some(&b'\n')),
747    )
748}
749
750fn append_path_is_safe(ctx: &AppContext, path: &str) -> bool {
751    let root = grep_project_root(ctx);
752    let candidate = if Path::new(path).is_absolute() {
753        Path::new(path).to_path_buf()
754    } else {
755        root.join(path)
756    };
757    let parent = candidate.parent().unwrap_or(&root);
758    if !parent.exists() {
759        return false;
760    }
761    let resolved_parent = std::fs::canonicalize(parent).unwrap_or_else(|_| parent.to_path_buf());
762    if !resolved_parent.starts_with(&root) {
763        return false;
764    }
765    if candidate.exists() {
766        let Ok(resolved) = std::fs::canonicalize(&candidate) else {
767            return false;
768        };
769        resolved.starts_with(&root)
770    } else {
771        true
772    }
773}
774
775fn grep_request(command: &str, binary: &str) -> Option<Value> {
776    let parsed = parse(command)?;
777    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != binary {
778        return None;
779    }
780
781    let mut case_sensitive = true;
782    let mut word_match = false;
783    let mut index = 1;
784
785    while let Some(arg) = parsed.args.get(index) {
786        if !arg.starts_with('-') || arg == "-" {
787            break;
788        }
789        for flag in arg[1..].chars() {
790            match flag {
791                'n' | 'r' => {}
792                'i' => case_sensitive = false,
793                'w' => word_match = true,
794                _ => return None,
795            }
796        }
797        index += 1;
798    }
799
800    let pattern = parsed.args.get(index)?.clone();
801    let path = parsed.args.get(index + 1).cloned();
802    if parsed.args.len() > index + 2 {
803        return None;
804    }
805
806    let pattern = if word_match {
807        format!(r"\b(?:{})\b", pattern)
808    } else {
809        pattern
810    };
811
812    if regex::RegexBuilder::new(&pattern)
813        .size_limit(REGEX_SIZE_LIMIT)
814        .build()
815        .is_err()
816    {
817        return None;
818    }
819
820    let mut params = json!({
821        "pattern": pattern,
822        "case_sensitive": case_sensitive,
823        "max_results": 100,
824    });
825    if let Some(path) = path {
826        params["path"] = json!(path);
827    }
828    Some(params)
829}
830
831fn find_request(command: &str) -> Option<Value> {
832    let parsed = parse(command)?;
833    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "find" {
834        return None;
835    }
836    if parsed.args.len() != 4 && parsed.args.len() != 6 {
837        return None;
838    }
839
840    let path = parsed.args.get(1)?.clone();
841    let mut name = None;
842    let mut saw_type_file = false;
843    let mut index = 2;
844
845    while index < parsed.args.len() {
846        match parsed.args[index].as_str() {
847            "-name" if name.is_none() && index + 1 < parsed.args.len() => {
848                name = Some(parsed.args[index + 1].clone());
849                index += 2;
850            }
851            "-type" if !saw_type_file && index + 1 < parsed.args.len() => {
852                if parsed.args[index + 1] != "f" {
853                    return None;
854                }
855                saw_type_file = true;
856                index += 2;
857            }
858            _ => return None,
859        }
860    }
861
862    let name = name?;
863    let pattern = format!("**/{name}");
864    if path == "." {
865        Some(json!({ "pattern": pattern }))
866    } else {
867        let trimmed = path.trim_end_matches('/');
868        if trimmed.is_empty() {
869            // Filesystem root (`find / ...`, `find // ...`): trimming the slash
870            // yields "" which downstream resolves as the PROJECT ROOT — silently
871            // searching the project instead of the whole filesystem. Don't
872            // rewrite; fall through to native `find`, which does what was asked.
873            // (A non-empty absolute path like `/tmp/foo` is preserved as-is.)
874            None
875        } else {
876            Some(json!({ "path": trimmed, "pattern": pattern }))
877        }
878    }
879}
880
881fn cat_read_request(command: &str) -> Option<Value> {
882    let parsed = parse(command)?;
883    if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
884        return None;
885    }
886    if parsed.args.len() != 2 || parsed.args.first()? != "cat" {
887        return None;
888    }
889    Some(json!({ "file": parsed.args[1] }))
890}
891
892fn head_tail_read_request(command: &str, command_name: &str) -> Option<Value> {
893    let parsed = parse(command)?;
894    if parsed.appends_to.is_some()
895        || parsed.heredoc.is_some()
896        || parsed.args.first()? != command_name
897    {
898        return None;
899    }
900
901    let (lines, file) = match parsed.args.as_slice() {
902        [_command, file] => (10_u64, file.as_str()),
903        [_command, flag, count, file] if flag == "-n" => {
904            (count.parse::<u64>().ok()?, file.as_str())
905        }
906        [_command, compact, file] => {
907            let count = compact.strip_prefix('-')?.parse::<u64>().ok()?;
908            (count, file.as_str())
909        }
910        _ => return None,
911    };
912    if lines == 0 {
913        return None;
914    }
915    Some(json!({ "file": file, "limit": lines }))
916}
917
918fn append_request(command: &str) -> Option<Value> {
919    let parsed = parse(command)?;
920    let file = parsed.appends_to.clone()?;
921
922    let append_content = if parsed.args == ["cat"] {
923        parsed.heredoc?
924    } else if parsed.heredoc.is_none()
925        && parsed.args.first().is_some_and(|arg| arg == "echo")
926        && parsed.args.len() >= 2
927        && !parsed.args[1].starts_with('-')
928    {
929        format!("{}\n", parsed.args[1..].join(" "))
930    } else {
931        return None;
932    };
933
934    Some(json!({
935        "op": "append",
936        "file": file,
937        "append_content": append_content,
938        "create_dirs": true,
939    }))
940}
941
942fn sed_request(command: &str) -> Option<Value> {
943    let parsed = parse(command)?;
944    if parsed.appends_to.is_some() || parsed.heredoc.is_some() {
945        return None;
946    }
947    if parsed.args.len() != 4 || parsed.args.first()? != "sed" || parsed.args[1] != "-n" {
948        return None;
949    }
950
951    let range = parsed.args[2].strip_suffix('p')?;
952    let (start, end) = range.split_once(',')?;
953    let start_line = start.parse::<u32>().ok()?;
954    let end_line = end.parse::<u32>().ok()?;
955    if start_line == 0 || end_line < start_line {
956        return None;
957    }
958
959    Some(json!({
960        "file": parsed.args[3],
961        "start_line": start_line,
962        "end_line": end_line,
963    }))
964}
965
966fn ls_request(command: &str, ctx: &AppContext) -> Option<Value> {
967    let parsed = parse(command)?;
968    if parsed.appends_to.is_some() || parsed.heredoc.is_some() || parsed.args.first()? != "ls" {
969        return None;
970    }
971
972    let mut path = None;
973    let mut include_hidden = false;
974    for arg in parsed.args.iter().skip(1) {
975        if let Some(flags) = arg.strip_prefix('-') {
976            if flags.is_empty() {
977                return None;
978            }
979            for flag in flags.chars() {
980                match flag {
981                    // -R: recursive listing — `read` of a directory is
982                    // single-level only, but the result is still a useful
983                    // approximation of "what's in this tree".
984                    'R' => {}
985                    // Plain `ls` hides dotfiles, while `read` normally includes
986                    // them. Preserve the caller's explicit request to show all
987                    // entries without changing direct `read` behavior.
988                    'a' => include_hidden = true,
989                    // `ls -A` shows hidden entries except `.` and `..`. Keep this
990                    // distinct spelling unsupported so native bash preserves its
991                    // exact contract.
992                    'A' => return None,
993                    // -l: long format. Shows size, mtime, permissions, owner.
994                    // `read` returns directory entries (no metadata) or file
995                    // contents (not metadata at all). Rewriting drops the
996                    // info the user asked for, so fall through to real bash.
997                    // Reported by user dogfooding the v0.18 bash experimentals.
998                    _ => return None,
999                }
1000            }
1001        } else if path.is_none() {
1002            path = Some(arg.clone());
1003        } else {
1004            return None;
1005        }
1006    }
1007
1008    // Even without -l, `ls FILE` and `read FILE` have entirely different
1009    // semantics: `ls FILE` echoes the filename, `read FILE` dumps the file
1010    // contents. The rewrite is only safe when the path resolves to a
1011    // directory (or is missing/cwd, where `read` of cwd also makes sense).
1012    // Stat the path and fall through to bash for files.
1013    let target = path.clone().unwrap_or_else(|| ".".to_string());
1014    let root = grep_project_root(ctx);
1015    let target_for_metadata = if Path::new(&target).is_absolute() {
1016        Path::new(&target).to_path_buf()
1017    } else {
1018        root.join(&target)
1019    };
1020    if let Ok(metadata) = std::fs::metadata(&target_for_metadata) {
1021        if !metadata.is_dir() || !path_is_safe(ctx, &target, true) {
1022            return None;
1023        }
1024    } else {
1025        // Path doesn't exist (yet)? Let bash handle the error itself — its
1026        // wording is well-known to agents, and rewriting a guaranteed failure
1027        // would change the native error outcome.
1028        return None;
1029    }
1030
1031    Some(json!({ "file": target, "include_hidden": include_hidden }))
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use std::fs;
1037    use std::time::{Duration, SystemTime};
1038
1039    use serde_json::json;
1040
1041    use super::{find_request, should_suppress_grep_footer, HeadRule, TailRule};
1042    use crate::bash_rewrite::{RewriteDecision, RewriteRule};
1043    use crate::config::Config;
1044    use crate::context::{default_language_provider_factory, AppContext};
1045    use crate::hashline::integration::RegistrationRequest;
1046    use crate::protocol::DEFAULT_SESSION_ID;
1047
1048    fn fixture() -> tempfile::TempDir {
1049        let dir = tempfile::tempdir().unwrap();
1050        fs::create_dir(dir.path().join("src")).unwrap();
1051        fs::write(dir.path().join("src/app.ts"), "foo\n").unwrap();
1052        dir
1053    }
1054
1055    #[test]
1056    fn single_named_file_suppresses_grep_footer() {
1057        let dir = fixture();
1058        assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1059    }
1060
1061    #[test]
1062    fn directory_path_keeps_grep_footer() {
1063        let dir = fixture();
1064        filetime::set_file_mtime(
1065            dir.path().join("src"),
1066            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1067        )
1068        .unwrap();
1069        assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1070    }
1071
1072    #[test]
1073    fn no_path_keeps_grep_footer() {
1074        let dir = fixture();
1075        assert!(!should_suppress_grep_footer(None, dir.path()));
1076    }
1077
1078    #[test]
1079    fn external_file_suppresses_grep_footer() {
1080        let dir = fixture();
1081        let external = tempfile::NamedTempFile::new().unwrap();
1082        assert!(should_suppress_grep_footer(
1083            external.path().to_str(),
1084            dir.path()
1085        ));
1086    }
1087
1088    #[test]
1089    fn freshly_modified_file_suppresses_grep_footer() {
1090        let dir = fixture();
1091        let file = dir.path().join("src/app.ts");
1092        fs::write(&file, "foo\nbar\n").unwrap();
1093        assert!(should_suppress_grep_footer(file.to_str(), dir.path()));
1094    }
1095
1096    #[test]
1097    fn old_directory_inside_project_root_keeps_grep_footer() {
1098        let dir = fixture();
1099        let directory = dir.path().join("src");
1100        filetime::set_file_mtime(
1101            &directory,
1102            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1103        )
1104        .unwrap();
1105        assert!(!should_suppress_grep_footer(Some("src"), dir.path()));
1106    }
1107
1108    #[test]
1109    fn old_file_inside_project_root_suppresses_grep_footer() {
1110        let dir = fixture();
1111        let file = dir.path().join("src/app.ts");
1112        filetime::set_file_mtime(
1113            &file,
1114            filetime::FileTime::from_system_time(SystemTime::now() - Duration::from_secs(61)),
1115        )
1116        .unwrap();
1117        assert!(should_suppress_grep_footer(Some("src/app.ts"), dir.path()));
1118    }
1119
1120    #[test]
1121    fn find_absolute_path_uses_glob_path_arg() {
1122        assert_eq!(
1123            find_request(r#"find /tmp/foo -name "*.ts" -type f"#),
1124            Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1125        );
1126    }
1127
1128    #[test]
1129    fn find_dot_keeps_project_root_relative_pattern() {
1130        assert_eq!(
1131            find_request(r#"find . -name "*.ts" -type f"#),
1132            Some(json!({ "pattern": "**/*.ts" }))
1133        );
1134    }
1135
1136    #[test]
1137    fn find_relative_path_uses_glob_path_arg() {
1138        assert_eq!(
1139            find_request(r#"find ./src -name "*.go""#),
1140            Some(json!({ "path": "./src", "pattern": "**/*.go" }))
1141        );
1142    }
1143
1144    #[test]
1145    fn find_trims_trailing_slash_from_path_arg() {
1146        assert_eq!(
1147            find_request(r#"find /tmp/foo/ -name "*.ts""#),
1148            Some(json!({ "path": "/tmp/foo", "pattern": "**/*.ts" }))
1149        );
1150    }
1151
1152    #[test]
1153    fn find_filesystem_root_is_not_rewritten() {
1154        // `find /` must NOT rewrite — trimming the slash would yield "" which
1155        // resolves as the project root, silently searching the wrong scope.
1156        assert_eq!(find_request(r#"find / -name "*.rs""#), None);
1157        assert_eq!(find_request(r#"find // -name "*.rs""#), None);
1158    }
1159
1160    #[test]
1161    fn head_and_tail_rewrites_are_hashline_only_and_tail_keeps_absolute_numbering() {
1162        let dir = fixture();
1163        let root = fs::canonicalize(dir.path()).unwrap();
1164        fs::write(root.join("src/app.ts"), "one\ntwo\nthree\nfour\n").unwrap();
1165        let ctx = AppContext::new(
1166            default_language_provider_factory(),
1167            Config {
1168                project_root: Some(root.clone()),
1169                experimental_bash_rewrite: true,
1170                ..Default::default()
1171            },
1172        );
1173
1174        assert!(matches!(
1175            HeadRule.decide("head -2 src/app.ts", "head-off", None, &ctx),
1176            RewriteDecision::Decline(_)
1177        ));
1178        assert!(matches!(
1179            TailRule.decide("tail -2 src/app.ts", "tail-off", None, &ctx),
1180            RewriteDecision::Decline(_)
1181        ));
1182
1183        ctx.hashline_bindings().register(
1184            &root,
1185            DEFAULT_SESSION_ID.to_string(),
1186            RegistrationRequest {
1187                configured_enabled: true,
1188                edit_slot_survives: true,
1189            },
1190        );
1191        let RewriteDecision::Accept(request) =
1192            TailRule.decide("tail -2 src/app.ts", "tail-on", None, &ctx)
1193        else {
1194            panic!("effective hashline tail should rewrite");
1195        };
1196        let response = TailRule.execute(&request, &ctx);
1197        assert!(response.success, "{}", response.data);
1198        let content = response.data["content"].as_str().unwrap();
1199        assert!(content.starts_with("[src/app.ts#"), "{content}");
1200        assert!(content.contains("3:three\n4:four\n"), "{content}");
1201    }
1202}