Skip to main content

aft/bash_permissions/
mod.rs

1//! Bash permission scanner for hoisted bash.
2//!
3//! Ports OpenCode's tree-sitter-based permission scan that walks the parsed
4//! command tree to identify sub-commands that touch external directories or
5//! match permission rules.
6
7pub mod arity;
8pub mod scan;
9
10use crate::context::AppContext;
11use serde::{Deserialize, Serialize};
12use std::path::{Component, Path, PathBuf};
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct PermissionAsk {
16    pub kind: PermissionKind,
17    pub patterns: Vec<String>,
18    pub always: Vec<String>,
19}
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub enum PermissionKind {
23    #[serde(rename = "external_directory")]
24    ExternalDirectory,
25    #[serde(rename = "bash")]
26    Bash,
27}
28
29/// Scan a bash command and return the list of permission asks needed.
30pub fn scan(command: &str, ctx: &AppContext) -> Vec<PermissionAsk> {
31    scan::scan(command, ctx)
32}
33
34/// Returns true for scratch paths that should not create external-directory asks.
35/// This only affects prompt decisions; hard project-root validation stays separate.
36pub(crate) fn is_system_temp_path(path: &Path) -> bool {
37    let resolved = resolve_with_existing_ancestors(path);
38    system_temp_roots().into_iter().any(|root| {
39        let root = normalize_path(&root);
40        path_is_under(&root, &resolved)
41    })
42}
43
44fn system_temp_roots() -> Vec<PathBuf> {
45    let mut roots = Vec::new();
46    roots.push(std::env::temp_dir());
47
48    #[cfg(unix)]
49    roots.extend([
50        PathBuf::from("/tmp"),
51        PathBuf::from("/var/tmp"),
52        PathBuf::from("/private/tmp"),
53        PathBuf::from("/private/var/tmp"),
54    ]);
55
56    #[cfg(target_os = "macos")]
57    roots.extend([
58        PathBuf::from("/var/folders"),
59        PathBuf::from("/private/var/folders"),
60    ]);
61
62    let mut expanded = Vec::with_capacity(roots.len() * 2);
63    for root in roots {
64        expanded.push(root.clone());
65        if let Ok(canonical) = std::fs::canonicalize(&root) {
66            expanded.push(canonical);
67        }
68    }
69    expanded
70}
71
72fn path_is_under(root: &Path, path: &Path) -> bool {
73    path == root || path.starts_with(root)
74}
75
76fn resolve_with_existing_ancestors(path: &Path) -> PathBuf {
77    if let Ok(canonical) = std::fs::canonicalize(path) {
78        return normalize_path(&canonical);
79    }
80
81    let normalized = normalize_path(path);
82    if !normalized.is_absolute() {
83        return normalized;
84    }
85
86    let mut missing: Vec<PathBuf> = Vec::new();
87    let mut current = normalized.clone();
88    loop {
89        if let Ok(real_parent) = std::fs::canonicalize(&current) {
90            let mut resolved = real_parent;
91            for part in missing.iter().rev() {
92                resolved.push(part);
93            }
94            return normalize_path(&resolved);
95        }
96
97        let Some(parent) = current.parent() else {
98            return normalized;
99        };
100        if parent == current {
101            return normalized;
102        }
103        if let Some(name) = current.file_name() {
104            missing.push(PathBuf::from(name));
105        }
106        current = parent.to_path_buf();
107    }
108}
109
110fn normalize_path(path: &Path) -> PathBuf {
111    let mut result = PathBuf::new();
112    for component in path.components() {
113        match component {
114            Component::CurDir => {}
115            Component::ParentDir => {
116                if !result.pop() {
117                    result.push(component.as_os_str());
118                }
119            }
120            other => result.push(other.as_os_str()),
121        }
122    }
123    result
124}
125
126#[cfg(test)]
127mod system_temp_path_tests {
128    use super::is_system_temp_path;
129    use std::path::Path;
130
131    #[cfg(unix)]
132    #[test]
133    fn unix_temp_roots_match_by_component() {
134        for path in [
135            "/tmp",
136            "/tmp/aft-file.txt",
137            "/var/tmp/aft-file.txt",
138            "/private/tmp/aft-file.txt",
139            "/private/var/tmp/aft-file.txt",
140        ] {
141            assert!(
142                is_system_temp_path(Path::new(path)),
143                "{path} should be temp"
144            );
145        }
146
147        for path in ["/tmpfoo/x", "/Users/x", "/home/user/tmp/x"] {
148            assert!(
149                !is_system_temp_path(Path::new(path)),
150                "{path} should not be temp"
151            );
152        }
153    }
154
155    #[cfg(target_os = "macos")]
156    #[test]
157    fn macos_var_folders_matches_tmpdir_tree() {
158        for path in [
159            "/var/folders/zz/aft/T/file.txt",
160            "/private/var/folders/zz/aft/T/file.txt",
161        ] {
162            assert!(
163                is_system_temp_path(Path::new(path)),
164                "{path} should be temp"
165            );
166        }
167    }
168
169    #[test]
170    fn process_temp_dir_and_children_are_exempt() {
171        let temp_dir = std::env::temp_dir();
172        assert!(is_system_temp_path(&temp_dir));
173        assert!(is_system_temp_path(
174            &temp_dir.join("aft-temp-exemption").join("file.txt")
175        ));
176    }
177
178    #[test]
179    fn relative_paths_are_not_temp_paths() {
180        for path in ["tmp/x", "./tmp/x", "project/tmp/x"] {
181            assert!(
182                !is_system_temp_path(Path::new(path)),
183                "{path} should not be temp"
184            );
185        }
186    }
187
188    #[cfg(windows)]
189    #[test]
190    fn windows_temp_dir_shape_is_exempt() {
191        let temp_dir = std::env::temp_dir();
192        assert!(is_system_temp_path(&temp_dir));
193        assert!(is_system_temp_path(
194            &temp_dir.join("aft-temp-exemption").join("file.txt")
195        ));
196    }
197}