返回 CodeWhale
read_guard.rs
根目录 / crates / tui / src / sandbox / read_guard.rs
1 //! Read deny-list (S1, #5568 follow-up).
2 //!
3 //! # What this is
4 //!
5 //! Every sandbox posture Codewhale ships — including `read-only` — grants the
6 //! sandboxed process read access to the entire filesystem
7 //! (`policy.rs::has_full_disk_read_access`). #5568 added the *plumbing* for an
8 //! opt-in deny-list (Seatbelt last-match-wins `deny file-read*` rules;
9 //! bubblewrap masks), but the list shipped empty, so in practice nothing was
10 //! denied. This module supplies (a) a curated default set covering the obvious
11 //! credential stores and (b) the in-process matcher that Codewhale's own
12 //! file-reading tools consult — those tools call `std::fs` directly inside the
13 //! harness process and are never wrapped by `sandbox-exec` or `bwrap` at all,
14 //! so the OS-level rules alone left the largest hole wide open.
15 //!
16 //! # What this is NOT
17 //!
18 //! **This is defense-in-depth, not a security boundary.** It raises the cost of
19 //! a confused or prompt-injected agent stumbling into `~/.ssh/id_ed25519`; it
20 //! does not contain a deliberate attacker. Specifically it does NOT stop:
21 //!
22 //! - **Hardlinks.** A hardlink is a second *name* for the same inode with no
23 //! trace of the first. `foo` hardlinked to `~/.ssh/id_rsa` canonicalizes to
24 //! `foo`, matches nothing, and is read. No path-based deny-list can fix this;
25 //! only an inode-level or MAC-label check could.
26 //! - **Content already elsewhere.** A key copied into the workspace before the
27 //! agent ran, or pasted into the conversation, is readable.
28 //! - **Indirect reads.** `ssh-agent`, `security find-generic-password`,
29 //! `aws sts get-session-token`, a helper the user installed — a process that
30 //! *hands over* a secret without the agent reading the file. On macOS
31 //! `~/Library/Keychains` is denied but the keychain *daemon* is not.
32 //! - **`danger-full-access`.** That posture bypasses the OS wrapper entirely
33 //! (`should_sandbox() == false`). The in-process tool checks still apply, but
34 //! a shell command does not.
35 //! - **Anything on the network side.** A denied read does not stop exfiltration
36 //! of what *was* read.
37 //! - **Reads by MCP servers and other child processes** that Codewhale did not
38 //! itself wrap.
39 //!
40 //! Treat it as a seatbelt, and keep the real controls (least-privilege
41 //! credentials, short-lived tokens, approval prompts) doing the real work.
42 //!
43 //! # Matching rules
44 //!
45 //! - **Deny wins.** A path matching any deny rule is refused; there is no allow
46 //! rule that can override one. Exemptions (`sandbox_read_denylist_exempt`)
47 //! subtract from the *built-in defaults* only, before matching — a path the
48 //! user explicitly listed in `sandbox_denied_read_paths` can never be
49 //! exempted back open.
50 //! - **Symlinks.** Both the literal path and its `canonicalize()`d target are
51 //! tested, so a symlink pointing into `~/.ssh` is denied by its target even
52 //! though its own name is innocuous. The *rules* are resolved the same way
53 //! when they are built: a rule spelled `/etc/ssh` also denies
54 //! `/private/etc/ssh` on macOS, where `/etc` is itself a symlink, and a rule
55 //! written against a `/var/...` directory fires for the `/private/var/...`
56 //! spelling that `canonicalize` and `current_dir` hand back. Without that,
57 //! the resolved candidate never matched a literal rule, which is exactly the
58 //! shape hosted macOS CI runs in (`$TMPDIR` under `/var/folders`).
59 //! Exemptions are matched the same way, so exempting `/private/etc/sudoers`
60 //! — the spelling a denial message may name — reopens the `/etc/sudoers`
61 //! rule it resolves from. Rules are resolved when the list is built
62 //! (startup and config reload); a symlink retargeted afterwards is seen
63 //! through the literal candidate only until the list is rebuilt, which is
64 //! within the defense-in-depth posture above.
65 //! - **`..` and relative paths.** Two candidates are tested. The literal one is
66 //! lexically normalized (`.`/`..` folded without touching the disk), which
67 //! catches traversal into a denied tree even when nothing on the path exists
68 //! yet. The resolved one keeps `..` components raw and lets the OS apply
69 //! them *after* resolving each symlink component — the secure order — because
70 //! with `pub/link -> denied/sub`, the path `pub/link/../secret` really reads
71 //! `denied/secret`; folding `..` first would hide that. When the path does
72 //! not exist, the deepest existing ancestor is canonicalized (with the same
73 //! raw order) and the remainder re-appended.
74 //! - **Case.** On macOS and Windows — where the default filesystem is
75 //! case-insensitive — comparison is case-folded, so `~/.SSH/ID_RSA` is denied.
76 //! On Linux comparison is exact, matching the filesystem's own semantics.
77 //! - **Boundaries are component-wise.** `~/.awsome/notes.md` is not under
78 //! `~/.aws`; a plain string `starts_with` would have said it was.
79
80 use std::ffi::OsString;
81 use std::path::{Component, Path, PathBuf};
82 use std::sync::{Arc, OnceLock, RwLock};
83
84 /// Why a read was refused, so callers can render one clear message.
85 ///
86 /// A denial is always an explicit error. It is never rendered as an empty file,
87 /// a zero-length result, or a "not found" — a silent empty read teaches an agent
88 /// that the file is empty and invites it to try a dozen sibling paths.
89 #[derive(Debug, Clone, PartialEq, Eq)]
90 pub struct ReadDenial {
91 /// The path the caller asked for, as written.
92 pub requested: PathBuf,
93 /// The deny rule that matched.
94 pub rule: DenyRule,
95 /// True when the match was on the symlink target rather than the literal
96 /// path — worth saying out loud, or the refusal looks arbitrary.
97 pub via_symlink: bool,
98 }
99
100 impl ReadDenial {
101 /// One-line, non-leaky refusal message.
102 ///
103 /// Names the *rule*, not the resolved secret path: telling the model that
104 /// `notes.txt` really points at `/Users/x/.ssh/id_ed25519` hands it the
105 /// location it was looking for.
106 #[must_use]
107 pub fn message(&self, tool: &str) -> String {
108 let via = if self.via_symlink {
109 " (reached through a symlink)"
110 } else {
111 ""
112 };
113 format!(
114 "{tool} refused to read {}{via}: the sandbox read deny-list blocks {}. \
115 This path is treated as a credential store. If it is genuinely needed, \
116 add it to `sandbox_read_denylist_exempt` in your Codewhale config.",
117 self.requested.display(),
118 self.rule.describe(),
119 )
120 }
121 }
122
123 /// A single deny rule. Kept as an enum rather than a bare path so the refusal
124 /// message can name the rule ("SSH keys") instead of echoing a secret path.
125 #[derive(Debug, Clone, PartialEq, Eq)]
126 pub enum DenyRule {
127 /// Everything at or below a directory (or a single file at that path).
128 Subtree {
129 /// Normalized absolute path, as configured.
130 path: PathBuf,
131 /// `path` with symlinks resolved, kept only when it differs. A read is
132 /// matched against both spellings: the literal one catches
133 /// `/etc/sudoers` as written, this one catches `/private/etc/sudoers`
134 /// — the same file, reached by the name the OS actually uses.
135 resolved: Option<PathBuf>,
136 /// Human label, e.g. "SSH keys (~/.ssh)".
137 label: &'static str,
138 },
139 /// Any file whose *name* matches, anywhere on disk. Used for `.env`, which
140 /// has no fixed location.
141 FileName {
142 /// Human label.
143 label: &'static str,
144 },
145 }
146
147 impl DenyRule {
148 /// A subtree rule that also remembers where its path really leads.
149 fn subtree(path: PathBuf, label: &'static str) -> Self {
150 let resolved = canonicalize_best_effort(&path);
151 DenyRule::Subtree {
152 resolved: (resolved != path).then_some(resolved),
153 path,
154 label,
155 }
156 }
157
158 /// True when this rule, under either spelling, lies at or below one of
159 /// `roots`. Exemptions subtract whole rules, so both spellings count.
160 fn is_within_any(&self, roots: &[PathBuf]) -> bool {
161 let DenyRule::Subtree { path, resolved, .. } = self else {
162 return false;
163 };
164 roots.iter().any(|root| {
165 path_is_within(path, root)
166 || resolved
167 .as_deref()
168 .is_some_and(|real| path_is_within(real, root))
169 })
170 }
171
172 #[must_use]
173 fn describe(&self) -> String {
174 match self {
175 DenyRule::Subtree { label, .. } => (*label).to_string(),
176 DenyRule::FileName { label } => (*label).to_string(),
177 }
178 }
179 }
180
181 /// The compiled deny-list.
182 #[derive(Debug, Clone, Default)]
183 pub struct ReadDenylist {
184 subtrees: Vec<DenyRule>,
185 deny_env_files: bool,
186 }
187
188 impl ReadDenylist {
189 /// An empty deny-list: denies nothing. Used when the user turns defaults
190 /// off and configures no paths of their own.
191 #[must_use]
192 pub fn empty() -> Self {
193 Self::default()
194 }
195
196 /// Build the effective deny-list.
197 ///
198 /// * `include_defaults` — apply the built-in credential-store set.
199 /// * `extra` — user-configured `sandbox_denied_read_paths`; these are
200 /// absolute (or `~`-prefixed) paths and are never exemptable.
201 /// * `exempt` — user-configured `sandbox_read_denylist_exempt`; subtracts
202 /// from the built-in defaults only.
203 #[must_use]
204 pub fn build(include_defaults: bool, extra: &[PathBuf], exempt: &[PathBuf]) -> Self {
205 // Each exemption is kept in both spellings too, so exempting the path a
206 // denial named (`/private/etc/sudoers` on macOS) reopens the rule it
207 // resolved from (`/etc/sudoers`), and vice versa.
208 let exempt_normalized: Vec<PathBuf> = exempt
209 .iter()
210 .cloned()
211 .map(expand_home_prefix)
212 .map(|p| normalize_lexically(&p))
213 .flat_map(|p| {
214 let resolved = canonicalize_best_effort(&p);
215 let real = (resolved != p).then_some(resolved);
216 std::iter::once(p).chain(real)
217 })
218 .collect();
219
220 let mut subtrees = Vec::new();
221 let mut deny_env_files = false;
222
223 if include_defaults {
224 // The `.env` rule has no fixed location, so its exemption is
225 // name-shaped: any exempt entry whose FILE NAME is `.env` — bare
226 // `.env`, `~/.env`, `some/project/.env` — disables the whole
227 // filename rule. Comparing the raw string could never match: the
228 // entries above were normalized to absolute paths.
229 deny_env_files = !exempt_normalized.iter().any(|p| {
230 p.file_name()
231 .is_some_and(|name| name == std::ffi::OsStr::new(".env"))
232 });
233 for (raw, label) in default_denied_subtrees() {
234 let rule = DenyRule::subtree(normalize_lexically(&raw), label);
235 if rule.is_within_any(&exempt_normalized) {
236 continue;
237 }
238 subtrees.push(rule);
239 }
240 }
241
242 // User-listed denies are appended last and are NOT filtered by the
243 // exempt list: deny wins over allow, without exception.
244 for raw in extra {
245 let path = normalize_lexically(&expand_home_prefix(raw.clone()));
246 if path.as_os_str().is_empty() {
247 continue;
248 }
249 subtrees.push(DenyRule::subtree(
250 path,
251 "a path in `sandbox_denied_read_paths`",
252 ));
253 }
254
255 Self {
256 subtrees,
257 deny_env_files,
258 }
259 }
260
261 /// True when nothing is denied — i.e. the posture really does grant read of
262 /// every file on disk.
263 #[must_use]
264 pub fn is_empty(&self) -> bool {
265 self.subtrees.is_empty() && !self.deny_env_files
266 }
267
268 /// Every literal subtree path, for handing to the OS wrappers
269 /// (`SandboxManager::set_denied_read_subpaths`). The filename rule (`.env`)
270 /// has no fixed path and therefore cannot be expressed to Seatbelt or
271 /// bubblewrap as a subpath — it is enforced in-process only, which is a
272 /// real gap for shell commands and is documented as such.
273 #[must_use]
274 pub fn subtree_paths(&self) -> Vec<PathBuf> {
275 self.subtrees
276 .iter()
277 .filter_map(|rule| match rule {
278 DenyRule::Subtree { path, .. } => Some(path.clone()),
279 DenyRule::FileName { .. } => None,
280 })
281 .collect()
282 }
283
284 /// Check a path a tool is about to read.
285 ///
286 /// `requested` may be relative, may contain `..`, may be a symlink, and may
287 /// not exist. Both the lexically normalized path and the canonicalized
288 /// target are tested; either matching is a denial.
289 pub fn check(&self, requested: &Path) -> Result<(), ReadDenial> {
290 if self.is_empty() {
291 return Ok(());
292 }
293
294 let literal = absolutize(requested);
295 let resolved = canonicalize_best_effort(requested);
296 let via_symlink = resolved != literal;
297
298 for candidate in [&literal, &resolved] {
299 if self.deny_env_files && is_env_file(candidate) {
300 return Err(ReadDenial {
301 requested: requested.to_path_buf(),
302 rule: DenyRule::FileName {
303 label: "environment files (`.env`, `.env.<name>`)",
304 },
305 via_symlink: via_symlink && candidate == &resolved,
306 });
307 }
308 for rule in &self.subtrees {
309 let DenyRule::Subtree {
310 path,
311 resolved: rule_resolved,
312 ..
313 } = rule
314 else {
315 continue;
316 };
317 let hit = path_is_within(candidate, path)
318 || rule_resolved
319 .as_deref()
320 .is_some_and(|real| path_is_within(candidate, real));
321 if hit {
322 return Err(ReadDenial {
323 requested: requested.to_path_buf(),
324 rule: rule.clone(),
325 via_symlink: via_symlink && candidate == &resolved,
326 });
327 }
328 }
329 }
330
331 Ok(())
332 }
333 }
334
335 // ---------------------------------------------------------------------------
336 // Process-wide active deny-list
337 //
338 // Codewhale's file-reading tools (`read_file`, `read`, `read_media`, …) run
339 // in-process and are never wrapped by `sandbox-exec` or `bwrap`, so they have
340 // to consult the deny-list themselves. Threading config through every tool
341 // signature would touch dozens of call sites for one read; a process-global
342 // set once at startup keeps the blast radius to the tools that actually read
343 // files.
344 // ---------------------------------------------------------------------------
345
346 static ACTIVE: RwLock<Option<Arc<ReadDenylist>>> = RwLock::new(None);
347 static FALLBACK: OnceLock<Arc<ReadDenylist>> = OnceLock::new();
348
349 /// Install the deny-list resolved from user config. Called once during startup.
350 pub fn set_active(list: ReadDenylist) {
351 if let Ok(mut slot) = ACTIVE.write() {
352 *slot = Some(Arc::new(list));
353 }
354 }
355
356 /// The deny-list in force for this process.
357 ///
358 /// Falls back to the built-in defaults when startup has not installed one, so
359 /// a code path that runs before config load is protected rather than open.
360 #[must_use]
361 pub fn active() -> Arc<ReadDenylist> {
362 if let Ok(slot) = ACTIVE.read()
363 && let Some(list) = slot.as_ref()
364 {
365 return Arc::clone(list);
366 }
367 Arc::clone(FALLBACK.get_or_init(|| Arc::new(ReadDenylist::build(true, &[], &[]))))
368 }
369
370 /// `.env`, `.env.local`, `.env.production` — but deliberately NOT
371 /// `.env.example`, `.env.sample`, `.env.template`, `.env.defaults`, or
372 /// `.env.dist`. Those are committed placeholders that a coding agent has a
373 /// legitimate, routine reason to read (they document which variables a project
374 /// needs), and denying them would break ordinary development for no security
375 /// gain — they contain no secrets by construction.
376 fn is_env_file(path: &Path) -> bool {
377 let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
378 return false;
379 };
380 let name = fold_case_str(name);
381 if name == ".env" {
382 return true;
383 }
384 let Some(suffix) = name.strip_prefix(".env.") else {
385 return false;
386 };
387 const PLACEHOLDER_SUFFIXES: &[&str] = &[
388 "example", "sample", "template", "defaults", "dist", "schema",
389 ];
390 !PLACEHOLDER_SUFFIXES.contains(&suffix)
391 }
392
393 /// The built-in default deny set.
394 ///
395 /// Chosen against one test: **would denying this break ordinary development?**
396 /// Everything here is a credential store that build tools, language servers,
397 /// test runners, and source reading never need. Deliberately excluded, despite
398 /// containing or neighbouring secrets:
399 ///
400 /// - `~/.gitconfig` — read constantly by tooling; holds config, not secrets.
401 /// (`~/.git-credentials`, which holds the secrets, IS denied.)
402 /// - `~/.cargo`, `~/.npm`, `~/.m2` as a whole — the seatbelt profile already
403 /// grants these read+write because `cargo build` and `npx` fail without
404 /// them. Only the credential *files* inside them are denied.
405 /// - `~/.docker` as a whole — `docker build` reads it. Only
406 /// `~/.docker/config.json` (registry auth) is denied.
407 /// - `~/.config` as a whole — far too broad; individual credential dirs inside
408 /// it are listed instead.
409 /// - The user's source tree, `~/Documents`, `~/Downloads` — a coding agent must
410 /// still be able to read the user's code, which is the entire point.
411 fn default_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
412 let Some(home) = dirs::home_dir() else {
413 // No home directory: only the machine-wide entries are meaningful.
414 return machine_wide_denied_subtrees();
415 };
416 let h = |rel: &str| home.join(rel);
417
418 let mut out = vec![
419 // --- SSH / GPG ---
420 (h(".ssh"), "SSH keys and known-hosts (~/.ssh)"),
421 (h(".gnupg"), "GnuPG keyring (~/.gnupg)"),
422 // --- Cloud provider credentials ---
423 (h(".aws"), "AWS credentials (~/.aws)"),
424 (
425 h(".config/gcloud"),
426 "Google Cloud credentials (~/.config/gcloud)",
427 ),
428 (h(".azure"), "Azure credentials (~/.azure)"),
429 (h(".kube"), "Kubernetes credentials (~/.kube)"),
430 (h(".oci"), "Oracle Cloud credentials (~/.oci)"),
431 (
432 h(".config/doctl"),
433 "DigitalOcean credentials (~/.config/doctl)",
434 ),
435 (h(".config/fly"), "Fly.io credentials (~/.config/fly)"),
436 (h(".vercel"), "Vercel credentials (~/.vercel)"),
437 (
438 h(".wrangler/config"),
439 "Cloudflare credentials (~/.wrangler/config)",
440 ),
441 (
442 h(".config/gh/hosts.yml"),
443 "GitHub CLI tokens (~/.config/gh/hosts.yml)",
444 ),
445 (
446 h(".config/glab-cli"),
447 "GitLab CLI tokens (~/.config/glab-cli)",
448 ),
449 // --- Package-registry and network credential files ---
450 (h(".netrc"), "netrc credentials (~/.netrc)"),
451 (h("_netrc"), "netrc credentials (~/_netrc)"),
452 (h(".pgpass"), "PostgreSQL password file (~/.pgpass)"),
453 (h(".my.cnf"), "MySQL credentials (~/.my.cnf)"),
454 (h(".npmrc"), "npm auth tokens (~/.npmrc)"),
455 (h(".pypirc"), "PyPI auth tokens (~/.pypirc)"),
456 (
457 h(".git-credentials"),
458 "stored git credentials (~/.git-credentials)",
459 ),
460 (
461 h(".cargo/credentials"),
462 "crates.io token (~/.cargo/credentials)",
463 ),
464 (
465 h(".cargo/credentials.toml"),
466 "crates.io token (~/.cargo/credentials.toml)",
467 ),
468 (
469 h(".docker/config.json"),
470 "Docker registry auth (~/.docker/config.json)",
471 ),
472 (
473 h(".m2/settings-security.xml"),
474 "Maven master password (~/.m2)",
475 ),
476 (
477 h(".gradle/gradle.properties"),
478 "Gradle credentials (~/.gradle/gradle.properties)",
479 ),
480 // --- Codewhale's own credential stores ---
481 // Duplicated with `tools::file::is_codewhale_credential_path` on
482 // purpose: that guard is scoped to the *active* config, this one is
483 // unconditional, and neither should depend on the other still existing.
484 (
485 h(".codewhale/secrets"),
486 "Codewhale secret store (~/.codewhale/secrets)",
487 ),
488 (
489 h(".deepseek/secrets"),
490 "Codewhale secret store (~/.deepseek/secrets)",
491 ),
492 // --- Browser profiles (cookies, saved passwords, session tokens) ---
493 (h(".mozilla"), "Firefox profile (~/.mozilla)"),
494 (
495 h(".config/google-chrome"),
496 "Chrome profile (~/.config/google-chrome)",
497 ),
498 (
499 h(".config/chromium"),
500 "Chromium profile (~/.config/chromium)",
501 ),
502 (
503 h(".config/BraveSoftware"),
504 "Brave profile (~/.config/BraveSoftware)",
505 ),
506 ];
507
508 if cfg!(target_os = "macos") {
509 out.extend([
510 (
511 h("Library/Keychains"),
512 "macOS keychain (~/Library/Keychains)",
513 ),
514 (
515 h("Library/Application Support/Google/Chrome"),
516 "Chrome profile (~/Library/Application Support/Google/Chrome)",
517 ),
518 (
519 h("Library/Application Support/Firefox"),
520 "Firefox profile (~/Library/Application Support/Firefox)",
521 ),
522 (
523 h("Library/Application Support/BraveSoftware"),
524 "Brave profile (~/Library/Application Support/BraveSoftware)",
525 ),
526 (h("Library/Safari"), "Safari profile (~/Library/Safari)"),
527 (
528 h("Library/Cookies"),
529 "macOS cookie store (~/Library/Cookies)",
530 ),
531 ]);
532 }
533
534 out.extend(machine_wide_denied_subtrees());
535 out
536 }
537
538 fn machine_wide_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
539 let mut out: Vec<(PathBuf, &'static str)> = vec![
540 (
541 PathBuf::from("/etc/shadow"),
542 "system password hashes (/etc/shadow)",
543 ),
544 (
545 PathBuf::from("/etc/sudoers"),
546 "sudoers policy (/etc/sudoers)",
547 ),
548 (PathBuf::from("/etc/ssh"), "system SSH host keys (/etc/ssh)"),
549 ];
550 if cfg!(target_os = "macos") {
551 out.push((
552 PathBuf::from("/Library/Keychains"),
553 "system keychain (/Library/Keychains)",
554 ));
555 }
556 out
557 }
558
559 // ---------------------------------------------------------------------------
560 // Path handling
561 //
562 // The evasion cases this has to survive are the whole reason the module exists;
563 // a deny-list a symlink walks around is theater.
564 // ---------------------------------------------------------------------------
565
566 /// Expand a leading `~` to the user's home directory.
567 fn expand_home_prefix(path: PathBuf) -> PathBuf {
568 let Some(text) = path.to_str() else {
569 return path;
570 };
571 if text == "~" {
572 return dirs::home_dir().unwrap_or(path);
573 }
574 if let Some(rest) = text.strip_prefix("~/")
575 && let Some(home) = dirs::home_dir()
576 {
577 return home.join(rest);
578 }
579 path
580 }
581
582 /// Fold `.` and `..` without touching the disk, and make the path absolute
583 /// against the current directory when it is relative.
584 ///
585 /// Purely lexical on purpose: this is the check that catches
586 /// `workspace/../../../.ssh/id_rsa` even when nothing on that path exists yet.
587 /// It is paired with — never a substitute for — `canonicalize_best_effort`,
588 /// which is what catches symlinks.
589 fn normalize_lexically(path: &Path) -> PathBuf {
590 let absolute = if path.is_absolute() {
591 path.to_path_buf()
592 } else {
593 std::env::current_dir()
594 .unwrap_or_else(|_| PathBuf::from("/"))
595 .join(path)
596 };
597
598 let mut out = PathBuf::new();
599 for component in absolute.components() {
600 match component {
601 Component::CurDir => {}
602 Component::ParentDir => {
603 // Never pop past the root: `/..` is `/`.
604 if out
605 .components()
606 .next_back()
607 .is_some_and(|c| !matches!(c, Component::RootDir | Component::Prefix(_)))
608 {
609 out.pop();
610 }
611 }
612 other => out.push(other.as_os_str()),
613 }
614 }
615 out
616 }
617
618 fn absolutize(path: &Path) -> PathBuf {
619 normalize_lexically(path)
620 }
621
622 /// Make a path absolute against the current directory WITHOUT folding `.` or
623 /// `..` components.
624 ///
625 /// Folding first is unsound: with `pub/link -> denied/sub`, the path
626 /// `pub/link/../secret` lexically becomes `pub/secret`, but the OS resolves the
627 /// symlink *before* applying `..` and really reads `denied/secret`. Keeping the
628 /// raw `..` components lets `fs::canonicalize` apply the secure order —
629 /// resolve each component, then let `..` pop the resolved result.
630 fn absolutize_raw(path: &Path) -> PathBuf {
631 if path.is_absolute() {
632 path.to_path_buf()
633 } else {
634 std::env::current_dir()
635 .unwrap_or_else(|_| PathBuf::from("/"))
636 .join(path)
637 }
638 }
639
640 /// Resolve symlinks as far as the filesystem allows.
641 ///
642 /// `canonicalize` fails on a path that does not exist, which is exactly the
643 /// case for a read of a file that is about to be created — and also the case an
644 /// evader would reach for. So on failure we walk up the RAW ancestor chain
645 /// (each surviving `..` included) to the deepest ancestor that *does* exist,
646 /// canonicalize that — resolving any symlinks, with the OS applying any `..`
647 /// components above it in the secure order — and re-append the remaining
648 /// components verbatim. A dropped `..` can only leave the candidate *deeper*
649 /// inside an already-resolved ancestor, which subtree matching still denies;
650 /// the old lexical-first fold popped symlinks out of existence instead.
651 fn canonicalize_best_effort(path: &Path) -> PathBuf {
652 let absolute = absolutize_raw(path);
653 if let Ok(resolved) = std::fs::canonicalize(&absolute) {
654 return resolved;
655 }
656
657 let mut suffix: Vec<OsString> = Vec::new();
658 let mut cursor = absolute.as_path();
659 loop {
660 let Some(parent) = cursor.parent() else {
661 return absolute;
662 };
663 if let Some(name) = cursor.file_name() {
664 suffix.push(name.to_os_string());
665 }
666 if let Ok(resolved) = std::fs::canonicalize(parent) {
667 let mut out = resolved;
668 for name in suffix.iter().rev() {
669 out.push(name);
670 }
671 return out;
672 }
673 cursor = parent;
674 }
675 }
676
677 /// Case-fold when — and only when — the platform's default filesystem is
678 /// case-insensitive. Folding on Linux would deny `~/.SSH` on a system where
679 /// that is a genuinely different directory.
680 fn fold_case_str(text: &str) -> String {
681 if cfg!(any(target_os = "macos", target_os = "windows")) {
682 text.to_lowercase()
683 } else {
684 text.to_string()
685 }
686 }
687
688 fn fold_component(component: &std::ffi::OsStr) -> OsString {
689 match component.to_str() {
690 Some(text) => OsString::from(fold_case_str(text)),
691 None => component.to_os_string(),
692 }
693 }
694
695 /// True when `candidate` is `root` itself or lives beneath it.
696 ///
697 /// Compared component by component, not by string prefix: `~/.awsome` must not
698 /// match the `~/.aws` rule, and `starts_with` on the raw strings says it does.
699 /// (`Path::starts_with` is already component-wise; the case folding is what
700 /// forces the manual walk.)
701 fn path_is_within(candidate: &Path, root: &Path) -> bool {
702 let mut root_components = root.components().map(|c| fold_component(c.as_os_str()));
703 let mut candidate_components = candidate
704 .components()
705 .map(|c| fold_component(c.as_os_str()));
706
707 loop {
708 match (root_components.next(), candidate_components.next()) {
709 (None, _) => return true,
710 (Some(_), None) => return false,
711 (Some(r), Some(c)) if r == c => {}
712 (Some(_), Some(_)) => return false,
713 }
714 }
715 }
716
717 #[cfg(test)]
718 mod tests {
719 use super::*;
720
721 fn denylist_for(paths: &[PathBuf]) -> ReadDenylist {
722 ReadDenylist::build(false, paths, &[])
723 }
724
725 // Two tests below move the process-wide cwd. libtest runs tests as
726 // parallel threads of one process, so they take this lock; nextest runs
727 // each test in its own process and never contends for it.
728 static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
729
730 fn lock_cwd() -> std::sync::MutexGuard<'static, ()> {
731 CWD_LOCK
732 .lock()
733 .unwrap_or_else(|poisoned| poisoned.into_inner())
734 }
735
736 #[test]
737 fn empty_denylist_denies_nothing_and_reports_full_disk_read() {
738 let list = ReadDenylist::empty();
739 assert!(list.is_empty());
740 assert!(list.check(Path::new("/etc/hosts")).is_ok());
741 }
742
743 #[test]
744 fn direct_path_under_a_denied_root_is_refused() {
745 let secret = tempfile::tempdir().expect("tempdir");
746 let file = secret.path().join("id_ed25519");
747 std::fs::write(&file, "KEY").expect("write");
748
749 let list = denylist_for(&[secret.path().to_path_buf()]);
750 let denial = list.check(&file).expect_err("must deny");
751 assert_eq!(denial.requested, file);
752 assert!(!denial.via_symlink);
753 }
754
755 #[test]
756 fn sibling_with_a_shared_string_prefix_is_not_denied() {
757 // `~/.awsome` must not be caught by the `~/.aws` rule. This is the bug
758 // a naive string `starts_with` ships with.
759 let tmp = tempfile::tempdir().expect("tempdir");
760 let denied = tmp.path().join("aws");
761 let innocent = tmp.path().join("awsome");
762 std::fs::create_dir_all(&denied).expect("mkdir");
763 std::fs::create_dir_all(&innocent).expect("mkdir");
764 let note = innocent.join("notes.md");
765 std::fs::write(&note, "notes").expect("write");
766
767 let list = denylist_for(std::slice::from_ref(&denied));
768 assert!(
769 list.check(&note).is_ok(),
770 "sibling prefix must stay readable"
771 );
772 }
773
774 #[test]
775 fn dot_dot_traversal_out_of_the_workspace_is_refused() {
776 let tmp = tempfile::tempdir().expect("tempdir");
777 let secret_dir = tmp.path().join("secrets");
778 let workspace = tmp.path().join("workspace");
779 std::fs::create_dir_all(&secret_dir).expect("mkdir");
780 std::fs::create_dir_all(&workspace).expect("mkdir");
781 let secret = secret_dir.join("token");
782 std::fs::write(&secret, "TOKEN").expect("write");
783
784 let list = denylist_for(std::slice::from_ref(&secret_dir));
785 let sneaky = workspace.join("..").join("secrets").join("token");
786 list.check(&sneaky)
787 .expect_err("`..` must not walk around the deny-list");
788 }
789
790 #[test]
791 #[cfg(unix)]
792 fn symlink_pointing_into_a_denied_tree_is_refused_by_its_target() {
793 let tmp = tempfile::tempdir().expect("tempdir");
794 let secret_dir = tmp.path().join("secrets");
795 let workspace = tmp.path().join("workspace");
796 std::fs::create_dir_all(&secret_dir).expect("mkdir");
797 std::fs::create_dir_all(&workspace).expect("mkdir");
798 let secret = secret_dir.join("id_rsa");
799 std::fs::write(&secret, "KEY").expect("write");
800
801 let link = workspace.join("harmless.txt");
802 std::os::unix::fs::symlink(&secret, &link).expect("symlink");
803
804 let list = denylist_for(std::slice::from_ref(&secret_dir));
805 let denial = list.check(&link).expect_err("symlink must not walk around");
806 assert!(denial.via_symlink, "denial should report the symlink hop");
807 assert!(denial.message("read_file").contains("symlink"));
808 }
809
810 #[test]
811 #[cfg(unix)]
812 fn symlinked_parent_directory_is_refused() {
813 // The link is on a *directory* in the middle of the path, not the leaf.
814 let tmp = tempfile::tempdir().expect("tempdir");
815 let secret_dir = tmp.path().join("secrets");
816 let workspace = tmp.path().join("workspace");
817 std::fs::create_dir_all(&secret_dir).expect("mkdir");
818 std::fs::create_dir_all(&workspace).expect("mkdir");
819 std::fs::write(secret_dir.join("token"), "TOKEN").expect("write");
820
821 let link_dir = workspace.join("data");
822 std::os::unix::fs::symlink(&secret_dir, &link_dir).expect("symlink");
823
824 let list = denylist_for(std::slice::from_ref(&secret_dir));
825 list.check(&link_dir.join("token"))
826 .expect_err("symlinked parent must not walk around");
827 }
828
829 /// F5 regression: `..` must be applied by the OS *after* resolving each
830 /// symlink component, never folded lexically first. With
831 /// `pub/link -> denied/sub`, the path `pub/link/../secret` really reads
832 /// `denied/secret`; the old lexical-first fold produced `pub/secret` and
833 /// the check returned Ok while the read sailed through.
834 #[test]
835 #[cfg(unix)]
836 fn dot_dot_through_a_symlink_is_applied_after_symlink_resolution() {
837 let tmp = tempfile::tempdir().expect("tempdir");
838 let denied = tmp.path().join("denied");
839 std::fs::create_dir_all(denied.join("sub")).expect("mkdir");
840 std::fs::write(denied.join("sub").join("secret"), "TOKEN").expect("write");
841 let pub_dir = tmp.path().join("pub");
842 std::fs::create_dir_all(&pub_dir).expect("mkdir");
843 std::os::unix::fs::symlink(denied.join("sub"), pub_dir.join("link")).expect("symlink");
844
845 let list = denylist_for(std::slice::from_ref(&denied));
846 list.check(&pub_dir.join("link").join("..").join("secret"))
847 .expect_err("`..` through a symlink must not walk around the deny-list");
848
849 // Same evasion with a not-yet-existing leaf: the direct canonicalize
850 // fails, so the raw-ancestor walk has to carry the check.
851 list.check(&pub_dir.join("link").join("..").join("not-yet"))
852 .expect_err("the raw-ancestor walk must resolve the symlink before `..`");
853 }
854
855 /// A chain of symlinks (link → link → secret) must be followed to the
856 /// final target, not just one hop. Integrator defeat attempt: indirection
857 /// depth is not a bypass.
858 #[test]
859 #[cfg(unix)]
860 fn symlink_chains_resolve_to_the_denied_target() {
861 let tmp = tempfile::tempdir().expect("tempdir");
862 let denied = tmp.path().join("denied");
863 std::fs::create_dir_all(&denied).expect("mkdir");
864 std::fs::write(denied.join("id_ed25519"), "KEY").expect("write");
865 std::os::unix::fs::symlink(denied.join("id_ed25519"), tmp.path().join("hop2"))
866 .expect("symlink");
867 std::os::unix::fs::symlink(tmp.path().join("hop2"), tmp.path().join("hop1"))
868 .expect("symlink");
869 let list = denylist_for(std::slice::from_ref(&denied));
870 list.check(&tmp.path().join("hop1"))
871 .expect_err("a symlink chain must resolve to the denied target");
872 }
873
874 /// A relative read issued with the process cwd INSIDE a denied subtree
875 /// must be refused: the absolutization against cwd lands under the rule.
876 /// (Serialized with the other cwd-moving test; restores cwd regardless.)
877 #[test]
878 fn relative_read_from_inside_a_denied_tree_is_refused() {
879 let _cwd = lock_cwd();
880 let tmp = tempfile::tempdir().expect("tempdir");
881 let denied = tmp.path().join("denied");
882 std::fs::create_dir_all(&denied).expect("mkdir");
883 std::fs::write(denied.join("id_ed25519"), "KEY").expect("write");
884 let prior = std::env::current_dir().expect("cwd");
885 struct Restore(std::path::PathBuf);
886 impl Drop for Restore {
887 fn drop(&mut self) {
888 let _ = std::env::set_current_dir(&self.0);
889 }
890 }
891 let _restore = Restore(prior);
892 std::env::set_current_dir(&denied).expect("chdir into the denied tree");
893
894 let list = denylist_for(std::slice::from_ref(&denied));
895 list.check(Path::new("id_ed25519"))
896 .expect_err("a relative read from inside the denied tree must be refused");
897 list.check(Path::new("./id_ed25519"))
898 .expect_err("the dotted spelling must not differ from the bare one");
899 }
900
901 /// Separator noise must not dodge matching: repeated slashes collapse and a
902 /// trailing slash never changes which subtree a path belongs to.
903 #[test]
904 fn double_slash_and_trailing_slash_variants_are_refused() {
905 let tmp = tempfile::tempdir().expect("tempdir");
906 let secret_dir = tmp.path().join("secrets");
907 std::fs::create_dir_all(&secret_dir).expect("mkdir");
908 std::fs::write(secret_dir.join("token"), "TOKEN").expect("write");
909
910 let list = denylist_for(std::slice::from_ref(&secret_dir));
911 let root = secret_dir.parent().expect("parent");
912 let double = PathBuf::from(format!("{}/secrets//token", root.display()));
913 list.check(&double)
914 .expect_err("double slashes must not walk around the deny-list");
915 let trailing = PathBuf::from(format!("{}/secrets/", root.display()));
916 list.check(&trailing)
917 .expect_err("a trailing slash must not walk around the deny-list");
918 // And the `.env` filename rule is name-based, so a trailing slash on it
919 // still leaves the file name intact.
920 let list = ReadDenylist::build(true, &[], &[]);
921 let env_dir = tmp.path().join("nested");
922 std::fs::create_dir_all(&env_dir).expect("mkdir");
923 list.check(&env_dir.join(".env"))
924 .unwrap_err_or_panic("`.env` under any directory is denied by name");
925 }
926
927 /// The real attack spelling on macOS: `~/.SSH/ID_RSA` on a case-insensitive
928 /// filesystem is `~/.ssh/id_rsa`. (The tempdir sibling above covers the
929 /// mechanism; this one pins the default rule itself.)
930 #[cfg(target_os = "macos")]
931 #[test]
932 fn macos_case_variation_of_the_default_ssh_rule_is_refused() {
933 let Some(home) = dirs::home_dir() else {
934 return;
935 };
936 if !home.join(".ssh").is_dir() {
937 // Nothing to match against; skip rather than fake a pass.
938 return;
939 }
940 let list = ReadDenylist::build(true, &[], &[]);
941 list.check(&home.join(".SSH").join("ID_RSA"))
942 .expect_err("~/.SSH/ID_RSA is ~/.ssh/id_rsa on a case-insensitive filesystem");
943 }
944
945 #[cfg(any(target_os = "macos", target_os = "windows"))]
946 #[test]
947 fn case_variation_is_refused_on_case_insensitive_filesystems() {
948 let tmp = tempfile::tempdir().expect("tempdir");
949 let secret_dir = tmp.path().join("Secrets");
950 std::fs::create_dir_all(&secret_dir).expect("mkdir");
951 std::fs::write(secret_dir.join("id_rsa"), "KEY").expect("write");
952
953 let list = denylist_for(std::slice::from_ref(&secret_dir));
954 let shouted = tmp.path().join("SECRETS").join("ID_RSA");
955 list.check(&shouted)
956 .expect_err("case variation must not walk around on a case-insensitive FS");
957 }
958
959 #[test]
960 fn nonexistent_path_under_a_denied_root_is_still_refused() {
961 // canonicalize() fails here; the deepest-existing-ancestor walk is what
962 // has to carry the check.
963 let tmp = tempfile::tempdir().expect("tempdir");
964 let secret_dir = tmp.path().join("secrets");
965 std::fs::create_dir_all(&secret_dir).expect("mkdir");
966
967 let list = denylist_for(std::slice::from_ref(&secret_dir));
968 list.check(&secret_dir.join("not-created-yet").join("key"))
969 .expect_err("a not-yet-existing path under a denied root must still be denied");
970 }
971
972 #[test]
973 fn env_files_are_denied_but_committed_placeholders_are_not() {
974 let list = ReadDenylist::build(true, &[], &[]);
975 let tmp = tempfile::tempdir().expect("tempdir");
976
977 for denied in [".env", ".env.local", ".env.production"] {
978 let path = tmp.path().join(denied);
979 list.check(&path)
980 .unwrap_err_or_panic(&format!("{denied} should be denied"));
981 }
982 for allowed in [".env.example", ".env.sample", ".env.template", ".env.dist"] {
983 let path = tmp.path().join(allowed);
984 assert!(
985 list.check(&path).is_ok(),
986 "{allowed} is a committed placeholder and must stay readable"
987 );
988 }
989 }
990
991 /// F3 regression: exempting `.env` used to compare a *normalized absolute*
992 /// path against the bare string `.env`, which could never match — the
993 /// exemption was dead code. The rule is name-shaped, so any exempt entry
994 /// whose file name is `.env` must disable it.
995 #[test]
996 fn exempting_env_by_name_disables_the_env_file_rule() {
997 let tmp = tempfile::tempdir().expect("tempdir");
998 let env_file = tmp.path().join(".env");
999 std::fs::write(&env_file, "SECRET=1\n").expect("write");
1000
1001 // Bare `.env` — the spelling the docs advertise.
1002 let list = ReadDenylist::build(true, &[], &[PathBuf::from(".env")]);
1003 assert!(
1004 list.check(&env_file).is_ok(),
1005 "exempting `.env` must disable the env-file rule everywhere"
1006 );
1007
1008 // Any path ending in `/.env` — e.g. `~/.env` or `project/.env`.
1009 let home_spelled = dirs::home_dir().map(|h| h.join(".env"));
1010 let exempt_path = home_spelled.as_deref().unwrap_or(env_file.as_path());
1011 let list = ReadDenylist::build(true, &[], &[exempt_path.to_path_buf()]);
1012 assert!(
1013 list.check(&env_file).is_ok(),
1014 "an exempt entry named `.env` must disable the whole env-file rule"
1015 );
1016
1017 // An exemption for anything else must leave the rule armed.
1018 let unrelated = tmp.path().join("notes");
1019 let list = ReadDenylist::build(true, &[], std::slice::from_ref(&unrelated));
1020 list.check(&env_file)
1021 .unwrap_err_or_panic("an unrelated exemption must not reopen `.env` files");
1022 }
1023
1024 #[test]
1025 fn ordinary_source_files_stay_readable_under_the_defaults() {
1026 let list = ReadDenylist::build(true, &[], &[]);
1027 let tmp = tempfile::tempdir().expect("tempdir");
1028 for ordinary in [
1029 "main.rs",
1030 "Cargo.toml",
1031 "README.md",
1032 ".gitignore",
1033 ".env.example",
1034 ] {
1035 let path = tmp.path().join(ordinary);
1036 assert!(
1037 list.check(&path).is_ok(),
1038 "{ordinary} must stay readable — a coding agent has to read the source tree"
1039 );
1040 }
1041 }
1042
1043 #[test]
1044 fn defaults_cover_ssh_and_cloud_credential_stores() {
1045 let Some(home) = dirs::home_dir() else {
1046 return;
1047 };
1048 let list = ReadDenylist::build(true, &[], &[]);
1049 for rel in [
1050 ".ssh/id_ed25519",
1051 ".aws/credentials",
1052 ".config/gcloud/x",
1053 ".netrc",
1054 ] {
1055 list.check(&home.join(rel))
1056 .unwrap_err_or_panic(&format!("~/{rel} should be denied by default"));
1057 }
1058 }
1059
1060 #[test]
1061 fn exempt_narrows_the_defaults_but_never_an_explicit_deny() {
1062 let Some(home) = dirs::home_dir() else {
1063 return;
1064 };
1065 let ssh = home.join(".ssh");
1066
1067 // Exempting the default rule reopens it.
1068 let exempted = ReadDenylist::build(true, &[], std::slice::from_ref(&ssh));
1069 assert!(
1070 exempted.check(&ssh.join("id_rsa")).is_ok(),
1071 "an exempted default must be readable again"
1072 );
1073
1074 // The same exemption must NOT reopen a path the user explicitly denied.
1075 let both =
1076 ReadDenylist::build(true, std::slice::from_ref(&ssh), std::slice::from_ref(&ssh));
1077 both.check(&ssh.join("id_rsa"))
1078 .unwrap_err_or_panic("deny must win over allow");
1079 }
1080
1081 #[test]
1082 fn defaults_can_be_turned_off_entirely() {
1083 let Some(home) = dirs::home_dir() else {
1084 return;
1085 };
1086 let list = ReadDenylist::build(false, &[], &[]);
1087 assert!(list.is_empty());
1088 assert!(list.check(&home.join(".ssh/id_rsa")).is_ok());
1089 }
1090
1091 #[test]
1092 fn subtree_paths_feed_the_os_wrappers_and_omit_the_filename_rule() {
1093 let tmp = tempfile::tempdir().expect("tempdir");
1094 let list = ReadDenylist::build(false, &[tmp.path().to_path_buf()], &[]);
1095 let paths = list.subtree_paths();
1096 assert_eq!(paths.len(), 1);
1097 assert_eq!(paths[0], normalize_lexically(tmp.path()));
1098 }
1099
1100 #[test]
1101 #[cfg(unix)]
1102 fn denial_message_names_the_rule_without_echoing_the_resolved_secret_path() {
1103 let tmp = tempfile::tempdir().expect("tempdir");
1104 let secret_dir = tmp.path().join("secrets");
1105 let workspace = tmp.path().join("workspace");
1106 std::fs::create_dir_all(&secret_dir).expect("mkdir");
1107 std::fs::create_dir_all(&workspace).expect("mkdir");
1108 std::fs::write(secret_dir.join("id_rsa"), "KEY").expect("write");
1109
1110 let link = workspace.join("notes.txt");
1111 std::os::unix::fs::symlink(secret_dir.join("id_rsa"), &link).expect("symlink");
1112
1113 let list = denylist_for(std::slice::from_ref(&secret_dir));
1114 let message = list.check(&link).expect_err("deny").message("read_file");
1115 assert!(message.contains("notes.txt"), "{message}");
1116 assert!(
1117 !message.contains("id_rsa"),
1118 "the refusal must not hand back the secret's real location: {message}"
1119 );
1120 assert!(
1121 message.contains("sandbox_read_denylist_exempt"),
1122 "{message}"
1123 );
1124 }
1125
1126 #[test]
1127 fn root_parent_traversal_does_not_escape_above_root() {
1128 // Spell the traversal from the current drive/root so the assertion
1129 // holds on Windows too: there `/../../etc` resolves against the cwd's
1130 // drive and normalizes to `D:\etc`, not a bare `/etc`.
1131 let cwd = std::env::current_dir().expect("cwd");
1132 let root: PathBuf = cwd
1133 .components()
1134 .take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
1135 .collect();
1136 let traversal = root.join("..").join("..").join("etc");
1137 assert_eq!(normalize_lexically(&traversal), root.join("etc"));
1138 if cfg!(unix) {
1139 assert_eq!(
1140 normalize_lexically(Path::new("/../../etc")),
1141 PathBuf::from("/etc")
1142 );
1143 }
1144 }
1145
1146 /// Hosted macOS CI hands `tempfile` a `/var/folders/...` directory that is
1147 /// really `/private/var/folders/...`; `canonicalize` and `current_dir`
1148 /// return the resolved spelling while the rule was written against the
1149 /// literal one, and no symlink test above fired. Build that shape
1150 /// explicitly so the regression is caught on every host, not only where
1151 /// `$TMPDIR` happens to be a symlink.
1152 #[test]
1153 #[cfg(unix)]
1154 fn rule_spelled_through_a_symlinked_root_matches_the_resolved_spelling() {
1155 let _cwd = lock_cwd();
1156 let tmp = tempfile::tempdir().expect("tempdir");
1157 let real_root = tmp.path().join("real");
1158 let denied = real_root.join("secrets");
1159 std::fs::create_dir_all(&denied).expect("mkdir");
1160 std::fs::write(denied.join("id_rsa"), "KEY").expect("write");
1161 let alias_root = tmp.path().join("alias");
1162 std::os::unix::fs::symlink(&real_root, &alias_root).expect("symlink");
1163
1164 // The rule names the alias, the way a `/var/...` tempdir rule does.
1165 let list = denylist_for(&[alias_root.join("secrets")]);
1166
1167 // A read spelled through the real directory is the same file.
1168 list.check(&denied.join("id_rsa"))
1169 .expect_err("the resolved spelling of a denied tree must be refused");
1170 // An innocuous symlink resolves to the real spelling, never the alias.
1171 let link = tmp.path().join("notes.txt");
1172 std::os::unix::fs::symlink(denied.join("id_rsa"), &link).expect("symlink");
1173 list.check(&link)
1174 .expect_err("a symlink into the denied tree must be refused by its target");
1175 // A relative read from inside it absolutizes against the real cwd.
1176 let prior = std::env::current_dir().expect("cwd");
1177 struct Restore(std::path::PathBuf);
1178 impl Drop for Restore {
1179 fn drop(&mut self) {
1180 let _ = std::env::set_current_dir(&self.0);
1181 }
1182 }
1183 let _restore = Restore(prior);
1184 std::env::set_current_dir(&denied).expect("chdir into the denied tree");
1185 list.check(Path::new("id_rsa"))
1186 .expect_err("a relative read from inside the denied tree must be refused");
1187 // And the innocent sibling of the real directory stays readable.
1188 let sibling = real_root.join("notes.md");
1189 std::fs::write(&sibling, "notes").expect("write");
1190 assert!(list.check(&sibling).is_ok(), "sibling must stay readable");
1191 }
1192
1193 /// A denial names the path as requested, so on macOS it may say
1194 /// `/private/etc/sudoers`; exempting that spelling must reopen the
1195 /// `/etc/sudoers` rule it resolves from, and the literal spelling must
1196 /// keep working too. Unrelated defaults stay armed either way.
1197 #[cfg(target_os = "macos")]
1198 #[test]
1199 fn exempting_either_spelling_of_a_symlinked_default_rule_reopens_it() {
1200 for exempt in ["/private/etc/sudoers", "/etc/sudoers"] {
1201 let list = ReadDenylist::build(true, &[], &[PathBuf::from(exempt)]);
1202 assert!(
1203 list.check(Path::new("/etc/sudoers")).is_ok(),
1204 "exempting {exempt} must reopen the literal spelling"
1205 );
1206 assert!(
1207 list.check(Path::new("/private/etc/sudoers")).is_ok(),
1208 "exempting {exempt} must reopen the resolved spelling"
1209 );
1210 list.check(Path::new("/private/etc/ssh/ssh_host_ed25519_key"))
1211 .unwrap_err_or_panic("an unrelated default rule stays armed");
1212 }
1213 }
1214
1215 /// On macOS `/etc` is a symlink to `/private/etc`, so the machine-wide
1216 /// default rules were readable under their real names.
1217 #[cfg(target_os = "macos")]
1218 #[test]
1219 fn macos_private_spelling_of_a_machine_wide_rule_is_refused() {
1220 let list = ReadDenylist::build(true, &[], &[]);
1221 list.check(Path::new("/private/etc/sudoers"))
1222 .unwrap_err_or_panic("/private/etc/sudoers is /etc/sudoers");
1223 list.check(Path::new("/private/etc/ssh/ssh_host_ed25519_key"))
1224 .unwrap_err_or_panic("/private/etc/ssh is /etc/ssh");
1225 assert!(
1226 list.check(Path::new("/private/etc/hosts")).is_ok(),
1227 "/etc/hosts is not a credential store"
1228 );
1229 }
1230
1231 // Small helper so the intent of a "must be denied" assertion reads clearly.
1232 trait ExpectDenied {
1233 fn unwrap_err_or_panic(self, message: &str);
1234 }
1235 impl ExpectDenied for Result<(), ReadDenial> {
1236 fn unwrap_err_or_panic(self, message: &str) {
1237 assert!(self.is_err(), "{message}");
1238 }
1239 }
1240 }
1241
1241 lines RUST