Leash — For the Bots v1
This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.
Document snapshot
Leash — For the Bots
An operator manual for AI coding agents (Claude Code, Codex, and similar). Dense and action-oriented: install, configure, edit, and operate Leash from a shell. Every path, command, and field is real (from
products/leash/v1/). Humans: see README / HOWITWORKS.
Your mental model
Leash is a Linux-only, per-UID egress and action enforcer. You name one host user (the
leashed user, default fido), run the agent as that user, and Leash hard-allows /
hard-blocks that UID across four surfaces: network, filesystem, commands, tools. It
works because Leash runs privileged (root + CAP_SYS_ADMIN/CAP_NET_ADMIN) while the agent
is unprivileged. Enforcement is real, not cooperative (except tools):
- network — nftables NAT rule matches the leashed UID and redirects its outbound TCP to
a transparent proxy on
127.0.0.1:8443, which reads the destination fromHost:/TLS SNI (no decryption) and allows/resets. Falls back to iptables--uid-owner REDIRECT. - filesystem + commands — one fanotify permission daemon (
FAN_OPEN_PERM | FAN_OPEN_EXEC_PERM, mount-marked) checks each open/exec only for the leashed UID; a deny surfaces asEPERM. Other UIDs pass through untouched. - tools — a cooperative library (registry + regex input inspection for secrets/PII/priv-esc); only enforced if the harness (Sentinel) calls it.
Prerequisites & install
- Root Linux host, kernel ≥ 5.0 (for
FAN_OPEN_EXEC_PERM);nft(preferred) oriptables; Python ≥ 3.12;useradd,rsync,systemctl.
sudo bash install.sh # in products/leash/v1/
It: creates the leashed user (useradd --system --create-home --shell /usr/sbin/nologin fido; name via LEASH_USER), rsyncs to /opt/leash/v1 (venv .venv), writes the CLI
wrapper /usr/local/bin/leash, drops default config to /etc/leash/leash.yaml
(preserved on reinstall — new defaults go to leash.yaml.example), renders
/etc/systemd/system/leash.service, and offers to enable+start it. Override paths
with LEASH_INSTALL_{PREFIX,CONFIG,UNIT,BIN_DIR,PYTHON} / LEASH_USER.
sudo systemctl restart leash.service # apply changes
sudo bash uninstall.sh # removes everything EXCEPT the leashed user account
The unit runs python -m leash as root with AmbientCapabilities=CAP_SYS_ADMIN CAP_NET_ADMIN and — importantly — ExecStopPost=-/usr/sbin/nft delete table ip leash, so
stopping/crashing Leash tears down the redirect (the leashed user isn't blackholed at a dead
proxy).
Configuration — /etc/leash/leash.yaml
Override with -c/--config <path> or LEASH_CONFIG. Pydantic-validated. Each of the four
surfaces is a {mode, allow, deny} policy; mode is:
whitelist= default-deny; onlyallowentries pass.blacklist= default-allow; onlydenyentries are blocked.
user: fido # REQUIRED; UID resolved via pwd.getpwnam at load
fail_mode: closed # closed = deny on undecidable (recommended) | open = allow
network:
mode: whitelist # default-deny egress
allow: [github.com, api.openai.com] # exact/suffix domain, *.wildcard, IP/CIDR, or host:port
deny: []
filesystem:
mode: whitelist # default-deny opens (allow implicitly permits ancestor dirs)
allow: [/home/fido/work] # exact path, dir-prefix, or glob
deny: []
commands:
mode: blacklist # default-allow exec; matches by basename OR absolute path
allow: []
deny: [rm, ssh]
tools:
mode: whitelist
allow: [search, read_file]
deny: []
inspect: {secrets: block, pii: log} # categories: secrets|pii|priv_esc; actions: block|log|allow
logging:
level: info
decisions: /var/log/leash/decisions.jsonl # a path, or "stdout"
enforcers: {network: true, filesystem: true, tools: true} # NOTE: the filesystem toggle also covers commands
Matching: network domains match exact or suffix (github.com ⊇ api.github.com), IPs by
CIDR, optional host:port pin (bare host = any port). Commands match by basename or
absolute path only — a renamed/copied binary evades a deny (use whitelist for honest
command blocking).
The CLI (complete surface)
Entry: leash (/usr/local/bin/leash). Bare leash runs the service (what systemd
invokes) — don't run it by hand to "check" things. Global -c/--config <path>. Surface
aliases: network/net, filesystem/fs, commands/cmd, tools/tool. Every
allow/deny/mode/init auto-restarts leash.service.
| Command | Purpose |
|---|---|
leash init --user <u> | Record the leashed user (creates config if absent) + restart. |
leash status | Config path, user, fail_mode, each surface's mode + allow/deny counts, toggles, log sink. |
leash validate | Load + validate the config (<path>: ok). |
leash check <surface> <subject> | Dry-run a decision against live config → allow/block: <reason>. Network subject may be host:port. |
leash <surface> allow <entry> | Add to a surface's allow list (idempotent) + restart. |
leash <surface> deny <entry> | Add to a surface's deny list + restart. |
leash <surface> mode whitelist|blacklist | Set a surface's mode + restart. |
leash restart | Restart the service. |
leash logs [--follow] [--tail N] | Tail the JSONL decision log (default tail 200). |
Common tasks (recipes)
# Allow the leashed user to reach a host (network is default-deny whitelist)
leash net allow api.anthropic.com
leash net allow 10.0.0.0/8 # CIDR
leash net allow db.internal:5432 # pin a port
# Command control
leash cmd mode whitelist # honest exec control: only listed binaries run
leash cmd allow /usr/bin/python3.12
leash cmd mode blacklist # or default-allow with a denylist
leash cmd deny rm
# Inspect / dry-run before you commit
leash status
leash check network github.com:443
leash check commands /bin/rm
leash check filesystem /home/fido/work/x.txt
# Default-deny a user's egress entirely: whitelist mode, empty allow list
leash net mode whitelist
# Watch blocks live
leash logs --follow
# Apply a raw config edit (mutators auto-restart; if you edited the file yourself):
sudo systemctl restart leash.service
Key paths / where changes take effect
| Item | Value |
|---|---|
| Install prefix | /opt/leash/v1 (venv /opt/leash/v1/.venv) |
| Config | /etc/leash/leash.yaml (reference: .yaml.example) |
| CLI wrapper | /usr/local/bin/leash |
| Service | leash.service (/etc/systemd/system/leash.service) |
| Decision log | /var/log/leash/decisions.jsonl — {ts, ref, uid, surface, action, reason, subject}; only denials logged for fs/exec; secrets/PII masked |
| nftables table | ip leash (installed at runtime) · proxy 127.0.0.1:8443 |
| Env | LEASH_CONFIG |
Editing config → restart the service (CLI mutators do it for you). On start Leash (re)installs the nftables redirect and (re)marks mounts; on stop it tears down the table.
Gotchas & failure modes
- Network default-deny bites first.
network.mode: whitelistwith an emptyallowreaches nothing — including the agent's own loopback LLM/Muzzle endpoint. Allow127.0.0.1and your model host or the agent can't call anything. - Filesystem whitelist can brick the agent.
filesystem.mode: whitelistdefault-denies opens including exec of the interpreter → the worker getsEPERMrunning its own venv python. Preferblacklistwith targeted denies (Sentinel does this — see below). - Command blacklist is weak (basename/path match); a copied/renamed binary bypasses it.
A denylist with
rm/sshsilently blocks those for the agent. fail_mode: closedblocks anything undecidable (e.g. a domain that won't resolve → fail-closed).openallows on error.- Root required to mutate + restart; the daemon needs
CAP_SYS_ADMIN+CAP_NET_ADMIN. - Linux-only, kernel ≥ 5.0. The package imports on macOS (dev/tests) but never enforces off Linux.
- v1 coarseness: UDP/IPv6/raw sockets are default-blocked for the UID (not policy-driven).
toolsis cooperative — bypassed entirely if the harness doesn't call it.- Reinstall never overwrites an existing
/etc/leash/leash.yaml; new defaults land in.yaml.exampleand you merge manually.
Integration with Sentinel (and Muzzle)
Sentinel is the harness that drives Leash:
sentinel initcallsleash init --user <leashed_user>.- Sentinel rewrites only a pristine Leash example into a Sentinel baseline: flips
filesystemtoblacklist(denying/etc/shadow,/home/*/.ssh,/root), keepsnetworkwhitelist but adds127.0.0.1,pypi.org,files.pythonhosted.org,github.com, the Muzzle loopback host, etc., and setscommandsblacklist[rm, ssh]. A custom config is never overwritten (it only warns aboutEPERMif it sees a whitelist-mode filesystem). - Runtime mutation goes through Sentinel's root broker (a closed verb registry, no
shell):
leash_allow {host}→leash network allow <host>(host validated), andleash_apply_config {text}→ atomically writes/etc/leash/leash.yaml+ restarts. This is how Sentinel hot-allows egress for a new MCP server / datasource / git remote.
Muzzle vs Leash: complementary. Muzzle inspects LLM request/response/tool-call content (prompt-injection/secrets/PII). Leash never reads payloads — only the destination name — and governs where the agent can go and what it can run. (Leash's tool-input inspection reuses Muzzle's detector patterns.)
Quick reference
Config: /etc/leash/leash.yaml Service: leash.service (proxy 127.0.0.1:8443)
Surfaces: network | filesystem | commands | tools Modes: whitelist (deny-by-default) | blacklist
Allow a host: leash net allow <host[:port]> (auto-restarts)
Dry-run: leash check network <host:port>
Watch blocks: leash logs --follow
Apply raw edit: systemctl restart leash.service