← Back to document history
Document version

How Leash Works v1

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
Jun 29, 2026, 6:22 AM UTC
Source digest
5678e583d8602f1cb0e8b00a293ff2c4d35e68253d166f58ed7ed92c09d2cb58

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

  • 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).
  • 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.
  • 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) 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 (UDP / IPv6 / raw)default-block for the UIDCoarse 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_modeclosed (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 }

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

This is the first approved version, so there is no previous diff.