Locks · lockmq
Running lockmq in Docker
lockmq runs in Docker, with nothing on your machine but Docker and the
lockmq launcher. The launcher is a short shell script
(scripts/lockmq): every command you type,
lockmq console, lockmq version, lockmq mcp and the others, runs in
the lockmq image, never in a binary of its own on your machine. The image
holds the separated setup that keeps the console apart from the agent:
- the console runs as
lockmq(uid 10011), the only account that holds the broker credentials; - the agent's MCP server and CLI run as
agent(uid 10012), a member oflockmq-clients(gid 10010); /etc/lockmq/system.tomlis owned by root and the binary by root; the image has nosudo;- the socket directory
/run/lockmq(mode 2710,lockmq:lockmq-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 ids differ from the locksql image's, so that one agent image can be a client of both.
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/lockmq/$V/scripts/install.sh"
less install.sh
LOCKMQ_VERSION=$V sh install.sh
It checks the release's cosign signature and checksum, and installs the
launcher as /usr/local/bin/lockmq (with sudo when needed; set
LOCKMQ_INSTALL_DIR for another directory). The launcher runs the image of
its own release, ghcr.io/davidgodefroid/lockmq:X.Y.Z, which Docker pulls
on first use: install.sh checks the image's cosign signature too (and its
-vault variant's) and writes the verified digests into the launcher, which
runs the image by digest, never by a tag that could be moved. Linux and
macOS (Docker Desktop); on Windows, install it in WSL 2 with Docker
Desktop's WSL integration and run it there.
lockmq version # the launcher's version and its image's
lockmq update # the latest release: a new launcher, then docker pull
lockmq update vX.Y.Z
lockmq update checks the signatures and the checksum as install.sh
does, and needs cosign the same way. With LOCKMQ_SKIP_COSIGN=1 nothing is
verified and the launcher runs the image by its tag.
What runs where #
The launcher mounts the project (the nearest directory up from where you
are that holds .lockmq/, or that directory itself) at its own path, and
runs each command with the account it needs:
| Command | Runs as | Volumes |
|---|---|---|
console |
lockmq, in a container of its own per project (lockmq-console-<n>; LOCKMQ_CONSOLE_NAME names it), with --add-host=host.docker.internal:host-gateway, no log driver, no capability, no-new-privileges, a read-only root with a 64 MB /tmp, at most 256 processes and 1 GB of memory |
lockmq-run, lockmq-home |
init |
you (your uid and gid), with your home mounted: it writes the agents' files | |
profile add |
you, with HOME=/tmp |
|
the agent commands: mcp, status, queues, read, peek, doctor... |
agent |
the socket volume of the workspace's lockmq lock that lox run runs, read-only (below) |
doctor --fix |
root for its repairs only, then agent for every check, as doctor |
as doctor |
everything else: help... |
agent |
none |
The socket name is derived from the project path, so the console and the
agent see the same one, and the console's hints name real paths. The agent
account must enter the project directory, the only one of the path that
keeps its host mode in the container: mode 0755, the usual, does it
(lockmq doctor reports a project it cannot enter). doctor checks
.lockmq/ for your uid, which the launcher passes as LOCKMQ_HOST_UID.
The launcher reads:
LOCKMQ_IMAGE: the image to run instead of the release's, for example your own build (below);LOCKMQ_VAULT=1: the image with thevaultCLI (Credentials from Vault);LOCKMQ_DOCKER_ARGS: moredocker runarguments, split on spaces, for exampleLOCKMQ_DOCKER_ARGS="--network lockmq-net" lockmq console;LOCKMQ_READONLY_PROJECT=1: the console mounts the project read-only and only.lockmq/writable (it must then be a directory, not a link), for a project your agents write;VAULT_ADDR,VAULT_NAMESPACE: passed to the console;VAULT_CACERTtoo, with its file mounted read-only at its own path.
The launcher never mounts the Docker socket.
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 lockmq 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 lockmq runs without a managed policy. A
path that is not a directory (a dangling link, a file) or holds a :
stops the launcher before docker run.
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/lockmq, 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 lockmq-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 lockmq 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 (10010); - 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.
The socket's name derives from the project (.lockmq/config.toml up from
the working directory) and the profile: run the agent from the workspace,
whose .lockmq lox run's console serves.
Paths #
A project path, $HOME (for init) or a VAULT_CACERT path holding : is
refused: docker -v separates on it. LOCKMQ_DOCKER_ARGS is split on
spaces and never globbed. With LOCKMQ_READONLY_PROJECT=1, .lockmq/ must
be a directory whose physical path is itself; the launcher checks it twice,
the second time right before docker run, but cannot close the instant
between that check and Docker's own mount: an agent that can replace
.lockmq in that instant could redirect the console's writable mount.
Mount .lockmq read-only in the agents' containers to close it.
On macOS (Docker Desktop), lockmq doctor --fix giving back to you a
.lockmq/ an older release gave to uid 10011 (a chown through Docker
Desktop's file sharing) is untested so far.
Your own build #
From a clone, or without cloning:
docker build -t lockmq .
docker build -t lockmq https://github.com/DavidGodefroid/lockmq.git
then LOCKMQ_IMAGE=lockmq lockmq .... The launcher of a clone
(sh scripts/lockmq ...) runs the image tagged lockmq by default.
--build-arg VERSION=vX.Y.Z sets the version that lockmq version prints.
The base images are pinned by digest in the Dockerfile.
Migrating #
From the former Docker guide: its docker run ... lockmq ... commands keep
working, but the launcher replaces them. Install it, then
use lockmq console, lockmq init, lockmq profile add (formerly
lockmq setup, removed); the lockmq-run and
lockmq-home volumes are the same. An agent entry that runs
sh -c 'exec docker run ... lockmq mcp' keeps working; lockmq init now
writes lockmq mcp, which goes through the launcher.
From a host install (the release archives and packages of v0.1.0, then
sudo lockmq install): install.sh replaces the binary with the launcher,
and the image refuses lockmq install and lockmq update. What the host
install created stays until you remove it: the lockmq account and the
lockmq-clients group, /etc/lockmq/, the socket directory (/run/lockmq,
/etc/tmpfiles.d/lockmq.conf on Linux), and the console account's state in
its home (approved policy, audit log). Remove a .deb, .rpm or .apk
package with your package manager first: releases no longer publish any.
Set up a project #
In the project directory, init writes .lockmq/config.toml with a
commented example profile, and the agent files (here Claude Code's):
lockmq init claude
The MCP command it writes, lockmq mcp, is the launcher: the agent starts
the MCP server in a throwaway container (Wire the agent).
Add a broker profile:
lockmq profile add
It asks for the management API URL, the AMQP and stream URLs, the vhosts,
the broker user and where the password comes from, never for the password
itself, and appends the profile to .lockmq/config.toml. Flags give the
answers instead, and --non-interactive never asks
(lockmq profile add --non-interactive --url http://host.docker.internal:15672 --user ro;
usage). Inside a container, localhost is the
container itself, and profile add knows it:
- a broker on your machine is
host.docker.internal(the default), which the launcher maps to your machine; - a broker in another container is reached by its name on a shared network
(
LOCKMQ_DOCKER_ARGS="--network NAME" lockmq console), as in A broker in a container; profile addrefuseslocalhost,127.0.0.1and::1;credentials = "keychain"needs an OS keychain, which the container does not have: usecredentials = "ask", or"vault"with the image below;- a remote broker takes TLS URLs (
https,amqps,rabbitmq-stream+tls), checked against the image's CA certificates (tls = "verify-full"by default); a production profile on a remote host refuses plain ones.
Then check the setup:
lockmq doctor --fix --yes
.lockmq/ stays yours: lox run reads it as you and copies only your own
regular files and directories that neither group nor others can write
(lockhub's docs/lox-run.md, "Profiles: a reviewed copy"); the console reads
that copy, never the project, and lox run shows you every line of its
TOML files before the copy. doctor reports what lox run would refuse;
--fix takes group and others' write away from your files and
directories there, and gives back to you what an older lockmq doctor --fix gave the console account (uid 10011: .lockmq/ itself and a copy of
pii.toml). Those repairs run as root (the launcher starts doctor --fix
as root); every check then runs as agent, as a doctor without --fix
does. A link, or a file of another account, is never repaired: replace it
with your own. The repair enters each directory and never follows a
symbolic link. On a fresh setup doctor exits 0: the console's line is a
warning while lox run runs none.
Run the console #
The console runs hub-attached only: lockhub's lox run starts it, with its
hub flags (lockmq console on its own refuses to start: "launched by lox run only"). Run lox run from the workspace, in a terminal of its own
outside the agent: it starts the hub and the locks, then serves the
decisions (the agent's reads and peeks) in the foreground until you quit.
lox add lockmq events
lox run # starts the hub and the locks, then asks you the agent's reads and peeks
lox attach events # another terminal: the console's own questions: policy review, password (detach: Ctrl-P Ctrl-Q)
The rest of this page describes the launcher's own console container
(lockmq console with the hub flags lox run writes): its options,
--profile P, --show-results or --allow-unmask, and
LOCKMQ_DOCKER_ARGS="--network NAME" for a broker container. The
lockmq-home volume keeps the console account's state from one run to the
next: the approved policy, the audit log and ~/.ssh/known_hosts for an
ssh profile. A key for an ssh tunnel must be in that volume, owned by
lockmq with mode 0600.
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/lockmq:X.Y.Z-vault, checked against
the pinned Vault release sums), which LOCKMQ_VAULT=1 selects. For your own
build, docker build --target vault -t lockmq:vault . and
LOCKMQ_IMAGE=lockmq:vault.
The console reads VAULT_ADDR and VAULT_NAMESPACE from its environment,
which the launcher passes on:
LOCKMQ_VAULT=1 VAULT_ADDR=https://vault.example.com lockmq console
VAULT_CACERT names a file on your machine: the launcher mounts it
read-only in the console's container, at the same path.
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 lockmq-home 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 #
lockmq init claude wrote lockmq mcp as the MCP command in .mcp.json:
the agent runs the launcher, which starts the MCP server in a throwaway
container, as agent, with the socket volume and the project mounted. Start
the agent at the project root, or below it.
{
"mcpServers": {
"lockmq": {
"type": "stdio",
"command": "lockmq",
"args": ["mcp"]
}
}
}
For every project instead, claude mcp add --scope user lockmq -- lockmq mcp. Gemini CLI and Cursor take the same command and args in the files
lockmq init writes for them (.gemini/settings.json, .cursor/mcp.json),
and Codex in the [mcp_servers.lockmq] table lockmq init codex prints for
~/.codex/config.toml. Keep the timeouts there (timeout,
tool_timeout_sec): the console waits up to 5 minutes for an approval.
The client commands run the same way: lockmq status, lockmq queues...
lockmq doctor runs as agent (--fix makes its repairs as root first); docker run --rm --user lockmq -v lockmq-run:/run/lockmq -v lockmq-home:/home/lockmq ghcr.io/davidgodefroid/lockmq:X.Y.Z doctor checks the console account.
Agent in its own container #
When the agent itself runs in a container that cannot reach the Docker
socket, the launcher cannot start the MCP server: the agent runs the
lockmq binary inside its own container instead, as a client of the
console container. The two containers 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:
lockmq console --allowed-uids 1000
or at build time, in an image of your own:
docker build -t lockmq --build-arg ALLOWED_UIDS=1000 .
LOCKMQ_IMAGE=lockmq lockmq console
--allowed-uids and ALLOWED_UIDS are comma-separated lists of uids: the
build writes them to allowed_uids in the image's /etc/lockmq/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 (10011) 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. 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. Every process of that uid,
in any container that mounts the socket volume, may then use the console:
name only the agent's.
The agent's image needs what a client host has:
- the
lockmqbinary, not the launcher: the one in the release archive (lockmq_X.Y.Z_linux_ARCH.tar.gz, checked asinstall.shchecks it), the same version as the console's image; /etc/lockmq/system.toml, owned by root, mode 0644, with the image'sservice_user,client_groupandsocket_dir: without it the client looks for the socket in the user's runtime directory;- an account
lockmqwith uid 10011: the client refuses a socket served by any uid butservice_user's; - a group
lockmq-clientswith gid 10010 that holds the agent's account: the socket directory is mode 2710 and the socket 0660, grouplockmq-clients.
Start the agent's container with the socket volume and the project at the same path as the console's, for example:
docker run … -v lockmq-run:/run/lockmq -v "$PWD:$PWD" -w "$PWD" agent-image
lockmq init claude then needs no change: the lockmq mcp it writes in
.mcp.json runs inside the agent's container. Check from there with
lockmq status.
A broker in a container #
Put the broker and the console on one network, and name the broker by its container name in the profile:
docker network create lockmq-net
docker run -d --name rabbitmq --network lockmq-net rabbitmq:4.1-management
[profiles.dev]
url = "http://rabbitmq:15672"
amqp_url = "amqp://rabbitmq:5672"
and start the console on it: LOCKMQ_DOCKER_ARGS="--network lockmq-net" lockmq console. A broker that
publishes its ports on your machine is host.docker.internal instead.
The stream protocol connects to the address the broker advertises, not
the one in stream_url: set stream.advertised_host and
stream.advertised_port in the broker's configuration to what the console's
container can reach (the broker's container name and port 5552 on a shared
network), or leave stream_url out and read streams over AMQP.
Create the read-only user once, with the container's admin:
docker exec rabbitmq rabbitmqctl add_user lockmq-ro 'choose-a-password'
docker exec rabbitmq rabbitmqctl set_user_tags lockmq-ro monitoring
docker exec rabbitmq rabbitmqctl set_permissions -p / lockmq-ro '^$' '^$' '^(order-events)$'
The last argument is the read permission: list the streams lockmq may
read (and the queues, only for a profile with tier peek; read also
allows a purge, see the security model).
Never give the agent docker exec on the broker container: it is a shell
with the broker's own rights.
What Docker does and does not separate #
Inside Docker the boundary is the same as on a host: the agent's container
holds no credential, has no broker connection and reaches the console only
through the socket; a process that is neither the console account nor in
lockmq-clients (nor in ALLOWED_UIDS or --allowed-uids) is refused.
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 open the lockmq-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 Docker mode like the in-agent approval it improves on:
it keeps credentials out of the agent's reach and the approval in your
terminal, but it does not stop an agent determined to go around it. For that
boundary, run the agent itself in a container with no access to the Docker
socket (Agent in its own container).