← Back to document history
Document version

How Leash Works v3

This is the stored snapshot for the approved document version. The diff below shows what changed from the previous version.

Preview
Source path
leash/HOWITWORKS.md
Source commit
No commit recorded
Created at
Sep 21, 2026, 3:05 PM UTC
Source digest
e748f3a0125b9f0baaefa43bb00dc1fbba7060b47c1815dbf87e9b079d97a408

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 fido records 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) or follow-blacklist (default-allow — only listed entries are blocked).
  • Matching: domains match exact or by suffix (so github.com matches api.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) of allow or block. Every decision is written to the JSONL decision log.

Enforcers

Network (transparent proxy) — kernel-enforced for the UID

  • 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 SO_ORIGINAL_DST. It calls decide_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, 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

  • A single privileged fanotify daemon marks the mount with both FAN_OPEN_PERM (filesystem opens — cat/ls/writes) and FAN_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_ALLOW or FAN_DENY. A denial surfaces to the agent as EPERM.
  • 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 to block. 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 leash CLI/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

SurfaceMechanismEnforcement
Network (TCP)nftables --uid-owner → transparent proxyKernel-enforced for the leashed UID
Filesystemfanotify FAN_OPEN_PERMKernel-enforced for the leashed UID (privileged daemon)
Commandsfanotify FAN_OPEN_EXEC_PERMKernel-enforced for the leashed UID (privileged daemon)
Toolsregistry + input inspection libraryCooperative — enforced by the harness (Sentinel)
Network (IPv6 TCP)nftables ip6 --uid-owner → same transparent proxyKernel-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 interceptedOutside 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.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)

The YAML config has these sections:

  • user — the leashed username (UID resolved at load).
  • fail_mode — closed (default) or open; governs every enforcer when it cannot reach a decision (see below).
  • network / filesystem / commands — each a { mode, allow, deny } surface policy. mode is whitelist or blacklist.
  • tools — the same { mode, allow, deny } plus inspect, a map of category (secrets, pii, priv_esc) → action (block, log, allow).
  • logging — { level, decisions }. decisions is a file path or stdout.
  • 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/tool are aliases for network/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, pass host: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`)