← Back to document history
Document version

How Leash Works v4

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:30 PM UTC
Source digest
b5dc069c3a87f7d57f43989873f05f81e65b96b72edc7225a122f4a9a6f4cc21

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: per-API (path/method) rules. IPv6 TCP and UDP are no longer future work — IPv6 goes through the same proxy and UDP is dropped, with DNS to the host's own resolvers excepted.
  • File-backed lists and an admin UI, mirroring Muzzle's later additions.

More

  • Getting started and v1 scope: products/leash/v1/README.md
  • CLI reference: CLI.md

Diff from previous

--- HOWITWORKS@3
+++ HOWITWORKS@4
@@ -220,11 +220,12 @@
   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.
+- **Broader network:** per-API (path/method) rules. IPv6 TCP and UDP are no longer
+  future work — IPv6 goes through the same proxy and UDP is dropped, with DNS to the
+  host's own resolvers excepted.
 - **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`
+- CLI reference: [`CLI.md`](CLI.md)