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;