Locks · locksql
Setting up locksql in Docker
locksql runs in Docker, with nothing on your machine but Docker and the
locksql launcher. The launcher is a short shell script
(scripts/locksql): every command you type,
locksql version, locksql mcp and the others, runs in the locksql image,
never in a binary of its own on your machine. The console runs in that
image too, but lox run starts it, not the launcher
(Run the console). The image holds the separated setup:
- the console runs as
locksql(uid 10001), the only account that holds the database credentials; - the agent's MCP server and CLI run as
agent(uid 10002), a member oflocksql-clients; /etc/locksql/system.tomlis owned by root and the binary by root;- the socket directory
/run/locksql(mode 2710,locksql:locksql-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.
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/locksql/$V/scripts/install.sh"
less install.sh
LOCKSQL_VERSION=$V sh install.sh
It checks the release's cosign signature and checksum, and installs the
launcher as /usr/local/bin/locksql (with sudo when needed; set
LOCKSQL_INSTALL_DIR for another directory). The launcher runs the image of
its own release, ghcr.io/davidgodefroid/locksql: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.
locksql version # the launcher's version and its image's
locksql update # the latest release: a new launcher, then docker pull
locksql update vX.Y.Z
locksql update checks the signatures and the checksum as install.sh
does, and needs cosign the same way. With LOCKSQL_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 .locksql/, or that directory itself, or the --project
you name) at its own path, and
runs each command with the account it needs:
| Command | Runs as | Volumes |
|---|---|---|
stats, forget |
locksql |
the home volume of the workspace's locksql lock that lox run runs, lockfamily-<8 hex>_<instance>-home, the one its console keeps its state in (--instance NAME for one of several; launcher.md) |
init, config |
you (your uid and gid), with your home mounted: they write the agents' files and your user config; a config file must be under the project or your home |
|
profile |
you, with HOME=/tmp |
|
doctor --fix |
you, with your home mounted (your agents' wiring); first as root, only to give back .locksql/ entries that are not yours |
|
the agent commands: mcp, status, tables, plan, run, doctor... |
agent |
the socket volume of the workspace's locksql lock that lox run runs, read-only (launcher.md) |
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 launcher reads:
LOCKSQL_IMAGE: the image to run instead of the release's, for example your own build (below);LOCKSQL_VAULT=1: the release's-vaultimage;LOCKSQL_DOCKER_ARGS: moredocker runarguments, split on spaces.
They apply to the launcher's own commands only. The console is not one of
them: lox run starts it with the image, volumes, networks and environment
of the lock in lockfamily.yaml (Run the console), and
locksql console exits 2 with the console is started by lox run only (lockhub): run lox run in the workspace.
A path holding : (the project, your home) cannot be mounted: the launcher
refuses it. On macOS, doctor --fix giving back .locksql/ entries that
are not yours (a chown as root through Docker Desktop's
file sharing) is untested.
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 locksql managed show, managed check, doctor,
profile, init and the agent commands run from the host see the bounds
the lock enforces. 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 locksql 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.
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).
Your own build #
From a clone, or without cloning:
docker build -t locksql .
docker build -t locksql https://github.com/DavidGodefroid/locksql.git
then LOCKSQL_IMAGE=locksql locksql .... The launcher of a clone
(sh scripts/locksql ...) runs the image tagged locksql by default.
--build-arg VERSION=v0.8.0 sets the version that locksql version prints.
Your build runs the launcher's commands, never the console: lox run
starts a lock only from a release image, pinned by digest and checked
against its release's cosign signature (locks.<name>.image: ghcr.io/davidgodefroid/locksql@sha256:...).
Migrating from a host install #
Earlier releases installed a locksql binary on the host, and
sudo locksql install a console account, its group, /etc/locksql/ and a
socket directory there. install.sh replaces that binary with the launcher;
the image refuses locksql install, locksql setup and its own
locksql update. What the host install created stays until you remove it:
the locksql account and the locksql-clients group, /etc/locksql/, the
socket directory (/run/locksql, /etc/tmpfiles.d/locksql.conf on Linux),
and the console account's state in its home (approved policy, audit log).
Agents wired with locksql mcp now reach the image through the launcher;
an .mcp.json entry that runs docker run ... locksql mcp itself keeps
working.
Set up a project #
In the project directory, init writes .locksql/config.toml with a
commented example profile, and the agent files (here Claude Code's):
locksql init claude
The MCP command it writes, locksql mcp, is the launcher: the agent starts
the MCP server in a throwaway container (Wire the agent).
Add a database profile with the wizard:
locksql profile add
It asks for the engine, host, port, TLS and where the password comes from,
never for the password itself, and appends the profile to
.locksql/config.toml. --engine and its flags skip their questions, and
--non-interactive asks none (locksql profile add --non-interactive --engine postgres --host db.internal --credentials vault --vault-path database/creds/ro). Inside a container, localhost is
the container itself, and the wizard knows it:
- a database on your machine is
host = "host.docker.internal", which the lock's egress gateway maps to your machine.tlsdefaults toprefer, as for any host: a local server without TLS connects in plain TCP, with a warning; - a database in another container: publish its port on your machine
(
docker run -p 127.0.0.1:5432:5432 ..., orports:in its compose file) and namehost.docker.internalwith that port. Underlox runthe lock sits on an internal network of its own and reaches only its profile's endpoints, through its gateway (lockhub: egress): it cannot join the container's network to reach it by name (lockhub#194); credentials = "keychain"needs an OS keychain, which the container does not have: usecredentials = "ask", or"vault"with the image below;- a SQLite
pathis relative to the project root, which is mounted.
Then check the setup, and repair what can be:
locksql doctor --fix --yes
It wires your agents and keeps .locksql/ as lox run reads it: lox run
copies it, once, for the console, which never reads the project itself, and
takes only directories and regular files that are yours and that no one
else can write. doctor --fix runs as you and removes the write bits of
group and others there (chmod go-w); entries owned by the console account
(uid 10001), which doctor --fix up to v0.13.0 gave it and lox run
refuses, it gives back to you as root, in a container of its own run only
then. The checks of the console and its profiles warn from this container,
which runs no console. Run it again whenever .locksql/ changes hands, by a
copy from another account for example.
Run the console #
The console runs attached to lockhub only: from the project directory,
lox add locksql sql-dev
lox run # a terminal of its own, outside the agent: keep it open
lox attach sql-dev # in another terminal
lox run starts the hub and the workspace's locks, then serves the
decisions in its terminal. It runs the console in a container of its own,
from the image lockfamily.yaml pins for the lock, with its hub flags
(console --hub URL --hub-key-file PATH --instance NAME, plus --profile P
from the lock's profile:: Start the console);
clear results (Ctrl-O) and unmasking (:unmask) are switched on at the
console. The launcher starts no console: locksql console exits 2 with
the console is started by lox run only (lockhub): run lox run in the workspace, and the console binary started without its hub flags exits with
launched by `lox run` only. lox run shows the console's start-up
questions in its own terminal, again when it restarts; lox attach sql-dev
attaches yours to the console instead (detach: Ctrl-P Ctrl-Q).
The lock's home volume, <compose project>_<instance>-home
(lockfamily-<8 hex of sha256(workspace)>_sql-dev-home), keeps the console
account's state from one run to the next: the approved policy, the audit
log, the PII rule sets and ~/.ssh/known_hosts for an ssh profile. The
files a profile names under ~/ (tls_ca, tls_cert, tls_key, the key
of auth = "key", owned by locksql with mode 0600) are in that volume
too: lox add and lox run check them and print the command that copies a
missing one there
(lockhub: the files a locksql profile names).
auth = "password" needs nothing there. The launcher's stats and
forget mount that volume. The locksql-home volume of earlier releases
holds no console's state any more: they mount it only in a directory that
lox run runs no lock for, when it exists, to read what a console started
by hand left there.
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/locksql:X.Y.Z-vault). Under lox run, Vault is a setting of the lock, not of the launcher: declare the lock
with Vault credentials, and lox add pins the -vault image, marks the
lock vault: true and writes the workspace's one vault: block (address,
namespace, CA file) in lockfamily.yaml:
lox add locksql sql-dev --credentials vault --vault-path database/creds/ro \
--vault-address https://vault.example.com:8200 # [--vault-namespace NS] [--vault-ca-file PATH]
lox run
lox vault set --address URL [--namespace NS] [--ca-file PATH] changes the
block later. lox run reviews it, then gives the console VAULT_ADDR, and
VAULT_NAMESPACE when set, nothing else; the CA file, copied once reviewed,
is mounted read-only in the lock. The launcher's LOCKSQL_VAULT and the
VAULT_* variables of your shell play no part. See lockhub: Vault, per
lock.
The console account needs no vault login: at start the console asks
Vault token for <VAULT_ADDR>: (no echo), which lox run shows on a card
in its terminal, and keeps the token in memory for the session, never in
the lock's home volume or anywhere on disk. Get the token without storing
it, then type it there:
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 (locksql doctor fails on it). For the same reason,
do not leave VAULT_TOKEN set in that shell. If you accept a token stored
in your account, vault_token: cli on the lock lets lox run answer the
prompt with your Vault CLI's token, checked first (lockhub: the host's
Vault CLI
token).
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.
Databases that publish no port #
A profile with proxy_command
(usage.md) is not supported
under lox run: a lock reaches only the endpoints its gateway can name,
and a program is not one; the command's client (ssh, docker, kubectl)
is not in the release image, and lox run runs no image of your own
(Your own build). lox run stops before the review on
such a lock
(lockhub: proxy_command):
egress of lock sql-dev: profile prod: proxy_command is not supported under lox run; reach the database through the built-in ssh bastion ([profiles.prod.ssh]) or publish its port (see docs/lox-run.md)
For a database behind an SSH host, use the profile's built-in ssh table
instead (usage.md): the
console opens the tunnel itself, and lox run allows the bastion, and only
it, as the lock's endpoint. In place of proxy_command = ["ssh", "deploy@bastion.example.com", "socat", "-", "TCP:db.internal:5432"]:
[profiles.prod]
engine = "postgres"
host = "db.internal" # as the bastion resolves it
port = 5432
[profiles.prod.ssh]
host = "bastion.example.com"
user = "deploy"
auth = "key"
key = "~/.ssh/bastion_key" # in the lock's home volume
For a database in a container on your machine, publish its port (Set up a project).
Wire the agent #
locksql init claude wrote locksql 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": {
"locksql": {
"type": "stdio",
"command": "locksql",
"args": ["mcp"]
}
}
}
For every project instead, claude mcp add --scope user locksql -- locksql mcp. Codex, Gemini CLI and Cursor take the same command and args in the
files listed in Agent wiring (keep
tool_timeout_sec or timeout there: the console waits up to 5 minutes for
an approval).
The client commands run the same way: locksql status, locksql tables...
locksql doctor runs as agent. To check the console account, run it as
locksql with the volumes lox run gives the lock (compose project
lockfamily-<8 hex of sha256(workspace)>; up.json names the socket one
as socket_volume): docker run --rm --user locksql -v lockfamily-<hash>_<instance>-run:/run/locksql -v lockfamily-<hash>_<instance>-home:/home/locksql ghcr.io/davidgodefroid/locksql:X.Y.Z doctor. It
warns that locksql has no login session: in Docker the container takes the
place of that session, and the checks of
locksql doctor about the host do not apply.
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
locksql 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 in the lock's allowed_uids, in
the lockfamily.yaml that lox run reviews with you (it passes
console --allowed-uids 1000 on), with the release image as it is.
--allowed-uids is a comma-separated list of uids, which the console adds
to allowed_uids of the image's /etc/locksql/system.toml for itself only
(it prints them at start). It refuses root (0), the console's own uid
(10001) and 65534 (nobody, the kernel's overflow uid). Only whoever starts
the console sets it: you, through lox run (lockhub v0.3.0 or later); no
client can. (docker build --build-arg ALLOWED_UIDS=1000 writes the same
list into an image of your own, which lox run does not run: Your own
build.) Every process of that uid, in any container that mounts the socket
volume, may then use the console. 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.
The agent's image needs what a client host has:
- the
locksqlbinary, not the launcher: the one in the release archive (locksql_X.Y.Z_linux_ARCH.tar.gz, checked asinstall.shchecks it), the same version as the console's image; /etc/locksql/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
locksqlwith uid 10001: the client refuses a socket served by any uid butservice_user's; - a group
locksql-clientswith gid 10000 that holds the agent's account: the socket directory is mode 2710 and the socket 0660, grouplocksql-clients.
Start the agent's container with lox run's socket volume of the lock
(socket_volume in .lockfamily/up.json), read-only, and the workspace at
the same path as the console's, for example:
docker run … --mount type=volume,src=lockfamily-<hash>_<instance>-run,dst=/run/locksql,readonly \
-v "$PWD:$PWD" -w "$PWD" agent-image
locksql init claude then needs no change: the locksql mcp it writes in
.mcp.json runs inside the agent's container. Check from there with
locksql status. locksql doctor run there also checks the project's
.locksql/ as lox run reads it: yours, writable by you only
(Set up a project).
What Docker does and does not separate #
Inside Docker the agent's container holds no credential, has no database
connection and reaches the console only through the socket; a process that
is neither the console account nor in locksql-clients (or allowed_uids,
--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 lock's 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 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): it then
reaches the console only through the socket volume.