Locks · lockfile
Using lockfile
lockfile has two sides, in two OS accounts:
- the console (
lockfile console), run by the human in thelockfileaccount. It alone holds the source credentials, the plugins, the fetched files and the audit log; - the agent side (
lockfile plan|run|…andlockfile mcp), run by the AI agent in the human's own account. It holds nothing and gets 1s and 0s.
Setup #
lockfile runs in Docker, through the lockfile launcher: it runs each command below in the
release image, with the account and the volumes it needs (docker.md):
| Commands | Runs as |
|---|---|
configuration: profile, marketplace |
you, on the workspace's .lockfile/ only, which lox run reviews and copies to the console (docker.md) |
console account: plugin, audit, forget, doctor |
lockfile, on the home of the console lox run starts and its profile copy, read-only |
| the console | lockfile, confined, under the shipped seccomp profile (Run the console); local sources mounted with LOCKFILE_DOCKER_ARGS |
init, doctor --fix |
you, with your home and the project |
agent side: status, sources, plugins, plan, run, answers, release, mcp |
agent, with the socket volume of the workspace's lockfile lock that lox run starts, read-only, only (docker.md) |
lockfile version prints the launcher's version and the image's; lockfile update [vX.Y.Z]
replaces the launcher and the seccomp profile with a release's (cosign signatures and checksum
checked) and pulls its image.
The console refuses to run plugins without Landlock and seccomp (the container runs under the
shipped seccomp.json), and when other accounts could read the plugins' processes in /proc:
plans are then refused with proc_not_hidden (E_PROC_NOT_HIDDEN). In Docker the image's
container declaration stands in for hidepid=invisible; see
docker.md for its conditions.
From the console account, lockfile doctor also checks the sandbox, /proc, the work
directory (mode 0700, parents other accounts cannot traverse, not under a world-writable
directory such as /tmp, not on a network filesystem), every local source directory (not
reachable by other accounts or by the clients group: mount it under /srv/lockfile) and the
socket directory's owner (lockfile:lockfile-clients). It prints no path from the
configuration.
lockfile doctor [--fix [--yes]] [--json]
--fix wires the installed agents not wired yet for your account (the only repair it makes) and
reports them again; it asks before each one unless --yes, and refuses without a terminal and
without --yes; --yes without --fix is a usage error. --json prints one line:
{"lock":"lockfile","ok":…,"fixed":[…],"checks":[{"check":…,"status":"ok|warn|fail","detail":…,"fix":…}]},
or, when doctor could not check (a usage error, --fix refused), {"lock":"lockfile","code":"usage","error":…}.
The exit status is 0 exactly when no check fails (a warning does not fail).
Profiles (console account) #
The console reads its profiles from ~/.config/lockfile/config.toml: under lox run, the read-only
copy of the workspace's .lockfile/config.toml that lox run reviews and copies to it
(docker.md). lockfile profile add, run from the workspace, appends
one to .lockfile/config.toml, validated, without touching the rest of the file:
lockfile profile add --profile docs --source s3 --bucket documents --region eu-west-1 --prefix invoices/ \
--credentials vault --vault-path secret/data/lockfile/docs --production
lockfile profile add --profile partner --source sftp --host sftp.example.org --user reader --auth key \
--key ~/.ssh/partner_ed25519 --known-hosts ~/.ssh/known_hosts --base-dir /outgoing
lockfile profile add --profile samples --source local --path /srv/lockfile/samples # mounted read-only, docker.md
lockfile profile add [--non-interactive] [--json] [--project DIR] [--profile NAME] [--production]
[--credentials ask|vault [--vault-path P]] --source local|s3|sftp <source flags>
The source flags are named after the config keys below (--known-hosts for known_hosts, ...).
Without --profile the profile is default, the one lox run starts when lockfamily.yaml
names none. --project DIR writes that workspace's .lockfile/config.toml instead of the one of
the current directory. A profile already in the file is never rewritten: profile add fails
and writes nothing (exists). On a terminal, profile add asks for what the flags did not give
(the source, and its bucket, host, path, ...; the credential source, with none offered for an
SFTP key without a passphrase); with --non-interactive, or without a terminal, a missing value
is a usage error naming its flag. No form takes, asks for or writes a secret: a flag named like
one (--password, --vault-token, ...), a value that embeds a password (user:pw@host) and
any positional argument are refused, without quoting the value. --credentials takes ask (the
console asks at start, masked; the default) or vault with --vault-path; a local source takes
none. With --json, stdout carries exactly one line of JSON: on success
{"lock":"lockfile","profile":…,"config":…,"production":…,"credentials":…} (no credentials for a
local source), on failure {"lock":"lockfile","code":"usage|secret|exists|failed","error":…}.
These are the launcher verbs lox add drives every lock with (lockkit's GUIDE.md §14); lox add lockfile asks the questions and runs them (lox add).
[profiles.docs]
source = "s3" # s3 | sftp | local
bucket = "documents"
region = "eu-west-1"
endpoint = "" # S3-compatible server; https:// (http:// only to host.docker.internal or localhost, never production)
prefix = "invoices/" # keys outside the prefix are refused
credentials = "vault" # ask | vault (none: local, or an unencrypted sftp key); keychain: not in Docker
vault_path = "secret/data/lockfile/docs" # username = access key id, password = secret key
production = true # the profile name is typed for every approval
max_object_bytes = "50MiB" # larger objects are not fetched
max_plugins_per_handle = 16 # answers revealed about one fetched file, at most
handle_ttl = "30m" # a fetched file is deleted after this
answer_quantum = "5s" # results leave the console only on multiples of this
tickets_per_window = 30 # hub tickets a human decides, per ticket_window at most
ticket_window = "5m"
max_pending = 30 # hub tickets pending a decision at once, at most
The ticket limits bound the questions an agent can put to you on lockhub
(configuration.md); more tickets, a shorter ticket_window or a higher
max_pending is a loosening, a lowering applies at once.
Top-level work_dir (absolute, default under the state directory) and max_workspace_bytes
(default 1GiB, all fetched files together) are part of the approved policy: the console
uses them, and handle_ttl, only once :review has accepted them. Moving work_dir, raising
max_workspace_bytes or lengthening handle_ttl is a loosening; a fetched file keeps the TTL
it was fetched with.
Credentials: S3 takes the access key id as user and the secret access key as the secret;
SFTP auth = "password" takes user + password; auth = "key" reads the private key (mode
0600 or stricter, no symlink) in the lockfile account and takes its passphrase as the secret.
A leading ~/ in key and known_hosts is the lockfile account's home.
On SFTP a key whose path below base_dir goes through a symbolic link on the server, or that
the server resolves outside base_dir, is not fetched (fetch_failed).
The container has no OS keychain: use ask (typed in the console at each start) or vault
(the image's vault target, docker.md).
Plugins and marketplaces (console account) #
lockfile plugin install pdf.parses@1.0.0 # exact versions only; signatures checked with cosign
lockfile plugin update pdf.parses@1.1.0 # shows the manifest diff; a changed question needs the name typed
lockfile plugin list | verify [NAME] | remove NAME
lockfile marketplace add acme --index https://plugins.acme.example/index.json \
--identity-regexp '^https://github\.com/acme/lockfile-plugins/\.github/workflows/release\.yml@refs/tags/'
lockfile marketplace add beta --index https://plugins.beta.example/index.json --public-key cosign.pub
lockfile marketplace list | remove NAME
In a separated setup every plugin and marketplace command, list and verify included,
refuses to run outside the lockfile account (from another account the store looks empty).
A version its marketplace withdraws (yanked) refuses to run (plugin_yanked). The yanked
status comes from the marketplace's signed index: plugin install, plugin update and
plugin verify read it and record it in plugins.lock; run lockfile plugin verify
regularly, and plugin update to a version that is not yanked.
The official marketplace's signer is pinned in the binary. An unofficial marketplace's
--identity-regexp must start with ^https://; marketplace add warns when it has an
unescaped . or does not pin HOST/OWNER/REPO/ (other signers would match). It needs
its name typed when added (in a terminal: a typed name is never read from a pipe, nor the
plugin name plugin update asks for), is flagged on every approval screen and in every agent result,
and is never auto-approved.
An unofficial marketplace trusts one signer: a keyless identity (--identity-regexp, and
--issuer, by default GitHub Actions), or a cosign public key (--public-key FILE, the PEM
cosign.pub; ECDSA P-256 or P-384, Ed25519, or RSA of 2048 bits or more), never both. Its
index, manifests and binaries are checked with cosign verify-blob --key and must have a
Rekor entry (a bundle signed with --tlog-upload=false is refused); sign them with
cosign sign-blob --new-bundle-format --key cosign.key --bundle FILE.sigstore.json FILE.
In the config the key is held inline, not as a path: public_key is the base64 body of the
PEM on one line (marketplace add writes it), so it travels with the config and the approval
of the config covers it; a key file swapped on disk cannot change the signer. A key that does
not parse is refused at load, without quoting it. Screens, marketplace list and JSON outputs
show the key's SHA-256 fingerprint (of its DER encoding), never the key; check it with the
marketplace. Anyone holding the private key can publish plugins there.
[[marketplaces]]
name = "beta"
index_url = "https://plugins.beta.example/index.json"
public_key = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
A running console sees an installed plugin or an added marketplace as a policy loosening:
it applies it only after :review in the console.
The console #
lockfile console --profile docs --hub URL [--hub-plain-http] --hub-key-file FILE --instance NAME [--allowed-uids UID,...]
The console is launched by lox run only (lockhub's CLI), which writes the hub flags: without
--hub, --hub-key-file and --instance it refuses to start ("launched by lox run only").
There is no standalone console and no auto mode.
--allowed-uids UID,... lets these uids connect besides the members of lockfile-clients, as
allowed_uids in the image's /etc/lockfile/system.toml does: an agent in a container of its
own whose uid has no account in the console's image
(docker.md). Comma-separated decimal uids; root (0),
65534 (nobody), leading zeros and the console's own uid are refused; the console prints them at start.
Run in its container (docker.md), it checks the isolation (the
console account, its own terminal), the sandbox and /proc, asks for the secret, connects,
and serves the profile's socket in /run/lockfile (the lockfile-run volume). Every plan shows an approval screen: the key as
the agent typed it, each plugin with its question and the meaning of 1 and 0, how many answers
the plan reveals, and a red line per unofficial plugin; it is decided on lockhub (below). Keys:
:review, :status, :quit. Results show as plan …: answered or refused <code>; the
answers, causes, sizes and hashes are in the audit:
lockfile audit [--since 24h] [--json] # console account only
Hub mode. With --hub URL --hub-key-file FILE --instance NAME (the three are required;
lox run writes them), plans are decided on lockhub, never on this terminal; the
policy and :review stay here. file.plan fetches the object at once, hashes it, and asks
the hub with a signed ticket (an object that cannot be fetched is not told to the agent then:
the plan goes to the hub, the screen says so, and an approval answers fetch_failed; the plan
answer waits for the profile's answer_quantum): the screen masked (personal data in the key), the plan hash over
the content's sha256, the key or handle, the plugins and their pins, under the policy in force.
The agent hears pending with the ticket and runs it once decided (lockfile run TICKET,
lockfile_run). The run checks the decision (pinned hub key, this instance, the ticket, still
usable, nonce once), the policy and the plugins, fetches again, and refuses other bytes
stale before any plugin. A ticket runs once, for the agent that asked (its lea token). The
decision is audited with the ticket, the approver, the facade and the hub's signature.
lockfile hub-key --instance NAME prints the instance key for lox run to pin (made once in
the console account's home, 0600).
lox run also passes --hub-plain-http for its http://lockhub:8443 on the hub's own network:
without it an http:// hub URL other than loopback is refused, and with it the URL must still
be a service name without a dot or a private address.
The hub URL is http(s)://host[:port]: no path, query, fragment, user or password (lockhub
serves its API at the root and authenticates the lock by signature).
Supported lockhub versions. Which lockhub releases
(its lox CLI, named lock before lockhub v0.3.0, and its hub image) each lockfile release works with under lox run:
| lockfile | lockkit | lockhub |
|---|---|---|
| v0.8.1 | v0.14.1 | as v0.6.0; v0.10.0 or later for lox run (lox up is deprecated) |
| v0.8.0 | v0.13.0 | as v0.6.0 |
| v0.7.0 | v0.11.0 | as v0.6.0 |
| v0.6.0 | v0.10.0 | v0.3.0 or later (lox): the console runs under lox run only, and the launcher's agent commands, profile add and plugin commands read its .lockfamily/up.json (version 1) |
| v0.5.3 | v0.8.1 | as v0.5.2; the hub URL is http(s)://host[:port] only (lox run's http://lockhub:8443 is one) |
| v0.5.2 | v0.6.0 | as v0.5.1 |
| v0.5.1 | v0.6.0 | as v0.5.0; allowed_uids (--allowed-uids, for lea's agents) from lockhub v0.2.4 |
| v0.5.0 | v0.6.0 | v0.2.0 or later: hub mode, devices: true (paired phones), reveals (reveal: true, Ctrl-O) and renewals |
| v0.4.0 | v0.3.1 | v0.2.0 or later: hub mode only; leave devices and reveal off |
| v0.3.0 and older | older than v0.3.1, or none | none (no hub mode) |
lox run starts these consoles from the lox CLI of lockhub v0.2.0 on (the console account, a
terminal, the reviewed profile copy); the lox run of lockhub v0.1.x cannot run them. A console
older than a feature lockfamily.yaml asks of it refuses the flag and does not start: leave
devices/reveal/allowed_uids off for it. A lock on lockkit older than v0.5.0 ignores the hub's reveal and
renewal requests, which then time out. lockhub's CI does not run a lockfile image: the table
follows each release's lockkit and lockhub's
CHANGELOG ("Supported locks",
v0.2.0).
The agent side #
--instance NAME (on every command below and on lockfile mcp [--profile P] [--instance NAME])
reaches one lock among several of this type that lea mounts (a name is lowercase letters, digits
and -). Without it the client takes $LOCKFILE_INSTANCE, else the one lock in
$LEA_LOCKFILE_INSTANCES. When lea mounts several and neither chooses one, the client reaches
them all: sources and status list the profiles of every lock, and a command with a profile
reaches the lock that serves it (profile names are unique in a workspace), so the one
lockfile mcp server lockfile init writes serves every lockfile lock. A name no lock mounted
here has is refused with no lock directory for the instance set by --instance or LOCKFILE_INSTANCE: no lock of that name is mounted here. lockfile mcp starts whatever the
instance: when it cannot be resolved (a bad name, a LOCKFILE_INSTANCE lea does not list), every
tool, lockfile_status first, answers with the reason.
lockfile console and lockfile hub-key take a different --instance: the name lox run gives
the lock (the hub instance). lockfile doctor takes none.
| Command | MCP tool | What |
|---|---|---|
lockfile status [--profile P] [--instance NAME] |
lockfile_status |
the running consoles' state (busy: see below) |
lockfile sources [--instance NAME] |
lockfile_sources |
profiles of the running consoles (busy: see below) |
lockfile plugins [--profile P] [--instance NAME] |
lockfile_plugins |
questions, meaning of 1 and 0 |
lockfile plan --key K --plugin X… [--input PLUGIN.INPUT='${NAME}'…] [--intent T] [--run] [--profile P] [--instance NAME] |
lockfile_plan |
ask the human to approve (inputs: see placeholders below) |
lockfile run PLAN_ID [--profile P] [--instance NAME] (hub mode: TICKET) |
lockfile_run |
run an approved plan once (hub mode: the ticket of a pending plan, once decided) |
lockfile plan --handle H --plugin X… --run |
lockfile_plan + lockfile_run |
follow-up on the same file |
lockfile answers HANDLE [--profile P] [--instance NAME] |
lockfile_answers |
answers already given |
lockfile release HANDLE [--profile P] [--instance NAME] |
lockfile_release |
delete the fetched file now (answers at the next answer_quantum) |
Every command takes --json and --profile P (optional when one console runs). Exit codes
depend on the outcome only, never on an answer: 0 answered, 1 no console running or another
error, 2 refused (an E_* refusal; unknown_profile is 2 whether the client or the console finds
it), 3 denied or no answer in time, 64 usage. status exits 1 when a profile it reports on has
no console.
A console serves one request at a time. busy means it did not answer the probe within 2 s
because it is serving another one: a plan waiting for the human, or a run until its
answer_quantum. A console whose profile name is too long for a plain socket name cannot be
listed while it is busy; passing its profile with --profile still reaches it.
Hub mode and expired tickets. A ticket whose approval the console can no longer
use (lockkit reports it expired; lockfile's read_pii tickets have no TTL, so this is a
decision the console's clock refuses) is not a timeout: lockfile run TICKET and
lockfile_run answer pending with a new ticket for the same plan, which the human approves
again on lockhub, shown which plan inputs moved (by name: profile, key, handle,
sha256, plugins, pins, unfetched; never values). Run the new ticket once decided. The console
re-checks the policy and the plugins before it asks again (a changed policy is not renewed:
plan again); the content cannot be checked without fetching it, so the renewal names
sha256 unchecked (never shown as unchanged, never one click) and the run checks it when
it fetches again, as for any ticket; so is a plan whose object could not be fetched (its
approval answers fetch_failed). A ticket that cannot be asked again answers a
timeout ("ticket expired, and could not be asked again: plan the request again"); the console prints and audits why
(hubclient.AnswerCode: e.g. policy_changed, recheck_failed for a plugin that changed,
not_renewable). The console
never runs without its hub (--hub is required): there is no standalone mode, and no
rollback to one. The other hub flags lox run writes (--devices-file, --require-device,
--seal-clear, --facade-keys-file for a reveal of the clear screen on a local facade,
Ctrl-O) come with --hub from lockkit; the console audits reveals and renewals.
Output is capped in text and in JSON alike: a value (a text line) is cut at 512 characters, and
output above 64 KiB is not printed (output too large, exit 1).
There is no listing or stat: the agent asks the user for the exact key.
Placeholders: values the human types #
A protocol-2 plugin may take inputs ("does the extracted text contain ${needle}?";
lockfile plugins lists them). The agent never gives their values: it binds each input to a
placeholder, and the human types the value.
lockfile plan --key invoices/2026/10/abc123.pdf --plugin pdf.contains \
--input 'pdf.contains.needle=${email}' --intent "is the customer's address in it?"
(MCP: "inputs": {"pdf.contains.needle": "${email}"}.) A literal value is refused
E_LITERAL_INPUT, before anything is shown or pushed. The approval screen shows the question
with its placeholder and values to type: ${email}; lox run asks the value on the ticket card,
without echo, and seals it in the decision to the ticket's one-use key, so neither the hub nor
the agent sees it. Such a ticket is never decided by an auto rule.
The console then holds the value in memory for the rest of its session (wiped when it ends): a
plan on another file with the same ${email} asks nothing new, and its screen says
${email}: typed at 14:02, reused · replayed on 3 files this session; the plan answer lists
the placeholders held (held_inputs). To search another value, use another placeholder name.
A value that does not fit its input's kind (text, email, digits) or max_bytes refuses
the run E_INPUT_INVALID; the console prints ${email}: not a valid email; plan again, never the
value. Each (plugin, distinct placeholder) pair counts as one answer in max_plugins_per_handle.
Where the value is (the human only). A plugin with detail may also say where it found the
value (found on pages 4512, 12307). The console prints it, escaped, under shown here only; the agent got 1/0, only while clear results are on: Ctrl-O or :results in the console window,
which you reach with lox attach, and which lockkit refuses at desktop security levels 1 and 2
(the agent's account can read that window). lockfile console has no --show-results flag.
The detail never goes to the agent, the audit or the hub, but it stays in the terminal's
scrollback.
Wiring an agent #
lockfile init claude|codex|cursor|gemini (the agent is required), run from the project,
writes the skill or rules file and lockfile mcp as the MCP command into the project root (the
nearest directory up holding .lockfile/config.toml, or else .git): the launcher starts the
MCP server in a throwaway container with the socket volume only (docker.md).
It never asks and is idempotent. For every project instead: lockfile init <agent> --user, or
lockfile doctor --fix, which wires every installed agent. Claude Code
may call lockfile_status, lockfile_sources and lockfile_plugins without a prompt; plans
and runs always go through Claude Code's permission prompt and the console.
Updates #
lockfile update (or lockfile update vX.Y.Z) replaces the launcher and the seccomp profile,
then pulls the release's image; restart the console, the volumes keep its state. The agent's
containers start from the new image on their next run. With your own build, rebuild it and
keep LOCKFILE_IMAGE pointing at it.
A marketplace index older than the last one verified for that marketplace is refused (a
replayed index could lift a yank); the last generated time of each marketplace is kept next
to plugins.lock.