How Leash Works v3
This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.
Document snapshot
How Leash Works
Leash is a per-user enforcement layer for local AI agents on Linux. You attach it to one host user — the leashed UID the agent runs as — and it makes a hard allow/block decision for everything that user does across four surfaces. The core is one shared policy engine; the surfaces are thin enforcers that intercept at the OS layer, ask the engine, and apply the verdict.
Identity model
leash init --user fidorecords the leashed username in config. The installer creates the account if it does not exist. The agent runs as that user.- Leash itself runs as a privileged service (root, with the capabilities to
install nftables rules and a fanotify mark —
CAP_SYS_ADMIN+CAP_NET_ADMIN). This is the asymmetry that makes enforcement real: the agent is unprivileged and runs as the leashed UID, so it cannot remove Leash's rules or unmark the daemon. - The username is resolved to a UID at load (
pwd.getpwnam). Every decision is keyed to that UID.
One engine, four enforcers
The reusable core is a policy engine that every surface shares:
identify UID → resolve the subject → engine.decide(surface, subject) → allow | block(reason) → log
The engine is pure logic with no OS calls, so it is fully unit-tested. Enforcers are thin adapters that intercept at the OS layer, resolve the subject, call the engine, and apply the result.
- Surfaces & subjects: network
{domain, ip, port}, filesystem{path}, command{binary}, tool{name, inputs}. - Per-surface mode:
follow-whitelist(default-deny — only listed entries are allowed) orfollow-blacklist(default-allow — only listed entries are blocked). - Matching: domains match exact or by suffix (so
github.commatchesapi.github.com), with*.wildcard support; IPs match by CIDR network; paths match by exact, directory-prefix, or glob; commands match by basename or absolute path; tools match by exact name. - Decision: a
Decision(action, reason)ofalloworblock. Every decision is written to the JSONL decision log.
Enforcers
Network (transparent proxy) — kernel-enforced for the UID
nftablesrules match the leashed UID's outbound TCP (--uid-owner) over both IPv4 and IPv6 and redirect it to a local Leash proxy (the service installs these at runtime; the installer does not, to keep installs offline and testable).- For each redirected connection, the proxy peeks the first bytes to read the
destination host from the HTTP
Host:header or the TLSSNI— with no decryption — and reads the original destination IP and port viaSO_ORIGINAL_DST. It callsdecide_network({domain, ip, port}). - Allow → it opens a connection to the real destination and splices both directions through untouched. Block → it closes the connection (a reset) and logs the reason.
- Because the redirect is keyed to the UID in the kernel, the agent cannot dodge it by ignoring proxy environment variables.
- UDP is dropped, not proxied. A TCP proxy cannot broker UDP, and forwarding what
you cannot inspect is not enforcement — so a filter chain drops it for the leashed
UID. That is also what closes QUIC / HTTP3 on UDP/443, which a modern client will
otherwise negotiate straight past the proxy. The one exception is DNS to the host's
own resolvers (from
/etc/resolv.conf, ornetwork.dns_resolvers), because nothing resolves without it; UDP/53 to arbitrary addresses stays closed, since that is a tunnel.network.udp: allowrestores unfiltered UDP as an explicit choice. - Scope: TCP over IPv4 and IPv6 (HTTP/HTTPS and any TCP by IP/port), with UDP
dropped. Raw sockets are not intercepted — a process with
CAP_NET_RAWis outside this boundary. - If
nftis unavailable the iptables fallback covers IPv4 TCP only, and says so on stderr rather than letting you assume parity.
Filesystem + Commands (one fanotify daemon) — kernel-enforced for the UID
- A single privileged fanotify daemon marks the mount with both
FAN_OPEN_PERM(filesystem opens —cat/ls/writes) andFAN_OPEN_EXEC_PERM(process execution). Both surfaces ride one daemon because fanotify exposes both event types under one mark. - For each permission event it resolves the acting PID's UID (via
/proc/<pid>/status) and the target path (via/proc/self/fd). Only if it is the leashed UID does it ask the engine:decide_command({binary})for exec events,decide_path({path})for opens. Anything from another UID is allowed straight through. - It then responds
FAN_ALLOWorFAN_DENY. A denial surfaces to the agent asEPERM. - Requires: a privileged daemon and a Linux kernel ≥ 5.0 (for
FAN_OPEN_EXEC_PERM).
Tools (cooperative library) — harness-enforced
- A tools module: a registry (per-tool allow/deny + mode) plus input
inspection that reuses Muzzle's detector patterns — secrets (API keys, tokens,
private keys), PII (emails), and privilege-escalation patterns
(
sudo,chmod +s,setuid,pkexec,doas,rm -rf /). check_tool(name, inputs)first checks the registry; if the tool name is allowed, it inspects the inputs and blocks if any flagged category is configured toblock. Sensitive matches are masked when logged.- The enforcement point is the harness (Sentinel), which calls Leash before
running a tool. v1 exposes it via the
leashCLI/library so it is testable now and drop-in for the harness later. Unlike the other three surfaces, this one is cooperative — it is only as strong as the harness that calls it.
Fidelity matrix
| Surface | Mechanism | Enforcement |
|---|---|---|
| Network (TCP) | nftables --uid-owner → transparent proxy | Kernel-enforced for the leashed UID |
| Filesystem | fanotify FAN_OPEN_PERM | Kernel-enforced for the leashed UID (privileged daemon) |
| Commands | fanotify FAN_OPEN_EXEC_PERM | Kernel-enforced for the leashed UID (privileged daemon) |
| Tools | registry + input inspection library | Cooperative — enforced by the harness (Sentinel) |
| Network (IPv6 TCP) | nftables ip6 --uid-owner → same transparent proxy | Kernel-enforced for the leashed UID |
| Network (UDP) | nftables inet filter chain → drop (DNS to host resolvers excepted) | Kernel-enforced for the leashed UID |
| Network (raw sockets) | not intercepted | Outside the boundary — needs CAP_NET_RAW |
"Kernel-enforced" means the interception point is unbypassable — the UID's traffic, file opens, and execs are forced through Leash. It does not mean a determined agent can't defeat a name-based rule. v1 is a preview. Two gaps are closed and one is not:
- Closed: when a decision is made on a domain, the proxy connects to an address
it resolves rather than the one the agent supplied, so
allow: github.comcannot be used to reach an arbitrary IP behind a forged SNI. Ports are matched —host:portentries ([2001:db8::1]:443for IPv6) pin one. - Open: command rules match by name and path, so a copied or renamed binary evades
a blacklist.
leash validatereports this as a warning rather than leaving you to discover it. Use whitelist (default-deny) mode for honest blocking, especially for commands.
And the thing worth checking before you trust any of it: a surface in blacklist
mode is default-allow, and its allow: list is never consulted. leash validate
exits non-zero on that, because a policy which enforces nothing should not look like
one that does.
Configuration (leash.yaml)
The YAML config has these sections:
user— the leashed username (UID resolved at load).fail_mode—closed(default) oropen; governs every enforcer when it cannot reach a decision (see below).network/filesystem/commands— each a{ mode, allow, deny }surface policy.modeiswhitelistorblacklist.tools— the same{ mode, allow, deny }plusinspect, a map of category (secrets,pii,priv_esc) → action (block,log,allow).logging—{ level, decisions }.decisionsis a file path orstdout.enforcers—{ network, filesystem, tools }booleans to toggle each surface on or off.
Example:
user: fido
fail_mode: closed
network: { mode: whitelist, allow: ["github.com", "api.openai.com", "10.0.0.0/8"], deny: [] }
filesystem: { mode: whitelist, allow: ["/home/fido/work", "/tmp/fido"], deny: [] }
commands: { mode: blacklist, allow: [], deny: ["ssh", "rm", "curl"] }
tools: { mode: whitelist, allow: ["read_file", "search"], deny: [],
inspect: { secrets: block, pii: log, priv_esc: block } }
logging: { level: info, decisions: /var/log/leash/decisions.jsonl }
enforcers: { network: true, filesystem: true, tools: true }
Who may read and write the policy
/etc/leash/leash.yaml is 0640, owned root:<leashed user>. Leash itself runs
as root and writes it; the leashed user gets group read so a console can show
the policy — read through the group, never through ownership, because owning the
file would let the confined account rewrite its own leash.
It was previously created by a shell redirect with no mode set at all, which
means it took whatever the installer's umask gave it — usually 0644. A
world-readable egress allowlist and command denylist is not a catastrophe, but it
hands any local account the exact shape of the policy to plan around, and a mode
nobody declared is one that is right by luck. An install now states it, and
re-states it on upgrade so an existing box is repaired rather than left as it was.
CLI (leash)
Same ergonomics as muzzle. The config path comes from --config/-c or
LEASH_CONFIG (default /etc/leash/leash.yaml). With no subcommand, leash runs the
service.
leash init --user fido— record the leashed user (creating the config if needed).leash status— print the user, each surface's mode and list sizes, enforcer toggles, and the decision-log sink.leash validate— load and validate the config.leash net|fs|cmd|tool allow|deny <entry>— add an entry to a surface's allow or deny list. (net/fs/cmd/toolare aliases fornetwork/filesystem/commands/tools.)leash <surface> mode whitelist|blacklist— set the surface's mode.leash check <surface> <subject>— dry-run a decision against the live config (for network, passhost:port).leash logs --follow [--tail N]— tail the decision log.leash restart— restart the service. List/mode edits save and restart automatically.
Decision logging
Every decision is written as one JSON line to the configured logging.decisions sink
(a file path or stdout): {ts, ref, uid, surface, action, reason, subject}. The
short ref ties a block back to its log line. Sensitive tool-input matches (secrets,
PII) are masked before they are written, reusing Muzzle's masking approach.
Error handling / fail mode
fail_mode governs every enforcer. If the engine cannot decide or an enforcer hits an
internal error, closed blocks and open allows. The shapes of a block differ by
surface: network block = connection reset; filesystem/exec block = EPERM; tool block
= an error to the caller. If a daemon is not running, that surface is simply
unenforced, and leash status reports each enforcer's toggle plainly.
Linux only
Leash relies on Linux kernel primitives (nftables UID matching, fanotify permission events) and is not portable to other platforms. The Python package imports cleanly on non-Linux hosts for development and unit testing — every Linux-only API is referenced lazily inside the runtime functions — but enforcement only runs on Linux.
Roadmap (post-v1)
- Sentinel integration: the fail-to-approval loop. A blocked action emits an approval request to Sentinel; a human approves; Leash allows it, either once or by adding a persistent whitelist entry. Tools enforcement becomes a first-class harness gate rather than a cooperative library.
- Broader network: UDP / IPv6 / raw-socket policy, and per-API (path/method) rules.
- File-backed lists and an admin UI, mirroring Muzzle's later additions.
More
- Getting started and v1 scope:
products/leash/v1/README.md - Full design:
docs/plans/2026-06-25-leash-v1-design.md
Diff from previous
--- HOWITWORKS@2 +++ HOWITWORKS@3 @@ -44,9 +44,9 @@ ### Network (transparent proxy) — kernel-enforced for the UID -- An `nftables` rule matches the leashed UID's outbound TCP (`--uid-owner`) and - **redirects it to a local Leash proxy** (the service installs this rule at runtime; - the installer does not, to keep installs offline and testable). +- `nftables` rules match the leashed UID's outbound TCP (`--uid-owner`) over **both + IPv4 and IPv6** and **redirect it to a local Leash proxy** (the service installs + these at runtime; the installer does not, to keep installs offline and testable). - For each redirected connection, the proxy peeks the first bytes to read the destination host from the **HTTP `Host:` header** or the **TLS `SNI`** — with **no decryption** — and reads the original destination IP and port via @@ -56,9 +56,18 @@ logs the reason. - Because the redirect is keyed to the UID in the kernel, the agent cannot dodge it by ignoring proxy environment variables. -- **v1 scope:** TCP (covers HTTP/HTTPS and any TCP by IP/port). UDP, IPv6, and raw - sockets are default-blocked for the leashed UID and are noted in the fidelity matrix - as coarse for v1. +- **UDP is dropped**, not proxied. A TCP proxy cannot broker UDP, and forwarding what + you cannot inspect is not enforcement — so a filter chain drops it for the leashed + UID. That is also what closes **QUIC / HTTP3 on UDP/443**, which a modern client will + otherwise negotiate straight past the proxy. The one exception is **DNS to the host's + own resolvers** (from `/etc/resolv.conf`, or `network.dns_resolvers`), because nothing + resolves without it; UDP/53 to arbitrary addresses stays closed, since that is a + tunnel. `network.udp: allow` restores unfiltered UDP as an explicit choice. +- **Scope:** TCP over IPv4 and IPv6 (HTTP/HTTPS and any TCP by IP/port), with UDP + dropped. Raw sockets are not intercepted — a process with `CAP_NET_RAW` is outside + this boundary. +- If `nft` is unavailable the **iptables fallback covers IPv4 TCP only**, and says so + on stderr rather than letting you assume parity. ### Filesystem + Commands (one fanotify daemon) — kernel-enforced for the UID @@ -98,15 +107,27 @@ | Filesystem | fanotify `FAN_OPEN_PERM` | Kernel-enforced for the leashed UID (privileged daemon) | | Commands | fanotify `FAN_OPEN_EXEC_PERM` | Kernel-enforced for the leashed UID (privileged daemon) | | Tools | registry + input inspection library | Cooperative — enforced by the harness (Sentinel) | -| Network (UDP / IPv6 / raw) | default-block for the UID | Coarse in v1 | +| Network (IPv6 TCP) | nftables `ip6` `--uid-owner` → same transparent proxy | Kernel-enforced for the leashed UID | +| Network (UDP) | nftables `inet` filter chain → drop (DNS to host resolvers excepted) | Kernel-enforced for the leashed UID | +| Network (raw sockets) | not intercepted | Outside the boundary — needs `CAP_NET_RAW` | **"Kernel-enforced" means the interception point is unbypassable** — the UID's traffic, file opens, and execs are forced through Leash. It does **not** mean a determined agent -can't defeat a *name-based* rule. v1 is a **preview** with known gaps (see the design's -"Known gaps / v1 hardening backlog"): the network proxy currently trusts the agent-chosen -destination IP after a domain match; command rules match by name/path, so a copied or -renamed binary evades a blacklist; and `port` isn't matched yet. Use **whitelist -(default-deny)** mode for honest blocking, especially for commands. +can't defeat a *name-based* rule. v1 is a **preview**. Two gaps are closed and one is not: + +- *Closed:* when a decision is made on a **domain**, the proxy connects to an address + **it** resolves rather than the one the agent supplied, so `allow: github.com` cannot + be used to reach an arbitrary IP behind a forged SNI. Ports **are** matched — + `host:port` entries (`[2001:db8::1]:443` for IPv6) pin one. +- *Open:* command rules match by name and path, so a **copied or renamed binary evades + a blacklist**. `leash validate` reports this as a warning rather than leaving you to + discover it. Use **whitelist (default-deny)** mode for honest blocking, especially for + commands. + +And the thing worth checking before you trust any of it: a surface in **`blacklist` +mode is default-allow**, and its `allow:` list is never consulted. `leash validate` +exits non-zero on that, because a policy which enforces nothing should not look like +one that does. ## Configuration (`leash.yaml`)