Skip to main content

cosh_tools/util/path_guard/
mod.rs

1use std::io::BufRead;
2use std::path::{Component, Path, PathBuf};
3
4/// Windows `canonicalize` returns verbatim (`\\?\C:\…`) paths. Every consumer
5/// downstream (rollback keys, seen-lines keys, hashline headers, diagnostics)
6/// spells paths the plain way, so a verbatim-resolved key silently splits the
7/// store in two: `record("C:\…")` vs `seen_lines(r"\\?\C:\…")` never meet.
8/// Strip the prefix — mapping `\\?\UNC\server\share` back to `\\server\share` —
9/// so the resolved path keeps the plain drive spelling.
10#[cfg(windows)]
11fn strip_windows_verbatim(path: PathBuf) -> PathBuf {
12    let text = path.as_os_str().to_string_lossy();
13    if let Some(stripped) = text.strip_prefix(r"\\?\UNC\") {
14        PathBuf::from(format!(r"\\{stripped}"))
15    } else if let Some(stripped) = text.strip_prefix(r"\\?\") {
16        PathBuf::from(stripped)
17    } else {
18        path
19    }
20}
21
22#[cfg(not(windows))]
23fn strip_windows_verbatim(path: PathBuf) -> PathBuf {
24    path
25}
26
27/// Result of a path validation check.
28#[derive(Debug, PartialEq, Eq)]
29pub enum GuardResult {
30    /// Path is allowed. Contains the normalized (safe) path.
31    Allowed(PathBuf),
32    /// Path is explicitly denied.
33    Denied(String),
34    /// Configuration error (path matched both allowlist and blocklist).
35    Mismatch(String),
36}
37
38/// Centralized path guard that combines lexical validation with filesystem
39/// canonicalization.
40///
41/// Every tool that accepts filesystem paths should use this guard to ensure
42/// consistent security checks. Usage:
43///
44/// ```ignore
45/// let guard = PathGuard::new(&self.root, self.allowlist.as_deref(), self.blocklist.as_deref());
46/// let safe_path = guard.resolve(path)?;
47/// ```
48///
49/// The error message is uniform across all tools, making it easy for the AI
50/// agent to understand why a path was denied.
51/// Name of the harness scratch directory (under the OS temp dir).
52///
53/// The harness writes truncated tool-output logs here (see `harness::truncate`)
54/// and the model reads them back with `fs_read`/`find_grep`. Temp dirs are
55/// ephemeral by nature, so this directory is exempt from the outside-root
56/// denial — but never from the blocklist, which keeps priority.
57///
58/// The exemption is shared by every tool using this guard (reads AND writes):
59/// writes into the scratch dir are still gated by the harness approval dialog
60/// in Build/Ask modes — the guard alone no longer blocks them.
61pub const HARNESS_SCRATCH_DIR: &str = "cosh";
62
63pub struct PathGuard {
64    root: PathBuf,
65    allowlist: Option<Vec<PathBuf>>,
66    blocklist: Option<Vec<PathBuf>>,
67}
68
69impl PathGuard {
70    /// Create a new `PathGuard` with the project root and optional allow/block lists.
71    ///
72    /// The lists are cloned internally so the caller retains ownership.
73    #[must_use]
74    pub fn new(root: &Path, allowlist: Option<&[PathBuf]>, blocklist: Option<&[PathBuf]>) -> Self {
75        Self {
76            root: root.to_path_buf(),
77            allowlist: allowlist.map(|l| l.to_vec()),
78            blocklist: blocklist.map(|l| l.to_vec()),
79        }
80    }
81
82    /// Validate and canonicalize `path` against the guard's root, allowlist, and blocklist.
83    ///
84    /// On success, returns the canonicalized (real) path on the filesystem.
85    /// On failure, returns a descriptive error string explaining why the path was denied.
86    ///
87    /// # Errors
88    ///
89    /// Returns an error if:
90    /// - The path is blocked by the blocklist.
91    /// - The path is outside the project root and not in the allowlist.
92    /// - The path is in both the allowlist and blocklist simultaneously.
93    /// - The path cannot be resolved on the filesystem.
94    pub fn resolve(&self, path: &str) -> Result<PathBuf, String> {
95        let allowlist = self.allowlist.as_deref();
96        let blocklist = self.blocklist.as_deref();
97
98        match validate_path(path, &self.root, allowlist, blocklist) {
99            GuardResult::Allowed(normalized) => {
100                // ── Filesystem canonicalization ──────────────────────────
101                // Resolve symlinks and catch escapes. If canonicalize fails
102                // (file doesn't exist yet), try the parent directory. If that
103                // also fails and the path is inside the project root, use the
104                // normalized path directly.
105                // Strip the verbatim prefix here too: the containment check
106                // below compares against `resolved`, which is plain-spelled.
107                let Ok(root_canon) = self.root.canonicalize().map(strip_windows_verbatim) else {
108                    return Err(format!(
109                        "permission denied: `{path}` is outside the project directory"
110                    ));
111                };
112
113                let root_norm = normalize_path(&self.root, &self.root);
114                let in_root = normalized.starts_with(&root_norm);
115
116                let resolved = match normalized.canonicalize() {
117                    Ok(canon) => strip_windows_verbatim(canon),
118                    Err(_) => match normalized.parent() {
119                        Some(parent) => match parent.canonicalize() {
120                            Ok(parent_canon) => {
121                                let file_name = normalized.file_name().unwrap_or_default();
122                                strip_windows_verbatim(parent_canon.join(file_name))
123                            }
124                            Err(_) => {
125                                if in_root {
126                                    normalized
127                                } else {
128                                    return Err(format!(
129                                        "permission denied: `{path}` is outside the project directory"
130                                    ));
131                                }
132                            }
133                        },
134                        None => {
135                            return Err(format!(
136                                "permission denied: `{path}` is outside the project directory"
137                            ));
138                        }
139                    },
140                };
141
142                if in_root && !resolved.starts_with(&root_canon) {
143                    return Err(format!(
144                        "permission denied: `{path}` is outside the project directory"
145                    ));
146                }
147
148                Ok(resolved)
149            }
150            GuardResult::Denied(reason) => Err(format!("permission denied: `{path}` — {reason}")),
151            GuardResult::Mismatch(msg) => Err(format!("permission denied: `{path}` — {msg}")),
152        }
153    }
154
155    /// Get the project root path (read-only reference).
156    #[must_use]
157    pub const fn root(&self) -> &PathBuf {
158        &self.root
159    }
160
161    /// Get the allowlist (read-only reference).
162    #[must_use]
163    pub fn allowlist(&self) -> Option<&[PathBuf]> {
164        self.allowlist.as_deref()
165    }
166
167    /// Get the blocklist (read-only reference).
168    #[must_use]
169    pub fn blocklist(&self) -> Option<&[PathBuf]> {
170        self.blocklist.as_deref()
171    }
172
173    /// Add a path to the allowlist (for session-level persistence).
174    ///
175    /// If the allowlist is `None`, it is created. Duplicate paths are ignored.
176    pub fn add_allowlist_path(&mut self, path: PathBuf) {
177        let list = self.allowlist.get_or_insert_with(Vec::new);
178        if !list.contains(&path) {
179            list.push(path);
180        }
181    }
182
183    /// Remove a path from the allowlist (for AllowOnce cleanup).
184    ///
185    /// If the path is not in the allowlist, this is a no-op.
186    /// If the allowlist becomes empty after removal, it stays as `Some(vec![])`
187    /// to preserve the distinction between "no allowlist" (deny everything)
188    /// and "empty allowlist" (deny everything outside root).
189    pub fn remove_allowlist_path(&mut self, path: &Path) {
190        if let Some(list) = self.allowlist.as_mut() {
191            list.retain(|p| p != path);
192        }
193    }
194}
195
196/// Normalize a path by resolving `.` and `..` components lexically.
197/// If the path is relative, it is first made absolute against the given root.
198///
199/// This is purely lexical — no filesystem access, no symlink resolution.
200#[must_use]
201pub fn normalize_path(path: &Path, root: &Path) -> PathBuf {
202    let absolute = if path.is_relative() {
203        root.join(path)
204    } else {
205        path.to_path_buf()
206    };
207
208    let mut out: Vec<Component> = Vec::new();
209    for c in absolute.components() {
210        match c {
211            Component::CurDir => {}
212            Component::ParentDir => {
213                if matches!(out.last(), Some(Component::Normal(_))) {
214                    out.pop();
215                } else {
216                    out.push(c);
217                }
218            }
219            other => out.push(other),
220        }
221    }
222    out.iter().collect()
223}
224
225/// Check that `path` is allowed by the project root, allowlist, and blocklist.
226///
227/// The path is first normalized (`.`/`..` resolved). The root is also normalized
228/// so both sides are compared on equal footing.
229///
230/// Besides the project root and the explicit allowlist, paths under the
231/// harness scratch directory (`<OS temp>/cosh`, where truncated tool-output
232/// logs live) are allowed: the OS temp dir is ephemeral scratch by definition,
233/// and blocking it would break the agent's ability to read its own logs back.
234/// The blocklist always takes priority, so even scratch paths can be denied.
235///
236/// Returns `Allowed(normalized_path)` when the path passes all checks,
237/// `Denied(reason)` when it is blocked, and `Mismatch(msg)` when the
238/// path appears in both the allowlist and blocklist simultaneously.
239#[must_use]
240pub fn validate_path(
241    path: &str,
242    root: &Path,
243    allowlist: Option<&[PathBuf]>,
244    blocklist: Option<&[PathBuf]>,
245) -> GuardResult {
246    let path = Path::new(path);
247    let normalized = normalize_path(path, root);
248    let root_norm = normalize_path(root, root);
249
250    let blocked = blocklist.is_some_and(|list| {
251        list.iter().any(|entry| {
252            let e = normalize_path(entry, root);
253            normalized.starts_with(&e) || normalized == e
254        })
255    });
256    let allowed = allowlist.is_some_and(|list| {
257        list.iter().any(|entry| {
258            let e = normalize_path(entry, root);
259            normalized == e
260        })
261    });
262    let in_root = normalized.starts_with(&root_norm);
263    // Ephemeral scratch exemption — absolute temp dir is already absolute, so
264    // normalize_path ignores `root` for it. Blocklist keeps priority below.
265    let in_scratch = normalized.starts_with(normalize_path(
266        &std::env::temp_dir().join(HARNESS_SCRATCH_DIR),
267        root,
268    ));
269
270    if blocked && allowed {
271        return GuardResult::Mismatch(
272            "Security Alert: path is in both blocklist and allowlist.".into(),
273        );
274    }
275    if blocked {
276        return GuardResult::Denied("path is in blocklist".into());
277    }
278    if !in_root && !allowed && !in_scratch {
279        return GuardResult::Denied("path is outside project root".into());
280    }
281
282    GuardResult::Allowed(normalized)
283}
284
285/// How many leading lines are scanned for auto-generated markers.
286///
287/// Generated-file headers always live in the first few lines of the file, so a
288/// small scan window keeps the check cheap (a lazy line-by-line read of at most
289/// this many lines, never a full-file load).
290const AUTO_GENERATED_SCAN_LINES: usize = 10;
291
292/// Return the auto-generated marker found in `line` (case-insensitive), or `None`.
293///
294/// The set is deliberately small and conventional: the Go/Protobuf
295/// `DO NOT EDIT.` convention, the TypeScript `@generated` annotation, and the
296/// common plain-language "automatically generated" variants. Anything else
297/// passes the check.
298fn auto_generated_marker(line: &str) -> Option<&'static str> {
299    let lower = line.to_ascii_lowercase();
300    [
301        "do not edit",
302        "@generated",
303        "automatically generated",
304        "auto-generated",
305        "autogenerated",
306    ]
307    .into_iter()
308    .find(|&marker| lower.contains(marker))
309}
310
311/// Refuse to modify a file that declares itself machine-generated.
312///
313/// This guard is called ONLY by write-path tools ([`crate::fs::write`],
314/// [`crate::fs::edit`], [`crate::fs::ast_edit`]) because those tools replace
315/// file content. Read-only tools (`read`, `grep`) never call it — surfacing a
316/// generated file is harmless.
317///
318/// The check is deliberately cheap: only the first [`AUTO_GENERATED_SCAN_LINES`]
319/// lines are scanned, and only for a small set of conventional markers. A file
320/// without one of those markers, a new file (nothing is being overwritten), and
321/// an unreadable file all pass — the caller surfaces its own read error.
322///
323/// # Errors
324///
325/// Returns `Err` when `path` exists and its header carries an auto-generated
326/// marker, with an actionable message (regenerate instead, or remove the
327/// marker to force the write).
328pub fn assert_editable_file(path: &Path) -> Result<(), String> {
329    if !path.exists() {
330        return Ok(());
331    }
332    let Ok(file) = std::fs::File::open(path) else {
333        return Ok(());
334    };
335    // Lazy line-by-line read: only the first `AUTO_GENERATED_SCAN_LINES` lines
336    // are pulled from disk, never the whole file.
337    let reader = std::io::BufReader::new(file);
338    for line in reader.lines().take(AUTO_GENERATED_SCAN_LINES) {
339        let Ok(line) = line else {
340            return Ok(());
341        };
342        if let Some(marker) = auto_generated_marker(&line) {
343            return Err(format!(
344                "refusing to modify `{}`: its header marks it as auto-generated \
345                 (`{marker}`). Generated files are owned by a tool — your change \
346                 would be overwritten on the next generation run. Edit the \
347                 generator instead, or remove the marker from the file to force \
348                 the write.",
349                path.display()
350            ));
351        }
352    }
353    Ok(())
354}
355
356/// Validate an asset path for skills `read_asset`.
357///
358/// The asset path must be relative, must not contain `..` components, and
359/// after joining with `base_dir` and canonicalizing (filesystem resolution),
360/// must stay within `base_dir`.
361///
362/// # Errors
363///
364/// Returns an error string describing the violation or the reason the
365/// canonicalized path could not be resolved.
366pub fn validate_asset_path(base_dir: &Path, asset_path: &str) -> Result<PathBuf, String> {
367    let requested = Path::new(asset_path);
368    if requested.is_absolute() {
369        return Err("absolute path not allowed, use a relative path".into());
370    }
371    if requested.components().any(|c| c == Component::ParentDir) {
372        return Err("path must not contain '..' (parent directory references)".into());
373    }
374
375    let base_canon = base_dir
376        .canonicalize()
377        .map_err(|e| format!("could not resolve base directory: {e}"))?;
378    let resolved = base_canon.join(asset_path);
379    let resolved_canon = resolved
380        .canonicalize()
381        .map_err(|e| format!("asset not found: {e}"))?;
382
383    if !resolved_canon.starts_with(&base_canon) {
384        return Err("path points outside the skill directory".into());
385    }
386
387    Ok(resolved_canon)
388}
389
390#[cfg(test)]
391mod tests;