safe-chains 0.231.0

Auto-allow safe bash commands in agentic coding tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
# 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.
#   • WRITES stop at the workspace, its siblings and scratch. Everything else is `unknown`, whose
#     write face is `machine` — above where writes reach — so it denies and the harness prompts.
#
# READS are the asymmetry, and it is deliberate. They run to the machine rung: `/etc/hosts`,
# `~/.zshrc`, `/usr/bin/python3`, a sibling checkout and a vendored crate source all read without
# a prompt, because confining reads to the worktree cost a prompt on nearly every legitimate thing
# an agent does and bought protection only for files that mostly are not secret. What bounds a
# read is therefore this file's shield — a test on the NAME — plus three rungs that sit ABOVE
# machine because they are not ordinary files at all: `device` (raw disks and memory),
# `system-integrity` (the trust substrate) and `kernel`.
#
# The shield being a name test is what forces the rest of the model. A name test can only answer
# about a path someone actually wrote down, so anything that reads files it never names has to be
# refused wholesale above the workspace: an unbounded sweep (`grep -r foo ~`), a glob (`/etc/*`), a
# traversal's placeholder (`find ~ -exec cat {}`) and an unpinnable path (`$SECRET`, a `..` with no
# cwd to resolve against). Those are not special cases bolted on; they are the same rule.
#
# 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.raw-device]
description = "Raw block/char devices. Reading one is not reading a file — `/dev/rdisk0` is the whole disk and `/dev/mem` is physical memory, so both hand over every secret on the host in one operand. Sits at the `device` rung on both faces, which is ABOVE the machine rung ordinary local reads reach, so it stays refused when reads open up without needing to be enumerated as a secret."
read_locus = "device"
write_locus = "device"
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"
[[region]]
path = "/dev/"
role = "raw-device"
note = """
Everything else under /dev is a raw device until shown otherwise. The standard streams above are
Exact nodes and outrank this subtree, so they keep reading and writing normally.

Positive classification rather than a list of dangerous device names: `/dev/mem`, `/dev/kmem`,
`/dev/rdisk0`, `/dev/sda`, `/dev/nvme0n1` and every future spelling land here by default, and a
device that turns out to be ordinary can be given its own node. Enumerating the dangerous ones
would have to be complete to be worth anything.
"""
researched = "2026-08-12"
[[region]]
path = "/proc/*/environ"
role = "credential-store"
os = ["linux"]
note = "A process's environment block — where an agent's own API tokens and cloud keys live. Per-pid, so the component is a wildcard; /proc/self/environ matches it too."
researched = "2026-08-12"
[[region]]
path = "/proc/*/mem"
role = "credential-store"
os = ["linux"]
note = "A process's address space: every secret it has decrypted or been handed, readable as a file."
researched = "2026-08-12"
[[region]]
path = "/proc/*/cmdline"
role = "credential-store"
os = ["linux"]
note = "A process's argv, which routinely carries tokens passed as flags (`--password`, `--token`)."
researched = "2026-08-12"
# NOT shielded: safe-chains' own decision log at `~/.local/state/safe-chains/`.
#
# It was, for one release. The reasoning — it records command lines verbatim, so it accumulates
# whatever secrets appeared on one — is true and was the wrong call anyway. Logging is entirely
# opt-in. Someone who turns it on has already weighed that trade and did it in order to READ the
# result, so shielding the file removes the only reason it exists. Every question asked of it ("why
# was this denied", "what is prompting most") needs a manual approval, which is the feature
# defeating itself.
#
# The general rule stands and covers this: an ordinary file under `~` reads, a credential store
# does not. The log is not a credential store — it is a log the user asked for.

[[region]]
path = "/var/log/auth.log"
role = "credential-store"
os = ["linux"]
note = "Authentication log — sudo/su/ssh events, and passwords typed into a username prompt land here verbatim."
researched = "2026-08-12"
[[region]]
path = "/var/lib/mysql/"
role = "credential-store"
os = ["linux"]
note = "MySQL/MariaDB data directory. The `mysql.user` table lives here as a file, so the password hashes for every database account are readable straight off disk."
researched = "2026-08-12"
[[region]]
path = "/var/lib/postgresql/"
role = "credential-store"
os = ["linux"]
note = "PostgreSQL data directory — `pg_authid` carries the role password hashes, and pgpass/certs are commonly kept alongside."
researched = "2026-08-12"
[[region]]
path = "/var/log/secure"
role = "credential-store"
os = ["linux"]
note = "The RHEL/Fedora spelling of auth.log."
researched = "2026-08-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 = ".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"

# ── Credential DOTFILES. A prerequisite for admitting home dotfiles at all, not a follow-up. ──
#
# Home dotfiles are simultaneously the most ordinary read on the disk (`.zshrc`, `.gitconfig`) and
# the densest concentration of plaintext credentials on it. `home_role` currently excludes any hidden
# component for that reason — but measured, that exclusion protects nothing once local reads open:
# an excluded dotfile falls through to `unknown` → `machine`, and `machine` reads are exactly what
# the read policy admits. `cat ~/.git-credentials` ALLOWS with the cap lifted. These nodes are the
# only thing that will actually stand in front of them.
#
# SEGMENT matches for the bare filenames, so a project-local copy is shielded too — a repo `.npmrc`
# carries the same `_authToken` as the home one. Over-denying an innocent one is the accepted trade
# the shield header already states.
[[region]]
path = ".git-credentials"
role = "credential-store"
note = "git-credential-store's default file: one plaintext `https://user:pass@host` per line. Its XDG twin `~/.config/git/credentials` is already covered by the `~/.config/git/` node. Verified: git-scm.com/docs/git-credential-store."
researched = "2026-08-15"
[[region]]
path = ".npmrc"
role = "credential-store"
note = "npm config; holds `//registry.example/:_authToken=…` registry tokens. Segment-matched so a project-local .npmrc is shielded too — it carries the same token."
researched = "2026-08-15"
[[region]]
path = ".pypirc"
role = "credential-store"
note = "PyPI upload credentials — `username`/`password` or an API token per repository section."
researched = "2026-08-15"
[[region]]
path = ".pgpass"
role = "credential-store"
note = "libpq password file, `host:port:db:user:password` per line. Postgres refuses it unless mode 0600, which is the tell."
researched = "2026-08-15"
[[region]]
path = ".my.cnf"
role = "credential-store"
note = "MySQL client config; `[client] password=` is the documented way to avoid typing it."
researched = "2026-08-15"
[[region]]
path = ".dockercfg"
role = "credential-store"
note = "The pre-1.7 Docker registry auth file (base64 user:pass). Superseded by ~/.docker/config.json, which the `.docker` segment already shields, but still read by older tooling."
researched = "2026-08-15"
[[region]]
path = ".terraform.d"
role = "credential-store"
note = "Holds credentials.tfrc.json — the Terraform Cloud/Enterprise API token. A SEGMENT, like `.ssh`/`.aws`: written with a trailing slash it became a subtree PREFIX anchored at the start of the path, which matched nothing real and let `$(fd pat /work)/.terraform.d` out-permit the literal `/work/.terraform.d` — caught by substitution_is_never_more_permissive_than_a_path_it_could_produce."
researched = "2026-08-15"
[[region]]
path = "~/.azure/"
role = "credential-store"
note = "Azure CLI token cache (msal_token_cache.json / accessTokens.json) and the active subscription profile."
researched = "2026-08-15"
[[region]]
path = "~/.cargo/credentials"
role = "credential-store"
note = "crates.io registry token. Cargo writes `credentials.toml` since 1.68 but still reads and prefers the extensionless name when both exist, so BOTH are shielded. Deliberately exact paths, not a `.cargo` segment: the sibling `~/.cargo/registry/` is published crate source and stays readable. Verified: doc.rust-lang.org/cargo/reference/config.html."
researched = "2026-08-15"
[[region]]
path = "~/.cargo/credentials.toml"
role = "credential-store"
note = "As above — the current default spelling of the crates.io token file."
researched = "2026-08-15"
[[region]]
path = "~/.gem/credentials"
role = "credential-store"
note = "RubyGems `:rubygems_api_key:` — the push token."
researched = "2026-08-15"
[[region]]
path = "~/.m2/settings.xml"
role = "credential-store"
note = "Maven `<server><password>` entries for deploy repositories."
researched = "2026-08-15"
[[region]]
path = "~/.gradle/gradle.properties"
role = "credential-store"
note = "Conventional home for signing keys and repository credentials (`signing.password`, `*.apiKey`)."
researched = "2026-08-15"
[[region]]
path = "~/.composer/auth.json"
role = "credential-store"
note = "Composer HTTP-basic and OAuth tokens for private PHP package repositories."
researched = "2026-08-15"
# ── Second pass. The first list was assembled from memory and an adversarial probe found ten more
#    readable in one go — including `.boto`, which the accompanying TODO note had already WRITTEN
#    DOWN as missing. Recorded because it is the measurement that matters about this approach: the
#    shield is an enumeration, and an enumeration of "config files that hold credentials" trails the
#    ecosystem permanently. See TODO.md for the structural question that raises.
[[region]]
path = ".boto"
role = "credential-store"
note = "Legacy boto/gsutil config; holds aws_access_key_id / aws_secret_access_key and gs_oauth2_refresh_token."
researched = "2026-08-15"
[[region]]
path = ".vault-token"
role = "credential-store"
note = "HashiCorp Vault CLI token, written bare into the file by `vault login`. A live credential to everything the token can reach."
researched = "2026-08-15"
[[region]]
path = ".databrickscfg"
role = "credential-store"
note = "Databricks CLI profiles; `token = dapi…` personal access tokens."
researched = "2026-08-15"
[[region]]
path = ".authinfo"
role = "credential-store"
note = "Emacs/GNU auth-source netrc-format store — the `.netrc` of the Emacs world, same plaintext shape. (`.authinfo.gpg` is encrypted but shielded by the same segment.)"
researched = "2026-08-15"
[[region]]
path = "~/.pip/pip.conf"
role = "credential-store"
note = "pip config; `index-url = https://user:pass@host/simple` is the documented way to reach a private index."
researched = "2026-08-15"
[[region]]
path = "~/.kaggle/"
role = "credential-store"
note = "kaggle.json — username plus API key."
researched = "2026-08-15"
[[region]]
path = "~/.subversion/auth/"
role = "credential-store"
note = "Subversion's cached credentials; the simple/ provider stores passwords in cleartext."
researched = "2026-08-15"
[[region]]
path = "~/.oci/"
role = "credential-store"
note = "Oracle Cloud CLI config plus the API signing private key it points at."
researched = "2026-08-15"
[[region]]
path = "~/.snowflake/"
role = "credential-store"
note = "Snowflake CLI connections.toml — account passwords and private-key paths."
researched = "2026-08-15"
[[region]]
path = ".hgrc"
role = "credential-store"
note = "Mercurial config; the `[auth]` section holds per-prefix usernames and passwords in cleartext."
researched = "2026-08-15"

# ── 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/ssh/"
role = "credential-store"
note = """
Host private keys (`ssh_host_*_key`) — impersonating the host is exactly what they enable. The
whole directory, not the key files: node matching has no globs, and the readable half (sshd_config,
known_hosts) is worth little enough that shielding it costs nothing next to naming five key
patterns and missing the sixth.
"""
researched = "2026-08-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"