safe-chains 0.210.2

Auto-allow safe bash commands in agentic coding tools
Documentation
# 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, secret } ────────────────────────────────────────
[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.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."
read_locus = "worktree-trusted"
write_locus = "machine"
reads_secret = false
pinned = true

[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"

# ── 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"

# ── 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"