Leash — For the Bots v3
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 rules match the leashed UID and redirect its outbound TCP
(IPv4 and IPv6) to
a transparent proxy on loopback —
127.0.0.1:18443for a single-agent install, or one listener per leashed UID on that UID's own port from/var/lib/leash/ports.json(range18000-18999) — which reads the destination fromHost:/TLS SNI (no decryption) and allows/resets. Each connection is judged against the policy of the listener it arrived on and logged under that listener's UID; a connection that was not redirected (made straight to a listener — its original destination comes back as the listener's own bind address) is refused and loggedcriticalwithout being judged, so one agent cannot have its traffic judged under another's policy, and nor can a peer with no leash rules at all. Falls back to iptables--uid-owner REDIRECT(single UID only). - filesystem + commands — one fanotify permission daemon (
FAN_OPEN_PERM | FAN_OPEN_EXEC_PERM, mount-marked) for every leashed UID, holding a UID → policy map (marks are per mount, so a second watcher would double-answer the same events). It checks each open/exec only for a UID in the map, against that UID's own policy, and logs the denial under that UID; a deny surfaces asEPERM. A UID not in the map passes through untouched and is not logged — unlike the proxy, which refuses a listener with no policy, because fanotify sees every process on the host and denying unmapped UIDs would deny the box. A UID that is in the map with no resolved policy is denied (that is the misconfiguration case) and loggedcriticalonce at startup. Subjects can be added/removed while it runs; a new subject is unwatched for up to 5s (mark re-scan interval). - 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).
Entries are hosts, never URLs. The network grammar is host, *.host (leading
only, same as host), IP, CIDR, each with an optional :port (1–65535), and
[v6]:port for IPv6. http://host, host/path, user@host, whitespace, * or
api.*.com, .host, host:http and non-ASCII names can never match. The same goes
for a relative or ~ filesystem path, a relative command path (bin/ssh), and a
direct entry that is not bare IPv4[:port]. Writing one is refused
(leash <surface> allow|deny, leash profile save, leash bind; --json →
refusal.kind: "entry" plus inert_entries: [{field, entry, problem, instead, severity}]). A file already holding one still loads. It is reported by
leash validate (floor + every profile; an inert deny is critical → exit 1),
at startup, and per subject on every reconcile. Grammar: leash.engine.match.*_entry_problem;
walker: leash.config.inert_entries.
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). |
leash profile list / show <name> | List the profile library / print one fragment. |
leash profile save <name> [--from PATH|-] [--ack SCOPE=FIELD:VALUE …] [--dry-run] | Create/replace a profile from a YAML fragment (stdin by default). Validated by the resolution engine; revalidates every binding that names it and refuses an edit that would loosen a bound subject beyond its acks. --ack agent:<acct>=<token> gives ONE agent's consent in the same write (appended to that binding only, R2.2); profile + bindings are written together under the bindings lock, bindings first, rolled back on a failed profile write (BindingStore.save_profile). --ack for global, for an agent the profile does not reach, or for a token the edit does not make is kind: ack. --json: subjects[] {scope, global_floor, acknowledged, findings} on every answer, changed, dry_run. Root (not for --dry-run). Then ONE SIGHUP. |
leash profile delete <name> | Remove a profile. Leaves the bindings (they then dangle, and those subjects go fail-closed) and lists them. Root. Then SIGHUP. |
leash bind <scope> <profile> [--ack F:V …] [--dry-run] | Attach a profile to global / agent:<account> (two tiers; team: is refused since 2026-10-14 — bind one profile to each agent instead). Refuses an unacknowledged loosening of the floor, listing every finding with its exact --ack token and the whole command to re-run. --dry-run reports and writes nothing. Root. Then SIGHUP. |
leash unbind <scope> [--ack F:V …] [--dry-run] | Detach a scope; refuses if nothing is bound to it. Consent-gated (2026-10-14): resolve.judge_removal resolves the subject WITH and WITHOUT the binding (both via resolve, global judged at the floor = what every subject gets) and layered.consent_for_removal asks is_loosening of every differing Config leaf — a dropped deny is asked as <surface>.unset.deny (deny's own rule is union-add, never loosening); a dropped inspect category reads as allow. Loosening -> refused (kind: consent) until every token is --acked; tighten/no-op -> proceeds. Dangling (or otherwise unresolvable) binding -> one finding fail_closed:<profile> (subject is denied everything now). Subject unresolvable WITHOUT the binding -> CannotJudgeRemoval, kind: engine. Unreadable floor -> kind: floor. Consent kept in <state>/unbinds.jsonl (0600, appended under the bindings lock BEFORE the write; ledger unwritable -> no unbind); BindingStore.unbind(expect=) refuses (BindingChanged) if the scope was rebound since it was judged. Root (not for --dry-run). Then SIGHUP. |
leash bindings | Every bound scope → profile, with that scope's acks. |
Each of the seven policy verbs above also takes --json (see below).
The write commands take --no-reconcile (write, don't signal) and exit non-zero
if the signal itself failed — the policy is on disk and not in force, which is the one
state worth an exit code. A stopped unit or a host with no systemctl is a skip
(exit 0). Reads (profile list/show, bindings) need no root; the four writes refuse
with a sudo … line unless LEASH_STATE_DIR is set (a dev checkout), since
/var/lib/leash is root-owned 0700.
Those seven verbs take --json (and nothing else does — argparse exits 2 on the
rest). One JSON document on stdout, nothing on stderr, for a refusal as much as
for a success, because the refusal is what carries the loosenings a console has to
show before anybody consents. Envelope: version, command, ok, refusal
(null or {kind, message}); everything else belongs to the verb. Exit codes do not
move (0 / 1 / argparse's 2) — what kind of refusal it was is refusal.kind
(consent, scope, profile, binding, ack, fragment, floor, engine,
root, reconcile, fatal), read structurally and never matched against the
message. A finding is {tier, field, value, token, because, acknowledged} — the
engine's own Loosening, not a ConfigWarning (that shape spends field, value
and acknowledged into prose), and fix is deliberately absent because A7 forbids
parsing it. findings is one key with one shape on the accepted bind, the dry run
and the refusal alike. A write verb also carries written and
reconcile {action, ok, unit, detail, argv} — separate because they disagree in the
case that matters: a written binding whose SIGHUP failed is written: true with
ok: false, policy on disk and not in force. unbind carries bind's shape too
(profile, dry_run, written, findings, acknowledged; plus legacy unbound
== written). bind/unbind carry global_floor
(true only for scope: global, which is the FLOOR and not one subject) and bind
carries three-valued subject_exists (null = could not read passwd, never
reported as "no such account"). Full shape: products/leash/CLI.md. Sentinel's
console is the caller — it cannot import leash (separate venvs), so it shells this.
A scope is an ACCOUNT name (B44): agent:leash-iris, not agent:Iris. There is
no team scope: team was removed as a Leash policy tier on 2026-10-14 (a Sentinel team
is still a team — shared workspace, delegation — it just has no Leash policy). A scope
outside the agent namespace is
refused — it could never match a subject, and discovery is consulted only to name
the real account in the refusal (never to derive one: the slug is lossy and
collision-suffixed). A well-formed name with no account yet is a note, not a
refusal: Sentinel mints it later and the binding goes live at the next reconcile.
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
# Give ONE agent its own policy on top of the floor (scopes are ACCOUNT names)
cat > /tmp/web-research.yaml <<'EOF'
network:
allow: [arxiv.org, github.com] # a fragment: only the fields it means to set
EOF
sudo leash profile save web-research --from /tmp/web-research.yaml
getent passwd | cut -d: -f1 | grep '^leash-' # find the account name
sudo leash bind agent:leash-iris web-research --dry-run # what would it need?
sudo leash bind agent:leash-iris web-research \
--ack 'network.allow:github.com' # consent, field AND value, per binding
sudo leash bindings
# Same profile for a whole team: bind it to EACH agent (no team scope; acks per agent)
sudo leash bind agent:leash-scout web-research --ack 'network.allow:github.com'
sudo leash bind agent:leash-vera web-research --ack 'network.allow:github.com'
# Tighten the floor for every subject on the host (folded into the floor, not overlaid)
sudo leash bind global house-rules
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:18443 (single agent) or 127.0.0.1:<that UID's port> |
| Port map | /var/lib/leash/ports.json (0600, in a 0700 state dir the map creates if absent) — {uid -> proxy port}, one listener per UID, range 18000-18999; a release retires a port and never reissues it |
| Profile library | /var/lib/leash/profiles/<name>.yaml (0600, in the same 0700 state dir) — named, reusable policy fragments (only the fields they set, never a whole config); the name grammar is a tier name ([A-Za-z0-9][A-Za-z0-9._-]{0,63}), and an acknowledged_overrides key inside one is refused — an ack belongs to the binding that attaches it (see Bindings below). Writing one revalidates every binding that uses it: an edit that loosens a bound subject beyond what that binding acknowledges is refused, naming every affected scope and the exact ack token each one would need (R2.1 c1 — reuse means one edit reaches eight agents). So ProfileStore needs the bindings reader and the floor injected (ProfileStore(dir, bindings=..., floor=<parsed /etc/leash/leash.yaml>)) to write at all — a store with neither can read but not write, and the gate is the bindings document, never "does the file exist" (a deleted profile keeps its bindings). The revalidation judges the edit against the floor FILE alone, so a scope: global binding folded beneath an agent binding is not consulted: that direction is caught at run start by merge, against the real base. leash.service resolves and applies these (see Subject discovery) at startup and again on SIGHUP, which re-resolves every subject and re-applies only the ones whose policy actually changed — so a profile edit reaches enforcement when the service is signalled, without disturbing any subject it did not change |
| Bindings | /var/lib/leash/bindings.yaml (0600, in the same 0700 state dir) — one document, bindings: [{scope, profile, acknowledged}], where scope is global / agent:<name> (a team: scope is refused, at write AND at read — a leftover one fails the whole document closed rather than being skipped) and acknowledged is that scope's ack tokens (<field>:<value>). One scope = one profile; the same profile may be bound to several scopes and each carries its OWN acks. Refused at write time: a profile the library cannot resolve, a malformed ack token, two bindings for one scope. A missing file = nothing bound; an empty or malformed one is a refusal, not "nothing bound". scope: global is folded into the floor, never applied as an overlay (and the fold is gated — see Policy resolution). leash.service resolves this at startup and on SIGHUP (see Subject discovery), so a binding written today takes effect when the service is signalled — and writing this file (via the store) rewrites it as plain YAML, losing comments, the same wart leash init has |
| Policy resolution | leash.resolve.resolve(floor, profiles, bindings, *, agent) — two tiers, global < agent. The floor (/etc/leash/leash.yaml) is always on; a scope: global binding is folded into it and every loosening the fold performs must be acknowledged on that binding first (judged as an overlay against the unfolded floor, because the floor itself classifies nothing); the agent binding is the one overlay — a scalar it sets wins, list entries union with the floor's, and a loosening needs that binding's ack. A binding naming a profile the library cannot produce is a refusal, never a skipped layer. (Until 2026-10-14 a team tier sat between them, reduced by intersection with pooled acks — removed by operator decision.) |
| Subject discovery | leash.service enumerates its own subjects at startup, and again on every SIGHUP (see Reconcile below), from the host's passwd database (pwd.getpwall — NSS, so LDAP/SSSD entries count): every account named leash-* (the prefix Sentinel mints an agent's Linux account with) is a subject. The group database is not read (team is not a policy tier). The configured user is always a subject too, resolved with no agent tier — so no agent: binding can re-point it. The tier name is the ACCOUNT name: a binding for an agent is agent:leash-<slug>, not the Sentinel display name — the uid the kernel judges and the identity the policy resolves for come from one source, and the account name cannot be inverted back to a display name (the slug is lossy and collision-suffixed). Each subject's policy is resolved independently and a subject that cannot be resolved fails alone: logged critical, given no nftables chain, and denied on every fs/exec event. With zero leash-* accounts the service takes the original single-subject path and never creates /var/lib/leash |
| Reconcile | SIGHUP re-reads the subjects and changes only what differs. New subject -> its own nft chain + a listener opened inside the already-running proxy loop (net.SubjectListeners, so the one-listener-per-UID attribution property is kept and no other subject's connections are dropped); changed policy -> apply_subject for that UID alone; unchanged subject -> not touched at all (compared by value, and the policy object in both live maps stays the one already there — a re-apply is byte-identical and still not incremental, because it takes the chain down and puts it back). Both enforcer maps are mutated in place, never replaced: fsexec.run_daemon_subjects and net.serve_subjects captured those objects' references and read them per event / per connection. Ordering fails closed: the fsexec entry is written as None (denied) before the chain goes in, because an unmapped UID is allowed. A subject that leaves discovery loses its policy and keeps its mechanism — out of the net map (so its listener refuses and logs every connection under its own UID) and None in fsexec; its chain is deliberately kept, since release_account never runs userdel and "renamed", "deleted" and "NSS answered short" are indistinguishable from here. One subject failing fails alone; a wholesale failure (passwd unreadable, port map corrupt) changes nothing and leaves what is applied in place. A SIGHUP during a reconcile is coalesced into one further pass, never re-entered; a SIGHUP during teardown is ignored. Who signals: Leash's own CLI, after every successful leash profile save/delete, leash bind and leash unbind (manage.reconcile_service, skippable with --no-reconcile); and Sentinel, at a new agent account (agent_users.reconcile_leash; not at a team-membership sync since 2026-10-14 — team is not a policy tier) — both run exactly systemctl kill --kill-whom=main --signal=HUP leash.service, and --kill-whom=main matters because Leash shells nft to short-lived children that SIGHUP would terminate. Never a restart from either: a restart runs ExecStopPost (leash teardown) and drops every other subject's chains mid-run. Not at release_account, deliberately, since discovery is keyed on the passwd prefix and not on sentinel-agents, so a released account stays a subject. The one case a signal cannot serve: a service that started with zero leash-* accounts is on the single-subject path — no per-subject listener set to join — so it cannot grow the host's first agent into itself and says so on the first SIGHUP; Sentinel systemctl restarts it for that one agent instead, which costs nobody any enforcement because nobody has any yet |
| Env | LEASH_CONFIG · LEASH_STATE_DIR (parent of ports.json, profiles/ and bindings.yaml; empty = the default, never the cwd) |
Editing /etc/leash/leash.yaml → restart the service (CLI mutators do it for you).
That file is the floor, and SIGHUP deliberately does not re-read it: the floor is
the baseline every loosening is judged against, and a floor that failed to re-read must
never degrade to {}, against which nothing reads as a loosening at all. Editing a
profile or a binding → SIGHUP (see Reconcile), which touches only the
subjects whose resolved policy actually changed — and leash profile save/delete,
leash bind and leash unbind send that signal themselves, so a hand edit of either
file is the only case left that needs one by hand. 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). - The fanotify watcher is a separate PROCESS (
python -m leash.enforcers.fswatch), child ofleash.service. It used to be a thread, and aSIGHUPreconcile froze the entire host (2026-10-14):pwd.getpwall()holds the GIL while glibc opens/etc/passwdon the marked root mount, and the watcher thread needed that GIL to answer the open. Do not move fanotify back into the service process, and do not add code to the watcher's event loop that opens a file on a marked mount: it is single-threaded, so nothing else can answer that open. Imports after the marks are refused (fswatch.seal_after_marks); anything new the loop needs must be imported infswatch._warm. If the loop stalls 10s,faulthandlerdumps stacks to the journal and exits it; the service respawns it from its map (3 deaths in 10 min → service exits for systemd). The service'sfsexecmap is anfswatch.WatcherPolicies: writes are forwarded + acked; deleting a key raises (absent = ALLOWED). - An event's UID comes from
/proc/<pid>/statusafter the event, so a recycled PID can be attributed to the wrong subject. Pre-existing and unmitigated; closing it needsFAN_REPORT_PIDFD. - 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. It is per subject — a failure judging one UID's event is answered under that UID'sfail_mode— and any value that is not exactlyopen(including a typo) is treated asclosed, so a misspelling cannot disable an enforcer.- 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).
- Loopback ports
18000-18999are reserved for Leash. Loopback is exempt from the redirect except for that range, so a leashed UID cannot reach a local service of its own on one of those ports — and cannot reach another agent's proxy listener either, which is the point: the attempt falls through to its own listener, which refuses it. The single-agent listener (18443) is inside that range for the same reason, and the port map never allocates it to a subject. It used to be8443, outside the range: the attempt was exempted, went straight out, arrived at the single-agent listener and was refused there — so the critical log line named the UID that was aimed at rather than the UID that tried it. - An unreadable
SO_ORIGINAL_DSTis a refusal, not an unknown destination. It is readable only on a redirected connection, so failing to read it means the connection cannot be shown to have been redirected. Refused, loggedcritical, no policy consulted. 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:18443)
Ports: /var/lib/leash/ports.json one listener per UID, 18000-18999 (reserved on loopback)
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
Per-agent: leash profile save <name> --from <f.yaml> then
leash bind agent:leash-<slug> <name> [--ack '<field>:<value>']
(scopes are ACCOUNT/GROUP names; widening the floor needs --ack;
the CLI SIGHUPs the service itself)
Diff from previous
--- forthebots@2
+++ forthebots@3
@@ -14,11 +14,27 @@
- **network** — nftables NAT rules match the leashed UID and redirect its outbound TCP
(IPv4 **and** IPv6) 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.
+ a transparent proxy on loopback — `127.0.0.1:18443` for a single-agent install, or **one
+ listener per leashed UID** on that UID's own port from `/var/lib/leash/ports.json` (range
+ `18000-18999`) — which reads the destination from `Host:`/TLS SNI
+ (no decryption) and allows/resets. Each connection is judged against the policy of the
+ listener it arrived on and logged under that listener's UID; a connection that was **not**
+ redirected (made straight to a listener — its original destination comes back as the
+ listener's own bind address) is refused and logged `critical` without being judged, so one
+ agent cannot have its traffic judged under another's policy, and nor can a peer with no
+ leash rules at all. Falls
+ back to iptables `--uid-owner REDIRECT` (single UID only).
+- **filesystem + commands** — **one** **fanotify** permission daemon (`FAN_OPEN_PERM |
+ FAN_OPEN_EXEC_PERM`, mount-marked) for every leashed UID, holding a **UID → policy map**
+ (marks are per *mount*, so a second watcher would double-answer the same events). It
+ checks each open/exec **only for a UID in the map**, against **that UID's own policy**,
+ and logs the denial under **that UID**; a deny surfaces as `EPERM`. A UID *not* in the
+ map passes through untouched and is not logged — unlike the proxy, which refuses a
+ listener with no policy, because fanotify sees every process on the host and denying
+ unmapped UIDs would deny the box. A UID that **is** in the map with no resolved policy is
+ denied (that is the misconfiguration case) and logged `critical` once at startup.
+ Subjects can be added/removed while it runs; a new subject is unwatched for up to 5s
+ (mark re-scan interval).
- **tools** — a **cooperative** library (registry + regex input inspection for
secrets/PII/priv-esc); only enforced if the harness (Sentinel) calls it.
@@ -86,6 +102,19 @@
absolute path only** — a renamed/copied binary evades a `deny` (use `whitelist` for honest
command blocking).
+**Entries are hosts, never URLs.** The network grammar is `host`, `*.host` (leading
+only, same as `host`), IP, CIDR, each with an optional `:port` (1–65535), and
+`[v6]:port` for IPv6. `http://host`, `host/path`, `user@host`, whitespace, `*` or
+`api.*.com`, `.host`, `host:http` and non-ASCII names can never match. The same goes
+for a relative or `~` filesystem path, a relative command path (`bin/ssh`), and a
+`direct` entry that is not bare IPv4[:port]. **Writing one is refused**
+(`leash <surface> allow|deny`, `leash profile save`, `leash bind`; `--json` →
+`refusal.kind: "entry"` plus `inert_entries: [{field, entry, problem, instead,
+severity}]`). **A file already holding one still loads.** It is reported by
+`leash validate` (floor + every profile; an inert `deny` is critical → exit 1),
+at startup, and per subject on every reconcile. Grammar: `leash.engine.match.*_entry_problem`;
+walker: `leash.config.inert_entries`.
+
## The CLI (complete surface)
Entry: `leash` (`/usr/local/bin/leash`). **Bare `leash` runs the service** (what systemd
@@ -104,6 +133,53 @@
| `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). |
+| `leash profile list` / `show <name>` | List the profile library / print one fragment. |
+| `leash profile save <name> [--from PATH\|-] [--ack SCOPE=FIELD:VALUE …] [--dry-run]` | Create/replace a profile from a YAML **fragment** (stdin by default). Validated by the resolution engine; **revalidates every binding that names it** and refuses an edit that would loosen a bound subject beyond its acks. `--ack agent:<acct>=<token>` gives ONE agent's consent in the same write (appended to that binding only, R2.2); profile + bindings are written together under the bindings lock, bindings first, rolled back on a failed profile write (`BindingStore.save_profile`). `--ack` for `global`, for an agent the profile does not reach, or for a token the edit does not make is `kind: ack`. `--json`: `subjects[]` `{scope, global_floor, acknowledged, findings}` on every answer, `changed`, `dry_run`. Root (not for `--dry-run`). Then ONE `SIGHUP`. |
+| `leash profile delete <name>` | Remove a profile. **Leaves the bindings** (they then dangle, and those subjects go fail-closed) and lists them. Root. Then `SIGHUP`. |
+| `leash bind <scope> <profile> [--ack F:V …] [--dry-run]` | Attach a profile to `global` / `agent:<account>` (two tiers; `team:` is **refused** since 2026-10-14 — bind one profile to each agent instead). **Refuses an unacknowledged loosening of the floor**, listing every finding with its exact `--ack` token and the whole command to re-run. `--dry-run` reports and writes nothing. Root. Then `SIGHUP`. |
+| `leash unbind <scope> [--ack F:V …] [--dry-run]` | Detach a scope; **refuses** if nothing is bound to it. **Consent-gated** (2026-10-14): `resolve.judge_removal` resolves the subject WITH and WITHOUT the binding (both via `resolve`, `global` judged at the floor = what every subject gets) and `layered.consent_for_removal` asks `is_loosening` of every differing `Config` leaf — a dropped `deny` is asked as `<surface>.unset.deny` (deny's own rule is union-add, never loosening); a dropped inspect category reads as `allow`. Loosening -> refused (`kind: consent`) until every token is `--ack`ed; tighten/no-op -> proceeds. Dangling (or otherwise unresolvable) binding -> one finding `fail_closed:<profile>` (subject is denied everything now). Subject unresolvable WITHOUT the binding -> `CannotJudgeRemoval`, `kind: engine`. Unreadable floor -> `kind: floor`. Consent kept in `<state>/unbinds.jsonl` (0600, appended under the bindings lock BEFORE the write; ledger unwritable -> no unbind); `BindingStore.unbind(expect=)` refuses (`BindingChanged`) if the scope was rebound since it was judged. Root (not for `--dry-run`). Then `SIGHUP`. |
+| `leash bindings` | Every bound scope → profile, with that scope's acks. |
+
+Each of the seven policy verbs above also takes `--json` (see below).
+
+**The write commands take `--no-reconcile`** (write, don't signal) and exit **non-zero**
+if the signal itself failed — the policy is on disk and not in force, which is the one
+state worth an exit code. A stopped unit or a host with no `systemctl` is a **skip**
+(exit 0). Reads (`profile list`/`show`, `bindings`) need no root; the four writes refuse
+with a `sudo …` line unless `LEASH_STATE_DIR` is set (a dev checkout), since
+`/var/lib/leash` is root-owned `0700`.
+
+**Those seven verbs take `--json`** (and nothing else does — argparse exits 2 on the
+rest). One JSON document on stdout, **nothing on stderr**, for a refusal as much as
+for a success, because the refusal is what carries the loosenings a console has to
+show before anybody consents. Envelope: `version`, `command`, `ok`, `refusal`
+(`null` or `{kind, message}`); everything else belongs to the verb. Exit codes do not
+move (0 / 1 / argparse's 2) — *what kind* of refusal it was is `refusal.kind`
+(`consent`, `scope`, `profile`, `binding`, `ack`, `fragment`, `floor`, `engine`,
+`root`, `reconcile`, `fatal`), read structurally and never matched against the
+message. A finding is `{tier, field, value, token, because, acknowledged}` — the
+engine's own `Loosening`, not a `ConfigWarning` (that shape spends `field`, `value`
+and `acknowledged` into prose), and `fix` is deliberately absent because A7 forbids
+parsing it. `findings` is one key with one shape on the accepted bind, the dry run
+and the refusal alike. A write verb also carries `written` and
+`reconcile {action, ok, unit, detail, argv}` — separate because they disagree in the
+case that matters: a written binding whose SIGHUP failed is `written: true` with
+`ok: false`, policy on disk and not in force. `unbind` carries bind's shape too
+(`profile`, `dry_run`, `written`, `findings`, `acknowledged`; plus legacy `unbound`
+== `written`). `bind`/`unbind` carry `global_floor`
+(true only for `scope: global`, which is the FLOOR and not one subject) and `bind`
+carries three-valued `subject_exists` (`null` = could not read passwd, never
+reported as "no such account"). Full shape: `products/leash/CLI.md`. Sentinel's
+console is the caller — it cannot `import leash` (separate venvs), so it shells this.
+
+**A scope is an ACCOUNT name** (B44): `agent:leash-iris`, not `agent:Iris`. There is
+no team scope: team was removed as a Leash policy tier on 2026-10-14 (a Sentinel team
+is still a team — shared workspace, delegation — it just has no Leash policy). A scope
+outside the agent namespace is
+**refused** — it could never match a subject, and discovery is consulted only to name
+the real account in the refusal (never to derive one: the slug is lossy and
+collision-suffixed). A well-formed name with no account yet is a **note**, not a
+refusal: Sentinel mints it later and the binding goes live at the next reconcile.
## Common tasks (recipes)
@@ -133,6 +209,25 @@
# Apply a raw config edit (mutators auto-restart; if you edited the file yourself):
sudo systemctl restart leash.service
+
+# Give ONE agent its own policy on top of the floor (scopes are ACCOUNT names)
+cat > /tmp/web-research.yaml <<'EOF'
+network:
+ allow: [arxiv.org, github.com] # a fragment: only the fields it means to set
+EOF
+sudo leash profile save web-research --from /tmp/web-research.yaml
+getent passwd | cut -d: -f1 | grep '^leash-' # find the account name
+sudo leash bind agent:leash-iris web-research --dry-run # what would it need?
+sudo leash bind agent:leash-iris web-research \
+ --ack 'network.allow:github.com' # consent, field AND value, per binding
+sudo leash bindings
+
+# Same profile for a whole team: bind it to EACH agent (no team scope; acks per agent)
+sudo leash bind agent:leash-scout web-research --ack 'network.allow:github.com'
+sudo leash bind agent:leash-vera web-research --ack 'network.allow:github.com'
+
+# Tighten the floor for every subject on the host (folded into the floor, not overlaid)
+sudo leash bind global house-rules
```
## Key paths / where changes take effect
@@ -144,11 +239,24 @@
| 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.
+| nftables table | `ip leash` (installed at runtime) · proxy `127.0.0.1:18443` (single agent) or `127.0.0.1:<that UID's port>` |
+| Port map | `/var/lib/leash/ports.json` (`0600`, in a `0700` state dir the map creates if absent) — `{uid -> proxy port}`, one listener per UID, range `18000-18999`; a release **retires** a port and never reissues it |
+| Profile library | `/var/lib/leash/profiles/<name>.yaml` (`0600`, in the same `0700` state dir) — named, reusable policy **fragments** (only the fields they set, never a whole config); the name grammar is a tier name (`[A-Za-z0-9][A-Za-z0-9._-]{0,63}`), and an `acknowledged_overrides` key inside one is **refused** — an ack belongs to the binding that attaches it (see **Bindings** below). **Writing one revalidates every binding that uses it**: an edit that loosens a bound subject beyond what *that binding* acknowledges is **refused**, naming every affected scope and the exact ack token each one would need (R2.1 c1 — reuse means one edit reaches eight agents). So `ProfileStore` needs the bindings reader and the floor injected (`ProfileStore(dir, bindings=..., floor=<parsed /etc/leash/leash.yaml>)`) to write at all — a store with neither can read but not write, and the gate is the bindings document, never "does the file exist" (a deleted profile keeps its bindings). The revalidation judges the edit against the floor FILE alone, so a `scope: global` binding folded beneath an agent binding is not consulted: that direction is caught at run start by `merge`, against the real base. `leash.service` resolves and applies these (see **Subject discovery**) at startup and again on **SIGHUP**, which re-resolves every subject and re-applies only the ones whose policy actually changed — so a profile edit reaches enforcement when the service is signalled, without disturbing any subject it did not change |
+| Bindings | `/var/lib/leash/bindings.yaml` (`0600`, in the same `0700` state dir) — one document, `bindings: [{scope, profile, acknowledged}]`, where `scope` is `global` / `agent:<name>` (a `team:` scope is **refused**, at write AND at read — a leftover one fails the whole document closed rather than being skipped) and `acknowledged` is **that scope's** ack tokens (`<field>:<value>`). One scope = one profile; the same profile may be bound to several scopes and each carries its OWN acks. Refused at write time: a profile the library cannot resolve, a malformed ack token, two bindings for one scope. A missing file = nothing bound; an empty or malformed one is a **refusal**, not "nothing bound". `scope: global` is folded into the floor, never applied as an overlay (and the fold is gated — see **Policy resolution**). `leash.service` resolves this at startup and on **SIGHUP** (see **Subject discovery**), so a binding written today takes effect when the service is signalled — and writing this file (via the store) rewrites it as plain YAML, losing comments, the same wart `leash init` has |
+| Policy resolution | `leash.resolve.resolve(floor, profiles, bindings, *, agent)` — **two tiers, global < agent**. The floor (`/etc/leash/leash.yaml`) is always on; a `scope: global` binding is **folded into it** and every loosening the fold performs must be acknowledged on that binding first (judged as an overlay against the unfolded floor, because the floor itself classifies nothing); the agent binding is the one overlay — a scalar it sets wins, list entries union with the floor's, and a loosening needs **that binding's** ack. A binding naming a profile the library cannot produce is a **refusal**, never a skipped layer. (Until 2026-10-14 a team tier sat between them, reduced by intersection with pooled acks — removed by operator decision.) |
+| Subject discovery | `leash.service` enumerates its own subjects at **startup**, and again on every **SIGHUP** (see **Reconcile** below), from the host's passwd database (`pwd.getpwall` — NSS, so LDAP/SSSD entries count): every account named `leash-*` (the prefix Sentinel mints an agent's Linux account with) is a subject. The group database is **not** read (team is not a policy tier). The configured `user` is always a subject too, resolved with **no** agent tier — so no `agent:` binding can re-point it. **The tier name is the ACCOUNT name**: a binding for an agent is `agent:leash-<slug>`, not the Sentinel display name — the uid the kernel judges and the identity the policy resolves for come from one source, and the account name cannot be inverted back to a display name (the slug is lossy and collision-suffixed). Each subject's policy is resolved independently and a subject that cannot be resolved fails **alone**: logged `critical`, given **no** nftables chain, and **denied** on every fs/exec event. With **zero** `leash-*` accounts the service takes the original single-subject path and never creates `/var/lib/leash` |
+| Reconcile | **`SIGHUP` re-reads the subjects and changes only what differs.** New subject -> its own `nft` chain + a listener opened inside the already-running proxy loop (`net.SubjectListeners`, so the one-listener-per-UID attribution property is kept and no other subject's connections are dropped); changed policy -> `apply_subject` for that UID alone; **unchanged subject -> not touched at all** (compared by value, and the policy object in both live maps stays the one already there — a re-apply is byte-identical and still not incremental, because it takes the chain down and puts it back). Both enforcer maps are **mutated in place**, never replaced: `fsexec.run_daemon_subjects` and `net.serve_subjects` captured those objects' references and read them per event / per connection. Ordering fails closed: the `fsexec` entry is written as `None` (**denied**) *before* the chain goes in, because an **unmapped** UID is *allowed*. A subject that leaves discovery loses its **policy** and keeps its **mechanism** — out of the `net` map (so its listener refuses and logs every connection under its own UID) and `None` in `fsexec`; its chain is deliberately **kept**, since `release_account` never runs `userdel` and "renamed", "deleted" and "NSS answered short" are indistinguishable from here. One subject failing fails alone; a **wholesale** failure (passwd unreadable, port map corrupt) changes **nothing** and leaves what is applied in place. A `SIGHUP` during a reconcile is coalesced into one further pass, never re-entered; a `SIGHUP` during teardown is ignored. **Who signals:** Leash's own CLI, after every successful `leash profile save`/`delete`, `leash bind` and `leash unbind` (`manage.reconcile_service`, skippable with `--no-reconcile`); and Sentinel, at a new agent account (`agent_users.reconcile_leash`; **not** at a team-membership sync since 2026-10-14 — team is not a policy tier) — both run exactly `systemctl kill --kill-whom=main --signal=HUP leash.service`, and `--kill-whom=main` matters because Leash shells `nft` to short-lived children that SIGHUP would terminate. **Never a restart** from either: a restart runs `ExecStopPost` (`leash teardown`) and drops every other subject's chains mid-run. **Not** at `release_account`, deliberately, since discovery is keyed on the passwd prefix and not on `sentinel-agents`, so a released account stays a subject. **The one case a signal cannot serve:** a service that started with **zero** `leash-*` accounts is on the single-subject path — no per-subject listener set to join — so it cannot grow the host's *first* agent into itself and says so on the first `SIGHUP`; Sentinel `systemctl restart`s it for that one agent instead, which costs nobody any enforcement because nobody has any yet |
+| Env | `LEASH_CONFIG` · `LEASH_STATE_DIR` (parent of `ports.json`, `profiles/` and `bindings.yaml`; empty = the default, never the cwd) |
+
+Editing `/etc/leash/leash.yaml` → **restart the service** (CLI mutators do it for you).
+That file is the **floor**, and `SIGHUP` deliberately does not re-read it: the floor is
+the baseline every loosening is judged against, and a floor that failed to re-read must
+never degrade to `{}`, against which nothing reads as a loosening at all. Editing a
+**profile** or a **binding** → `SIGHUP` (see **Reconcile**), which touches only the
+subjects whose resolved policy actually changed — and `leash profile save`/`delete`,
+`leash bind` and `leash unbind` send that signal themselves, so a hand edit of either
+file is the only case left that needs one by hand. On start Leash (re)installs the nftables
+redirect and (re)marks mounts; on stop it tears down the table.
## Gotchas & failure modes
@@ -158,14 +266,43 @@
- **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).
+- **The fanotify watcher is a separate PROCESS (`python -m leash.enforcers.fswatch`),
+ child of `leash.service`.** It used to be a thread, and a `SIGHUP` reconcile froze the
+ entire host (2026-10-14): `pwd.getpwall()` holds the GIL while glibc opens
+ `/etc/passwd` on the marked root mount, and the watcher thread needed that GIL to
+ answer the open. Do **not** move fanotify back into the service process, and do not
+ add code to the watcher's event loop that opens a file on a marked mount: it is
+ single-threaded, so nothing else can answer that open. Imports after the marks are
+ refused (`fswatch.seal_after_marks`); anything new the loop needs must be imported in
+ `fswatch._warm`. If the loop stalls 10s, `faulthandler` dumps stacks to the journal
+ and exits it; the service respawns it from its map (3 deaths in 10 min → service
+ exits for systemd). The service's `fsexec` map is an `fswatch.WatcherPolicies`:
+ writes are forwarded + acked; deleting a key raises (absent = ALLOWED).
+- **An event's UID comes from `/proc/<pid>/status` *after* the event**, so a recycled PID can
+ be attributed to the wrong subject. Pre-existing and unmitigated; closing it needs
+ `FAN_REPORT_PIDFD`.
- **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.
+ fail-closed). `open` allows on error. It is **per subject** — a failure judging one UID's
+ event is answered under that UID's `fail_mode` — and any value that is not exactly `open`
+ (including a typo) is treated as `closed`, so a misspelling cannot disable an enforcer.
- **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).
+- **Loopback ports `18000-18999` are reserved for Leash.** Loopback is exempt from the
+ redirect *except* for that range, so a leashed UID cannot reach a local service of its own
+ on one of those ports — and cannot reach another agent's proxy listener either, which is
+ the point: the attempt falls through to its own listener, which refuses it.
+ The single-agent listener (`18443`) is **inside** that range for the same reason, and the
+ port map never allocates it to a subject. It used to be `8443`, outside the range: the
+ attempt was exempted, went straight out, arrived at the single-agent listener and was
+ refused *there* — so the critical log line named the UID that was aimed at rather than the
+ UID that tried it.
+- **An unreadable `SO_ORIGINAL_DST` is a refusal, not an unknown destination.** It is
+ readable only on a redirected connection, so failing to read it means the connection
+ cannot be shown to have been redirected. Refused, logged `critical`, no policy consulted.
- **`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.
@@ -193,10 +330,15 @@
## Quick reference
```
-Config: /etc/leash/leash.yaml Service: leash.service (proxy 127.0.0.1:8443)
+Config: /etc/leash/leash.yaml Service: leash.service (proxy 127.0.0.1:18443)
+Ports: /var/lib/leash/ports.json one listener per UID, 18000-18999 (reserved on loopback)
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
-```
+Per-agent: leash profile save <name> --from <f.yaml> then
+ leash bind agent:leash-<slug> <name> [--ack '<field>:<value>']
+ (scopes are ACCOUNT/GROUP names; widening the floor needs --ack;
+ the CLI SIGHUPs the service itself)
+```