← Back to document history
Document version

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.

Preview
Source path
leash/forthebots.md
Source commit
No commit recorded
Created at
Jul 31, 2026, 8:22 PM UTC
Source digest
e9a0f4aff54a20604977db2cbfe802ca0639e71af8c6cb7e12b5c012bafae101

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 from Host:/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 as EPERM. 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) or iptables; 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; only allow entries pass.
  • blacklist = default-allow; only deny entries 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.comapi.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.

CommandPurpose
leash init --user <u>Record the leashed user (creates config if absent) + restart.
leash statusConfig path, user, fail_mode, each surface's mode + allow/deny counts, toggles, log sink.
leash validateLoad + 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|blacklistSet a surface's mode + restart.
leash restartRestart 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

ItemValue
Install prefix/opt/leash/v1 (venv /opt/leash/v1/.venv)
Config/etc/leash/leash.yaml (reference: .yaml.example)
CLI wrapper/usr/local/bin/leash
Serviceleash.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 tableip leash (installed at runtime) · proxy 127.0.0.1:8443
EnvLEASH_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: whitelist with an empty allow reaches nothing — including the agent's own loopback LLM/Muzzle endpoint. Allow 127.0.0.1 and your model host or the agent can't call anything.
  • Filesystem whitelist can brick the agent. filesystem.mode: whitelist default-denies opens including exec of the interpreter → the worker gets EPERM running its own venv python. Prefer blacklist with targeted denies (Sentinel does this — see below).
  • Command blacklist is weak (basename/path match); a copied/renamed binary bypasses it. A denylist with rm/ssh silently blocks those for the agent.
  • fail_mode: closed blocks anything undecidable (e.g. a domain that won't resolve → fail-closed). open allows 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).
  • tools is cooperative — bypassed entirely if the harness doesn't call it.
  • Reinstall never overwrites an existing /etc/leash/leash.yaml; new defaults land in .yaml.example and you merge manually.

Integration with Sentinel (and Muzzle)

Sentinel is the harness that drives Leash:

  • sentinel init calls leash init --user <leashed_user>.
  • Sentinel rewrites only a pristine Leash example into a Sentinel baseline: flips filesystem to blacklist (denying /etc/shadow, /home/*/.ssh, /root), keeps network whitelist but adds 127.0.0.1, pypi.org, files.pythonhosted.org, github.com, the Muzzle loopback host, etc., and sets commands blacklist [rm, ssh]. A custom config is never overwritten (it only warns about EPERM if 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), and leash_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

Diff from previous

This is the first approved version, so there is no previous diff.