# Filesystem region model (minimal, post-retreat).
#
# safe-chains classifies COMMANDS; it is deliberately NOT a filesystem trust database. Modeling
# "what every path on every machine means" is unbounded, per-machine, and drifts — and an
# adversarial audit of the old broad admit map (public /etc, /proc, /usr, /var/log, /Library)
# found it leaking (macOS keychain, Homebrew service configs, auth logs). Filesystem enforcement
# belongs to a sandbox / the OS / the harness, not a static string classifier (same scope stance
# as $PATH and hostile checkouts — see AGENTS.md §0.2).
#
# So this file is TINY and does only what a static classifier can honestly do:
# • the WORKSPACE (relative paths under the harness cwd) is read/write — the agent was invited
# there. That's the default `worktree` role; no node needed.
# • a few `.git`/`.envrc`-style in-project files are read-safe but WRITE-frozen (a hook/direnv
# write is code-injection).
# • scratch (`/tmp`) and standard streams (`/dev/null`, fd pseudo-files) are read/write.
# • a small CREDENTIAL SHIELD (`.ssh`, `.aws`, …) is denied and — crucially — a user grant can
# NEVER widen it (see `apply_grant`), so `grant ~/` still can't hand over an SSH key.
# • EVERYTHING ELSE (any absolute or `~` path matching nothing) → `unknown` → deny/abstain.
# Reading `/etc/hosts` or `/usr/bin/python3` now prompts instead of auto-approving; a user
# who wants those adds a read grant to ~/.config/safe-chains.toml. This is the retreat: we
# stop guessing the filesystem and let the user's grants encode their own machine's truth.
#
# Matching (header rules unchanged): a `path` ending `/` = SUBTREE prefix; ending `*` = STRING
# prefix; no `/` = SEGMENT (matches any path component == it, at any depth); else EXACT. Most
# specific wins; fail-closed to `unknown`. Credential shields are SEGMENTS so they bite whatever
# the spelling (`~/.ssh/id_rsa`, a relative `.ssh/id_rsa`, a `.ssh/` inside a granted tree).
#
# PROVENANCE: every node keeps a `note` (why it earns its role) and a dated `researched`.
# ── Roles: role → { read face, write face, rebind face, secret, frozen } ───────────────────
#
# THREE faces, not two. `read_locus` and `write_locus` are the obvious ones. `rebind_locus` is the
# locus of an operation that changes what the NAME refers to rather than the bytes under it: `rm`
# and `rmdir` unbind it, `ln` points it elsewhere, `mv` takes it away. It defaults to the write
# face and only needs stating where the two must differ.
#
# `frozen` says which face a user GRANT may not widen:
# (absent) a grant widens every face
# frozen = "rebind" a grant may write INTO the node but never replace it
# frozen = "write" a grant may not write it at all, and therefore cannot rebind it either
#
# The distinction is load-bearing rather than decorative. Freezing the WRITE face of `~/.config` to
# stop `ln -s /tmp/evil ~/.config` also denied `cp x ~/.config`, which is an ordinary thing to do in
# a directory you granted. Freezing only the REBIND face keeps both answers right. It cannot be
# derived from the operation, because `cp x DIR` and `ln -s y DIR` are both `create`/`transfer` to
# the engine — `ln` declares `rebinds_destination` in its own TOML to separate them.
[role.worktree]
description = "The project tree (relative paths under the workspace). Read + write."
read_locus = "worktree"
write_locus = "worktree"
reads_secret = false
[role.worktree-trusted]
description = "In-project files another tool auto-executes (.git internals, .envrc). Read-safe; writes frozen (a hook/config write is code-injection)."
read_locus = "worktree-trusted"
write_locus = "worktree-trusted"
reads_secret = false
[role.scratch]
description = "Process-scoped temporary scratch."
read_locus = "temp"
write_locus = "temp"
reads_secret = false
[role.standard-stream]
description = "Standard streams / fd pseudo-files — no real filesystem is touched."
read_locus = "process"
write_locus = "process"
reads_secret = false
[role.package-content]
description = "Distributed package CONTENT — docs, headers, libraries, vendored module sources. Read-admitted; writes stay denied."
read_locus = "adjacent"
write_locus = "machine"
reads_secret = false
[role.credential-store]
description = "Known credential/secret stores. Denied, and a user grant may NOT widen them (the shield)."
read_locus = "machine"
write_locus = "machine"
reads_secret = true
[role.safe-chains-config]
description = "safe-chains' own config. Read-OK, but write DENIED and un-grantable — an agent must not rewrite the file that governs what it may do. The write face sits at `system-integrity`, not `machine`: this file IS the classifier's trust substrate, and a level that admits ordinary machine administration must not thereby admit rewriting its own ceiling."
read_locus = "worktree-trusted"
write_locus = "system-integrity"
reads_secret = false
frozen = "write"
[role.trust-source]
description = "A file safe-chains derives its own trust from: a harness settings file it reads allow-rules or read-approvals out of. Write DENIED and un-grantable — an agent that rewrites one of these decides what safe-chains approves next. Unlike safe-chains' own config the READ face stays machine, because a harness settings file can carry an `env` block with API keys. Its write face sits at `system-integrity` for the same reason safe-chains own config does: a level admitting ordinary machine administration must not thereby admit rewriting the file that decides what gets approved next."
read_locus = "machine"
write_locus = "system-integrity"
reads_secret = false
frozen = "write"
[role.trust-root-dir]
description = "The DIRECTORY a trust file lives in. Writing a file into it is ordinary and stays grantable; REBINDING it is not, because replacing the directory points the trust root at somewhere else entirely and every file pin beneath it becomes irrelevant. A grant on such a directory therefore permits `cp x ~/.config` and refuses `rm -rf ~/.config` and `ln -s /tmp/evil ~/.config`. The REBIND face sits at `system-integrity` so the freeze survives a raised level, while the write face stays at `machine` and remains grantable."
read_locus = "machine"
write_locus = "machine"
rebind_locus = "system-integrity"
reads_secret = false
frozen = "rebind"
[role.unknown]
description = "Fail-closed default: any absolute/home path matching no region. Deny read & write (the harness then prompts)."
read_locus = "machine"
write_locus = "machine"
reads_secret = false
# ── In-project trust carve-outs: read-safe, write-frozen (code-injection) ───────────────────
[[region]]
path = ".git"
role = "worktree-trusted"
note = "Git metadata dir; hooks/ and config execute code on git operations, so a write is code-injection. Nests at any depth."
researched = "2026-07-12"
[[region]]
path = ".envrc"
role = "worktree-trusted"
note = "direnv auto-executes .envrc on cd into its directory; a write plants code that runs on the next shell entry."
researched = "2026-07-12"
# ── Scratch + standard streams: read/write ─────────────────────────────────────────────────
[[region]]
path = "/tmp/"
role = "scratch"
note = "FHS world-writable temp; ephemeral scratch. Both OSes (macOS /tmp symlinks to /private/tmp)."
researched = "2026-07-12"
[[region]]
path = "/var/tmp/"
role = "scratch"
note = "FHS temp preserved across reboots; still process-throwaway in intent."
researched = "2026-07-12"
[[region]]
path = "/private/tmp/"
role = "scratch"
os = ["macos"]
note = "macOS's real temp directory; /tmp is a symlink to here."
researched = "2026-07-12"
[[region]]
path = "/private/var/tmp/"
role = "scratch"
os = ["macos"]
note = "macOS real location of /var/tmp."
researched = "2026-07-12"
[[region]]
path = "/dev/null"
role = "standard-stream"
note = "POSIX bit-bucket; discards writes, empty on read."
researched = "2026-07-12"
[[region]]
path = "/dev/stdout"
role = "standard-stream"
note = "POSIX stdout pseudo-file — the process's own output stream."
researched = "2026-07-12"
[[region]]
path = "/dev/stderr"
role = "standard-stream"
note = "POSIX stderr pseudo-file — the process's own error stream."
researched = "2026-07-12"
[[region]]
path = "/dev/tty"
role = "standard-stream"
note = "The controlling terminal; process-local I/O."
researched = "2026-07-12"
[[region]]
path = "/dev/fd/"
role = "standard-stream"
note = "Per-process file-descriptor pseudo-files (process substitution). Process-local."
researched = "2026-07-12"
# ── Package CONTENT: read-admitted, writes still denied. ───────────────────────────────────────
#
# The retreat deleted a broad admit map because an adversarial audit found it leaking — macOS
# `/Library/Keychains/System.keychain`, Homebrew `/usr/local/etc/*` service configs, `/var/log/*`
# auth tokens. That map failed because it admitted whole ROOTS, and a root is HETEROGENEOUS:
# `/usr/local` holds distributed package files next to machine-local config and state.
#
# These nodes cut at the layer the FHS already separates. `share`/`include`/`lib` is content a
# PACKAGE MANAGER put there; `etc` and `var` are this machine's config and state, which is where
# every audited leak lived. None of the three audit findings matches a prefix below: `/Library`
# is admitted only at `Developer/CommandLineTools/`, `etc` is never admitted at any root, and
# `/var` is not a root here at all.
#
# The argument for reading these at all: the content is public by construction. A crate README
# under `~/.cargo/registry` is published on crates.io; a man page ships in the OS. Refusing the
# local copy while the same bytes are a network fetch away is friction without a matching risk.
# WRITES stay denied — this widens disclosure only, not the ability to alter what runs.
[[region]]
path = "/usr/share/"
role = "package-content"
note = "OS-distributed docs, man pages, locale data. Sibling `/usr/etc` does not exist; config lives in /etc, which is NOT admitted."
researched = "2026-07-30"
[[region]]
path = "/usr/include/"
role = "package-content"
note = "System C headers — compiler input, no machine state."
researched = "2026-07-30"
[[region]]
path = "/usr/lib/"
role = "package-content"
note = "OS-distributed libraries."
researched = "2026-07-30"
[[region]]
path = "/usr/local/share/"
role = "package-content"
note = "Locally-installed package docs/data. Deliberately NOT /usr/local/etc, which the admit-audit found holding Homebrew service secrets."
researched = "2026-07-30"
[[region]]
path = "/usr/local/include/"
role = "package-content"
note = "Locally-installed headers."
researched = "2026-07-30"
[[region]]
path = "/usr/local/lib/"
role = "package-content"
note = "Locally-installed libraries."
researched = "2026-07-30"
[[region]]
path = "/opt/homebrew/share/"
role = "package-content"
note = "Homebrew package docs/data. NOT /opt/homebrew/etc (service configs) and NOT Cellar/, whose per-formula trees contain their own etc/."
researched = "2026-07-30"
[[region]]
path = "/opt/homebrew/include/"
role = "package-content"
note = "Homebrew headers."
researched = "2026-07-30"
[[region]]
path = "/opt/homebrew/lib/"
role = "package-content"
note = "Homebrew libraries and node_modules for globally-installed packages."
researched = "2026-07-30"
[[region]]
path = "/Library/Developer/CommandLineTools/"
role = "package-content"
note = "Apple SDK headers and toolchain sources. Scoped to Developer/ so it cannot reach /Library/Keychains, which the admit-audit found leaking."
researched = "2026-07-30"
[[region]]
path = "/nix/store/"
role = "package-content"
note = "Nix store paths are immutable, content-addressed and world-readable by design."
researched = "2026-07-30"
[[region]]
path = "~/.cargo/registry/"
role = "package-content"
note = "Vendored crate sources fetched from a registry. Scoped to registry/ so it cannot reach ~/.cargo/credentials.toml."
researched = "2026-07-30"
[[region]]
path = "~/.rustup/toolchains/"
role = "package-content"
note = "Installed Rust toolchains and their std sources."
researched = "2026-07-30"
[[region]]
path = "~/go/pkg/mod/"
role = "package-content"
note = "Go module cache — downloaded dependency sources."
researched = "2026-07-30"
[[region]]
path = "~/.nvm/versions/"
role = "package-content"
note = "nvm-installed node runtimes and their global node_modules."
researched = "2026-07-30"
[[region]]
path = "~/.local/bin/"
role = "package-content"
note = "Installed user-level executables (mise/pipx/cargo-install shims). Reading one — `strings`, `cat` on a wrapper script — discloses an installed tool, not machine state. Writes stay denied: this is where a planted binary would go."
researched = "2026-07-30"
[[region]]
path = "~/.local/share/mise/installs/"
role = "package-content"
note = "mise-installed tool versions. Scoped to installs/ so it cannot reach mise's own config or state."
researched = "2026-07-30"
# ── Credential shield: SEGMENT matches, denied everywhere, and NOT widenable by a grant. The
# only "denylist" we keep, and only because a broad grant (`grant ~/`) would otherwise hand
# over these. A project that literally contains one of these dirs is denied too — rare and
# acceptable. Everything else in home is already deny-by-default; these just can't be granted. ─
[[region]]
path = ".ssh"
role = "credential-store"
note = "SSH private keys / authorized_keys / known_hosts — credential + trust store."
researched = "2026-07-12"
[[region]]
path = ".aws"
role = "credential-store"
note = "AWS access keys / session credentials."
researched = "2026-07-12"
[[region]]
path = ".gnupg"
role = "credential-store"
note = "GnuPG private keyring and trust database."
researched = "2026-07-12"
[[region]]
path = ".kube"
role = "credential-store"
note = "Kubernetes kubeconfig — cluster credentials and tokens."
researched = "2026-07-12"
[[region]]
path = ".docker"
role = "credential-store"
note = "Docker registry auth tokens / credential-helper config."
researched = "2026-07-12"
[[region]]
path = ".netrc"
role = "credential-store"
note = "Plaintext machine login credentials for ftp/curl/git."
researched = "2026-07-12"
# ── System credential stores + other-user homes: absolute paths a broad grant (`grant /` or
# `grant /etc/`) would otherwise expose. Kept as un-grantable shields (the dotfile rule only
# covers hidden entries; these are not dotfiles). ──────────────────────────────────────────
[[region]]
path = "/etc/shadow"
role = "credential-store"
os = ["linux"]
note = "Hashed user passwords — the credential store proper."
researched = "2026-07-12"
[[region]]
path = "/etc/gshadow"
role = "credential-store"
os = ["linux"]
note = "Hashed group passwords."
researched = "2026-07-12"
[[region]]
path = "/etc/master.passwd"
role = "credential-store"
os = ["macos"]
note = "macOS password hash store."
researched = "2026-07-12"
[[region]]
path = "/etc/ssl/private/"
role = "credential-store"
note = "TLS/service private keys."
researched = "2026-07-12"
[[region]]
path = "/root/"
role = "credential-store"
os = ["linux"]
note = "root's home — privileged private data + credentials."
researched = "2026-07-12"
[[region]]
path = "/var/root/"
role = "credential-store"
os = ["macos"]
note = "macOS root home."
researched = "2026-07-12"
# Well-known credential stores NESTED under ~/.config (non-hidden, so the dotfile rule can't
# catch them under an explicit `grant ~/.config/`). Not exhaustive — an explicit grant of a
# config dir can still expose an unlisted app's secrets; the docs advise granting narrowly.
[[region]]
path = "~/.config/gh/"
role = "credential-store"
note = "GitHub CLI OAuth token (hosts.yml)."
researched = "2026-07-12"
[[region]]
path = "~/.config/gcloud/"
role = "credential-store"
note = "Google Cloud SDK credentials / tokens."
researched = "2026-07-12"
[[region]]
path = "~/.config/rclone/"
role = "credential-store"
note = "rclone remote credentials (rclone.conf)."
researched = "2026-07-12"
[[region]]
path = "~/.config/git/"
role = "credential-store"
note = "git's `store` credential helper — plaintext https://user:token@host."
researched = "2026-07-12"
# ── macOS ~/Library credential stores. The dotfile rule keeps a broad `grant ~/` from widening
# HIDDEN entries, but on macOS the keychain, browser password stores, cookies, and the iMessage
# DB live under ~/Library — NOT dot-prefixed — so a broad grant would otherwise sweep them up.
# Shielded (un-grantable) like the credential dotdirs. Not exhaustive (Application Support holds
# countless app secrets); the docs advise granting narrowly. ─────────────────────────────────
[[region]]
path = "~/Library/Keychains/"
role = "credential-store"
os = ["macos"]
note = "macOS keychain databases (login.keychain-db)."
researched = "2026-07-12"
[[region]]
path = "~/Library/Cookies/"
role = "credential-store"
os = ["macos"]
note = "Per-app HTTP cookie stores (session tokens)."
researched = "2026-07-12"
[[region]]
path = "~/Library/Safari/"
role = "credential-store"
os = ["macos"]
note = "Safari history / autofill / per-site data."
researched = "2026-07-12"
[[region]]
path = "~/Library/Messages/"
role = "credential-store"
os = ["macos"]
note = "iMessage database (chat.db) — private message history."
researched = "2026-07-12"
[[region]]
path = "~/Library/Application Support/Firefox/"
role = "credential-store"
os = ["macos"]
note = "Firefox profiles: logins.json + key4.db (offline-decryptable saved passwords)."
researched = "2026-07-12"
[[region]]
path = "~/Library/Application Support/Google/Chrome/"
role = "credential-store"
os = ["macos"]
note = "Chrome profile: Login Data / Cookies stores."
researched = "2026-07-12"
[[region]]
path = "~/Library/Application Support/Chromium/"
role = "credential-store"
os = ["macos"]
note = "Chromium profile credential/cookie stores."
researched = "2026-07-12"
[[region]]
path = "~/Library/Application Support/BraveSoftware/"
role = "credential-store"
os = ["macos"]
note = "Brave profile credential/cookie stores."
researched = "2026-07-12"
[[region]]
path = "~/Library/Application Support/Microsoft Edge/"
role = "credential-store"
os = ["macos"]
note = "Edge profile credential/cookie stores."
researched = "2026-07-12"
# ── safe-chains' own config: read-OK, write-DENIED, un-grantable (the trust root). An
# UNPROTECTED path (no harness blesses a third-party config location) — a stopgap until one
# exists; a write here is best-effort caught (a python/editor write escapes the command
# classifier). This is the ONLY location safe-chains reads its config from — `XDG_CONFIG_HOME`
# is not honored (an agent-mutable env var must not relocate the trust root). See the
# protected-config research item. ────────────────────────────────────────────────────────────
[[region]]
path = "~/.config/safe-chains.toml"
role = "safe-chains-config"
note = "safe-chains' user config (grants). Writing it would let an agent grant itself permissions."
researched = "2026-07-12"
[[region]]
path = "~/.claude/settings.json"
role = "trust-source"
note = "safe-chains reads `permissions.allow` command patterns AND Read() path approvals out of this file (src/allowlist.rs, regions.rs::claude_settings_read_grants), and the hook gates on the coverage those patterns produce. Writing it is therefore a way to decide what safe-chains approves next, exactly like writing safe-chains' own config."
researched = "2026-08-02"
# ── Trust-root DIRECTORIES. Freezing the trust FILES is not enough on its own: a grant on the
# parent let an agent delete the directory and replace it with a symlink to one it controls,
# after which safe-chains read its grants (and its `level` ceiling) from the agent's own file.
# Every step of that was auto-approved. safe-chains classifies a path by its literal spelling and
# does not follow symlinks (AGENTS.md §0.2), which is right for classification but means the
# RELOCATION has to be stopped where it happens, at the point the directory is replaced.
#
# EXACT matchers, so only the directory node itself is frozen. Files underneath are untouched and
# a `~/.config` grant still works normally for every other tool's config living there.
# `/etc` needs no equivalent: it already denies as `machine`.
[[region]]
path = "~/.config"
role = "trust-root-dir"
note = "Parent of safe-chains' own config. `rm -rf ~/.config` then `ln -s /tmp/evil ~/.config` relocated the whole trust root."
researched = "2026-08-02"
[[region]]
path = "~/.claude"
role = "trust-root-dir"
note = "Parent of the harness settings file safe-chains reads allow-rules from. Same relocation."
researched = "2026-08-02"
# ── System-integrity substrate: the machine's own identity/auth/boot/loader files, where a WRITE is
# compromise-complete — add a UID-0 user, grant yourself sudo, rewrite the auth/login path, redirect
# the dynamic loader, or change the boot path. The READ face is ordinary machine config; the WRITE
# face worst-cases to `system-integrity`, which sits ABOVE `machine` (and above local-admin), so
# owning the trust substrate is a yolo-level grant — distinct from ordinary machine admin (a service
# restart, an app-config edit). Deny-ward and fail-closed like the credential shield above: this is
# NOT an admit map (the auto-approve band already denies all of `machine`); it only splits the
# upper-level bands so "run the machine as admin" (local-admin) and "own the machine's trust root"
# (yolo) are different grants. The secret-READ stores (/etc/shadow, master.passwd) stay in the
# credential shield; these are the write-compromise files that are not themselves secrets. ────────
[role.system-integrity]
description = "The machine's identity/auth/boot/loader substrate. Readable as ordinary config; a WRITE is compromise-complete and worst-cases above local-admin (system-integrity)."
read_locus = "machine"
write_locus = "system-integrity"
reads_secret = false
[[region]]
path = "/etc/passwd"
role = "system-integrity"
note = "System account database; a write can add a UID-0 user — full compromise."
researched = "2026-07-16"
[[region]]
path = "/etc/group"
role = "system-integrity"
note = "System group database; a write can grant membership in privileged groups (wheel/sudo/admin)."
researched = "2026-07-16"
[[region]]
path = "/etc/sudoers"
role = "system-integrity"
note = "sudo policy; a write grants arbitrary root — the ultimate escalation."
researched = "2026-07-16"
[[region]]
path = "/etc/sudoers.d/"
role = "system-integrity"
note = "Drop-in sudo policy fragments; same grant-root power as /etc/sudoers."
researched = "2026-07-16"
[[region]]
path = "/etc/pam.d/"
role = "system-integrity"
note = "PAM authentication stack (Linux and macOS); a write rewrites how every login authenticates."
researched = "2026-07-16"
[[region]]
path = "/etc/ld.so.conf"
role = "system-integrity"
os = ["linux"]
note = "Dynamic-loader search config; a write can redirect every program to attacker libraries."
researched = "2026-07-16"
[[region]]
path = "/etc/ld.so.conf.d/"
role = "system-integrity"
os = ["linux"]
note = "Dynamic-loader search drop-ins; same library-hijack power as ld.so.conf."
researched = "2026-07-16"
[[region]]
path = "/etc/ld.so.preload"
role = "system-integrity"
os = ["linux"]
note = "Force-loaded shared objects for every dynamically-linked program — a machine-wide code-injection hook."
researched = "2026-07-16"
[[region]]
path = "/boot/"
role = "system-integrity"
os = ["linux"]
note = "Kernel images, initramfs, and bootloader config; a write changes what the machine boots."
researched = "2026-07-16"