How Leash Works v1
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
- An
nftablesrule 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). - 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.
- 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.
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 (UDP / IPv6 / raw) | default-block for the UID | Coarse in v1 |
"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.
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 }
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