safe-chains 0.223.0

Auto-allow safe bash commands in agentic coding tools
Documentation
# How It Works

## Built-in rules

safe-chains knows {{#include includes/command-count.md}} 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 directory the command wants to affect. Generally, commands are approved when they operate within the _current working directory_ (from where you are running the agent, like `~/projects/abc`). It also approves some read and write locations outside of that, like `/tmp`.

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!

```bash
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            # not approved: outside the project; you're prompted
cat ~/.ssh/id_rsa         # not approved: a credential
cp notes.txt /etc/x       # not approved: writing outside the project
```

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.

```toml
# 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.

```toml
# 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:

```bash
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](#installation.md), safe-chains parses this and validates every leaf:

1. **Pipeline segment 1:** `find src -name "*.rs" -exec grep -l "TODO" {} \;`
   - `find` is allowed with positional predicates
   - `-exec` triggers delegation: the inner command `grep -l "TODO" {}` is extracted and validated separately
   - `grep -l` passes (`-l` is an allowed flag)
2. **Pipeline segment 2:** `sort` passes
3. **Pipeline segment 3:** `while read f; do ...; done` is a compound command, parsed recursively:
   - `read f` passes (shell builtin)
   - `echo "=== $f ==="` passes
   - `grep -n "TODO" "$f"` passes (`-n` is 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](custom-commands.md) 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](configuration.md#cleaning-up-approved-commands).

For example, given `cargo test && npm run build && ./generate-docs.sh`:

- `cargo test` passes built-in rules
- `npm run build` matches `Bash(npm run:*)` from settings
- `./generate-docs.sh` matches `Bash(./generate-docs.sh:*)` from settings