lock family docs GitHub

Security · The family

How lea keeps your machine safe

  • lea web listens on the host's 127.0.0.1 only, which no lea container shares, and acts as you through the daemon's host socket, as the deck does: an agent reaches neither. The link it prints carries a random one-time token (32 bytes) that the browser exchanges for an HttpOnly, SameSite=Strict cookie; every API call needs that cookie, and a request whose Host or Origin is not 127.0.0.1:PORT or localhost:PORT is refused (another site's page, DNS rebinding). The API forwards a fixed list of the daemon's methods, for this lea's agents only; there is no CORS, and a strict Content-Security-Policy keeps any other script or origin out of the page. To use it from another machine, forward the port over SSH (ssh -L 8414:127.0.0.1:8414 host, then lea web --port 8414 on the host).
  • The container sees the projects directory, a read-only allow-list of ~/.claude (keybindings.json, hooks, skills, agents, commands, output-styles, the top-level *.md files), its own writable copies of settings.json and plugins (never written back: Plugins and permissions in lea) and, with locksql, lockmq, lockfile or locklog, the socket volume of their lox run lock. Nothing else.
  • .lockfamily in the projects directory (the Lock Family hub's config, the locks' public keys, the routing) is read-only in the container: lea makes it, owned by you on the host, when it is missing, and refuses to start when it is a symbolic link or a file.
  • With the Lock Family's lox run in the projects directory, its locks are the consoles lea's agents reach (locksql, lockmq, lockfile, locklog and LockHTTP run under lox run only): for each tool that is on (LEA_LOCKSQL, LEA_LOCKMQ, LEA_LOCKFILE, LEA_LOCKLOG), lea mounts the socket volume lox run names in .lockfamily/up.json (lockhub's docs/lock-up.md) on /run/<tool>, read-only, once docker confirms it is that lock's socket volume for this projects directory (compose project, dev.lockfamily.role=socket, instance and workspace labels): never a lock's home or the hub's network. lea ignores an up.json that is a link, that others than you can write, or that names another projects directory. Several locks of one tool each get /run/<tool>/<instance>, LEA_<TOOL>_INSTANCES names them, a Codex agent gets one MCP server per lock (<tool>-<instance>, run with --instance), and lea's MCP server tells every agent to pass --instance NAME. A lock lea cannot use (a bad name, a volume docker does not confirm) gets no socket at all, never the fixed one of another console; a warning says so.
  • Egress goes through a proxy: HTTPS (and plain HTTP) to an allowlist of public domains (plus LEA_INTERNAL_HOSTS), SSH only to LEA_GIT_SSH. Private and link-local addresses are refused.
  • By default the allowlist is lea's own: github.com, githubusercontent.com, ghcr.io, gitlab.com (git, gh, glab, release assets), golang.org (Go modules and checksums), registry.npmjs.org, registry.yarnpkg.com, pypi.org, pythonhosted.org, docker.io, docker.com, quay.io (images for nested builds), fedoraproject.org, alpinelinux.org (their packages), openai.com, chatgpt.com (Codex), and always anthropic.com, claude.ai, claude.com (the model API and its login). Each domain covers its subdomains. Anything else (documentation sites fetched by an agent, another forge, a Fedora mirror outside fedoraproject.org) is refused. The refusal's first line names the host and the command that allows it (lea-proxy refused deb.debian.org: not in the egress allowlist. To allow it, run on the host: lea egress allow deb.debian.org (applies live, no restart).). HTTPS clients hide that page behind CONNECT tunnel failed, response 403: lea's agents are told to recognise it and to hand you that command, never to work around the proxy.
  • To allow a domain, run on the host lea egress allow deb.debian.org (several at once if you like): the running proxy takes it at once, without restarting the proxy, the containers or the agents, and the domain is kept in LEA_EGRESS_ALLOW in ~/.config/lea/lea.conf (default kept). lea egress deny DOMAIN takes back a domain added so; lea egress list shows the list and where each entry comes from; lea egress denied [--since 24h] lists the hosts the proxy refused lately, with the containers that asked and the command for each. The command runs on the host only: no agent, MCP tool, grant or file in an agent's reach can change the list.
  • You can also edit LEA_EGRESS_ALLOW="default,corp.example,docs.example.org" yourself (default keeps lea's list); without default, only the domains you list (and the model API) are reachable. The next lea applies an edited list to the running proxy live, the same way. To open the web to any domain, as lea did before, set LEA_EGRESS_ALLOW='*' (lea doctor warns): a change to or from * restarts the proxy. Internal hosts are not affected. lea doctor shows the active list.
  • Whatever LEA_EGRESS_ALLOW says, the proxy refuses public DNS-over-HTTPS resolvers (dns.google, cloudflare-dns.com, quad9.net, nextdns.io, any doh.* name …), which would reopen the DNS channel the internal network closes, and public IP literals (https://1.1.1.1/, decimal or hex forms, IPv6): a public host is reached by its name. A host of your network is reached by its address once listed in LEA_INTERNAL_HOSTS.
  • An internal host name grants that name only, not every vhost of the ingress behind it: the proxy peeks at the TLS handshake (no decryption) and passes a tunnel only when its SNI is the allowed name, so a tunnel to an internal name must be TLS with SNI. The proxy cannot see the Host header inside: if your ingress serves a Host other than the SNI, require SNI = Host on it. An IPv4 literal grants the whole address, every vhost on it included.
  • The API keys of your providers' endpoints stay in the proxy container: no agent mounts a key file, and the key forwarder reaches an endpoint only through the proxy's egress rules. So does lea's GitHub token (lea github login, see Git and forges).
  • The proxy container, the only one on both the agents' networks and the egress network, runs confined: every capability dropped but SETUID and SETGID, no-new-privileges, a read-only root (tmpfs on /run, /tmp and /var/cache/squid), a pids and a memory limit. No service in it runs as root: squid as squid, the SSH and OTel forwarders as nobody, lea-keyproxy as you (the owner of the key files; under rootless Podman, the container's root, which is you on the host); only its entrypoint, PID 1, stays root to wait, or squid is PID 1 when no endpoint is configured. A proxy that could not read a key file stops at start and says which.
  • lea never gets the Docker socket; the agents' own containers run in a rootless Podman inside lea, under seccomp and, on Linux, AppArmor. lea's seccomp profile (image/seccomp.json) is Docker's default (moby/profiles, vendored and pinned in image/seccomp/) with a reviewed list of changes, each with its reason, in image/seccomp/lea.json; image/seccomp/generate.py builds the profile from them and CI fails on any other difference. It allows what the nested rootless Podman needs and Docker's default keeps for CAP_SYS_ADMIN (clone and unshare of namespaces, setns, mount, umount2, open_tree, move_mount, mount_setattr, pivot_root), so unprivileged user namespaces are available in lea (unshare -U -r works: the rootless Podman runs in one). The rest stays refused as in Docker's default: the kernel keyring (keyctl; nested containers join no keyring), perf_event_open, bpf, reboot, syslog, the new mount API's fsopen... It also refuses socketcall, the kernel crypto API sockets (AF_ALG, CVE-2026-31431), audit netlink sockets (NETLINK_AUDIT, as lea drops CAP_AUDIT_WRITE), and what lea's earlier profile refused that Docker's default allows (vmsplice, cachestat, map_shadow_stack, the futex2 calls, io_pgetevents). 32-bit x86 (i386) programs do not run in lea: their socketcall hides the socket family from seccomp, so the profile leaves that architecture out. seccomp compares 64-bit registers where the kernel reads an int argument's low 32 bits, so the generator refuses any test of an int argument that high bits could get past (socket(16, 3, 0x100000009) is an audit socket to the kernel). The AppArmor profile lea is docker-default with its /proc write denies, plus mounts and user namespaces for the nested Podman.
  • No way back to root in lea: the entrypoint drops to your uid with no capabilities, the image has no sudo and no setuid or setgid file. newuidmap and newgidmap keep their file capabilities, which the rootless Podman maps its sub-ids with; that is why lea runs without no-new-privileges.
  • Every lea container shares the host kernel (unless LEA_RUNTIME puts the agents on gVisor or Kata: a second kernel), so the host's patch level is part of the containment. The kernel must carry the fixes for CVE-2026-74753, CVE-2026-80521 and CVE-2026-80841; on Ubuntu, check with apt changelog linux-image-$(uname -r) | grep -E 'CVE-2026-(74753|80521|80841)'. lea doctor shows the kernel, Docker Engine, containerd and runc versions and fails below the kernel minimum of config/host-minimums; a distribution without a kernel minimum there gets a warning. On a Podman host (docker is Podman), lea doctor warns that lea is built for Docker Engine, and below Podman 5.8.4 (CVE-2026-57231) the launcher warns too.
  • Each agent container's internal network has gateway_mode_ipv4=isolated (Docker Engine 28.0 or later): the host has no address on it, so host services listening on 0.0.0.0 (databases, dev servers, a Docker API over TCP) are out of the agents' reach. When the engine cannot give it (an older Docker Engine, Podman, an engine whose version lea cannot read, or a network left without it by an older lea), lea refuses to start the container, for a network that already exists as for a new one; it also refuses one whose bridge, as the host sees it, holds a host address, whatever its option says; LEA_ALLOW_GATEWAY=1 (in your configuration or the environment) accepts a plain internal network instead, with a warning. lea doctor prints the gateway mode of each internal network and fails on one that is not isolated (a warning under LEA_ALLOW_GATEWAY=1). When Docker has no free address pool left for lea's network (all predefined address pools have been fully subnetted), lea says so with Docker's message: remove unused networks (docker network prune) or add default-address-pools in /etc/docker/daemon.json; LEA_ALLOW_GATEWAY=1 does not help there. lea doctor counts the free pools (Docker's defaults hold 31 networks).
  • The agents resolve no external name: lea starts each agent container with --dns 192.0.2.1 (TEST-NET-1, never routed; not a loopback address, which Docker may query from the host's namespace), out of reach of its internal network, so Docker's embedded resolver answers the names of the containers on its network (lea-proxy) and forwards nothing to the host's resolver, which would otherwise carry a query name past the proxy. lea-proxy resolves the names the agents reach; nested containers use the same resolver. lea doctor fails on a running container that resolves example.com, and warns about one an older lea started without this resolver.
  • Work in lea, not on the host, inside the projects directory: an agent can write files your host tools would run. When the last session closes, lea reports changes to git hooks, risky git configuration, .mcp.json and .claude/ settings.
  • Change Claude Code's configuration on the host: it is read-only in lea, but for lea's own copies of settings.json and plugins. After editing a single file (settings.json, a top-level *.md, keybindings.json), restart lea (lea stop).

What an agent can read #

Nothing of your machine's credentials: no API key or token is in the agent container's environment or image. What lea gives the agents, though, they run as the owner of, so any agent (or anything it runs: a test, a postinstall, a nested container with the directory mounted) can read it. lea doctor lists what is there, by path (never a value):

Credential Path in the lea container Readable by the agents
Claude Code's OAuth login (access and refresh token) ~/.claude/.credentials.json yes, read-write: Claude Code refreshes it. It is lea's own login for this projects directory, never your host's; with LockHTTP's MITM, the gate refuses on the Claude API a token that is not this container's.
Codex's login ~/.codex/auth.json yes, read-write (Codex refreshes it)
GitHub token none: the proxy's (lea github login) no; usable through gh and git
GitLab and other forges (glab auth login, git's store) /lea/state/glab-cli/config.yml, /lea/state/git/credentials yes
Container registry logins (podman login) /lea/state/containers/auth.json yes
Kubernetes ~/.kube/lea/config yes, read-only: a view token of lea kube refresh, 8 h
lea's SSH key (LEA_GIT_SSH) /lea/state/ssh (read-only) no: usable through the ssh-agent socket only
Provider API keys (~/.config/lea/providers) none: the proxy's no; usable through the key forwarder

Claude Code has no way, today, to take a short-lived token or one a proxy adds: its refresh token stays on disk, so scope what that login reaches. Keep the readable ones narrow: a GitLab token limited to its projects, registry logins for the registries the agents need.

A second kernel: gVisor or Kata #

By default the agent container runs on the engine's default runtime (runc): one kernel, shared with the host, so a single kernel bug reachable from the container is enough to escape, whatever seccomp and AppArmor refuse. LEA_RUNTIME puts a second kernel in the way, for the agent containers only (the proxy and the gates run no agent code and stay on the default runtime):

  • LEA_RUNTIME=runsc: gVisor, a user-space kernel (the Sentry) that answers the agents' system calls; the host kernel sees a few of its own.
  • LEA_RUNTIME=kata: Kata Containers, a lightweight virtual machine with its own guest kernel per container (needs hardware virtualisation: /dev/kvm).

The runtime is installed and registered with dockerd first (lea installs nothing on the host); then set the key (~/.config/lea/lea.conf) and restart lea's containers (lea stop --all, then lea). An unknown runtime refuses the start and names those the engine knows; a running container left on another runtime gets a warning; lea doctor shows LEA_RUNTIME, whether the engine knows it, the runtime of each running lea container (docker inspect -f '{{.HostConfig.Runtime}}' <container> shows it too), and warns when the runtime would leave lea's seccomp profile out (a runsc registration without --oci-seccomp, a Kata configuration.toml without disable_guest_seccomp = false). tests/integration/install-runtime.sh runsc (or kata) does the installation the way lea's CI does (sudo, pinned releases checked against digests pinned in the script: gVisor's sha512 for release-20261102.0 on x86_64 and aarch64 (GVISOR_SHA512 required for any other release or architecture), Kata's sha256 for 4.3.1 on amd64 and arm64 (KATA_SHA256 required for any other version or architecture); it restarts dockerd), and LEA_TEST_RUNTIME=runsc make test-isolation runs the isolation checks on it.

gVisor (runsc) Kata (kata)
Minimum release release-20260803.0, the first weekly release after gVisor's 30 July 2026 change to file descriptors passed over Unix sockets (the fix tied to OpenAI's sandbox report); lea's CI runs release-20261102.0 3.27.0 (CVE-2026-24834, guest privilege escalation through virtio-pmem); 4.2.0 or later recommended, the first release after the two Kata changes of August 2026 that the same report cites; lea's CI runs 4.3.1 (runtime-rs, QEMU)
dockerd registration "runsc": {"path": "/opt/gvisor/runsc", "runtimeArgs": ["--oci-seccomp", "--host-uds=open", "--host-fifo=open"]} "kata": {"runtimeType": "io.containerd.kata.v2"}, and in Kata's configuration.toml, disable_guest_seccomp = false and "--xattr" in virtio_fs_extra_args (the nested Podman's newuidmap needs its file capability)
Nested Podman (docker, docker compose in lea) works, but its containers share lea's network namespace (gVisor refuses the bind mount Podman keeps a network namespace with): their ports are lea's, and a port below 1024 cannot be bound works, with its own network namespaces; its images and Buildah's /var/tmp live in tmpfs inside the VM (rootless Podman cannot unpack a layer, nor Buildah make a RUN step's root, on virtio-fs), so they count against the container's memory and are pulled again after each restart
lea's bus (messaging between agents), the Lock consoles' sockets work (--host-uds=open lets the sandbox connect to host Unix sockets) unavailable: a host Unix socket does not cross into the VM (lea says so at start; make test-isolation prints info lea's bus socket is not reachable)
Clipboard (copy from an agent to the host) works (--host-fifo=open) off: a host FIFO cannot be bound into the VM (lea leaves it out and says so)
seccomp, AppArmor lea's seccomp profile is applied inside the sandbox only with --oci-seccomp (runsc ignores it otherwise); runsc ignores the AppArmor profile, the Sentry runs under gVisor's own seccomp filter lea's seccomp profile is applied in the guest only with disable_guest_seccomp = false; the AppArmor profile is not applied in the VM
Cost syscall-heavy work (builds, git, package installs) is slower; little memory a VM per container: its memory (the guest's default size) and a few seconds more at start

--host-uds=open and --host-fifo=open widen what the sandbox may open on the host (gVisor advises against host sockets where they are not needed): they are what lea needs (its bus socket, the Lock consoles' sockets, the clipboard FIFO), on paths lea mounts; leave them out and lea starts, but without messaging between agents, the Locks or the clipboard. Keep the host patched anyway: the second kernel narrows the shared kernel's surface, it does not remove it.