Skip to main content

koda_core/
bash_path_lint.rs

1//! Bash path lint — detect commands that escape the project root.
2//!
3//! Heuristic analysis that catches common accidental path escapes.
4//! Not designed for adversarial inputs — that's a kernel sandbox concern.
5//!
6//! ## What it catches
7//!
8//! - Absolute paths outside the project (e.g., `cat /etc/passwd`)
9//! - Relative escapes (e.g., `cd ../../../`)
10//! - Home directory access (e.g., `rm ~/.bashrc`)
11//!
12//! ## What it allows
13//!
14//! - Temp directories (`/tmp`, `$TMPDIR`)
15//! - Device files (`/dev/null`, `/dev/stdout`)
16//! - Paths inside the project root
17//!
18//! ## What it intentionally ignores
19//!
20//! - Dynamic targets (`cd $VAR`, `cd $(cmd)`) — can't statically resolve
21//! - Quoted strings (commit messages, echo) — stripped before analysis
22//!
23//! See [`crate::bash_safety`] for the complementary command classification.
24
25use path_clean::PathClean;
26use std::path::{Path, PathBuf};
27
28use crate::bash_safety::split_command_segments;
29use crate::bash_safety::strip_env_vars;
30use crate::bash_safety::strip_quoted_strings;
31
32/// Whether `resolved` is a path that is safe to access outside the project root.
33///
34/// Safe paths include:
35/// - Temp directories: `/tmp`, `$TMPDIR` (on macOS `/tmp` → `/private/tmp`,
36///   `$TMPDIR` → `/private/var/folders/.../T/`)
37/// - Device files: `/dev/null`, `/dev/stdout`, `/dev/stderr`
38pub fn is_safe_external_path(resolved: &Path) -> bool {
39    // Device files — not real filesystem writes
40    if resolved.starts_with("/dev/") {
41        return true;
42    }
43
44    // Canonical /tmp (covers /private/tmp on macOS via symlink)
45    let canonical_tmp = PathBuf::from("/tmp")
46        .canonicalize()
47        .unwrap_or_else(|_| PathBuf::from("/tmp"));
48    if resolved.starts_with(&canonical_tmp) || resolved.starts_with("/tmp") {
49        return true;
50    }
51
52    // $TMPDIR (e.g. /var/folders/.../T/ on macOS)
53    if let Ok(tmpdir) = std::env::var("TMPDIR") {
54        let tmpdir_path = PathBuf::from(&tmpdir);
55        let canonical_tmpdir = tmpdir_path
56            .canonicalize()
57            .unwrap_or_else(|_| tmpdir_path.clone());
58        if resolved.starts_with(&canonical_tmpdir) || resolved.starts_with(&tmpdir_path) {
59            return true;
60        }
61    }
62
63    false
64}
65
66/// Result of linting a bash command for path escapes.
67#[derive(Debug, Clone, Default)]
68pub struct BashPathLint {
69    /// Paths in the command that escape project_root.
70    pub outside_paths: Vec<String>,
71    /// Whether the command contains `cd ~` or bare `cd` (→ $HOME).
72    pub home_escape: bool,
73}
74
75impl BashPathLint {
76    /// Whether the lint found any warnings.
77    pub fn has_warnings(&self) -> bool {
78        !self.outside_paths.is_empty() || self.home_escape
79    }
80}
81
82/// Lint a bash command for paths that escape project_root.
83///
84/// # Examples
85///
86/// ```
87/// use std::path::Path;
88/// use koda_core::bash_path_lint::lint_bash_paths;
89///
90/// let lint = lint_bash_paths("cat src/main.rs", Path::new("/project"));
91/// assert!(!lint.has_warnings());
92///
93/// let lint = lint_bash_paths("cat /etc/passwd", Path::new("/project"));
94/// assert!(lint.has_warnings());
95/// ```
96pub fn lint_bash_paths(command: &str, project_root: &Path) -> BashPathLint {
97    let mut lint = BashPathLint::default();
98    let trimmed = command.trim();
99    if trimmed.is_empty() {
100        return lint;
101    }
102
103    let segments = split_command_segments(trimmed);
104
105    for segment in &segments {
106        let seg = segment.trim();
107
108        // Check for cd targets
109        if let Some(target) = extract_cd_target(seg) {
110            match target {
111                CdTarget::Home => lint.home_escape = true,
112                CdTarget::Dynamic => {} // can't resolve, skip
113                CdTarget::Path(p) => {
114                    let path = Path::new(&p);
115                    let resolved = if path.is_absolute() {
116                        path.to_path_buf().clean()
117                    } else {
118                        project_root.join(&p).clean()
119                    };
120                    if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
121                        lint.outside_paths.push(p);
122                    }
123                }
124            }
125        }
126
127        // Check for absolute path arguments (not cd).
128        // Strip quoted strings first so paths inside commit messages,
129        // echo strings, etc. are not falsely flagged (#562).
130        let unquoted = strip_quoted_strings(seg);
131        for token in unquoted.split_whitespace().skip(1) {
132            if token.starts_with('-') {
133                continue;
134            }
135            if token.starts_with('/') {
136                let resolved = Path::new(token).to_path_buf().clean();
137                if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
138                    lint.outside_paths.push(token.to_string());
139                }
140            }
141            if token.contains("..") {
142                let resolved = project_root.join(token).clean();
143                if !resolved.starts_with(project_root) && !is_safe_external_path(&resolved) {
144                    lint.outside_paths.push(token.to_string());
145                }
146            }
147        }
148    }
149
150    lint.outside_paths.sort();
151    lint.outside_paths.dedup();
152    lint
153}
154
155#[derive(Debug)]
156enum CdTarget {
157    Home,
158    Dynamic,
159    Path(String),
160}
161
162/// Extract the target of a `cd` command from a segment.
163fn extract_cd_target(segment: &str) -> Option<CdTarget> {
164    let seg = segment.trim();
165    let seg = strip_env_vars(seg);
166    let seg = seg.trim();
167
168    if seg == "cd" {
169        return Some(CdTarget::Home);
170    }
171    if !seg.starts_with("cd ") && !seg.starts_with("cd\t") {
172        return None;
173    }
174
175    let target = seg[2..].trim();
176
177    if target.is_empty() || target == "~" {
178        return Some(CdTarget::Home);
179    }
180    if target.starts_with('$') || target.starts_with('`') || target.contains("$(") {
181        return Some(CdTarget::Dynamic);
182    }
183
184    Some(CdTarget::Path(
185        target
186            .split_whitespace()
187            .next()
188            .unwrap_or(target)
189            .to_string(),
190    ))
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196
197    fn project() -> std::path::PathBuf {
198        std::path::PathBuf::from("/home/user/project")
199    }
200
201    #[test]
202    fn test_lint_safe_command() {
203        let lint = lint_bash_paths("cargo test", &project());
204        assert!(!lint.has_warnings());
205    }
206
207    #[test]
208    fn test_lint_cd_inside_project() {
209        let lint = lint_bash_paths("cd src && ls", &project());
210        assert!(!lint.has_warnings());
211    }
212
213    #[test]
214    fn test_lint_cd_outside_project() {
215        let lint = lint_bash_paths("cd /etc && ls", &project());
216        assert!(lint.has_warnings());
217        assert!(lint.outside_paths.contains(&"/etc".to_string()));
218    }
219
220    #[test]
221    fn test_lint_cd_home() {
222        let lint = lint_bash_paths("cd ~", &project());
223        assert!(lint.home_escape);
224    }
225
226    #[test]
227    fn test_lint_bare_cd() {
228        let lint = lint_bash_paths("cd", &project());
229        assert!(lint.home_escape);
230    }
231
232    #[test]
233    fn test_lint_cd_dynamic_ignored() {
234        let lint = lint_bash_paths("cd $SOME_DIR", &project());
235        assert!(!lint.has_warnings());
236    }
237
238    #[test]
239    fn test_lint_absolute_path_arg() {
240        let lint = lint_bash_paths("cp file.txt /etc/hosts", &project());
241        assert!(lint.has_warnings());
242        assert!(lint.outside_paths.contains(&"/etc/hosts".to_string()));
243    }
244
245    #[test]
246    fn test_lint_relative_escape() {
247        let lint = lint_bash_paths("cat ../../../etc/passwd", &project());
248        assert!(lint.has_warnings());
249    }
250
251    #[test]
252    fn test_lint_relative_inside() {
253        let lint = lint_bash_paths("cat ../project/src/main.rs", &project());
254        assert!(!lint.has_warnings());
255    }
256
257    #[test]
258    fn test_lint_path_inside_project_absolute() {
259        let lint = lint_bash_paths("ls /home/user/project/src", &project());
260        assert!(!lint.has_warnings());
261    }
262
263    #[test]
264    fn test_lint_empty_command() {
265        let lint = lint_bash_paths("", &project());
266        assert!(!lint.has_warnings());
267    }
268
269    #[test]
270    fn test_lint_deduplicates() {
271        let lint = lint_bash_paths("cp /etc/a /etc/b", &project());
272        assert!(lint.has_warnings());
273        assert_eq!(lint.outside_paths.len(), 2);
274    }
275
276    // ── Temp path allowlist (#560) ──
277
278    #[test]
279    fn test_lint_tmp_path_allowed() {
280        let lint = lint_bash_paths("cat /tmp/issue-draft.md", &project());
281        assert!(!lint.has_warnings());
282    }
283
284    #[test]
285    fn test_lint_cd_tmp_allowed() {
286        let lint = lint_bash_paths("cd /tmp && ls", &project());
287        assert!(!lint.has_warnings());
288    }
289
290    #[test]
291    fn test_lint_tmp_subdir_allowed() {
292        let lint = lint_bash_paths("cp file.txt /tmp/koda/output.md", &project());
293        assert!(!lint.has_warnings());
294    }
295
296    #[test]
297    fn test_lint_dev_null_allowed() {
298        let lint = lint_bash_paths("echo test > /dev/null", &project());
299        assert!(!lint.has_warnings());
300    }
301
302    #[test]
303    fn test_lint_etc_still_blocked() {
304        let lint = lint_bash_paths("cat /etc/passwd", &project());
305        assert!(lint.has_warnings());
306    }
307
308    // ── Quote-aware path lint (#562) ──
309
310    #[test]
311    fn test_lint_path_in_commit_message_ignored() {
312        let lint = lint_bash_paths(
313            r#"git commit -m "allow /tmp and /dev/* and /etc/hosts""#,
314            &project(),
315        );
316        assert!(!lint.has_warnings());
317    }
318
319    #[test]
320    fn test_lint_path_in_single_quotes_ignored() {
321        let lint = lint_bash_paths("echo 'fixed /etc/hosts parsing'", &project());
322        assert!(!lint.has_warnings());
323    }
324
325    #[test]
326    fn test_lint_path_outside_quotes_still_flagged() {
327        let lint = lint_bash_paths(r#"cp /etc/hosts "destination.txt""#, &project());
328        assert!(lint.has_warnings());
329        assert!(lint.outside_paths.contains(&"/etc/hosts".to_string()));
330    }
331
332    #[test]
333    fn test_lint_merge_with_message() {
334        let lint = lint_bash_paths(
335            r#"git merge fix/branch -m "feat: allow /tmp, $TMPDIR, and /dev/* (#560)""#,
336            &project(),
337        );
338        assert!(!lint.has_warnings());
339    }
340
341    #[test]
342    fn test_strip_quoted_strings() {
343        assert_eq!(
344            strip_quoted_strings(r#"git commit -m "allow /tmp""#),
345            r#"git commit -m "          ""#
346        );
347        assert_eq!(
348            strip_quoted_strings("echo 'path /etc/hosts'"),
349            "echo '               '"
350        );
351        // Unquoted content preserved
352        assert_eq!(strip_quoted_strings("cp /etc/a /etc/b"), "cp /etc/a /etc/b");
353    }
354}