返回 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 mut out = machine_wide_denied_subtrees();
413 if let Ok(Some(active)) = codewhale_paths::codewhale_home() {
414 out.push((
415 active.join("secrets"),
416 "Codewhale secret store (active home)",
417 ));
418 out.push((
419 active.join("credentials"),
420 "Codewhale OAuth credentials (active home)",
421 ));
422 }
423 if let Some(legacy) = codewhale_paths::legacy_deepseek_home() {
424 out.push((
425 legacy.join("secrets"),
426 "Codewhale secret store (legacy home)",
427 ));
428 out.push((
429 legacy.join("credentials"),
430 "Codewhale OAuth credentials (legacy home)",
431 ));
432 }
433 out.push((
434 crate::oauth::auth_file_path(),
435 "Codex CLI login (resolved auth file)",
436 ));
437 let Some(home) = dirs::home_dir() else {
438 return out;
439 };
440 let h = |rel: &str| home.join(rel);
441
442 let mut ambient = vec![
443 // --- SSH / GPG ---
444 (h(".ssh"), "SSH keys and known-hosts (~/.ssh)"),
445 (h(".gnupg"), "GnuPG keyring (~/.gnupg)"),
446 // --- Cloud provider credentials ---
447 (h(".aws"), "AWS credentials (~/.aws)"),
448 (
449 h(".config/gcloud"),
450 "Google Cloud credentials (~/.config/gcloud)",
451 ),
452 (h(".azure"), "Azure credentials (~/.azure)"),
453 (h(".kube"), "Kubernetes credentials (~/.kube)"),
454 (h(".oci"), "Oracle Cloud credentials (~/.oci)"),
455 (
456 h(".config/doctl"),
457 "DigitalOcean credentials (~/.config/doctl)",
458 ),
459 (h(".config/fly"), "Fly.io credentials (~/.config/fly)"),
460 (h(".vercel"), "Vercel credentials (~/.vercel)"),
461 (
462 h(".wrangler/config"),
463 "Cloudflare credentials (~/.wrangler/config)",
464 ),
465 (
466 h(".config/gh/hosts.yml"),
467 "GitHub CLI tokens (~/.config/gh/hosts.yml)",
468 ),
469 (
470 h(".config/glab-cli"),
471 "GitLab CLI tokens (~/.config/glab-cli)",
472 ),
473 // --- Package-registry and network credential files ---
474 (h(".netrc"), "netrc credentials (~/.netrc)"),
475 (h("_netrc"), "netrc credentials (~/_netrc)"),
476 (h(".pgpass"), "PostgreSQL password file (~/.pgpass)"),
477 (h(".my.cnf"), "MySQL credentials (~/.my.cnf)"),
478 (h(".npmrc"), "npm auth tokens (~/.npmrc)"),
479 (h(".pypirc"), "PyPI auth tokens (~/.pypirc)"),
480 (
481 h(".git-credentials"),
482 "stored git credentials (~/.git-credentials)",
483 ),
484 (
485 h(".cargo/credentials"),
486 "crates.io token (~/.cargo/credentials)",
487 ),
488 (
489 h(".cargo/credentials.toml"),
490 "crates.io token (~/.cargo/credentials.toml)",
491 ),
492 (
493 h(".docker/config.json"),
494 "Docker registry auth (~/.docker/config.json)",
495 ),
496 (
497 h(".m2/settings-security.xml"),
498 "Maven master password (~/.m2)",
499 ),
500 (
501 h(".gradle/gradle.properties"),
502 "Gradle credentials (~/.gradle/gradle.properties)",
503 ),
504 // --- Codewhale's own credential stores ---
505 // Duplicated with `tools::file::is_codewhale_credential_path` on
506 // purpose: that guard is scoped to the *active* config, this one is
507 // unconditional, and neither should depend on the other still existing.
508 (
509 h(".codewhale/secrets"),
510 "Codewhale secret store (~/.codewhale/secrets)",
511 ),
512 (
513 h(".deepseek/secrets"),
514 "Codewhale secret store (~/.deepseek/secrets)",
515 ),
516 (
517 h(".codewhale/credentials"),
518 "Codewhale OAuth credentials (ambient home)",
519 ),
520 (
521 h(".deepseek/credentials"),
522 "Codewhale OAuth credentials (ambient legacy home)",
523 ),
524 (h(".kimi/credentials"), "Kimi CLI credentials"),
525 (h(".claude/.credentials.json"), "Claude Code credentials"),
526 (
527 h(".codewhale-cu/recordings"),
528 "Computer Use recordings and trajectories",
529 ),
530 // --- Browser profiles (cookies, saved passwords, session tokens) ---
531 (h(".mozilla"), "Firefox profile (~/.mozilla)"),
532 (
533 h(".config/google-chrome"),
534 "Chrome profile (~/.config/google-chrome)",
535 ),
536 (
537 h(".config/chromium"),
538 "Chromium profile (~/.config/chromium)",
539 ),
540 (
541 h(".config/BraveSoftware"),
542 "Brave profile (~/.config/BraveSoftware)",
543 ),
544 ];
545
546 if cfg!(target_os = "macos") {
547 ambient.extend([
548 (
549 h("Library/Keychains"),
550 "macOS keychain (~/Library/Keychains)",
551 ),
552 (
553 h("Library/Application Support/Google/Chrome"),
554 "Chrome profile (~/Library/Application Support/Google/Chrome)",
555 ),
556 (
557 h("Library/Application Support/Firefox"),
558 "Firefox profile (~/Library/Application Support/Firefox)",
559 ),
560 (
561 h("Library/Application Support/BraveSoftware"),
562 "Brave profile (~/Library/Application Support/BraveSoftware)",
563 ),
564 (h("Library/Safari"), "Safari profile (~/Library/Safari)"),
565 (
566 h("Library/Cookies"),
567 "macOS cookie store (~/Library/Cookies)",
568 ),
569 ]);
570 }
571
572 out.extend(ambient);
573 out
574 }
575
576 fn machine_wide_denied_subtrees() -> Vec<(PathBuf, &'static str)> {
577 let mut out: Vec<(PathBuf, &'static str)> = vec![
578 (
579 PathBuf::from("/etc/shadow"),
580 "system password hashes (/etc/shadow)",
581 ),
582 (
583 PathBuf::from("/etc/sudoers"),
584 "sudoers policy (/etc/sudoers)",
585 ),
586 (PathBuf::from("/etc/ssh"), "system SSH host keys (/etc/ssh)"),
587 ];
588 if cfg!(target_os = "macos") {
589 out.push((
590 PathBuf::from("/Library/Keychains"),
591 "system keychain (/Library/Keychains)",
592 ));
593 }
594 out
595 }
596
597 // ---------------------------------------------------------------------------
598 // Path handling
599 //
600 // The evasion cases this has to survive are the whole reason the module exists;
601 // a deny-list a symlink walks around is theater.
602 // ---------------------------------------------------------------------------
603
604 /// Expand a leading `~` to the user's home directory.
605 fn expand_home_prefix(path: PathBuf) -> PathBuf {
606 let Some(text) = path.to_str() else {
607 return path;
608 };
609 if text == "~" {
610 return dirs::home_dir().unwrap_or(path);
611 }
612 if let Some(rest) = text.strip_prefix("~/")
613 && let Some(home) = dirs::home_dir()
614 {
615 return home.join(rest);
616 }
617 path
618 }
619
620 /// Fold `.` and `..` without touching the disk, and make the path absolute
621 /// against the current directory when it is relative.
622 ///
623 /// Purely lexical on purpose: this is the check that catches
624 /// `workspace/../../../.ssh/id_rsa` even when nothing on that path exists yet.
625 /// It is paired with — never a substitute for — `canonicalize_best_effort`,
626 /// which is what catches symlinks.
627 fn normalize_lexically(path: &Path) -> PathBuf {
628 let absolute = if path.is_absolute() {
629 path.to_path_buf()
630 } else {
631 std::env::current_dir()
632 .unwrap_or_else(|_| PathBuf::from("/"))
633 .join(path)
634 };
635
636 let mut out = PathBuf::new();
637 for component in absolute.components() {
638 match component {
639 Component::CurDir => {}
640 Component::ParentDir => {
641 // Never pop past the root: `/..` is `/`.
642 if out
643 .components()
644 .next_back()
645 .is_some_and(|c| !matches!(c, Component::RootDir | Component::Prefix(_)))
646 {
647 out.pop();
648 }
649 }
650 other => out.push(other.as_os_str()),
651 }
652 }
653 out
654 }
655
656 fn absolutize(path: &Path) -> PathBuf {
657 normalize_lexically(path)
658 }
659
660 /// Make a path absolute against the current directory WITHOUT folding `.` or
661 /// `..` components.
662 ///
663 /// Folding first is unsound: with `pub/link -> denied/sub`, the path
664 /// `pub/link/../secret` lexically becomes `pub/secret`, but the OS resolves the
665 /// symlink *before* applying `..` and really reads `denied/secret`. Keeping the
666 /// raw `..` components lets `fs::canonicalize` apply the secure order —
667 /// resolve each component, then let `..` pop the resolved result.
668 fn absolutize_raw(path: &Path) -> PathBuf {
669 if path.is_absolute() {
670 path.to_path_buf()
671 } else {
672 std::env::current_dir()
673 .unwrap_or_else(|_| PathBuf::from("/"))
674 .join(path)
675 }
676 }
677
678 /// Resolve symlinks as far as the filesystem allows.
679 ///
680 /// `canonicalize` fails on a path that does not exist, which is exactly the
681 /// case for a read of a file that is about to be created — and also the case an
682 /// evader would reach for. So on failure we walk up the RAW ancestor chain
683 /// (each surviving `..` included) to the deepest ancestor that *does* exist,
684 /// canonicalize that — resolving any symlinks, with the OS applying any `..`
685 /// components above it in the secure order — and re-append the remaining
686 /// components verbatim. A dropped `..` can only leave the candidate *deeper*
687 /// inside an already-resolved ancestor, which subtree matching still denies;
688 /// the old lexical-first fold popped symlinks out of existence instead.
689 fn canonicalize_best_effort(path: &Path) -> PathBuf {
690 let absolute = absolutize_raw(path);
691 if let Ok(resolved) = std::fs::canonicalize(&absolute) {
692 return resolved;
693 }
694
695 let mut suffix: Vec<OsString> = Vec::new();
696 let mut cursor = absolute.as_path();
697 loop {
698 let Some(parent) = cursor.parent() else {
699 return absolute;
700 };
701 if let Some(name) = cursor.file_name() {
702 suffix.push(name.to_os_string());
703 }
704 if let Ok(resolved) = std::fs::canonicalize(parent) {
705 let mut out = resolved;
706 for name in suffix.iter().rev() {
707 out.push(name);
708 }
709 return out;
710 }
711 cursor = parent;
712 }
713 }
714
715 /// Case-fold when — and only when — the platform's default filesystem is
716 /// case-insensitive. Folding on Linux would deny `~/.SSH` on a system where
717 /// that is a genuinely different directory.
718 fn fold_case_str(text: &str) -> String {
719 if cfg!(any(target_os = "macos", target_os = "windows")) {
720 text.to_lowercase()
721 } else {
722 text.to_string()
723 }
724 }
725
726 fn fold_component(component: &std::ffi::OsStr) -> OsString {
727 match component.to_str() {
728 Some(text) => OsString::from(fold_case_str(text)),
729 None => component.to_os_string(),
730 }
731 }
732
733 /// True when `candidate` is `root` itself or lives beneath it.
734 ///
735 /// Compared component by component, not by string prefix: `~/.awsome` must not
736 /// match the `~/.aws` rule, and `starts_with` on the raw strings says it does.
737 /// (`Path::starts_with` is already component-wise; the case folding is what
738 /// forces the manual walk.)
739 fn path_is_within(candidate: &Path, root: &Path) -> bool {
740 let mut root_components = root.components().map(|c| fold_component(c.as_os_str()));
741 let mut candidate_components = candidate
742 .components()
743 .map(|c| fold_component(c.as_os_str()));
744
745 loop {
746 match (root_components.next(), candidate_components.next()) {
747 (None, _) => return true,
748 (Some(_), None) => return false,
749 (Some(r), Some(c)) if r == c => {}
750 (Some(_), Some(_)) => return false,
751 }
752 }
753 }
754
755 #[cfg(test)]
756 mod tests {
757 use super::*;
758
759 fn denylist_for(paths: &[PathBuf]) -> ReadDenylist {
760 ReadDenylist::build(false, paths, &[])
761 }
762
763 // Two tests below move the process-wide cwd. libtest runs tests as
764 // parallel threads of one process, so they take this lock; nextest runs
765 // each test in its own process and never contends for it.
766 static CWD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
767
768 fn lock_cwd() -> std::sync::MutexGuard<'static, ()> {
769 CWD_LOCK
770 .lock()
771 .unwrap_or_else(|poisoned| poisoned.into_inner())
772 }
773
774 // Unix only: the fixture redirects the home directory through `HOME`,
775 // and Windows resolves the profile through the known-folder API instead.
776 #[cfg(unix)]
777 #[test]
778 fn sandbox_read_guard_denies_active_and_known_agent_credentials_in_real_file_tool() {
779 use crate::tools::spec::{ToolContext, ToolSpec};
780 let _env = crate::test_support::lock_test_env();
781 let temp = tempfile::tempdir().expect("private fixture");
782 let home = temp.path().join("home");
783 let active_home = temp.path().join("active");
784 let codex = temp.path().join("codex");
785 let _home = crate::test_support::EnvVarGuard::set("HOME", home.to_str().unwrap());
786 let _active =
787 crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", active_home.to_str().unwrap());
788 let _codex = crate::test_support::EnvVarGuard::set("CODEX_HOME", codex.to_str().unwrap());
789 let _auth = crate::test_support::EnvVarGuard::remove("OPENAI_CODEX_AUTH_FILE");
790 assert_eq!(dirs::home_dir().as_deref(), Some(home.as_path()));
791 let denied = [
792 active_home.join("credentials/fixture.json"),
793 codex.join("auth.json"),
794 home.join(".kimi/credentials/fixture.json"),
795 home.join(".claude/.credentials.json"),
796 home.join(".codewhale-cu/recordings/trajectories/fixture.jsonl"),
797 ];
798 for path in &denied {
799 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
800 std::fs::write(path, "read-s10-synthetic-value").unwrap();
801 }
802 struct RestoreActive(Option<Arc<ReadDenylist>>);
803 impl Drop for RestoreActive {
804 fn drop(&mut self) {
805 *ACTIVE.write().unwrap() = self.0.take();
806 }
807 }
808 let previous = ACTIVE.write().unwrap().take();
809 let _restore = RestoreActive(previous);
810 set_active(ReadDenylist::build(true, &[], &[]));
811 let runtime = tokio::runtime::Builder::new_current_thread()
812 .enable_all()
813 .build()
814 .unwrap();
815 let context = ToolContext::new(temp.path());
816 for path in denied {
817 let result = runtime.block_on(
818 crate::tools::file::ReadFileTool
819 .execute(serde_json::json!({"path": path}), &context),
820 );
821 assert!(result.is_err(), "file tool read a credential source");
822 }
823 let safe = home.join(".claude/settings.json");
824 std::fs::write(&safe, "ordinary-readable-setting").unwrap();
825 let allowed = runtime
826 .block_on(
827 crate::tools::file::ReadFileTool
828 .execute(serde_json::json!({"path": safe}), &context),
829 )
830 .expect("ordinary settings remain readable");
831 assert!(allowed.content.contains("ordinary-readable-setting"));
832 }
833
834 #[test]
835 fn empty_denylist_denies_nothing_and_reports_full_disk_read() {
836 let list = ReadDenylist::empty();
837 assert!(list.is_empty());
838 assert!(list.check(Path::new("/etc/hosts")).is_ok());
839 }
840
841 #[test]
842 fn direct_path_under_a_denied_root_is_refused() {
843 let secret = tempfile::tempdir().expect("tempdir");
844 let file = secret.path().join("id_ed25519");
845 std::fs::write(&file, "KEY").expect("write");
846
847 let list = denylist_for(&[secret.path().to_path_buf()]);
848 let denial = list.check(&file).expect_err("must deny");
849 assert_eq!(denial.requested, file);
850 assert!(!denial.via_symlink);
851 }
852
853 #[test]
854 fn sibling_with_a_shared_string_prefix_is_not_denied() {
855 // `~/.awsome` must not be caught by the `~/.aws` rule. This is the bug
856 // a naive string `starts_with` ships with.
857 let tmp = tempfile::tempdir().expect("tempdir");
858 let denied = tmp.path().join("aws");
859 let innocent = tmp.path().join("awsome");
860 std::fs::create_dir_all(&denied).expect("mkdir");
861 std::fs::create_dir_all(&innocent).expect("mkdir");
862 let note = innocent.join("notes.md");
863 std::fs::write(&note, "notes").expect("write");
864
865 let list = denylist_for(std::slice::from_ref(&denied));
866 assert!(
867 list.check(&note).is_ok(),
868 "sibling prefix must stay readable"
869 );
870 }
871
872 #[test]
873 fn dot_dot_traversal_out_of_the_workspace_is_refused() {
874 let tmp = tempfile::tempdir().expect("tempdir");
875 let secret_dir = tmp.path().join("secrets");
876 let workspace = tmp.path().join("workspace");
877 std::fs::create_dir_all(&secret_dir).expect("mkdir");
878 std::fs::create_dir_all(&workspace).expect("mkdir");
879 let secret = secret_dir.join("token");
880 std::fs::write(&secret, "TOKEN").expect("write");
881
882 let list = denylist_for(std::slice::from_ref(&secret_dir));
883 let sneaky = workspace.join("..").join("secrets").join("token");
884 list.check(&sneaky)
885 .expect_err("`..` must not walk around the deny-list");
886 }
887
888 #[test]
889 #[cfg(unix)]
890 fn symlink_pointing_into_a_denied_tree_is_refused_by_its_target() {
891 let tmp = tempfile::tempdir().expect("tempdir");
892 let secret_dir = tmp.path().join("secrets");
893 let workspace = tmp.path().join("workspace");
894 std::fs::create_dir_all(&secret_dir).expect("mkdir");
895 std::fs::create_dir_all(&workspace).expect("mkdir");
896 let secret = secret_dir.join("id_rsa");
897 std::fs::write(&secret, "KEY").expect("write");
898
899 let link = workspace.join("harmless.txt");
900 std::os::unix::fs::symlink(&secret, &link).expect("symlink");
901
902 let list = denylist_for(std::slice::from_ref(&secret_dir));
903 let denial = list.check(&link).expect_err("symlink must not walk around");
904 assert!(denial.via_symlink, "denial should report the symlink hop");
905 assert!(denial.message("read_file").contains("symlink"));
906 }
907
908 #[test]
909 #[cfg(unix)]
910 fn symlinked_parent_directory_is_refused() {
911 // The link is on a *directory* in the middle of the path, not the leaf.
912 let tmp = tempfile::tempdir().expect("tempdir");
913 let secret_dir = tmp.path().join("secrets");
914 let workspace = tmp.path().join("workspace");
915 std::fs::create_dir_all(&secret_dir).expect("mkdir");
916 std::fs::create_dir_all(&workspace).expect("mkdir");
917 std::fs::write(secret_dir.join("token"), "TOKEN").expect("write");
918
919 let link_dir = workspace.join("data");
920 std::os::unix::fs::symlink(&secret_dir, &link_dir).expect("symlink");
921
922 let list = denylist_for(std::slice::from_ref(&secret_dir));
923 list.check(&link_dir.join("token"))
924 .expect_err("symlinked parent must not walk around");
925 }
926
927 /// F5 regression: `..` must be applied by the OS *after* resolving each
928 /// symlink component, never folded lexically first. With
929 /// `pub/link -> denied/sub`, the path `pub/link/../secret` really reads
930 /// `denied/secret`; the old lexical-first fold produced `pub/secret` and
931 /// the check returned Ok while the read sailed through.
932 #[test]
933 #[cfg(unix)]
934 fn dot_dot_through_a_symlink_is_applied_after_symlink_resolution() {
935 let tmp = tempfile::tempdir().expect("tempdir");
936 let denied = tmp.path().join("denied");
937 std::fs::create_dir_all(denied.join("sub")).expect("mkdir");
938 std::fs::write(denied.join("sub").join("secret"), "TOKEN").expect("write");
939 let pub_dir = tmp.path().join("pub");
940 std::fs::create_dir_all(&pub_dir).expect("mkdir");
941 std::os::unix::fs::symlink(denied.join("sub"), pub_dir.join("link")).expect("symlink");
942
943 let list = denylist_for(std::slice::from_ref(&denied));
944 list.check(&pub_dir.join("link").join("..").join("secret"))
945 .expect_err("`..` through a symlink must not walk around the deny-list");
946
947 // Same evasion with a not-yet-existing leaf: the direct canonicalize
948 // fails, so the raw-ancestor walk has to carry the check.
949 list.check(&pub_dir.join("link").join("..").join("not-yet"))
950 .expect_err("the raw-ancestor walk must resolve the symlink before `..`");
951 }
952
953 /// A chain of symlinks (link → link → secret) must be followed to the
954 /// final target, not just one hop. Integrator defeat attempt: indirection
955 /// depth is not a bypass.
956 #[test]
957 #[cfg(unix)]
958 fn symlink_chains_resolve_to_the_denied_target() {
959 let tmp = tempfile::tempdir().expect("tempdir");
960 let denied = tmp.path().join("denied");
961 std::fs::create_dir_all(&denied).expect("mkdir");
962 std::fs::write(denied.join("id_ed25519"), "KEY").expect("write");
963 std::os::unix::fs::symlink(denied.join("id_ed25519"), tmp.path().join("hop2"))
964 .expect("symlink");
965 std::os::unix::fs::symlink(tmp.path().join("hop2"), tmp.path().join("hop1"))
966 .expect("symlink");
967 let list = denylist_for(std::slice::from_ref(&denied));
968 list.check(&tmp.path().join("hop1"))
969 .expect_err("a symlink chain must resolve to the denied target");
970 }
971
972 /// A relative read issued with the process cwd INSIDE a denied subtree
973 /// must be refused: the absolutization against cwd lands under the rule.
974 /// (Serialized with the other cwd-moving test; restores cwd regardless.)
975 #[test]
976 fn relative_read_from_inside_a_denied_tree_is_refused() {
977 let _cwd = lock_cwd();
978 let tmp = tempfile::tempdir().expect("tempdir");
979 let denied = tmp.path().join("denied");
980 std::fs::create_dir_all(&denied).expect("mkdir");
981 std::fs::write(denied.join("id_ed25519"), "KEY").expect("write");
982 let prior = std::env::current_dir().expect("cwd");
983 struct Restore(std::path::PathBuf);
984 impl Drop for Restore {
985 fn drop(&mut self) {
986 let _ = std::env::set_current_dir(&self.0);
987 }
988 }
989 let _restore = Restore(prior);
990 std::env::set_current_dir(&denied).expect("chdir into the denied tree");
991
992 let list = denylist_for(std::slice::from_ref(&denied));
993 list.check(Path::new("id_ed25519"))
994 .expect_err("a relative read from inside the denied tree must be refused");
995 list.check(Path::new("./id_ed25519"))
996 .expect_err("the dotted spelling must not differ from the bare one");
997 }
998
999 /// Separator noise must not dodge matching: repeated slashes collapse and a
1000 /// trailing slash never changes which subtree a path belongs to.
1001 #[test]
1002 fn double_slash_and_trailing_slash_variants_are_refused() {
1003 let tmp = tempfile::tempdir().expect("tempdir");
1004 let secret_dir = tmp.path().join("secrets");
1005 std::fs::create_dir_all(&secret_dir).expect("mkdir");
1006 std::fs::write(secret_dir.join("token"), "TOKEN").expect("write");
1007
1008 let list = denylist_for(std::slice::from_ref(&secret_dir));
1009 let root = secret_dir.parent().expect("parent");
1010 let double = PathBuf::from(format!("{}/secrets//token", root.display()));
1011 list.check(&double)
1012 .expect_err("double slashes must not walk around the deny-list");
1013 let trailing = PathBuf::from(format!("{}/secrets/", root.display()));
1014 list.check(&trailing)
1015 .expect_err("a trailing slash must not walk around the deny-list");
1016 // And the `.env` filename rule is name-based, so a trailing slash on it
1017 // still leaves the file name intact.
1018 let list = ReadDenylist::build(true, &[], &[]);
1019 let env_dir = tmp.path().join("nested");
1020 std::fs::create_dir_all(&env_dir).expect("mkdir");
1021 list.check(&env_dir.join(".env"))
1022 .unwrap_err_or_panic("`.env` under any directory is denied by name");
1023 }
1024
1025 /// The real attack spelling on macOS: `~/.SSH/ID_RSA` on a case-insensitive
1026 /// filesystem is `~/.ssh/id_rsa`. (The tempdir sibling above covers the
1027 /// mechanism; this one pins the default rule itself.)
1028 #[cfg(target_os = "macos")]
1029 #[test]
1030 fn macos_case_variation_of_the_default_ssh_rule_is_refused() {
1031 let Some(home) = dirs::home_dir() else {
1032 return;
1033 };
1034 if !home.join(".ssh").is_dir() {
1035 // Nothing to match against; skip rather than fake a pass.
1036 return;
1037 }
1038 let list = ReadDenylist::build(true, &[], &[]);
1039 list.check(&home.join(".SSH").join("ID_RSA"))
1040 .expect_err("~/.SSH/ID_RSA is ~/.ssh/id_rsa on a case-insensitive filesystem");
1041 }
1042
1043 #[cfg(any(target_os = "macos", target_os = "windows"))]
1044 #[test]
1045 fn case_variation_is_refused_on_case_insensitive_filesystems() {
1046 let tmp = tempfile::tempdir().expect("tempdir");
1047 let secret_dir = tmp.path().join("Secrets");
1048 std::fs::create_dir_all(&secret_dir).expect("mkdir");
1049 std::fs::write(secret_dir.join("id_rsa"), "KEY").expect("write");
1050
1051 let list = denylist_for(std::slice::from_ref(&secret_dir));
1052 let shouted = tmp.path().join("SECRETS").join("ID_RSA");
1053 list.check(&shouted)
1054 .expect_err("case variation must not walk around on a case-insensitive FS");
1055 }
1056
1057 #[test]
1058 fn nonexistent_path_under_a_denied_root_is_still_refused() {
1059 // canonicalize() fails here; the deepest-existing-ancestor walk is what
1060 // has to carry the check.
1061 let tmp = tempfile::tempdir().expect("tempdir");
1062 let secret_dir = tmp.path().join("secrets");
1063 std::fs::create_dir_all(&secret_dir).expect("mkdir");
1064
1065 let list = denylist_for(std::slice::from_ref(&secret_dir));
1066 list.check(&secret_dir.join("not-created-yet").join("key"))
1067 .expect_err("a not-yet-existing path under a denied root must still be denied");
1068 }
1069
1070 #[test]
1071 fn env_files_are_denied_but_committed_placeholders_are_not() {
1072 let list = ReadDenylist::build(true, &[], &[]);
1073 let tmp = tempfile::tempdir().expect("tempdir");
1074
1075 for denied in [".env", ".env.local", ".env.production"] {
1076 let path = tmp.path().join(denied);
1077 list.check(&path)
1078 .unwrap_err_or_panic(&format!("{denied} should be denied"));
1079 }
1080 for allowed in [".env.example", ".env.sample", ".env.template", ".env.dist"] {
1081 let path = tmp.path().join(allowed);
1082 assert!(
1083 list.check(&path).is_ok(),
1084 "{allowed} is a committed placeholder and must stay readable"
1085 );
1086 }
1087 }
1088
1089 /// F3 regression: exempting `.env` used to compare a *normalized absolute*
1090 /// path against the bare string `.env`, which could never match — the
1091 /// exemption was dead code. The rule is name-shaped, so any exempt entry
1092 /// whose file name is `.env` must disable it.
1093 #[test]
1094 fn exempting_env_by_name_disables_the_env_file_rule() {
1095 let tmp = tempfile::tempdir().expect("tempdir");
1096 let env_file = tmp.path().join(".env");
1097 std::fs::write(&env_file, "SECRET=1\n").expect("write");
1098
1099 // Bare `.env` — the spelling the docs advertise.
1100 let list = ReadDenylist::build(true, &[], &[PathBuf::from(".env")]);
1101 assert!(
1102 list.check(&env_file).is_ok(),
1103 "exempting `.env` must disable the env-file rule everywhere"
1104 );
1105
1106 // Any path ending in `/.env` — e.g. `~/.env` or `project/.env`.
1107 let home_spelled = dirs::home_dir().map(|h| h.join(".env"));
1108 let exempt_path = home_spelled.as_deref().unwrap_or(env_file.as_path());
1109 let list = ReadDenylist::build(true, &[], &[exempt_path.to_path_buf()]);
1110 assert!(
1111 list.check(&env_file).is_ok(),
1112 "an exempt entry named `.env` must disable the whole env-file rule"
1113 );
1114
1115 // An exemption for anything else must leave the rule armed.
1116 let unrelated = tmp.path().join("notes");
1117 let list = ReadDenylist::build(true, &[], std::slice::from_ref(&unrelated));
1118 list.check(&env_file)
1119 .unwrap_err_or_panic("an unrelated exemption must not reopen `.env` files");
1120 }
1121
1122 #[test]
1123 fn ordinary_source_files_stay_readable_under_the_defaults() {
1124 let list = ReadDenylist::build(true, &[], &[]);
1125 let tmp = tempfile::tempdir().expect("tempdir");
1126 for ordinary in [
1127 "main.rs",
1128 "Cargo.toml",
1129 "README.md",
1130 ".gitignore",
1131 ".env.example",
1132 ] {
1133 let path = tmp.path().join(ordinary);
1134 assert!(
1135 list.check(&path).is_ok(),
1136 "{ordinary} must stay readable — a coding agent has to read the source tree"
1137 );
1138 }
1139 }
1140
1141 #[test]
1142 fn defaults_cover_ssh_and_cloud_credential_stores() {
1143 let Some(home) = dirs::home_dir() else {
1144 return;
1145 };
1146 let list = ReadDenylist::build(true, &[], &[]);
1147 for rel in [
1148 ".ssh/id_ed25519",
1149 ".aws/credentials",
1150 ".config/gcloud/x",
1151 ".netrc",
1152 ] {
1153 list.check(&home.join(rel))
1154 .unwrap_err_or_panic(&format!("~/{rel} should be denied by default"));
1155 }
1156 }
1157
1158 #[test]
1159 fn exempt_narrows_the_defaults_but_never_an_explicit_deny() {
1160 let Some(home) = dirs::home_dir() else {
1161 return;
1162 };
1163 let ssh = home.join(".ssh");
1164
1165 // Exempting the default rule reopens it.
1166 let exempted = ReadDenylist::build(true, &[], std::slice::from_ref(&ssh));
1167 assert!(
1168 exempted.check(&ssh.join("id_rsa")).is_ok(),
1169 "an exempted default must be readable again"
1170 );
1171
1172 // The same exemption must NOT reopen a path the user explicitly denied.
1173 let both =
1174 ReadDenylist::build(true, std::slice::from_ref(&ssh), std::slice::from_ref(&ssh));
1175 both.check(&ssh.join("id_rsa"))
1176 .unwrap_err_or_panic("deny must win over allow");
1177 }
1178
1179 #[test]
1180 fn defaults_can_be_turned_off_entirely() {
1181 let Some(home) = dirs::home_dir() else {
1182 return;
1183 };
1184 let list = ReadDenylist::build(false, &[], &[]);
1185 assert!(list.is_empty());
1186 assert!(list.check(&home.join(".ssh/id_rsa")).is_ok());
1187 }
1188
1189 #[test]
1190 fn subtree_paths_feed_the_os_wrappers_and_omit_the_filename_rule() {
1191 let tmp = tempfile::tempdir().expect("tempdir");
1192 let list = ReadDenylist::build(false, &[tmp.path().to_path_buf()], &[]);
1193 let paths = list.subtree_paths();
1194 assert_eq!(paths.len(), 1);
1195 assert_eq!(paths[0], normalize_lexically(tmp.path()));
1196 }
1197
1198 #[test]
1199 #[cfg(unix)]
1200 fn denial_message_names_the_rule_without_echoing_the_resolved_secret_path() {
1201 let tmp = tempfile::tempdir().expect("tempdir");
1202 let secret_dir = tmp.path().join("secrets");
1203 let workspace = tmp.path().join("workspace");
1204 std::fs::create_dir_all(&secret_dir).expect("mkdir");
1205 std::fs::create_dir_all(&workspace).expect("mkdir");
1206 std::fs::write(secret_dir.join("id_rsa"), "KEY").expect("write");
1207
1208 let link = workspace.join("notes.txt");
1209 std::os::unix::fs::symlink(secret_dir.join("id_rsa"), &link).expect("symlink");
1210
1211 let list = denylist_for(std::slice::from_ref(&secret_dir));
1212 let message = list.check(&link).expect_err("deny").message("read_file");
1213 assert!(message.contains("notes.txt"), "{message}");
1214 assert!(
1215 !message.contains("id_rsa"),
1216 "the refusal must not hand back the secret's real location: {message}"
1217 );
1218 assert!(
1219 message.contains("sandbox_read_denylist_exempt"),
1220 "{message}"
1221 );
1222 }
1223
1224 #[test]
1225 fn root_parent_traversal_does_not_escape_above_root() {
1226 // Spell the traversal from the current drive/root so the assertion
1227 // holds on Windows too: there `/../../etc` resolves against the cwd's
1228 // drive and normalizes to `D:\etc`, not a bare `/etc`.
1229 let cwd = std::env::current_dir().expect("cwd");
1230 let root: PathBuf = cwd
1231 .components()
1232 .take_while(|c| matches!(c, Component::Prefix(_) | Component::RootDir))
1233 .collect();
1234 let traversal = root.join("..").join("..").join("etc");
1235 assert_eq!(normalize_lexically(&traversal), root.join("etc"));
1236 if cfg!(unix) {
1237 assert_eq!(
1238 normalize_lexically(Path::new("/../../etc")),
1239 PathBuf::from("/etc")
1240 );
1241 }
1242 }
1243
1244 /// Hosted macOS CI hands `tempfile` a `/var/folders/...` directory that is
1245 /// really `/private/var/folders/...`; `canonicalize` and `current_dir`
1246 /// return the resolved spelling while the rule was written against the
1247 /// literal one, and no symlink test above fired. Build that shape
1248 /// explicitly so the regression is caught on every host, not only where
1249 /// `$TMPDIR` happens to be a symlink.
1250 #[test]
1251 #[cfg(unix)]
1252 fn rule_spelled_through_a_symlinked_root_matches_the_resolved_spelling() {
1253 let _cwd = lock_cwd();
1254 let tmp = tempfile::tempdir().expect("tempdir");
1255 let real_root = tmp.path().join("real");
1256 let denied = real_root.join("secrets");
1257 std::fs::create_dir_all(&denied).expect("mkdir");
1258 std::fs::write(denied.join("id_rsa"), "KEY").expect("write");
1259 let alias_root = tmp.path().join("alias");
1260 std::os::unix::fs::symlink(&real_root, &alias_root).expect("symlink");
1261
1262 // The rule names the alias, the way a `/var/...` tempdir rule does.
1263 let list = denylist_for(&[alias_root.join("secrets")]);
1264
1265 // A read spelled through the real directory is the same file.
1266 list.check(&denied.join("id_rsa"))
1267 .expect_err("the resolved spelling of a denied tree must be refused");
1268 // An innocuous symlink resolves to the real spelling, never the alias.
1269 let link = tmp.path().join("notes.txt");
1270 std::os::unix::fs::symlink(denied.join("id_rsa"), &link).expect("symlink");
1271 list.check(&link)
1272 .expect_err("a symlink into the denied tree must be refused by its target");
1273 // A relative read from inside it absolutizes against the real cwd.
1274 let prior = std::env::current_dir().expect("cwd");
1275 struct Restore(std::path::PathBuf);
1276 impl Drop for Restore {
1277 fn drop(&mut self) {
1278 let _ = std::env::set_current_dir(&self.0);
1279 }
1280 }
1281 let _restore = Restore(prior);
1282 std::env::set_current_dir(&denied).expect("chdir into the denied tree");
1283 list.check(Path::new("id_rsa"))
1284 .expect_err("a relative read from inside the denied tree must be refused");
1285 // And the innocent sibling of the real directory stays readable.
1286 let sibling = real_root.join("notes.md");
1287 std::fs::write(&sibling, "notes").expect("write");
1288 assert!(list.check(&sibling).is_ok(), "sibling must stay readable");
1289 }
1290
1291 /// A denial names the path as requested, so on macOS it may say
1292 /// `/private/etc/sudoers`; exempting that spelling must reopen the
1293 /// `/etc/sudoers` rule it resolves from, and the literal spelling must
1294 /// keep working too. Unrelated defaults stay armed either way.
1295 #[cfg(target_os = "macos")]
1296 #[test]
1297 fn exempting_either_spelling_of_a_symlinked_default_rule_reopens_it() {
1298 for exempt in ["/private/etc/sudoers", "/etc/sudoers"] {
1299 let list = ReadDenylist::build(true, &[], &[PathBuf::from(exempt)]);
1300 assert!(
1301 list.check(Path::new("/etc/sudoers")).is_ok(),
1302 "exempting {exempt} must reopen the literal spelling"
1303 );
1304 assert!(
1305 list.check(Path::new("/private/etc/sudoers")).is_ok(),
1306 "exempting {exempt} must reopen the resolved spelling"
1307 );
1308 list.check(Path::new("/private/etc/ssh/ssh_host_ed25519_key"))
1309 .unwrap_err_or_panic("an unrelated default rule stays armed");
1310 }
1311 }
1312
1313 /// On macOS `/etc` is a symlink to `/private/etc`, so the machine-wide
1314 /// default rules were readable under their real names.
1315 #[cfg(target_os = "macos")]
1316 #[test]
1317 fn macos_private_spelling_of_a_machine_wide_rule_is_refused() {
1318 let list = ReadDenylist::build(true, &[], &[]);
1319 list.check(Path::new("/private/etc/sudoers"))
1320 .unwrap_err_or_panic("/private/etc/sudoers is /etc/sudoers");
1321 list.check(Path::new("/private/etc/ssh/ssh_host_ed25519_key"))
1322 .unwrap_err_or_panic("/private/etc/ssh is /etc/ssh");
1323 assert!(
1324 list.check(Path::new("/private/etc/hosts")).is_ok(),
1325 "/etc/hosts is not a credential store"
1326 );
1327 }
1328
1329 // Small helper so the intent of a "must be denied" assertion reads clearly.
1330 trait ExpectDenied {
1331 fn unwrap_err_or_panic(self, message: &str);
1332 }
1333 impl ExpectDenied for Result<(), ReadDenial> {
1334 fn unwrap_err_or_panic(self, message: &str) {
1335 assert!(self.is_err(), "{message}");
1336 }
1337 }
1338 }
1339
1339 lines RUST