lock family docs GitHub

Locks · lockfile

Running lockfile in Docker

lockfile runs in Docker, with nothing on your machine but Docker, the lockfile launcher and the seccomp profile of the console's container. The launcher is a short shell script (scripts/lockfile): every command you type, lockfile console, lockfile plugin install, lockfile mcp and the others, runs in the lockfile image, never in a binary of its own on your machine.

The image holds the separated setup in two kinds of containers that share only the socket volume:

  • the console runs as lockfile (uid 10031), the only account that holds the source credentials, the plugins, the fetched files and the audit log;
  • the agent's MCP server and CLI run as agent (uid 10032), a member of lockfile-clients (gid 10030), each in a throwaway container;
  • /etc/lockfile/system.toml (x11 = "refuse"), /etc/lockfile/container.toml and the binaries (lockfile, cosign) are owned by root; there is no sudo and the image starts no shell;
  • the socket directory /run/lockfile (mode 2750, lockfile:lockfile-clients) is a named volume, shared by the console container and the agent's containers (read-only there): lox run's socket volume of the lock (lockfamily-<hash>_<instance>-run, named in .lockfamily/up.json). The kernel checks every peer on the socket as on a host;
  • the console's configuration is the workspace's .lockfile/ directory (config.toml: profiles and marketplaces; SFTP keys and known_hosts): lox run reviews it and copies it, read-only, to the console's ~/.config/lockfile (the volume lockfamily-<hash>_profile-lockfile);
  • the console's home is lox run's volume lockfamily-<hash>_<instance>-home: the plugin store and plugins.lock, the approved policies, the audit log, the fetched files (work_dir), cosign's trust root cache.

Requirements #

  • Docker on Linux, on a kernel with Landlock (5.13 or later, landlock in /sys/kernel/security/lsm; most distributions enable it). Docker Desktop runs containers in a VM whose kernel may lack it: lockfile doctor tells.
  • The seccomp profile seccomp.json, next to the Dockerfile. It is Docker's default profile (moby/profiles seccomp/v0.2.4), which already allows what the plugin sandbox calls (Landlock's three system calls, seccomp with filters, prctl, close_range, prlimit64): shipping it pins that allowance whatever your Docker version. install.sh installs it as /usr/local/share/lockfile/seccomp.json, and the launcher runs every console container with --security-opt seccomp= that file, and refuses to start one without it. Never seccomp=unconfined: it would lift every restriction Docker puts on the console itself, and the sandbox does not need it.

Install #

Download the install script from the release you install, read it, then run it with that release:

V=vX.Y.Z
curl -fsSLO "https://raw.githubusercontent.com/DavidGodefroid/lockfile/$V/scripts/install.sh"
less install.sh
LOCKFILE_VERSION=$V sh install.sh

cosign is required. The script checks the signatures of checksums.txt and of the release's images (ghcr.io/davidgodefroid/lockfile:X.Y.Z and its -vault variant) against lockfile's release workflow run on that exact tag, and the archive against checksums.txt. It then installs the launcher as /usr/local/bin/lockfile and the seccomp profile as /usr/local/share/lockfile/seccomp.json (with sudo when needed; LOCKFILE_INSTALL_DIR and LOCKFILE_SHARE_DIR choose other directories), root-owned, and writes the verified image digests into the launcher: it runs the image by digest, never by a tag that could be moved. Docker pulls it on first use. On Windows, install it in WSL 2 with Docker Desktop's WSL integration (whose kernel must have Landlock: lockfile doctor tells).

lockfile version    # the launcher's version and its image's
lockfile update     # the latest release: a new launcher and profile, then docker pull
lockfile update vX.Y.Z

lockfile update checks the signatures and the checksum as install.sh does.

What runs where #

Command Runs as Volumes, mounts
console lockfile, in a container of its own per profile (lockfile-console-<n>; LOCKFILE_CONSOLE_NAME names it), under the seccomp profile, with no capability, no-new-privileges, a read-only root with a 64 MB /tmp, at most 512 processes and 4 GB of memory, no log driver, --add-host=host.docker.internal:host-gateway lockfile-run, lockfile-home
profile, marketplace you (your uid and gid): they write the workspace's configuration the workspace's .lockfile/ only, at its own path (below)
plugin, audit, forget lockfile the home of the console lox run starts, and its profile copy, read-only (--instance NAME picks one of several); none without lox run: they exit 2 and say why
doctor lockfile, under the seccomp profile lox run's socket volume of the lock, read-only, its home and its profile copy, read-only, when there is one; none otherwise
init, doctor --fix you (your uid and gid): they write your agents' files your home, and the project (the nearest directory up holding .lockfile/, or else .git), each at its own path
the agent commands: mcp, status, sources, plugins, plan, run, answers, release agent the socket volume of the workspace's lockfile lock that lox run starts, read-only, only (below)
everything else: help... agent none

The launcher reads:

  • LOCKFILE_IMAGE: the image to run instead of the release's, for example your own build (below);
  • LOCKFILE_VAULT=1: the image with the vault CLI (Credentials from Vault);
  • LOCKFILE_DOCKER_ARGS: more docker run arguments, split on spaces and never globbed, for example the read-only mount of a local source: LOCKFILE_DOCKER_ARGS="-v /data/samples:/srv/lockfile/samples:ro" lockfile console --profile samples;
  • VAULT_ADDR, VAULT_NAMESPACE: passed to the console; VAULT_CACERT too, with its file mounted read-only at its own path.

The company's managed configuration #

When this machine has the Lock Family's managed directory (/etc/lockfamily on Linux, /Library/Application Support/lockfamily on macOS: configuration.md), the launcher mounts it read-only on /etc/lockfamily in every container it runs, the path the image reads managed.toml from, as lox run mounts it in its locks. So lockfile managed show, managed check, doctor, profile, init, the agent commands and the console see the bounds the locks enforce. The whole directory is mounted: the signature bundle, signer.toml and the managed.toml.verified stamp come along. Ownership is checked in the container as on the host (a bind mount keeps owner and mode): the file and the directory must be root's and writable by root only. No directory: no mount, and lockfile runs without a managed policy. A path that is not a directory (a file, a dangling link) or that holds a : stops the launcher before Docker runs. Only the one-shot account-id migration before the console (root, no network) runs without it: it reads no policy.

On macOS, Docker Desktop shares /Users, /Volumes, /private, /tmp and /var/folders only: add /Library/Application Support/lockfamily in Settings > Resources > File sharing, or the launcher's commands fail with mounts denied (never without the policy).

The socket of lox run's lock #

The consoles run hub-attached only, started by lox run (lockhub): each under compose, with its socket in the volume lockfamily-<8 hex of sha256(workspace)>_<instance>-run, which lox run names in <workspace>/.lockfamily/up.json (lockhub's docs/lox-run.md). The agent commands mount that volume on /run/lockfile, read-only (connecting to a socket needs no write), and no other: without it they stop (exit 2) and say why, and what to do (lox run), never falling back to the fixed lockfile-run. The launcher takes the volume only after the checks lea makes for its own agents:

  • up.json is found in the nearest directory up from here whose .lockfamily holds one (that directory is the workspace); the .lockfamily directory and up.json are no link, are yours, and neither group nor others may write them; up.json is lox run's state (version 1, as lox run writes it: two-space indented JSON, the values the launcher reads unescaped) of this very workspace (its workspace, and its project lockfamily-<8 hex of sha256(workspace)>);
  • the lock is the workspace's only lockfile lock, or the one --instance NAME names (the launcher's own option, taken out of the command line; with several and no --instance, the error names them). In .mcp.json, add it to the arguments: "args": ["mcp", "--instance", "NAME"];
  • its socket volume is the one lox run names for it, its clients group the image's (10030);
  • Docker confirms the volume: lox run's labels (compose's project and volume, dev.lockfamily.role=socket, its instance, type and workspace), read as JSON and compared whole; and every container that mounts it is lox run's own (compose ran it from lox run's state directory: its project.working_dir is $XDG_STATE_HOME/lockfamily/<project> or ~/.local/state/lockfamily/<project>, its config_files - or that directory's compose.yaml) or mounts it read-only (the agents', as the launcher's own). A compose file written in the workspace, with lox run's project name and volume, or any container mounting the volume read-write, could serve the socket from an image of its own: the launcher then trusts none of that socket.

up.json only names the lock: what is mounted is what Docker's labels prove. Anything else fails closed.

What these checks are for: mistakes (a stale or copied up.json, another workspace's lock, a volume or compose file of the same name) and files others than you can write. They are no boundary against an agent that runs as you on the host with Docker access (Claude Code started in a terminal usually does): it can write up.json, make volumes and containers with any labels, or docker exec into lox run's containers. Even then it holds none of the console's credentials and cannot sign a decision (the hub signs them, the console checks the hub's key), but it could serve an impostor socket to another agent. To contain the agent itself, run it in lea, whose containers have no Docker access.

lox run starts the console of the default profile (--profile default) unless the lock names another; the agent commands find its socket in the volume.

It never mounts the Docker socket, and refuses a path holding : (docker -v separates on it).

Your own build #

From a clone, or without cloning:

docker build -t lockfile .
docker build -t lockfile https://github.com/DavidGodefroid/lockfile.git

then LOCKFILE_IMAGE=lockfile lockfile .... The launcher of a clone (sh scripts/lockfile ...) runs the image tagged lockfile by default, with the clone's seccomp.json. --build-arg VERSION=vX.Y.Z sets the version that lockfile version prints. The base images are pinned by digest, and cosign (and the Vault CLI, below) by sha256.

Migrating #

From the former Docker guide: its docker run ... lockfile ... commands keep working, but the launcher replaces them; the lockfile-run and lockfile-home volumes are the same. An agent entry that runs docker run -i --rm -v lockfile-run:/run/lockfile lockfile mcp keeps working; lockfile init writes lockfile mcp, which now goes through the launcher.

From a release before 0.3: the image's accounts moved from locksql's ids (10000-10002) to lockfile's own, lockfile-clients 10030, lockfile 10031 and agent 10032. The launcher gives the files of lockfile-run and lockfile-home the new ids before the console, doctor and the commands on the home volume, once (it changes only the old ids): in a throwaway container of the image, as root with CAP_CHOWN and CAP_DAC_READ_SEARCH only and no network. Without the launcher, run the same once before the console:

docker run --rm --security-opt seccomp=/usr/local/share/lockfile/seccomp.json \
  --cap-drop=ALL --cap-add=CHOWN --cap-add=DAC_READ_SEARCH \
  --security-opt no-new-privileges --read-only --network none --user 0:0 \
  -v lockfile-run:/run/lockfile -v lockfile-home:/home/lockfile \
  ghcr.io/davidgodefroid/lockfile migrate-ids

An agent in a container of its own that reached the console through the gid 10000 now needs gid 10030; ALLOWED_UIDS is unchanged.

From a release before 0.6: profile, plugin and marketplace worked in the lockfile-home volume, which the console lox run starts never reads. Add the profiles again from the workspace (lockfile profile add, or copy the config.toml into .lockfile/, yours, 0600), run lox run, then install the plugins again; lockfile-home can then go (docker volume rm lockfile-home).

From a host install (a release binary, then sudo lockfile install): install.sh replaces the binary with the launcher, and the image refuses lockfile install and lockfile update. What the host install created stays until you remove it: the lockfile account and the lockfile-clients group, /etc/lockfile/, the socket directory (/run/lockfile, /etc/tmpfiles.d/lockfile.conf), and the console account's home (profiles, plugins, approved policies, audit log): copy what you keep into the lockfile-home volume.

Set up the console #

The console of lox run #

lox run (lockhub) runs the console and never mounts your workspace in it. The launcher's setup commands write where lox run reads:

  • profile, marketplace edit the workspace's .lockfile/config.toml, as you, in a container that mounts only .lockfile/ (made 0700 when missing; the file 0600). The workspace is the one the agent commands reach: the nearest directory up from here whose .lockfamily/ holds up.json, else, before the first lox run, the nearest one with .lockfamily/, else this directory (where lox add makes it). They refuse a .lockfile or config.toml that is a link, is not yours, or that others may write: lox run would copy none of it. lox run shows the directory's files and every line of config.toml in its review, and copies them to the console's ~/.config/lockfile, read-only; a change reaches the console at the next lox run.
  • plugin, audit, forget run as the console account with the home of the console lox run starts (lockfamily-<hash>_<instance>-home) and its profile copy, read-only, once Docker shows both are lox run's (compose's labels). Run them after lox run; with several lockfile locks, --instance NAME picks one. A running console applies an installed plugin after :review (lox attach INSTANCE).

Profiles #

lockfile profile add appends a validated profile to the workspace's .lockfile/config.toml (see usage.md for every key):

lockfile profile add --profile docs --source s3 --bucket documents --region eu-west-1 --prefix invoices/ \
  --credentials vault --vault-path secret/data/lockfile/docs --production

--project DIR names another workspace (the launcher passes it on as an absolute path and mounts that workspace's .lockfile/ only).

Inside a container, a few things differ from a host:

  • local sources. The path is the console container's, not your machine's. Mount the directory read-only under /srv/lockfile (the image makes that directory 0700, owned by lockfile, so no other account in the container can reach what is mounted below it) and give that path to the profile:

    lockfile profile add --profile samples --source local --path /srv/lockfile/samples
    

    and start the console with LOCKFILE_DOCKER_ARGS="-v /data/samples:/srv/lockfile/samples:ro" (below). The files must be readable by uid 10031.

  • localhost is the container itself. A server on your machine is host.docker.internal, which the launcher maps to your machine; a server in another container is reached by its name on a shared network (LOCKFILE_DOCKER_ARGS="--network NAME" lockfile console ...). A local S3-compatible store without TLS (MinIO, LocalStack) is endpoint = "http://host.docker.internal:9000": plain http:// is accepted to host.docker.internal and localhost only, never on a production profile, and the policy review, the status screen, lockfile doctor and profile add warn that the connection is NOT encrypted. An endpoint on localhost is flagged there too: under lox run it is the lock's own container.

  • No keychain. credentials = "keychain" needs an OS keychain, which the container does not have: use credentials = "ask" (typed in the console at each start), or "vault" with the vault image below.

  • SFTP keys (auth = "key") and known_hosts go in the workspace's .lockfile/, yours, the key mode 0600 (lox run keeps it owner-only in the copy, owned by the console):

    install -m 0600 keys/partner_ed25519 .lockfile/
    

    then --key /home/lockfile/.config/lockfile/partner_ed25519 (the copy's path in the console).

Plugins and marketplaces #

They are installed as the console account, in the home of the console lox run starts; marketplaces are added to the workspace's .lockfile/config.toml (a new one reaches the console, and plugin install from it, after the next lox run). cosign is in the image; signatures are checked as on a host:

lockfile plugin install file.non-empty@1.0.0
lockfile plugin list
lockfile marketplace add acme --index https://plugins.acme.example/index.json --identity-regexp '^https://github\.com/acme/'

(marketplace add asks you to type the name.)

Check #

lockfile doctor

sandbox ok means Landlock and seccomp work under the profile; proc ok container: … is explained in What Docker does and does not separate.

Run the console #

The console runs hub-attached only: lockhub's lox run starts it with its hub flags (lockfile console on its own refuses to start: "launched by lox run only"):

lox add lockfile samples --profile samples
lox run
lox attach samples   # the console's policy review (detach: Ctrl-P Ctrl-Q)
lox approve          # the agent's plans

The rest of this page describes the launcher's own console container (lockfile console with the hub flags lox run writes, and for example LOCKFILE_DOCKER_ARGS="-v /data/samples:/srv/lockfile/samples:ro"). The launcher takes from the container what the console never needs: every capability, privilege gain (the plugin sandbox sets no_new_privs itself), a writable root; and bounds its processes and memory (a plugin may take up to 2 GB). The console refuses to run any plugin, with a message naming the reason, when Landlock or seccomp is unavailable, when it runs without a seccomp filter (seccomp=unconfined), or when the container is not private (below): there is no unsandboxed run. Sandbox and container are checked at start, when a plan arrives and again just before it runs.

Credentials from Vault #

credentials = "vault" runs the vault CLI in the console's container. The default image leaves it out (it adds some 550 MB); the release's -vault image has it (ghcr.io/davidgodefroid/lockfile:X.Y.Z-vault), which LOCKFILE_VAULT=1 selects. For your own build, docker build --target vault -t lockfile:vault . and LOCKFILE_IMAGE=lockfile:vault.

The console reads VAULT_ADDR and VAULT_NAMESPACE from its environment, which the launcher passes on, and VAULT_CACERT, whose file the launcher mounts read-only at the same path:

LOCKFILE_VAULT=1 VAULT_ADDR=https://vault.example.com lockfile console --profile docs

The console account needs no vault login: at start the console asks Vault token for <VAULT_ADDR>: in its terminal (no echo) and keeps the token in memory for the session, never in its home volume or anywhere on disk.

Get the token without storing it, then paste it at the prompt:

vault login -method=oidc -no-store -token-only

-no-store -token-only prints the token and writes no ~/.vault-token. A plain vault login would store the token in your account, where the agent runs: the agent could read it and fetch the login from Vault itself, bypassing the console. The console does not read a stored token (VAULT_TOKEN wins), so a stored one can be deleted. For the same reason, do not leave VAULT_TOKEN set in that shell. When Vault refuses a read (not authenticated or not allowed: the token is expired or revoked, or its policy may not read vault_path), the console asks for a token again; three refusals in a row stop the connection.

Wire the agent #

From the project root, as you:

lockfile init claude

writes the agent's skill or rules file and lockfile mcp as its MCP command (.mcp.json for Claude Code; or codex, cursor, gemini; --user for the whole account). lockfile mcp is the launcher: it starts the MCP server in a throwaway container, as agent, with only the socket volume.

{
  "mcpServers": {
    "lockfile": {
      "type": "stdio",
      "command": "lockfile",
      "args": ["mcp"]
    }
  }
}

For every project instead, claude mcp add --scope user lockfile -- lockfile mcp. Codex, Gemini CLI and Cursor take the same command and args (keep their tool timeout long: the console waits for your approval).

The agent's commands run the same way: lockfile sources, lockfile plan --key invoices/a.pdf --plugin file.non-empty --run --json.

Agent in its own container #

When the agent itself runs in a container without the Docker socket, the launcher cannot start the MCP server: the agent runs the lockfile binary inside its own container as a client of the console container; the two share only the socket volume. The console checks every peer by uid against its own container's accounts, where the agent's uid has none: name it when you start the console, with the release image as it is:

lockfile console --profile samples --allowed-uids 1000

or at build time, in an image of your own:

docker build -t lockfile --build-arg ALLOWED_UIDS=1000 .
LOCKFILE_IMAGE=lockfile lockfile console --profile samples

--allowed-uids and ALLOWED_UIDS are comma-separated lists of uids: the build writes them to allowed_uids in the image's /etc/lockfile/system.toml, the flag adds them to it for that console only (the console prints them at start). Both refuse root (0), the console's own uid (10031) and 65534 (nobody, the kernel's overflow uid). Only whoever starts the console sets them: the human, through the launcher (which passes the flag on) or through lox run (lockhub v0.2.4 or later: a lock's allowed_uids in the lockfamily.yaml lox run reviews with you); no client can. lockfile doctor in the agent's container cannot see a running console's flags: for a uid with no account it warns instead of failing. It is the uid the kernel reports for the agent's process, as the console sees it (the agent container's uid when neither container remaps user namespaces). Any other peer, neither lockfile's nor in lockfile-clients nor listed, is refused.

The agent's image needs what a client host has:

  • the lockfile binary, not the launcher: the one in the release archive (lockfile_X.Y.Z_linux_ARCH.tar.gz, checked as install.sh checks it), the same version as the console's image;
  • /etc/lockfile/system.toml, owned by root, mode 0644, with the image's service_user, client_group and socket_dir;
  • an account lockfile with uid 10031: the client refuses a socket served by any uid but service_user's;
  • a group lockfile-clients with gid 10030 that holds the agent's account: the socket directory is mode 2750 and the sockets 0660, group lockfile-clients.

Start it with -v lockfile-run:/run/lockfile; lockfile init claude then needs no change, and lockfile status checks the link.

What Docker does and does not separate #

Inside Docker the boundary is the one of a host: the agent's containers hold no credential, no plugin and no fetched file, reach the console only through the socket, and a peer that is neither the console account, nor in lockfile-clients, nor in ALLOWED_UIDS is refused. The plugins run in the console's container under Landlock, seccomp and rlimits, which Docker's seccomp profile (the shipped one) lets the console install.

/proc and pid namespaces. On a host, lockfile requires /proc mounted with hidepid=invisible: otherwise any account can read a running plugin's /proc/<pid> entries (command line, thread names, memory counters), which the plugin writes from inside its sandbox, a covert channel to the agent. Docker cannot mount /proc with that option. The image declares instead (/etc/lockfile/container.toml, root-owned) that the console runs in a container of its own, and the console then accepts a plain /proc only when, at start, when a plan arrives and just before it runs:

  • that declaration is a regular file owned by root that others cannot write;
  • the console is in a pid namespace other than the host's, and /proc is that namespace's;
  • no process listed in /proc runs as an account other than root and lockfile;
  • the console runs under a seccomp filter (never seccomp=unconfined).

Otherwise it refuses, naming the condition. So never start the console with --pid=host (the host's processes, your agent's among them, would share its /proc; the console refuses), nor with --pid=container:…, nor start an agent container with --pid=container:lockfile-console (the agent would list the plugins; the console refuses once it sees the agent's process). What remains is the host's own pid namespace, which lists every container's processes: the host's accounts read them unless the host's /proc has hidepid=invisible. In the setup above that is your own account, which has Docker anyway (next paragraph).

Your own account is another matter. Whoever can run docker commands can docker attach to the console and type an approval, docker exec into it and read its memory or the fetched files, or open its home volume. An agent that runs shell commands in your account usually can: the launcher it runs for the MCP server is itself a docker run. The console cannot see this (its terminal belongs to the container), so treat this setup as keeping the sources' credentials and the files out of the agent's reach and the approval in your terminal, not as stopping an agent determined to go around it. For that boundary, run the agent itself in a container with no access to the Docker socket and no shared pid namespace (Agent in its own container).