Locks · locklog
Using locklog
This page covers the profile file, the sources, the agent's tools and the budgets. The security model is in security-model.md.
The profile file #
locklog profile add (which lox add locklog <name> calls) writes
.locklog/config.toml in your project. lox run reviews the profile
directory, and the console reviews the policy at its first start. After that it serves only the approved
copy. A change that loosens the policy waits for you at the console's
:review.
version = 1
[profiles.staging]
production = false # profile add asks; an unanswered question means true
[profiles.staging.sources.nginx]
kind = "file"
paths = ["/var/log/nginx/access.log*"] # rotated and .gz/.zst files included
format = "combined"
[profiles.staging.sources.system]
kind = "journald"
units = ["nginx.service", "shop-*.service"]
# A docker source cannot share a profile with file or journald sources: it runs in a
# console of its own, so a lock of its own (profile add writes it as <profile>-docker).
[profiles.staging-docker]
production = false
[profiles.staging-docker.sources.api]
kind = "docker"
containers = ["shop-api-*"]
format = "json"
A Loki source:
[profiles.staging.sources.loki]
kind = "loki"
url = "https://loki.internal:3100"
selector = { namespace = "shop" }
trusted_labels = ["app", "level", "pod"]
auth = "bearer"
credentials = "ask" # typed at the console, never written to disk
Profile keys #
| Key | Default | Meaning |
|---|---|---|
production |
false when the key is absent; locklog profile add asks (or takes --production), and an unanswered question means true |
Turns on the production rules: redact-unknown, unmask never by default, authenticated backends over TLS, tighter rolling budgets, prefilter refused, the host-agent trial refused |
structured |
redact-unknown on production, else detect |
How structured leaves are masked: redact-unknown redacts every leaf that is neither trusted nor allowed |
text |
detect |
Free text: detect, or template. Accepted and reviewed, but not applied yet: free text is masked by the detectors in both |
detectors |
all default detectors | Value detectors to run |
unmask |
never on production, else approved |
Whether the agent may ask you to unmask a value |
lookup |
free |
locklog_lookup free and rate-limited, or approved (a ticket each time) |
strict |
false |
Accepted and reviewed, but not applied yet (planned: every search and count becomes a ticket) |
prefilter |
false |
Opt-in speed-up for non-production; reveals through latency how many records hold a clear substring, and turns off the masked_failures count of text searches (see The agent's tools) |
user_journals |
false |
Also serve user-*.journal files |
pseudonym_rotation |
7d |
Epoch length (session or a duration; never is refused on production) |
pseudonym_max_age |
30d |
How long a replaced epoch key is kept; older ones are deleted when the console opens the profile |
limits |
see Budgets | Per-call and rolling budgets |
auto |
every action ask |
The profile's auto mode for lox run: "full-auto", or a table of read, unmask, lookup set to auto, ask or deny (see What lox run decides) |
Sources take kind, format, multiline (none or indent) and
trusted (fields shown in clear after the detectors, selectable and
countable), plus per kind:
| Kind | Keys |
|---|---|
file |
paths (absolute globs; ** only with recursive = true, depth ≤ 4). In cri or docker-json format, the frame's stream is trusted by default, as a label; frame attrs are not kept (only a docker source keeps them), so trusting attrs or attrs.<key> is refused |
journald |
units (globs on _SYSTEMD_UNIT), allow (extra fields, reviewed; core dumps, command lines, executables, audit and OBJECT_ fields can never be allowed) |
docker |
containers (name globs; a glob matching a locklog console is refused), attrs (json-file log-opt attributes to keep) |
loki |
url, selector, trusted_labels, trusted_metadata, auth (none refused on production, basic, bearer, mtls), org_id, user (basic), credentials (ask or vault, with vault_path), tls, tls_ca, tls_cert and tls_key (mtls) |
elastic, opensearch (preview) |
url, selector, indices, trusted_fields, time_field, message_field, tiebreaker, read_only_roles (OpenSearch only), auth (as loki, plus api_key for elastic), user, credentials, vault_path, tls, tls_ca, tls_cert, tls_key |
Managed configuration #
A company can bound every locklog of a machine with the Lock Family's
managed file, /etc/lockfamily/managed.toml on Linux and
/Library/Application Support/lockfamily/managed.toml on macOS (schema,
signing and deployment:
lockfamily docs/managed.md).
The file and its directory must be owned by root and writable by root
only; a badly owned, invalid or badly signed file is a refusal to start,
never a file ignored. locklog reads its [locklog] section:
version = 1
organisation = "Acme"
[locklog.bounds] # the loosest policy allowed
unmask = "never"
lookup = "approved"
kinds = ["file", "journald", "loki"]
hosts = ["*.logs.acme.internal"]
credentials = ["vault"]
tls = "verify-full"
network = [] # no LOCKLOG_NETWORK
limits = { max_records = 100, max_session = "8h", remote = { range = "1d" } }
auto = { unmask = "ask" }
detectors = ["iban"] # always on, added to every profile
[[locklog.bounds.pii_mask]] # always on, before the project's rules
sources = ["app-*"]
field = "user.email"
[locklog.defaults] # for the keys a profile leaves unset
structured = "redact-unknown"
limits = { max_records = 50 }
[locklog.profiles.prod-loki] # served as written
production = true
[locklog.profiles.prod-loki.sources.loki]
kind = "loki"
url = "https://loki.logs.acme.internal"
selector = { env = "prod" }
auth = "bearer"
credentials = "vault"
vault_path = "secret/loki"
Precedence: hard-coded floor > managed bounds and profiles > project file > user file > managed defaults > built-in defaults.
- Profiles:
[locklog.profiles.<name>]takes every profile key,sources,autoandlimitsincluded, with the same checks as a profile of.locklog/config.toml(production rules too); the defaults fill what it leaves unset. A profile of the same name in the project or user config is refused at load, naming it: rename yours.locklog profile rulesmarks managed profiles(managed). - Defaults:
[locklog.defaults]takes the scalar profile keys,autoandlimits.*, notsources; a value of the config files wins over it. - Bounds: the merged profile is checked against
[locklog.bounds]at the console's start and at every policy poll (2 s). A profile beyond a bound is refused with no prompt, citing the key (blocked by company policy: locklog.bounds.unmask = never (profile dev: unmask = approved)), auditedrefused-managed, and no human can approve past it. A loosening edited in while the console runs is refused at the next poll, never pending. A duration or size beyond its bound is a violation, never clamped; the built-in default counts when the profile sets none. A managed default or a managed profile beyond the bounds is a load error. - PII:
detectorsare added to every profile.[[locklog.bounds.pii_mask]]takes the keys of a[[mask]]rule ofpii.toml(sources, which may be globs here,fieldorlabel,mode,kind): the rules are part of the policy, applied before the project's, and shown(managed)by the console's PII view andlocklog_pii_list. A project[[allow]]that could show what one masks is refused at load, citinglocklog.bounds.pii_mask. [family] vault = { required = true }acts ascredentials = ["vault"]on every remote source whoseauthis notnoneormtls, cited asfamily.vault.required.locklog managed showprints the file's path, organisation, fingerprint and signature, the bounds, the defaults, the managed PII rules and the managed profiles (no managed configurationand exit 0 without a file, the error and exit 3 for a refused one).locklog managed check [--profile NAME] [--project DIR]checks the project's profiles as the console does, with the launch environment (LOCKLOG_NETWORK,LOCKLOG_EXTRA_ROOTS), without a connection, a log, a credential, a hub or a terminal: oneblocked by company policy: …line per violation and exit 3, elseok: …and exit 0.lox runruns it before it starts the lock.locklog doctorhas amanagedline, andlocklog consoleexits 3 on a refusal.- The host launcher (
scripts/locklog) mounts the machine's managed directory read-only on/etc/lockfamilyin every container it runs, aslox runmounts it in its locks:managed show,managed check,doctor,profile,initandmcprun from the host see the bounds the console enforces (install.md).
| Bound key | Type | Looser (refused) |
|---|---|---|
production |
true |
a profile that is not production |
structured |
"redact-unknown" |
detect |
text |
"template" |
detect |
unmask |
"never" |
approved |
lookup |
"approved" |
free |
strict |
true |
strict = false |
prefilter, user_journals |
false |
true |
pseudonym_rotation |
session, a duration or never, a ceiling (session < durations < never) |
a longer rotation |
pseudonym_max_age |
duration ceiling | longer |
auto.<action> (read, unmask, lookup) |
deny, ask or auto, a ceiling; a preset (auto = "full-auto") is refused in a bound |
a looser rule (deny < ask < auto), the profile's preset included |
limits.local.*, limits.remote.* (range, count_range, max_bytes, bytes_per_hour; remote also metric_queries_per_hour, requests_per_minute) |
ceiling | a larger value |
limits.max_records, limits.max_output, limits.lookups_per_hour, limits.lookups_per_hour_per_uid, limits.pii_adds_per_hour, limits.pii_rules_max, limits.max_calls |
ceiling | a larger value |
limits.idle_timeout, limits.max_session |
duration ceiling | longer |
kinds |
allowed source kinds (file, journald, docker, loki, elastic, opensearch) |
a source of another kind |
hosts |
host patterns (* any run) |
a loki, elastic or opensearch url whose host matches none |
paths |
allowed directories | a file source path outside them |
auth |
allowed modes (none, basic, bearer, mtls, api_key) |
another mode |
credentials |
allowed modes (ask, vault) |
another mode |
tls |
a floor (disable < prefer < require < verify-ca < verify-full) |
a weaker remote source |
network |
allowed LOCKLOG_NETWORK names; [] none |
another network |
extra_roots |
allowed LOCKLOG_EXTRA_ROOTS directories; [] none |
a root outside them |
detectors |
required detectors | none: added to every profile |
[[pii_mask]] |
mask rules, as [[mask]] |
none: always on; an [[allow]] over one is refused |
A value outside an allowlist (kinds to extra_roots) is cited as
blocked by company policy: locklog.bounds.<key> does not allow <field> = <value> (profile <name>; allowed: …).
Wiring an agent #
locklog init <agent> wires an agent in the project (lox add locklog
runs locklog init claude; locklog doctor --fix wires every agent
installed here that is not wired yet). The launcher runs it in the image as
you, with the project writable. In an agent's own container, where the
recipe copies the locklog binary in, run locklog init <agent> there.
Every init also creates a commented .locklog/config.toml when the project
has none.
| Agent | What locklog init writes |
|---|---|
claude |
.mcp.json (the locklog mcp server), .claude/skills/locklog/SKILL.md, the allow list below |
codex |
a locklog section in AGENTS.md; it prints the ~/.codex/config.toml table to add yourself (it writes no file outside the project) |
cursor |
.cursor/mcp.json, .cursor/rules/locklog.mdc |
gemini |
.gemini/settings.json (timeout 600 s, which covers the console's 5-minute approval timeout), a locklog section in GEMINI.md |
For Claude Code it also adds the free tools to permissions.allow in
.claude/settings.local.json, the personal file, so they do not prompt.
It never writes the shared .claude/settings.json or your user settings,
and it warns when git does not ignore the personal file. Tools that ask
the console (locklog_request_change, locklog_scan, locklog_unmask)
are never on that list. Remove an entry to be asked again; a re-run adds
it back and says so.
The agent's tools #
| Tool | What it does | Cost |
|---|---|---|
locklog_status |
consoles, profiles, sources, budgets left, the pseudonym epoch | free |
locklog_sources |
per source: kind, format, field and label names (masked names as ⟦key:ID⟧), trusted values, coverage |
free |
locklog_search |
masked records of a range: where, text, regex, fields, limit ≤ 200, cursor |
free within budgets |
locklog_count |
histogram, by trusted or pseudonym fields and by_labels labels (≤ 2 in all), first_last |
free within budgets |
locklog_patterns |
top templates of the masked text, baseline diff |
free within budgets |
locklog_context |
records around a ref (≤ 50 each side) |
free |
locklog_trace |
one id or pseudonym across sources, merged timeline | free within budgets |
locklog_tail |
new records for ≤ 50 s | free (rolling budgets) |
locklog_lookup |
a clear value → its pseudonym (detector kinds only) | free, rate-limited |
locklog_pii_list, locklog_pii_add |
the masking rules; add masking (never remove it): [pseudonym ]source:field, a masked key by its ⟦key:ID⟧. Capped by pii_adds_per_hour and pii_rules_max; locklog_status counts the agent's rules |
free, capped, audited |
locklog_request_change |
ask for a loosening | you decide at :review |
locklog_scan |
one call over a budget, approved for its whole plan | you approve |
locklog_unmask |
the clear value of up to 8 exact pseudonyms of one record, once, with a reason | you approve a ticket at lox run, the deck or a phone (automatically only with auto.unmask = "auto"); off by default on production |
Every result carries the pseudonym epoch. A result with
complete: false has a cursor: passing it back with the same parameters
continues the same plan for free (the range may be left out: the cursor
holds the first call's window). Other parameters are refused with
E_STALE, naming the ones that differ. A docker source's container is a
label, not a field: where takes {label: "container"} and locklog_count
by_labels; as a field it is refused, naming the label. A record the masker fails on is withheld whole and counted in
masked_failures. One exception: with prefilter = true, a search whose
query has text or a contains predicate masks only the records whose
clear text holds those needles, so counting the failures would tell whether
a failing record holds them. Such a search does not count failures:
masked_failures is absent (0) although failing records are still
withheld. Counts, patterns, context, trace and tail are not pre-filtered
and always count them. Log lines are untrusted data. The skill installed by locklog init tells the agent so, and tells it to start with locklog_count on
trusted fields.
Query model #
{ "sources": ["nginx"], "from": "2026-10-30T13:00:00Z", "to": "2026-10-30T15:00:00Z",
"where": [ {"field": "status", "op": ">=", "value": 500},
{"field": "remote_addr", "op": "=", "value": "⟦ip:K2M9ZDQ81RTA⟧"} ],
"text": "/api/orders/", "limit": 100 }
= != in existson any field.< <= > >=only on trusted fields: ordering on a masked field would reveal it.containsandmatches(RE2, ≤ 256 bytes) run on the masked view. They never match inside a placeholder.- A predicate value the detectors flag as personal is refused with
E_PII_IN_QUERY. Uselocklog_lookupto get its pseudonym.
Context, trace and tail: known limits #
These never give complete: true with a lost or repeated new record; some
cases are pessimistic on purpose.
locklog_tailsayscomplete: false(it cannot vouch for every new record) when a file was empty at the start and then grows (common right after a logrotatecreate), when a rotated copy is compressed during the tail (.1to.1.gz), when two unrelated streams hold the same records at the same offsets (a copy cannot be told from them), or when streams keep changing for many polls.- A file truncated and rewritten with the same first 64 KiB keeps its stream identity, as for the file source.
- A multi-line record still receiving continuation lines when the tail ends is evaluated as it stands then.
- A tail plans every local stream at each poll; that work is not charged to the rolling budgets. While a tail runs (up to 50 s), the other tool calls of the same profile wait for it.
locklog_contexton a file reads back at most 16 granules before its record: it can return fewerbeforerecords than asked withcomplete: true(the start of what it read). The target record is always returned, even alone over the output cap. A ref from before a policy change is refused withE_POLICY_CHANGED, one from an older pseudonym epoch withE_STALE.locklog_tracematches a pseudonym in values, not in a masked field name (⟦key:ID⟧).
Budgets #
| Per call | Local (files, journald, Docker) | Remote (Loki) | Ceiling |
|---|---|---|---|
| range with text, regex or masked-field predicates | 7 d | 24 h | 400 d |
| range of trusted-field-only counts | 30 d | 30 d | 400 d |
| bytes admitted | 512 MiB | 512 MiB (decompressed responses) | 64 GiB |
| records / output | 200 / 64 KiB | 200 / 64 KiB | 1,000 / 1 MiB |
| Rolling | Local | Remote (non-production / production) |
|---|---|---|
| bytes per hour | 40 GiB | 20 GiB / 10 GiB |
| backend requests per minute | — | 600 / 240 |
| server-side metric queries per hour | — | 120 / 60 |
lookup distinct values per hour |
50 per console, 20 per uid (production: 10 per console) | same |
agent pii.add |
10 per hour, 50 rules | same |
The connection checks of a remote source are not charged to these budgets: they run once per connection, before the agent can call (up to 2 requests for Loki, up to 8 for Elasticsearch or OpenSearch).
A call over a budget is refused with E_OVER_BUDGET and the estimate,
before it touches any data. locklog_scan is the only way past. A call
also stops at its deadline (30 s, its planning included) and returns
complete: false with a cursor. The numbers behind these defaults are in
performance.md.
The console runs at most limits.max_calls agent calls at once (default
4, ceiling 64): parallel calls would otherwise multiply the per-call
budgets. One more is refused at once with E_BUSY, before any work; the
agent sends it again once one of its calls answered. A tail holds its slot
for its whole duration. A scan waits for its approval without a slot and
takes one once approved; an unmask or a lookup takes none.
The output cap applies to every answer:
- A record whose masked rendering alone exceeds it comes back as a stub:
its
source,refandts,"oversized": trueand thebytesof its rendering. Ask for some of its fields (fields), or for its context. - A whole count whose groups do not fit is paged: each page holds the next
groups in order (
groups_fromofgroups_total) and acursorto the rest, for free; the last page iscomplete. A partial count (its scan continues) shows the first groups that fit, with"truncated": true,omitted_groupsandomitted_n(the records in the groups left out for now). - A search keeps the matches its page cannot hold for the next pages.
When one call's matches outgrow that cache (64 MiB for the console), a
query without predicates (browsing a range) stops before the part that
did and continues from there with its cursor (it reads local logs in
parts small enough for this; a single remote slice whose matches alone
outgrow the cache is still refused). A query with predicates is
refused with
E_OVER_BUDGET("too many matching records in one call to page through"): where a call stops never depends on what its predicates match. Narrow its range, sources orfields, or make a predicate more selective.
Starting: lox run #
The console is started by lox run only (from lockhub v0.10.0, the first
release that knows the locklog type: lockhub#84, #86, #87), hub
attached: locklog console without its hub flags refuses with lockkit's
"the console is launched by lox run only". The journey:
brew trust davidgodefroid/tap
brew install davidgodefroid/tap/lockfamily # or: brew install davidgodefroid/tap/locklog
lox init # or: lox add locklog logs
lox run # in a terminal of its own, kept open
lox add locklog <name> runs the three launcher verbs (setup.md)
from the project, lox run starts the hub and the console, with the log
directories of the profile's mount plan bound read-only, and shows every
ticket and decides it, by its rules or by you. A host agent (Claude Code,
Codex...) reaches the console through the launcher's locklog mcp, which
.mcp.json names: the launcher finds .lockfamily/up.json up from the
current directory and mounts the socket volume of the locklog lock it
names, read-only. Without up.json, or without a locklog lock in it, it
says to run lox run first and exits 2. With several locklog locks,
--instance NAME picks one ("args": ["mcp", "--instance", "NAME"] in
.mcp.json); without it the error names them. lea's agents reach the
socket the same way, mounted by lea.
What lox run decides, and what it asks #
A scan, an unmask or an approved lookup becomes a ticket on lockhub. A
scan is risk read (one approval covers its whole plan, its continuations
need no ticket); an unmask and an approved lookup are read-pii, which
only the terminal, the deck or a phone approve, never a chat facade. The
free tools (search, count, patterns, context, trace, tail, a free lookup)
need no ticket. The tickets are version 2 (lockkit's kind read, the
profile as their subject), so that lox run can apply the profile's rules.
Each profile has an auto mode, one rule per action that becomes a ticket:
| Action | Ticket | Default |
|---|---|---|
read |
an approved scan (locklog_scan) |
ask |
unmask |
an unmask (locklog_unmask) |
ask |
lookup |
an approved lookup (lookup = "approved") |
ask |
[profiles.local]
auto = "full-auto" # every action auto
[profiles.prod.auto]
read = "auto" # scans left to lox run's auto mode
unmask = "deny" # never, even with your approval
# lookup = "ask" # the default
A table may start from a preset (preset = "full-auto" or "default")
and override actions. Unknown keys and values are refused, with the
profile named and the value never quoted. The console holds its side,
whatever lox run does:
deny: the console refuses the call before any ticket (E_AUTO_DENY, retryask_user), in both oflox run's modes.lookup = "deny"refuses free lookups too; the free tools are not affected byread.ask: the ticket is signednever_auto.lox runasks you; an auto rule's approval of it is refused by the console (lockkit'sErrAuto): nothing runs, the agent hears that the ticket waits for you, and your approval then runs it.auto: the ticket is left tolox run's auto mode, which may approve it without a question (manual mode still asks).
The rules belong to the approved policy (its fingerprint; the defaults
are stored sparsely, so a policy approved before the rules existed keeps
its fingerprint). Moving a rule toward auto (deny → ask → auto)
is a loosening: the console applies it only once you accept it at its
start or at :review, with the profile's name typed on a production
profile. A move toward deny applies at once; a ticket asked before it
is then refused and withdrawn. The rules in force show in the review, in
:status, and in locklog_status (auto).
locklog profile rules [--json] [--project DIR] [--profile NAME] prints
each profile's effective rules, the defaults filled in; this is what lox run reads. With the console's state readable (beside the console), the
rules are those of the approved policy ("source": "approved"), and a
config file whose rules differ is flagged pending_review with its
file_rules; elsewhere they are the config file's ("source": "files").
The audit records the rule in force (auto_rule, such as "auto.read = ask") on a ticket's pending, approved, refused and run records and on a
denied call, and what decided a ticket (decided_by: human, or
auto:<rule id> from lox run's decision).
The console #
The console prints nothing for a free call (the audit records it), unless
clear results are on (Ctrl-O, below) or a source fails: then it prints one
line with the method, the source, the class of failure the agent gets
(E_SOURCE) and the error, masked and sanitised (a file path, a backend's
status; never a record). A stale stream, which the agent just runs again,
is not printed. It never asks about an agent's request: those are tickets
on lockhub. It still decides its own widenings at its terminal (lox attach <name>): the policy review at its first start and every loosening at
:review (y; on a production profile, the profile's name typed exactly),
and :unmask.
| Command | What it does |
|---|---|
:status |
the profile, session, pending changes, sources, budgets and hub |
:review |
the policy change and change requests waiting for you |
:unmask |
allow clients to ask for unmasked output (y; on production, the profile's name) |
Ctrl-O (:results) |
clear results on or off: the records each agent call returns are printed in clear on this terminal only, never to the agent (see below) |
:quit |
end the console |
An unmask ticket's screen shows the record masked, the asked spans marked » «,
the agent's reason, how many records holding each pseudonym agents were
given in this epoch (counted by the console from the calls it answered and
audited, by pseudonym only, since the console started), and two warnings:
the agent receives these clear values once, and can link them to every
record with the same pseudonym in the epoch. After a yes the record is read again, and the unmask is refused
(E_STALE) if the record, the epoch or the policy changed.
With clear results on (Ctrl-O), the console prints, after each agent call,
the records it returned with their pseudonyms in clear; secrets,
⟦redacted⟧ values and masked field names stay as they are. The agent
still gets the masked result. The records are taken from the call itself
(a remote record is never fetched again for this; a page served from the
result cache is read again from a local source). The audit counts them
(shown_clear), never their content. An unmask screen opened while clear
results are on also shows its record in clear. lockkit refuses Ctrl-O at
desktop levels 1 and 2 and warns on a production profile. On a production
profile, where unmask = "never", this is how you read a record the agent
points you to.
Tickets on lockhub #
- The hub shows a ticket's screen masked; with
--seal-clear(lox run passes it fromhub.mobile) the clear screen (the record in clear, secrets still redacted) goes sealed to your pinned devices only. The agent hearspendingwith the ticket id, nothing else, and calls the same tool again with the ticket once you decided. - The run plans the call again: another record, other pseudonyms or spans,
another scan or a changed policy is refused (
E_STALE), and asking again is a new ticket. A ticket runs once; a scan's ticket covers its whole plan, and its continuations need none. - A
read-piidecision taken on a chat facade is refused by the console: only the terminal, the deck or a phone approve an unmask or an approved lookup. - The hub shows the requester as the console's peer credentials name it
(
uid N, oragentfor the image's agent account), never a name the agent declares; the agent's lea token only binds the ticket to it. - A ticket waits 24 hours; its run after that asks again (a renewal, which says the record could not be checked before the run).
- With the hub down, free reads go on; approvals stay pending and go out
when it answers.
locklog_statusshows the hub and the instance. - Decisions (who, on which facade, the hub's signature), renewals and reveals are audited.