How It Works
Built-in rules
safe-chains knows 1620 commands. For each one it validates specific subcommands and flags, allowing git log but not git push, allowing sed 's/foo/bar/' but not sed -i.
Files by location
When you run a bash command, in addition to checking the safety of the actual command, safe-chains checks the files the command wants to touch. Reads and writes are treated differently, and the difference is deliberate.
Reading is broad. Ordinary files read without a prompt wherever they live — your dotfiles, a sibling checkout, /etc/hosts, a vendored dependency’s source. Confining reads to the project cost a prompt on nearly everything an agent legitimately does, and protected a set of files that mostly are not secret. What stops a read instead is the file being a credential store — ~/.ssh, ~/.aws, ~/.npmrc, /etc/shadow and the rest — or being something that is not an ordinary file at all, like a raw disk or another process’s memory.
Writing stays close to home: your project, its sibling projects, and scratch (/tmp). Everything else prompts.
If you run an agent from a high-level directory like ~/, you give it a lot of power. This is the case whether or not you run safe-chains. Careful!
cat ./src/main.rs # approved: inside your working directory
echo hi > ./out.txt # approved: writing inside the project
grep -r TODO ./src # approved
cat /tmp/scratch.txt # approved: /tmp is scratch
cat /etc/hosts # approved: an ordinary file, read
cat ~/.zshrc # approved: your own config, read
cat ~/.ssh/id_rsa # not approved: a credential
cat /dev/mem # not approved: raw memory, not a file
cp notes.txt /etc/x # not approved: writing outside the project
Because the protection is now about which file, and not about where it sits, a command that reads files it never names is refused above your project — there is no name to check. That covers a recursive search (grep -r secret ~), a glob (cat /etc/*), a traversal (find ~ -exec cat {} \;) and a recursive copy (cp -r ~ ./backup). The same commands are fine inside your project, where the sweep is bounded by the directory you invited the agent into.
safe-chains allows reaching into sibling directories of the current working directory. E.g., when working in ~/projects/webapp, otherwise safe commands in ~/projects/mobileapp would be auto-approved, except deleting a sibling’s files. “Nephew” directories (e.g. ~/projects/mobileapp/android/config) are also approved. This does not apply when you’re working in children of user folders, root, etc.
Trusted directories
If you always want to allow reading and writing in additional directories, add them to ~/.config/safe-chains.toml with read = true and/or write = true. The binary will pick up these preferences. read and write are independent, so you can grant one without the other.
Most of the time you want write = true: ordinary reads already work everywhere. A read = true grant is still worth setting on a directory you want to search or copy wholesale — that is the case safe-chains otherwise refuses, because a sweep names no files, and granting the tree is exactly the statement that clears it.
# Work across every project under ~/projects, not just the current one
[[grant]]
path = "~/projects/"
read = true
write = true
# A scripts directory the agent both runs and edits
[[grant]]
path = "~/.runner-scripts/"
read = true
write = true
# Read a toolchain's install dir, but never let the agent write to it
[[grant]]
path = "~/.local/share/mise/"
read = true
A grant covers what it names
A grant covers the directory you name and everything under it. It does not reach into dot directories below that, because those are usually config and credentials that a broad grant should not sweep up. Name them to reach them.
# Covers ~/projects/app/src, but not ~/projects/app/.git or ~/projects/.ssh
[[grant]]
path = "~/projects"
read = true
write = true
# Covers ~/.ssh, because it names it
[[grant]]
path = "~/.ssh"
read = true
The same applies to credential stores. A grant on a parent directory never reaches ~/.ssh, ~/.aws or ~/Library/Keychains. A grant that names one does, and so does a grant on a path inside one.
Two things cannot be granted, however you name them. safe-chains will not auto-approve writes to its own config at ~/.config/safe-chains.toml, because an agent that could change that file could grant itself everything else. And it will not auto-approve writes to the files that decide who may log in and what they may do: /etc/passwd, /etc/sudoers, /etc/pam.d and the boot loader. Both stay readable. Ordinary files in /etc are not covered by this and can be granted normally.
Grants are read only from ~/.config/safe-chains.toml, never from a file inside a project. A project you have checked out cannot grant itself anything.
On macOS, ~/.ssh and ~/.SSH are the same directory, but a grant covers the spelling you write. Write the spelling you use. Protected paths work the other way around and match either spelling, so a case variant can never be used to slip past one.
Read approvals from ~/.claude/settings.json
If you use Claude Code, safe-chains also honors the file-read approvals already in your ~/.claude/settings.json. A permissions.allow entry such as Read(//Users/you/.local/share/mise/**) or Read(~/.gem/**) becomes a read-only trusted directory — the same effect as a [[grant]] with read = true, so you don’t have to declare a directory in two places. Only absolute (//…) and home (~/…) paths are honored; a bare “read anything” rule is not (grant that explicitly in safe-chains.toml if you really want it). Edit(…)/Write(…) rules are deliberately not turned into write grants — reads only — and the dotfile rule above still applies. A rule borrowed from Claude never reaches a credential store, even one that names it: Read(~/.ssh/**) was written to answer Claude’s permission prompt, and doesn’t say you want every command touching ~/.ssh auto-approved here. Grant it in safe-chains.toml if that’s what you want. Only your user-level ~/.claude/settings.json is read, never a project’s .claude/settings.json.
These rules count only when the harness is Claude Code. If you also run safe-chains under Codex, Cursor, Copilot or another tool, that tool gets safe-chains’ own classification and nothing borrowed from your Claude settings. A permission you granted to one agent is not a permission you granted to every agent, and on a harness that has no approval prompt of its own the difference is whether a command is blocked or simply runs.
Parsing example
Take this command from the introduction:
find src -name "*.rs" -exec grep -l "TODO" {} \; | sort | while read f; do echo "=== $f ==="; grep -n "TODO" "$f"; done
Normally, you would be prompted to run this by your agent, and you would have to run some sort of auto- or permission-skipping mode to not be prompted, which could allow anything to be run.
Running from a hook, safe-chains parses this and validates every leaf:
- Pipeline segment 1:
find src -name "*.rs" -exec grep -l "TODO" {} \;findis allowed with positional predicates-exectriggers delegation: the inner commandgrep -l "TODO" {}is extracted and validated separatelygrep -lpasses (-lis an allowed flag)
- Pipeline segment 2:
sortpasses - Pipeline segment 3:
while read f; do ...; doneis a compound command, parsed recursively:read fpasses (shell builtin)echo "=== $f ==="passesgrep -n "TODO" "$f"passes (-nis an allowed flag)
Every leaf is safe, so the entire command is auto-approved without over-extending permissions to the agent.
Interaction with approved commands
safe-chains runs as a pre-hook. If it approves, Claude Code skips the prompt. If it doesn’t recognize the command, Claude Code’s normal permission flow takes over (checking your Bash(...) patterns in settings, or prompting).
Where this gets interesting is chained commands. Claude Code matches approved patterns against the full command string. If you approved Bash(cargo test:*) and Claude runs cargo test && ./generate-docs.sh, Claude Code won’t match, since the full string isn’t just cargo test.
safe-chains splits the chain and checks each segment independently. cargo test passes built-in rules. ./generate-docs.sh matches Bash(./generate-docs.sh:*) from your settings. Both segments covered, chain auto-approved.
Once safe-chains is handling your safe commands, most of your existing approved patterns are redundant. Strip them down to project-specific scripts and tools safe-chains doesn’t know about, or write a Custom Command for those scripts and let safe-chains validate them with the same flag-level rules it applies to built-ins. See Cleaning up approved commands.
For example, given cargo test && npm run build && ./generate-docs.sh:
cargo testpasses built-in rulesnpm run buildmatchesBash(npm run:*)from settings./generate-docs.shmatchesBash(./generate-docs.sh:*)from settings