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 oflockfile-clients(gid 10030), each in a throwaway container; /etc/lockfile/system.toml(x11 = "refuse"),/etc/lockfile/container.tomland the binaries (lockfile,cosign) are owned by root; there is nosudoand 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 andknown_hosts):lox runreviews it and copies it, read-only, to the console's~/.config/lockfile(the volumelockfamily-<hash>_profile-lockfile); - the console's home is
lox run's volumelockfamily-<hash>_<instance>-home: the plugin store andplugins.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,
landlockin/sys/kernel/security/lsm; most distributions enable it). Docker Desktop runs containers in a VM whose kernel may lack it:lockfile doctortells. - The seccomp profile
seccomp.json, next to theDockerfile. It is Docker's default profile (moby/profilesseccomp/v0.2.4), which already allows what the plugin sandbox calls (Landlock's three system calls,seccompwith filters,prctl,close_range,prlimit64): shipping it pins that allowance whatever your Docker version.install.shinstalls 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. Neverseccomp=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 thevaultCLI (Credentials from Vault);LOCKFILE_DOCKER_ARGS: moredocker runarguments, split on spaces and never globbed, for example the read-only mount of alocalsource:LOCKFILE_DOCKER_ARGS="-v /data/samples:/srv/lockfile/samples:ro" lockfile console --profile samples;VAULT_ADDR,VAULT_NAMESPACE: passed to the console;VAULT_CACERTtoo, 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.jsonis found in the nearest directory up from here whose.lockfamilyholds one (that directory is the workspace); the.lockfamilydirectory andup.jsonare no link, are yours, and neither group nor others may write them;up.jsonis lox run's state (version 1, aslox runwrites it: two-space indented JSON, the values the launcher reads unescaped) of this very workspace (itsworkspace, and itsprojectlockfamily-<8 hex of sha256(workspace)>);- the lock is the workspace's only lockfile lock, or the one
--instance NAMEnames (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 runnames 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: itsproject.working_diris$XDG_STATE_HOME/lockfamily/<project>or~/.local/state/lockfamily/<project>, itsconfig_files-or that directory'scompose.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,marketplaceedit the workspace's.lockfile/config.toml, as you, in a container that mounts only.lockfile/(made0700when missing; the file0600). The workspace is the one the agent commands reach: the nearest directory up from here whose.lockfamily/holdsup.json, else, before the firstlox run, the nearest one with.lockfamily/, else this directory (wherelox addmakes it). They refuse a.lockfileorconfig.tomlthat is a link, is not yours, or that others may write:lox runwould copy none of it.lox runshows the directory's files and every line ofconfig.tomlin its review, and copies them to the console's~/.config/lockfile, read-only; a change reaches the console at the nextlox run.plugin,audit,forgetrun as the console account with the home of the consolelox runstarts (lockfamily-<hash>_<instance>-home) and its profile copy, read-only, once Docker shows both arelox run's (compose's labels). Run them afterlox run; with several lockfile locks,--instance NAMEpicks 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:
-
localsources. The path is the console container's, not your machine's. Mount the directory read-only under/srv/lockfile(the image makes that directory0700, owned bylockfile, 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/samplesand start the console with
LOCKFILE_DOCKER_ARGS="-v /data/samples:/srv/lockfile/samples:ro"(below). The files must be readable by uid 10031. -
localhostis the container itself. A server on your machine ishost.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) isendpoint = "http://host.docker.internal:9000": plainhttp://is accepted tohost.docker.internalandlocalhostonly, never on a production profile, and the policy review, the status screen,lockfile doctorandprofile addwarn that the connection is NOT encrypted. An endpoint onlocalhostis flagged there too: underlox runit is the lock's own container. -
No keychain.
credentials = "keychain"needs an OS keychain, which the container does not have: usecredentials = "ask"(typed in the console at each start), or"vault"with thevaultimage below. -
SFTP keys (
auth = "key") andknown_hostsgo in the workspace's.lockfile/, yours, the key mode 0600 (lox runkeeps 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
lockfilebinary, not the launcher: the one in the release archive (lockfile_X.Y.Z_linux_ARCH.tar.gz, checked asinstall.shchecks it), the same version as the console's image; /etc/lockfile/system.toml, owned by root, mode 0644, with the image'sservice_user,client_groupandsocket_dir;- an account
lockfilewith uid 10031: the client refuses a socket served by any uid butservice_user's; - a group
lockfile-clientswith gid 10030 that holds the agent's account: the socket directory is mode 2750 and the sockets 0660, grouplockfile-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
/procis that namespace's; - no process listed in
/procruns as an account other than root andlockfile; - 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).