| 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(¬e, "notes").expect("write"); |
| 766 | |
| 767 | let list = denylist_for(std::slice::from_ref(&denied)); |
| 768 | assert!( |
| 769 | list.check(¬e).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 |