Locks · lockkit
lockkit guide
lockkit is the product-neutral half of locksql: everything that keeps a secret-holding console separate from an AI agent, asks the human, and records what happened. A product (locksql, lockmq, lockfile, lockhttp, lockssh) supplies its names and its operations; lockkit supplies the rest. This guide is the contract between lockkit and the products built on it.
Status: v0.3, no stability promise before v1: pin an exact version. locksql runs on every package below except
console.Run/console.Server: its console still keeps its own modes, status row, policy review and start-up sequence (they are interleaved with its SQL state), on lockkit's terminal, approvers, isolation checks and request loop. Moving locksql ontoconsole.Runis the next locksql PR; until then the two copies of that logic must stay in step (the framework is the reference).
1. Package map #
| Package | Owns |
|---|---|
kit |
kit.Product: a product's names and directories |
refusal |
refusal codes registry, refusal.Error, structured Info with span |
secrets |
credential sources: terminal prompt, OS keychain (Keychain), Vault CLI; Sanitize, Wipe |
fingerprint |
fprintd verification (desktop security level 2) and a fake for tests |
sysconf |
the machine-wide /etc/<name>/system.toml of the separated setup; process, tty and relay probes |
ui |
palette, banner, status marks of human-facing output |
audit |
the console's append-only JSONL audit log, audit.Record, audit.Base |
config |
config file lookup, secret-safe TOML decoding, profile base, TLS/SSH, Policy, diff helpers, approved store |
managed |
the company-managed managed.toml of the whole family: OS path, root ownership, strict sections, optional cosign signature, fingerprint, Bounds/Check on your Diff, MergeProfiles, ClampDuration |
ipc |
console socket (private or shared dir), peer credentials, 1 MiB newline-delimited JSON-RPC 2.0, lockkit's methods and error codes |
client |
the secret-free side: dial (with the "no console" hint naming the start command), hello, Call |
tunnel |
SSH bastion: known_hosts, key/agent/password auth, keepalive, Dial to any address through it, NewSession on it |
pii |
value detectors, mask modes, name heuristics (LooksPersonal, LooksQuasi, LooksPersonNameColumn), the redacted-reference store |
render |
capped, escaped output for agents: Value (any typed JSON result) and Table (TSV/JSON) |
sanitize |
error text scrubbing (known secrets + registered patterns) and terminal-safe text |
update |
cosign signature check (keyless or key-pair) of a release artifact, checksums |
console |
the human side: terminal, approvers (typed, desktop, fingerprint), isolation checks, request loop, the generic Server and Run that host a product Backend |
mcpkit |
the agent side's MCP plumbing: server + stdio, profile resolution, progress while the human decides, untrusted framing, "no console" answer, structured refusals, exact integers |
ticket |
the lock ↔ lockhub contract: signed tickets and decisions, closed risk classes and their TTL, ticket kinds (connect, read, write, ddl, action, endpoint, policy) and the deciding rule, RFC 8785 canonical JSON, Ed25519 signatures, X25519 sealed boxes |
hubclient |
a lock's client for lockhub: signed requests, Submit of masked tickets, long-poll of decisions, Begin/Finish of a decided plan, Ask of a connect ticket; the instance key and hub-key; hubclient/hubtest is a fake hub for tests |
lazyconn |
backend connections opened on a connect ticket's approval, shared per profile, closed when idle and asked for again; the secret handed from the decider to the lock sealed |
plans |
a lock's persisted plans awaiting a decision: bound (peer UID, agent token, policy fingerprint, plan hash), single use, 0600 |
agentwire |
wiring Claude Code, Codex, Cursor and Gemini CLI to your MCP server, in a project or for the account: merge, never clobber |
verbs |
the launcher verbs every lock shares (init, profile add, doctor): the non-interactive form's secret refusal and credential source, the JSON lox add reads |
2. kit.Product #
Every lockkit function that used to say locksql takes a kit.Product
(or one of its fields). Declare one value in your product and pass it
everywhere:
var P = kit.Product{
Name: "lockmq", // binary, .lockmq/, <config>/lockmq, sockets
Tagline: "Your AI reads the queue. You hold the key.", // banner
Account: "lockmq", // console account
ClientsGroup: "lockmq-clients", // may reach the console
KeychainService: "lockmq", // OS keychain service
EnvPrefix: "LOCKMQ_", // LOCKMQ_RUNTIME_DIR, ...
}
| Method | Value for Name: "lockmq" |
|---|---|
ProjectDir() |
.lockmq |
UserConfigDir() |
os.UserConfigDir()/lockmq |
StateDir() |
$XDG_STATE_HOME/lockmq or ~/.local/state/lockmq (macOS: user config dir) |
RuntimeDir() |
$LOCKMQ_RUNTIME_DIR/lockmq, else $XDG_RUNTIME_DIR/lockmq (macOS $TMPDIR/lockmq), else /tmp/lockmq-<uid> |
SystemConfigPath() |
/etc/lockmq/system.toml |
SharedSocketDir() |
/run/lockmq (macOS /usr/local/var/run/lockmq) |
Validate() refuses a product whose names are unusable. Two products never
share a directory, a socket, an environment variable or a keychain item.
3. Refusals #
lockkit defines one code, refusal.Policy (E_POLICY, fail closed). A
product registers its own at init time:
func init() {
refusal.Register("E_VHOST_NOT_SERVED", refusal.RetryAskUser,
"a vhost this profile does not serve", "use a vhost listed by lockmq_status, or ask the user")
}
Codes are part of your client protocol: never rename one; refusal.Retire
keeps an old code known for past audit records.
4. Secrets #
kc := secrets.NewKeychain(P.KeychainService)
pw, legacy, err := kc.Get(profile, host, port, defaultPort)
secrets.PromptPassword reads from the controlling terminal;
secrets.VaultRead runs the vault CLI, which finds the Vault token
itself (VAULT_TOKEN or ~/.vault-token; the console does not use it);
secrets.VaultReadToken gives it the token the console asks at its start and holds (typed by the human) in the CLI's environment
only, never its arguments, and returns ErrVaultAuth when Vault refuses
it. Wipe every secret you are done with (secrets.Wipe).
5. Audit #
log, err := audit.Open(P, stateBase) // <stateBase>/<P.Name>/audit.log, dir 0700, file 0600, O_APPEND
err = log.Append(rec) // one JSON line: {"ts": RFC 3339 ms, rec's fields in order, "prev_hash" last}
sum, err := audit.Verify(file) // checks the chain; *audit.ChainError names the broken line
A record is any value implementing audit.Record (AuditEvent() string)
whose JSON is an object. audit.Base holds the product-neutral fields
(event, profile, decision, error, code, span, warnings, intent, reason,
approval, peer uid/pid/user, and in hub mode ticket, approver, facade and
hub_sig from the hub's signed decision); a product with fields of its own
declares its own struct with the same JSON names. Do not name a field
ts or prev_hash: Append writes them.
Each line carries prev_hash, the audit.LineHash of the line before it
(audit.Genesis on the first line of an empty log). audit.Verify refuses
a record edited, removed, inserted or reordered; records written before the
chain (without prev_hash) are read as legacy at the start of the log.
A cut tail, or a chain stripped of every prev_hash (read as legacy),
leaves a log that verifies: keep Summary.Anchor() elsewhere (the hub's
own log) and check with audit.VerifyAnchored. Appends hold a lock on the file, so a product
command may write the console's log while the console runs. When Append
returns an error, the event is not audited: in hub mode, refuse the
execution. A request handed to the hub is recorded as audit.EventPending
with its Ticket before the requester hears pending; when that record
fails, refuse the request and withdraw the ticket (hubclient.Withdraw),
never answer pending for an unrecorded ticket. Never put a secret, a row, a message
body or a file's content in a record; sanitise Error first. The event
names (audit.EventLogin, EventApproved, ...) are shared by every
product.
6. Config and policy: the AI tightens, the human loosens #
Files: .<name>/config.toml in the project (found by walking up,
config.FindProjectRoot(P, cwd)) and <user config>/<name>/config.toml
(config.UserConfigPath(P)). Decode your own raw profile struct with
config.Decode(P, data, name, &raw): it refuses any key that looks like a
secret (password, token, ...), any unknown key, and never quotes a value
in an error. Check every free-text value shown on an approval screen with
config.CheckValue(P, key, val) (embedded DSN passwords, terminal control
or bidi characters) and profile names with config.CheckProfileName.
Shared profile part: config.Base (user, credentials ask|keychain|vault,
vault_path, credentials_ttl, tls, tls_ca/cert/key, production, ssh bastion,
idle_timeout, max_session, tickets_per_window, ticket_window, max_pending),
config.ParseSSH for the ssh table, and config.DiffBase for its
loosening rules. Set Profile.TicketLimits to the profile's
Base.TicketLimits(): the console applies it to its hub client on start
and whenever a policy comes in force, so a raise counts only once
reviewed (typed name on production) and a cut at once. By default a lock opens at most 30
tickets a human decides per 5 minutes and 30 pending ones; tickets an auto
rule of lox run decided count only against a safety cap of 300. A refusal
is a hubclient.RateLimitError (ErrRateLimited) naming the limit, the
count, the setting that raises it and the retry-after; console.Asker
answers the agent with that text ("not submitted: too many tickets: ..."),
so a lock needs no wrapper of its own.
Your policy implements config.Policy:
type Policy interface {
Diff(approved Policy) []Change // your loosening rules; call DiffBase for the shared part
Describe() []string // lines for the console's status screen
}
The rule: a change with Loosens: true takes effect only after the human
confirms the diff in the console; a tightening applies at once. Helpers:
SetDiff (lists where a removal or an addition loosens), LooserLimit
(0 = unlimited), TLSRank, SortedSet. The approved store
(LoadApproved/SaveApproved(P, stateBase, ApprovedKey(root, profile), v))
keeps the last policy the human accepted; Fingerprint(v) hashes a
canonical form. Never let an IPC method loosen anything: an agent proposes
a loosening (change.request), only the console's human applies it.
6.1 Managed configuration: above the human #
A company can bound every policy of the machine with one file, the
family's managed.toml (schema: lockfamily's docs/managed.md):
/etc/lockfamily/managed.toml on Linux,
/Library/Application Support/lockfamily/managed.toml on macOS
(managed.DefaultPath). The user may tighten what it allows, never
loosen it, and no human can approve beyond it.
managed.Load(ctx)reads it: nil and no error when there is none. The file and its directory must be owned by root and writable by root only; a badly owned, invalid or unknown-keyed file is an error, never a file ignored (fail closed). Whenmanaged.toml.sigstore.jsonsits next to it, it is verified with cosign (update.Verify) against the company identity insigner.toml(root-owned:certificate_identity_regexp,oidc_issuer); present and invalid is an error, and[family] require_signature = truemakes it mandatory. The signature is re-checked only when the file, the bundle or the identity change.- A lock in a
lox runcontainer has neither cosign nor the network to check the signature: lox loads the file on the host and writesmanaged.toml.verifiednext to it, root-owned, holding the one linemanaged.VerifiedStamp(f)returns (sha256:<hex>over the file, the bundle andsigner.toml). When the bundle is present and the stamp matches,Loadcounts the file signed without calling cosign; a stamp that does not match, or that is badly owned, is an error (the signature is not checked instead); no stamp changes nothing, and without a bundle the stamp is ignored. It is trusted for the reason the file is: whoever can write it can already replacemanaged.tomland droprequire_signature. A lock does nothing for it. - No env var or flag moves it in a release build. Tests of a product build
with
-tags lockfamily_managed_testand callmanaged.SetTestPath(path)(no ownership check there). File.Decode(&v, "<tool>", managed.KindBounds|KindDefaults)andFile.Decode(&v, "<tool>", managed.KindProfiles, name)decode one table strictly;[lox]and[lea]decode whole (File.Decode(&v, "lox")).File.Fingerprintis the sha256 of the bytes,File.Describe()the "Managed by(sha256:…)" line, File.Family(RequireConfinedAgent,RequireSignature,Vault) the family rules.
Precedence: hard-coded floor > managed bounds and profiles > project file
user file > managed defaults > built-in defaults. In your config loader:
- decode
[<tool>.defaults]into your config struct first, then the user and project files over it (Decodeoverlays); - merge profiles with
managed.MergeProfiles(managed, project, user): a managed profile wins as a whole, and a user or project profile of the same name is an error; - implement
console.ManagedBackendso the console checks the merged result against[<tool>.bounds]:
// ManagedBounds returns p at its ceiling: every field the bounds name set
// to its bound (a list bound on a single value: p's own value when allowed,
// else the first allowed one). A nil Ceiling bounds nothing.
func (b *backend) ManagedBounds(p console.Policy, m *managed.File) (managed.Bounds, error) {
var raw myBounds
if ok, err := m.Decode(&raw, "lockmq", managed.KindBounds); err != nil || !ok {
return managed.Bounds{}, err
}
return managed.Bounds{Ceiling: raw.ceiling(p.(myPolicy))}, nil
}
managed.Check(p, bounds) runs p.Diff(ceiling): your own diff decides
what loosens, and every loosening field is a managed.Violation citing the
managed key (blocked by company policy: lockmq.bounds.tier = read (profile uat: tier = peek)). Bound keys are your profile keys; Bounds.Keys maps a
diff field to another key when they differ. Clamp a configured duration to
a managed maximum with managed.ClampDuration (the ticket.ClampTTL way).
The console then:
- at start (
Run), refuses an unreadable managed file and a policy beyond the bounds (console.ErrManaged, auditedrefused-managed) before any prompt, and also refuses the last approved policy kept after a refused loosening when it is beyond them; - at every policy poll (2 s), re-reads the managed file: unreadable, the
session ends; changed, it is audited
managed-changed, and a policy in force beyond it is replaced at once by the files' policy when that one is within it and only tightens (a connection field ends the session, as any tightening), else the session ends; - refuses a policy in the files beyond the bounds without a prompt:
audited
refused-managedonce, never pending, listed by:reviewas not approvable (Server.PolicyRefusedreports it); - records the managed fingerprint in
approved/<key>.json(config.ManagedField,managed_sha256) and in every audit record (audit.Base.Managed).
A backend that does not implement ManagedBackend refuses to start when
the managed file has a section for it: an old build never runs unbounded.
Products with their own start-up (not console.Run), building their
server with console.NewServer, get the same behaviour through two hooks:
- set
ServerConfig.Managedto the filemanaged.Loadreturned at start andServerConfig.LoadManagedtomanaged.Load: the file is re-read at every policy poll, as underRun(nil keepsManagedfor the session); - call
console.CheckManaged(product, backend, policy, managedFile)before any approval prompt: nil when the policy is within the bounds (or there is no file), else an error wrappingErrManagedwhoseconsole.ManagedLines(err)are the lines to print. Refuse the start and auditconsole.DecisionRefusedManagedyourself.
7. IPC, client, tunnel #
The wire protocol is newline-delimited JSON-RPC 2.0, one message ≤ 1 MiB
(ipc.WriteMsg, ipc.ReadMsg), ipc.ProtocolMajor exchanged in hello.
lockkit's methods, served by every console: hello, status, pii.list,
pii.add, change.request, logout (pii.* once your backend implements
console.PIIBackend, see §10; without it they answer "method not found"). Your methods use their own names
(op.run, query.plan, ...); errors use the shared codes (CodeRefused
with a refusal.Info in Data, CodeDenied, CodeTimeout,
CodePolicyPending, ...).
Sockets: ipc.SocketPath(P, projectKey, profile) in the user's private
runtime dir (0700, socket 0600) or ipc.SharedSocketPath(dir, ...) in a
separated setup (ipc.ListenShared(P, path, gid), dir 0710/2710, socket
0660). ipc.PeerCred is the kernel's word on who is calling.
c, err := client.Dial(P, cwd, profile) // *client.NoConsoleError (errors.Is ErrNoConsole): its hint asks the user to run `lox run`
err = c.Call(ctx, "op.run", params, &result) // hello first, no client-side timeout; ctx cancel closes the connection
Several locks of one type (lea mounts each socket volume on
<shared socket dir>/<instance> and lists them, space separated, in
LEA_<PREFIX>INSTANCES; one lock stays on the shared dir with no such
variable): your client commands and mcp take --instance NAME
(client.FlagInstance) and dial with
inst, err := client.ResolveInstance(P, instanceFlag) // --instance, else $<PREFIX>INSTANCE, else the one name lea lists; "" when lea lists none
c, err := client.DialInstance(P, cwd, profile, inst) // socket in <shared socket dir>/<inst>; "" is Dial
dir, err := client.SocketDirInstance(P, inst) // to list the running consoles: never build the directory yourself
ResolveInstance refuses a name that is not a lox instance name
(client.ErrBadInstance, naming the flag or variable, not the value), a
name lea does not list (no <tool> lock <name>: <a> <b>) and no choice
among several (several <tool> locks: <a> <b>; pass --instance NAME (or set <PREFIX>INSTANCE)). An instance needs the separated setup. An
instance whose directory is missing (a leftover value beside a single
lock, which lea mounts on the shared dir itself) is a DialInstance
error, not a NoConsoleError: no <tool> lock directory for the instance set by --instance or <PREFIX>INSTANCE: no lock of that name is mounted here. It names the flag and the variable, never the value.
tunnel.Open(ctx, tunnel.Options{Profile: sshProfile, HostKey: tunnel.KnownHosts(...), Secret: ask, ClientName: P.Name})
then t.Dial(ctx, "tcp", "broker:5671"): one tunnel serves every port the
bastion reaches; no local port is ever opened. t.NewSession() opens an
SSH session on the same connection, for a lock whose backend is the host
itself (lockssh runs its commands there).
8. Personal data #
-
pii.Detectors([]string{"email", "phone", "iban", "card"})(alsobe_niss,fr_nir,nl_bsn,us_ssn, and the opt-inphone_loose,eu_vat,be_company,be_eid,passport,birth_date,plate,postal_address);pii.Detect(v, ds)masks what they find in a text or integer value, or an integral float (|f| < 1e19), scanning at mostDetectLimitbytes.email: Unicode letters, digits and marks in the local part and the domain labels (RFC 6531); a letters-only top-level domain. An apostrophe may join two parts of the local part (o'hara), never open or close it ('a@b.com'keeps its quotes).- Wherever a space separates digits, U+00A0, U+202F and U+2009 do too; full-width digits count as digits. Other Unicode forms (full-width letters, other compatibility characters) are not folded.
phone:+and 8 to 15 digits,00and 10 to 17 digits, or a national number: a trunk 0, 9 to 11 digits, a first group of at least 2 digits (12 digits for a German mobile,015x/016x/017x).phone_looseadds national numbers without a trunk 0 ((415) 555-2671,612 345 678): 9 to 11 digits in at least two groups, the first of 2 to 4 digits; it also masks some grouped amounts (123 456 789), hence opt-in.
-
Context words: a word that announces a kind, case-insensitive, at the start of a word (compounds count:
rijksregisternummer;_and-read as spaces) and at most 32 bytes before a candidate on the same line, raises a candidate too weak on its own to a match. Context only adds matches, never removes one.phone(whenphone_looseis not enabled;tel,tél,gsm,mobile,phone,telefoon,handy, ...) raises the numbersphone_loosewould find (tel 612 345 678);be_niss(NISS,INSZ,rijksregister,numéro national, ...) a checksummed national number with other separators (85-07-30-033-28);be_company(KBO,BCE,ondernemingsnummer,n° d'entreprise,company number,TVA,BTW, ...) ten bare digits (0123456749).passportandbirth_datefind nothing without a context.pii.FindField(field, s, ds)isFindInViewswith the field name as the context of every candidate ofs(phone,kbo_nr,companyNumber). -
Match.Confidence:pii.ConfidenceHigh(a checksum verified:iban,card,be_niss,fr_nir,nl_bsn,eu_vat,be_company,be_eid),ConfidenceMedium(a pattern:phone,phone_loose,email,ip,secret,us_ssn,plate,postal_address, a French VAT number with a letter in its key, aDetectorof your own) orConfidenceLow(person_name,passport,birth_date, a candidate raised by a context, theCutKindrest). It never changes what is masked; keep it out of anything an agent sees, or it tells which masks hide a verified number. -
Opt-in, never in
AllDetectors(name them):eu_vat: EU VAT numbers, prefix in either case and an optional space after it:BE+ a company number (BE0123.456.749,BE 0123456749),FR+ a 2-character key + a 9-digit SIREN (the key checked when numeric),NL+ 9 digits +B+ 2 digits (the 11-proof or the mod 97 rule),DE+ 9 digits (ISO 7064 MOD 11,10),LU+ 8 digits (mod 89), the FR and DE digits also in groups of 3. An invalid check is never a match.be_company: a Belgian company number (KBO/BCE),0xxx.xxx.xxxor0xxx xxx xxx(first digit 0 or 1, mod 97); ten bare digits only after a context word. A Belgian mobile (0475 12 34 56) stays aphone.be_eid: a Belgian identity card number,ddd-ddddddd-dd, the last 2 digits the first 10 mod 97 (97 for 0).passport: a Belgian passport number, 2 capitals and 6 digits (EH123456), only after a passport word (passport,passeport,paspoort,Reisepass) or in a field so named.birth_date: a date (DD/MM/YYYY,DD-MM-YYYY,DD.MM.YYYY,MM/DD/YYYYwhen the day is above 12,YYYY-MM-DD,3 March 1985,March 3, 1985,1er mars 1985,3. März 1985; years 1900 to 2099) only after a birth word (date of birth,DOB,born,né le,date de naissance,geboren,geboortedatum,Geburtsdatum,geb.) or in a field so named (birth_date,dateOfBirth,date_naissance,dob): never every date.plate: a licence plate in upper case with its dashes: Belgian1-ABC-123andABC-123(not afterINV,REF,SKU,CVE,ISOand other reference prefixes), FrenchAB-123-CD, Dutch sidecodes 4 to 11 (XX-99-XX...XXX-99-X). Not found: plates with spaces or without dashes, in lower case, glued to another group (AB-123-CD-45).postal_address: a street (a type word,rue,avenue,boulevard,chaussée,place,straat,laan,weg,plein,steenweg,street,road, ..., then a name with a capitalised word, then a number; a Dutch compound,Kerkstraat 12; a number first,12 rue de la Paix,221B Baker Street), with a box (bte 3,bus 2), and the postcode (BE/FR 4/5 digits, NL1234 AB) and locality that follow on the same line: the match covers street through locality. Not found: a street without a type word (Damrak 1), in lower case (rue de la loi 16), a postcode and locality alone.ip: public IPv4 (dotted quad) and IPv6 (2000::/3, IPv4-mapped and IPv4-compatible forms of a public IPv4) addresses. Private, loopback, link-local, multicast, unspecified, broadcast and reserved addresses stay in clear; shared CGNAT (100.64/10) and the other IANA special-purpose ranges (documentation, benchmarking, 192.0.0/24) are masked. An address glued to a letter, a digit,_or a dot (v1.2.3.4,ip_8.8.8.8,1.2.3.4.5) is not one; a four-part version in public ranges (5.15.0.91) is masked.secret: credentials, masked as<redacted>(no first character, no length), and run before every other detector. It finds the values ofAuthorization/Proxy-Authorization(the scheme word stays),Cookie/Set-Cookie(to the end of the line),*-Token/*-Keyheaders, anykey=value/"key": "value"/key => value/--flag valuewhose nameLooksSecret, a high-entropy value (≥ 32 base64 characters, mixed case and digits, ≥ 4 bits/char, not a dotted class name) in a key position (not under a digest name such assha256),Bearer/Basic/Tokencredentials in text, JWTs, AWS/GitHub/GitLab/Slack/Stripe/Google/OpenAI/npm/PyPI/... token shapes, PEM private keys (to their END line, or the end of the text), URL userinfo (postgres://app:pw@db),curl -u user:pwandmysql -p<password>. Not found: secrets without a known shape or label (short or low-entropy tokens, passwords in prose, session ids in URL paths, connection strings without userinfo), hex and encoded content outside a key position, doubly escaped JSON, a value on the line after its key (YAML), XML elements (<password>x</password>), URL-encoded pairs (password%3Dx, unless the caller decodes first),:=assignments, an unquoted value cut by;or,, protocol commands (PASS x,AUTH x),redis-cli -a,sshpass -p, SQLIDENTIFIED BY, a URL password holding#or?, a value that opens with a delimiter (password=<x>,password=|x|), a key quoted inside brackets (params["password"]="x"), a key in full-width letters,STRIPE_KEY-style names whose owner is not listed, PuTTY.ppkbodies, plural names (api_keys,tokens), and JSON logged inside a string that is itself inside a string, and a flag and its value as separate list elements (["--password", "x"]), URLs with escaped slashes (PHP'shttps:\/\/user:pw@host), Go MySQL DSNs without a scheme (app:pw@tcp(db:3306)/x), and other clients' password flags (ldapsearch -w,sqlcmd -P,smbclient -U user%pw,sqlplus u/pw@db). A secret name with a JSON array or object value masks it whole, across lines, and to the end of the text when it never closes;Pwd=(ODBC) is a password unless its value is a path.docker/podman/nerdctl/buildah login -pandmongo*-pare masked;-pelsewhere is not. Status words under a secret name (auth=failed) are masked too. A bare stop word after a secret flag (--password or --token) is kept; a flag secret only throughauth(--auth,--auth-local) keeps an auth method name (--auth-host scram-sha-256,--auth-local peer) and masks any other value (--auth admin:pw). A dotted quad afterversion,build,release,oidor a product token (Chrome/124.0.0.0) is no address.
-
pii.LooksSecret(name): a field, header or flag name that holds a credential (password,client_secret,api_key,X-Api-Key,Authorization,Cookie,PHPSESSID, en/fr/nl/de/es); metadata about one (token_type,password_changed_at,api_key_id), token counts (max_tokens) andPWDare not. Check it beforeLooksPersonal: a secret is redacted, never pseudonymised or unmasked. -
For text bound for a model, use
pii.FindInViews(s, ds): the values found insand in every detection view of it, as byte ranges ofs([]pii.Match{Start, End, Kind}, sorted, non-overlapping;Kindis the detector name; overlapping matches merge, the earliest detector naming the kind). A value written behind an escape, a look-alike or an invisible character is covered whole, in its written form. Replace each match with your own placeholder (a pseudonym).pii.DetectionView(s)issfolded, NFKC (full-width letters, ligatures), every decimal digit as its ASCII digit (٤,३), and without what a value can hide behind: default-ignorable characters (format characters, variation selectors, the combining grapheme joiner, Hangul fillers), controls (C0 but tab and line ends, DEL, C1), the braille blank, combining marks; hyphens, dashes and minus signs read as-, ideographic full stops as., and Cyrillic and Greek letters drawn like Latin ones as those. Invalid UTF-8 bytes are kept in it;DetectionViewsadds a view without them, so a value an invalid byte splits is found whole: after masking, replace them (U+FFFD) rather than drop them before the text leaves your product.View.Source(start, end)maps a range ofView.Textback tos, covering what was dropped inside it.pii.DetectionViews(s)adds views without terminal sequences (introduced by ESC, a C1 control or a raw 8-bit C1 byte): CSI (C0 controls and DEL after the ESC and inside skipped, as terminals do, CAN and SUB cancelling it), OSC, DCS, SOS, PM and APC strings (ended by their terminator, CAN or SUB), nF and two-byte escapes (SCI with the character it swallows), each aborted by the next ESC or C1 control or cancelled by a non-ASCII character (swallowed, as xterm and VTE do), dropped whole whatever their length (otherwise only their introducer); one view keeps the strings' payload. These views are also made of every decoded view below, so\u001b[31minside a value does not split it. Then a view where introducers and final bytes read as spaces, the payload kept; the detection view ofspercent-decoded (once, twice), with JSON escapes decoded (\u0040), with HTML character references decoded (@,@), with all of them chained (jean%40example.com; until nothing changes, at most 16 rounds, the view cleaned up between rounds:jean%40,%4+zero-width+0,%4+ESC[m+0; a text still decoding after 16 rounds is one match whole), with\xHH,\eand\033escapes decoded, and form-decoded (+as a space) then percent-decoded. A view of a long text may stop early: apply only the matches ofFind/FindInViewsto a text, never ranges you computed on a view yourself.pii.Find(s, ds)is whatDetectmasks, as ranges: masking each withpii.MaskTextgivesDetect's output, except that Find masks more where a later detector matched across an earlier mask, and aDetectorof your own (no ranges) makes the whole text one match.- Past
DetectLimit(and where a view expands past twice that) the unscanned rest is one more match of kindpii.CutKind, starting earlier where a value runs across that point: at the start of the word the cut falls in, however long (blanks between digit groups included), or of a match the detectors found running across it (they scan 4 KiB past it): the matches are always safe to apply tositself.
-
Keyed pseudonyms:
p.ID(kind, value)is 12 characters of Crockford base32 (60 bits) ofHMAC-SHA256(key, "lockkit-pseudonym-v1" || 0 || kind || 0 || pii.Normalise(kind, value)), stable within one epoch; a kind is 1 to 32 of[a-z0-9_](ErrInvalidKind).Normalisefolds spellings of one value together: the detection view (or the decoding that is a whole value of the kind), then lower-case email (the local part too), phone digits with+for international numbers (00,+00→+,+32 (0)475→+32475), only the ASCII letters and digits of an IBAN, a VAT or a company number, upper case, a passport number in upper case, a birth date asYYYY-MM-DD, a plate's ASCII letters and digits in upper case, an address in lower case with single spaces, a person name in lower case with single spaces, digits of cards, national ids and eID cards, canonical IP (IPv4-mapped → IPv4). Keep epochs withpii.OpenEpochStore(dir, create, now): one file per epoch (0600) in a private directory (0700, owned by the process user; anything else, a symbolic link, a corrupt file or an all-zero key refuses the store), ordered by a sequence number, the directory locked for one process untilClose(which forgets the keys in memory:IDthen fails, andRotateandPrunefail withErrClosed; opening creates the.lockfile).Current(),Get(epoch)for a reference minted earlier,Rotate(now),RotationDue(now, every)(also true for an epoch created more thanClockSkewin the future),Prune(now, keep)deletes keys replacedkeepago and leftover temporary files (a clock that jumped forward may delete a key early; one set back keeps it at mostClockSkewlate, and set back further deletes it at once and makesRotationDuetrue: privacy first). On non-unix platforms there are no permission, owner or lock checks: one process per store.pii.NewEpoch(now)is an in-memory (session) epoch. Neither type prints or marshals its key.IDreturnsErrForgottenEpochoncePrunedeleted the key, through every copy of the value: redact the value then. Do not show an ID without the kind it was taken for. -
Opt-in
person_name(never inAllDetectors): a full name in free text, from embedded gazetteers (given names, family names, titles, in the languages above and common immigrant names). A known given name followed by a known surname in the same case ("Jean Dupont", "JOHN SMITH", "john smith", "john.smith", "john_smith"), or, in title case, by a title-case or upper-case word that is neither a common word nor camel case ("Kevin Vandeputte", "Kevin VANDEPUTTE"); an initial and a known surname ("J. Smith"); a title-case title before capitalised words ("Mr. Smith", "Mme Lefebvre", "Dr Janssens"; an upper-case title only before an upper-case name; "Ms", "Sr", "Miss" only before a known name); middle initials, particles ("van der") and hyphenated given names. Given names that are also words, places or months ("Will", "Max", "Jan", "Austin") need a known surname that is no common word, and are never joined by./_("max_price"). Not found: a given or family name alone, surname first ("DUPONT Jean", "Smith, John"), names whose given name is outside the gazetteer (about 1 350 given and 500 family names, Latin script only: Cyrillic, Arabic, CJK names are never found) without a title (see the held-out recall), user names other than first.last and first_last. Three-word names extend over one more title-case word.TestPersonNameRecall,TestPersonNameHeldOutRecallandTestPersonNameFalsePositiveslog the numbers of each release. -
pii.MaskValue(v, mode)/pii.Mask(mode, s):redact→<redacted>,partial→j***(12),email→j***@example.com. -
pii.LooksPersonal(name)andpii.LooksQuasi(name)read a field name in en/fr/nl/de/es: use them to propose mask rules (a proposal tightens: apply it at once). -
pii.LooksPersonNameColumn(table, column, samples)reads a column's sampled values: a column whose name means "name" (name,full_name,nom,naam,customer_name) holds person names when enough samples are each one name asperson_namefinds it ("Alice Dupont", "Chloé Lambert"): half in general, 30 % (and at least one) in a table of people (customers,users,employees,contacts,clients), 80 % in a table of things (products,roles,categories,tags,cities); two names at least outside a table of people. The column name alone is never enough: sample the text columnsLooksPersonalleaves out and propose those it reports. -
pii.RefStore:n := s.Begin()per result,s.PutDistinct(n, value)(orPut(n, "row.col", value)) returnsrN.K; showpii.RedactedRef(ref); resolve withGetin the console only. Values never leave the console's memory. -
The detection bench (
TestBench,pii/bench_test.go) scores every detector per language withFindInViewson the synthetic corpus ofpii/testdata/bench(written bygo run ./pii/internal/benchgen -out pii/testdata/bench), andLooksPersonal/LooksQuasion its field names; the figures are inpii/testdata/bench/RESULTS.md. A metric belowbaseline.jsonfailsgo test: a detector change that lowers one needs an explicit baseline commit (LOCKKIT_PII_BENCH_UPDATE=1 go test -run '^TestBench$' ./pii/). A new detector adds its corpus entries in benchgen (at least 50 positives per language it claims) and its baseline lines.
Which field is personal (columns, JSON paths, headers) is your product's decision; never send an unmasked value to a client unless the human unmasked it in the console.
9. Output, sanitising, signatures #
Results are typed JSON, not rows. render.Value(w, v, render.Caps{MaxCellChars, MaxOutputBytes})
writes any value as one JSON line: every string (keys included) escaped
and cut at MaxCellChars characters, numbers/booleans/nulls kept, the
whole at most MaxOutputBytes (else render.ErrOutputCap, nothing
written: page or narrow your result). render.Table + render.Render(w, t, caps, "tsv"|"json") is one helper among others, for tabular results.
Approval screens that leave the lock (to lockhub, in a ticket) are
render.MaskedScreen values, built only by render.MaskScreen: draw the
screen as render.ScreenSource with each body line in render.Parts,
structure apart from values, and name the column of each value you know.
src := render.ScreenSource{Header: "db / shop · tier read", Note: note, Body: []render.Line{{
{Text: "SELECT id FROM customers WHERE email = "}, {Text: "'" + email + "'", Value: true, Column: "email"},
}}}
ms := render.MaskScreen(src, render.Masking{Detectors: ds}) // ds: pii.Detectors(policy's names)
t.Screen, t.PII.Touched = ms.Screen(), ms.Touched()
A value whose column looks personal (pii.LooksPersonalColumn, or your
Masking.Personal) is masked whole; the detectors run over everything
else, structure included, which otherwise stays visible. The zero
MaskedScreen is not valid and does not encode.
Errors. Every error text that leaves the console (client, audit log)
goes through a sanitize.Sanitizer:
san := sanitize.New(sanitize.URLUserinfo) // your patterns: AMQP/HTTP URIs with userinfo, ...
san.Register(regexp.MustCompile(`Authorization: [^\r\n]*`)) // groups are removed, else the match becomes ***
msg := san.Error(err, password) // known secrets first (every encoded form), then patterns
Text printed for the human (approval screens, console lines) goes through
sanitize.Terminal(s, multiline): client-supplied text cannot drive or
spoof the terminal, nor hide text from the human (controls, format and
other invisible characters are escaped).
Signatures. update.Verify(ctx, artifact, sigstoreBundle, update.Identity{CertIdentityRegexp: "^https://github\\.com/<you>/<repo>/\\.github/workflows/release\\.yml@refs/tags/v1\\.2\\.3$", OIDCIssuer: "https://token.actions.githubusercontent.com"}, update.Options{})
runs cosign verify-blob; a missing cosign is update.ErrCosignMissing
(refuse, never skip), a bad signature an *update.VerifyError. Then
update.ChecksumFor(sums, name) and update.CheckSHA256(data, want).
Bind the identity to the exact tag you install (anchored, dots escaped),
never to v[0-9]: a tag moved to an older release's signed files would
verify.
A signer without an OIDC identity (a self-hosted marketplace built on
internal GitLab, Jenkins or a laptop) signs with a cosign key pair
(cosign sign-blob --key): pass update.Identity{PublicKey: pem}, the
bytes of its PEM PUBLIC KEY block, with CertIdentityRegexp and
OIDCIssuer empty (both modes, or neither, is an error). Your product
reads the key file with its own config rules; lockkit takes bytes, never a
path, writes them to a 0600 temporary file and runs cosign verify-blob --bundle B --key K ARTIFACT. Only a PKIX key is accepted (ECDSA P-256 or
P-384, Ed25519, RSA of at least 2048 bits); a certificate, a private key
or trailing data is refused, and the error never quotes the key. In both
modes the bundle must carry a Rekor transparency log entry
(--insecure-ignore-tlog is never passed): marketplace operators sign
with the tlog upload on, a bundle signed with --tlog-upload=false is an
*update.VerifyError.
Deployment. The products run in Docker only: the separated setup
(sysconf's system.toml, the console account, the clients group, the
socket directory) is baked into each product's image, and the host gets one
command, a POSIX shell launcher that runs every subcommand in the image with
the account and volumes it needs, pinned to the image digest its release's
cosign signature verifies. lockkit has no package for it: the launcher is
per product, its pattern is locksql's
docs/launcher.md.
Code that assumes a host install (installing the separated setup, replacing
the binary in place) serves only a product's legacy commands, which the
image refuses.
10. The console and the Backend seam #
Your console command is one call, and it is launched by lox run only:
the hub options are required (see Hub mode below; there is no
standalone console), unless the console only serves its policy
(Policy-only console below):
var hub console.HubOptions
hub.Register(fs) // --hub, --hub-key-file, --instance, ...
err := console.Run(ctx, P, backend, console.Options{
Profile: "prod", ProjectRoot: root, IO: term, TTY: os.Stdin, Hub: hub,
Sanitizer: sanitize.New(sanitize.URLUserinfo),
})
Run does, in order: the hub options (without --hub, --hub-key-file
and --instance it refuses with console.ErrNoHub, "launched by lox run
only", before anything else; with Options.PolicyOnly, none), the isolation checks (separated account, session or
desktop shape, X11, relays; refuses before any prompt or file write), core
dumps off (refused on production if it fails), the policy review (first
start: describe and approve; since the approved policy: tightenings apply,
loosenings are asked; on production the review takes the typed profile
name, not y), the production banner and the typed profile name
(not asked again when the review was approved at this start with it;
asked when the policy in
force loosens the one the name was last typed for: the record is
ConfirmedKey(key) in the approved-policy store; a plain restart of an
approved, unchanged profile does not ask it), the
user and the secret (ask, keychain with an offer to save, vault read
at every connection with the Vault token asked at start, masked), Backend.Connect, Backend.Checks (red findings,
typed continue; any finding refuses on production), the socket (private
or shared), then the request loop until logout, :quit, Ctrl-C, the idle
timeout or the maximum session. Only the human's input (console lines and
control keys) and the calls of the backend's methods (their start and their
end) count as activity for the idle timeout: hello, status, pii.list,
pii.add and change.request never do, so a client polling status does
not keep an unattended console open.
While it waits for an answer, Run keeps the question (the prompt, never
the answer) in QuestionPath(socket), next to the console's socket
(/run/<tool>/<project>-<profile>.question in a lox run container), and
removes it once answered: lox attach and lea read it to show the
question the console asks, which was printed before they attached. The
Terminal does it (SetQuestionFile); a product with its own start-up
calls console.RecordQuestions(io, socket) before its first question.
The file, mode 0600, holds only what the console printed as a question's
context. Its first line is the prompt; when the console printed context
lines since the previous question (or since it started), the second line is console.QuestionContextMark
and the lines follow, oldest first: the policy diff above Apply these changes?, the host key fingerprint above its prompt, the PRODUCTION
banner. They have their colours dropped and their controls escaped, and
are bounded (MaxQuestionLines, MaxQuestionBytes): when older lines
are left out, the first context line says how many (… N earlier lines not shown). What the human
types, echoed or not, is never written. console.ParseQuestion reads the
file; a reader that takes the first line still gets the prompt.
Context is opt-in. Print the context of a question with
console.PrintContext(io, line) (or console.PrintContext(env, line) in
a handler): it prints the line as Println does and, on a
ContextPrinter (the Terminal, the Server), records it with the next
question. A Println line is never recorded. The file sits in the socket
directory, a volume that agents' containers may mount: print as context
only what an agent may read (the PRODUCTION banner, a policy diff, a host
key fingerprint), never a result, a clear or unmasked value of the
backend's data, nor a secret.
type Backend interface {
Methods() map[string]console.Handler // your IPC methods (not hello/status/change.request/logout)
Connect(ctx context.Context, c console.Credentials) (console.Session, error) // c.Secret is wiped after the call
Checks(ctx context.Context, s console.Session) []console.Finding // privilege audit of the account
LoadPolicy() (console.Policy, error) // read from your config files, every 2s
Status(s console.Session) any // your part of `status`
}
type Policy interface {
Diff(approved config.Policy) []config.Change // your loosening rules (DiffBase for the shared part)
Describe() []string
Profile() console.Profile // name, production, where, host/port, user, credentials, vault, ttl, session limits
}
type Handler func(ctx context.Context, req ipc.Request, env console.Env) ipc.Response
A policy with mask rules implements console.PIIBackend on the backend;
the console then serves pii.list and pii.add (a mask rule only
tightens: saved to the file on disk, applied at once, kept in a pending
loosening, audited as policy/tightened):
type PIIBackend interface {
PIIRules(p console.Policy) (mask, allow []string)
CheckMask(pattern string) (string, error) // your syntax; returns the canonical form
SaveMask(pattern string) error // append to the config file as it is on disk
WithMask(p console.Policy, pattern string) console.Policy // pure: p plus the rule
}
Every pii.add decision is audited by the console with the client's peer
(peer_uid, peer_pid, peer_user), whoever refuses it: a rule added is
policy/tightened with its canonical pattern; a pattern CheckMask
refuses is policy/failed with the error class pii.add refused by the backend: invalid pattern and neither the pattern nor your error text (the
value you refused to canonicalise may be a name the policy shows masked);
a rule SaveMask refuses is policy/failed with the canonical
pattern and pii.add refused by the backend: mask rule not saved: plus
the sanitised error. Do not audit these refusals yourself: they would be
recorded twice, the second time without the peer.
A handler that needs the human calls env.Approve(ctx, req.ID, screen):
a := env.Approve(ctx, req.ID, console.Screen{
Header: "rabbitmq.example.com / orders · tier peek",
Note: params.Intent, // the agent's reason, escaped and audited
Body: []string{"PEEK orders.failed count 3"},
Warnings: []string{"NOT READ-ONLY: 3 messages requeued, redelivered=true"},
NeverAuto: true, // never approved without a human's own decision (the hub's auto mode, a renewal)
Hub: &console.HubTicket{...}, // the plan and masked screen for the hub (Hub mode below)
})
if a.Pending != nil && a.Decided {
return runTicket(ctx, req.ID, a.Pending.Ticket) // decided already (an auto rule): run it now
}
if !a.Approved {
return a.Refusal // pending (decided on lockhub), or refused
}
Under Run the request goes to lockhub (Hub mode below) and the
answer is pending. The console frames the screen (red on production) and
audits the decision before Approve returns. A console.Server built
with a terminal approver instead (tests) asks y or, on production, the
typed profile name, takes a finger at level 2, times out after 5 minutes,
and audits approved, denied, timeout or abandoned. An approval it cannot
record (the log unwritable, Screen.Record returning no valid record) is
refused with ipc.CodeInternal and told to the human; three failed
writes in a row end the session. Use Screen.Record to write the
decision as your own audit record type. env.Session(ctx) gives the
live session (reconnecting after env.Lost(), a credentials_ttl or a
Vault lease); env.Level(ctx, start) levels the response time;
env.ShowResults(), env.UnmaskAllowed() are the human's switches
(there is no auto mode: lockhub decides every request);
env.Sanitizer() scrubs error text; env.Safe escapes text for the
console.
A product that draws its own approval screen, and prompts inside it (a
value to type before the decision), closes it with a console.Asker
rather than env.Approve: Asker.Ask rings the bell, prompts (typed
profile name on production, an optional retype answer), takes the finger
and returns the same Approval, handing each outcome to a record callback.
Server.Approve is built on it, so both decide the same way.
Backend.Connect gets the policy in force in Credentials.Policy (at
start and at every reconnection): connect to what it says, never to what
the config files say now (an unconfirmed edit). The console wraps a
session whose login expires (a Vault lease): console.Unwrap(s) returns
yours, in Checks and after env.Session.
Product hooks. A product that ported a console of its own keeps what
its users and clients see by implementing optional interfaces on the
backend; a backend that implements none gets the console's behaviour
unchanged. StatusAnswerer answers the status method itself (a flat
answer instead of product); StatusRower lists the :status rows
(StatusResultsRow, StatusUnmaskRow,
StatusPendingRow, StatusBoundaryRow give the console's own values);
StatusLiner adds to the status row; ChangeFormatter renders policy
diffs; ConnectionComparer widens the connection change that ends the
session (a product-specific TLS setting, a tunnel) beyond the console's
own check, which already covers where/user/host/port, the credentials
mode and Vault path, and the shared tls* and ssh* fields of the diff
(config.ConnectionField), a tightening included; PolicyApplier compiles what the
product derives from a policy before it is in force (an error refuses the
policy, neither saved nor applied); Worder words the console's lines
(Words: statements rather than requests, the reconnection and lease
lines, the end of a session that became production). A Reconnect that
returns an EndError ends the session with its reason. A handler that
paints its own approval screen decides with console.AskerOf(env) (a
switch pressed meanwhile is refused, as for env.Approve), reads a
pending loosening with console.PolicyPendingOf(env), and records its own
approvals with Server.WriteAudit, which returns the failure (fail
closed). Server.PolicyRefused reports a refused loosening still in the
files.
Hub mode. A console is hub-attached only. console.HubOptions.Register(fs)
adds the flags lox run writes: --hub URL --hub-key-file PATH --instance NAME,
all three required (console.ErrNoHub otherwise), plus --hub-plain-http
for an http:// hub on its own network. There is no auto mode: lockhub
decides every request. The hub URL is
http(s)://host[:port] and nothing else: a user, password, path, query
or fragment is refused (hubclient.CanonicalURL), and the hub is printed,
logged and passed on (HubOptions.Args) in that form only. Pass them as Options.Hub: Run
reads the pinned hub key, the instance key (hubclient.InstanceKey
in the home volume) and opens the plans and the hub state under the
product's state directory. Requests are no longer asked on the terminal:
env.Approve pushes a ticket and answers pending (a result, with the
ticket id). For that the product puts a console.HubTicket on the
screen: the plan (made in console.HubOf(env).Store), its caller, the
risk class and the render.MaskedScreen; a screen without one is refused,
never asked here. The terminal keeps the console's own decisions only (a
policy change, :unmask): hub.Approver(base) hands requests to the hub
and those to the terminal approver base it wraps. The run of a decided ticket is the
product's: h.Client.Begin(h.Store, ticket, caller, recheck) (verified,
matched, still usable, binding unchanged, nonce consumed; it returns
the decision, whose approver, facade and signature go in the audit
record), the effect, then h.Client.Finish. env.Approve waits up to
hubclient.DecisionWait (2 s) for the hub's decision on the ticket it
pushed (Client.Await, as Watch polls): one decided by then, as lox run's auto rules decide in milliseconds, comes back Approval.Decided,
and the product runs the ticket in the same call, through the same code
as the agent's run of it (an approval runs, a denial answers denied),
rather than answer pending. A product that ignores Decided answers
pending, and the agent's next run of the ticket finds the decision. A ticket the human denies
or withdraws in lox run ends its plan as soon as Watch polls the
decision (Client.EndDenied), freeing its pending slot; the agent's next
run of it gets hubclient.ErrDenied, or hubclient.ErrWithdrawn (an
ErrDenied too, reason withdrawn by <human> in lox run) with the
decision for the audit. A pending answer is
audited (audit.EventPending, approval hub, the ticket); one that
cannot be recorded is withdrawn and refused. A product with its own console lifecycle
calls the same pieces as Run: console.OpenHub, hub.Approver(base)
for its Asker (base keeps the console's own decisions), and
hub.Watch(ctx, io) in a goroutine. A product also wires <lock> hub-key --instance NAME to hubclient.HubKeyCommand: it prints the instance's public key
for lox run to pin.
Grants. A product that lets the human approve many reads at once
pushes a ticket.RiskGrant ticket: its plan is the grant's scope
(database, tables, duration, read budget), PlanHash the hash of that
scope's canonical JSON, the screen shows it. The approval binds that
scope and nothing else; the lock then runs reads inside it without a
ticket each, and enforces the scope itself (the grant's own lifetime and
budget are part of it, not of the ticket's Expires). A grant ticket is
always Flags.NeverAuto.
Ticket kinds. A version-2 ticket (ticket.VersionKinds) names what
it asks for, so that lox run's rules decide on a structured request and
not on the screen's text: Kind is one of connect, read, write,
ddl, action (an MQ, file or HTTP verb), endpoint (a new host or
route) and policy, each allowed only with the risk classes
ticket.KindRisks lists, and Subject carries the request (profile,
endpoint without userinfo, credential source, verb, target; masked like
the screen, never a value or a secret). The subject is not masked, so the
ticket package bounds it: an endpoint is host[:port][/path] only (no
whitespace, ?, #, =, &, ;, @ or %, so a DSN or a URL query
never leaves the lock), a target has no whitespace, query string or
fragment; Subject.Validate checks one before a lock builds a screen. Set HubTicket.Kind and
Subject for a plan's ticket; NewTicket keeps writing version 1, and
both versions verify. A version-2 ticket is answered by a version-2
decision, whose Rule says what decided it (auto and the rule's id,
or human on Facade): put it in the audit. An auto rule's approval of
a NeverAuto ticket is refused (ticket.ErrAuto, from Usable).
Lazy connections. A lock that opens its backend only when an agent
needs it builds a lazyconn.Pool with the hub client as its Asker
(hubclient.Client.Ask), its Profile lookup and its Dial. It starts
disconnected; the first Acquire of a profile pushes a connect ticket
(its plan is its subject, ticket.ConnectPlanHash), waits for the
decision, and dials on a usable approval; concurrent requests share the
ticket and the connection, release gives it back, and it closes after
Config.Idle unused (DefaultIdle, 2 min); the next request asks
again. With Profile.NeedSecret (credentials ask, an expired Vault
token) the ticket carries a one-use X25519 key of the lock
(Subject.SecretTo); lox run verifies the ticket under the lock key
it pins and seals the secret to it (ticket.SealSecret, in
Decision.Secret); the hub relays a box it cannot open, and only Dial
sees the secret (Grant.Secret, wiped when Dial returns). Audit the
pool's OnEvent (connected, denied, failed, closed, with the decision's
rule). Connect tickets are not plans: a restarted lock starts
disconnected and asks again.
Typed inputs. A plan's read or action ticket can ask the human for
values the agent never chooses nor sees (a placeholder the plan runs
on): set HubTicket.Inputs to their names ([a-z0-9_]{1,32}, at most
16; ticket.Subject.Inputs, sorted for you) with Kind read or action;
any other kind is refused before a push. The hub client gives the ticket
a one-use X25519 key (Subject.SecretTo) when it signs it and keeps the
private half in the plans store's memory only (Store.KeepInputKey,
never written; dropped once the plan runs or ends), and sets
Flags.NeverAuto: no auto rule decides it (ticket.ErrAuto). lox run
verifies the ticket under the lock key it pins and seals the typed
values to it (ticket.SealInputs, in Decision.Secret, under their own
associated data lockkit/plan-inputs/v1: a connect secret never opens
as inputs, nor the reverse). Begin opens them (Decision.OpenInputs)
and returns them on the plan, Plan.Inputs (never stored): run the plan
with them, then secrets.Wipe each. Audit the names only,
audit.Base.Inputs = audit.InputNames(plan.Inputs) ("inputs": ["email"]), never a value. An approval without the values, or whose box
does not open, ends the plan Denied (hubclient.ErrBadDecision). A lock
restarted since it pushed the ticket has lost the key: Begin answers
ticket.ErrExpired, so h.RenewResp asks again, under a new key. The
console never caches a value: reusing one across tickets is the
product's business.
Reveal and re-approval. In hub mode pass the console's clear screen
to SubmitClear (the console's own Submit does): it stays in the plan,
and a local facade the lock pins (--facade-keys-file) can ask for it
(Ctrl-O); Watch seals it to the facade's ephemeral key and audits the
reveal (Hub.Requests). When Begin answers ticket.ErrExpired, answer
h.RenewResp(ctx, id, ticket, caller): the ticket is asked again and
the agent gets pending with the new ticket. Record the plan-hash inputs
at plan time (Binding.Inputs = ticket.InputDigests(inputs)) and give
h.Client.SetRecompute (from Options.OnHub under Run) the same
digests recomputed now: the human then
sees which inputs moved, and an unchanged non-production renewal is one
click.
Policy-only console. A lock whose requests are decided by another
process (lockhttp: its gate asks lockhub, its console only serves the
policy to the gate) runs its console with Options.PolicyOnly; lox run
starts such a console without hub flags. Run then needs no hub, and
refuses hub options (console.ErrPolicyOnlyHub: who decides would be
ambiguous) and Options.OnHub. It opens no hub and no plans store, and
every request a handler hands to env.Approve is refused
(console.ErrPolicyOnly, ipc.CodeDenied, audited refused with
reason policy-only console): never asked on the terminal, never
approved. console.HubOf(env) is nil. The console's own decisions (its
policy at start and at a loosening, :unmask) are still asked on its
terminal. A product with its own console lifecycle uses
console.PolicyOnlyApprover(base) in place of hub.Approver(base).
err := console.Run(ctx, P, backend, console.Options{
Profile: profile, IO: term, TTY: os.Stdin, PolicyOnly: true, // no Hub
})
No standalone mode. A console started without its hub (and not
policy-only) is refused, so there is no rollback of hub mode any more
(console.WithdrawStandalone and its types are gone since v0.10.0).
With no console started by hand, client.StartCommand is lox run and
the client.NoConsoleError hint (client.StartHint) asks the user to
run lox run, naming no profile or project.
A minimal product:
type queueBackend struct{ cfg *Config }
func (b *queueBackend) Methods() map[string]console.Handler {
return map[string]console.Handler{"op.read": b.read}
}
func (b *queueBackend) Connect(ctx context.Context, c console.Credentials) (console.Session, error) {
return dialBroker(ctx, b.cfg.URL, c.User, c.Secret)
}
func (b *queueBackend) Checks(ctx context.Context, s console.Session) []console.Finding {
if s.(*broker).IsAdministrator(ctx) {
return []console.Finding{{Text: "the account has the administrator tag", Refuse: true}}
}
return nil
}
func (b *queueBackend) LoadPolicy() (console.Policy, error) { return loadPolicy(b.cfg.Root, b.cfg.Profile) }
func (b *queueBackend) Status(s console.Session) any { return s.(*broker).Overview() }
func (b *queueBackend) read(ctx context.Context, req ipc.Request, env console.Env) ipc.Response {
var p readParams
if r := console.Decode(req, &p); r != nil {
return *r
}
sess, err := env.Session(ctx)
if err != nil {
return console.ErrResp(req.ID, ipc.CodeConnLost, err.Error())
}
if a := env.Approve(ctx, req.ID, readScreen(p)); !a.Approved {
return a.Refusal
}
msgs, err := sess.(*broker).Read(ctx, p)
if err != nil {
return console.ErrResp(req.ID, ipc.CodeInternal, env.Sanitizer().Error(err))
}
return console.OKResp(req.ID, mask(msgs)) // typed JSON; render.Value caps it client-side
}
The console's own commands and keys: :review (pending loosening and
change requests), :reopen (the loosening the human refused, at start or
at :review, pending again while it is still in the config files, for
:review to approve as a first approval would; audited reopened; no
client method re-opens it), :status, :results / Ctrl-O (clear results,
refused at levels 1 and 2, as is --show-results at start), :unmask (confirmed), :quit.
Every widening the console decides itself (the policy review at start,
a loosening at :review, :unmask allowing unmasked output) takes y
outside production and, on production, the profile name typed exactly,
like a request: Apply these changes? Type the profile name "prod" to apply them: . Turning production off is a loosening of a production
profile, so it takes the name too. console.WidenDecision builds such a
decision for a product's own widenings.
The pieces are exported for a product that assembles its own console:
console.NewTerminal, console.NewTypedApprover / NewDesktopTypedApprover
/ NewFingerprintApprover, console.CheckIsolation + RealIsolationEnv,
console.Harden, console.Loop, console.WithPeer / PeerText /
StampPeer, console.Level, console.ErrResp / OKResp / Decode.
11. Do not reimplement in your product #
- a socket, a peer check or the JSON-RPC framing:
ipc,client; - an approval prompt, a production typed name, a fingerprint wait, an
approval timeout or the audit of a decision:
console.Env.Approve; - clear results, unmask switches, the status row:
console.Server; - reading a secret from the terminal, the keychain or Vault:
console.Run(orsecrets); - the config lookup, secret-key refusal, approved store, fingerprints and
the "AI tightens, human loosens" review:
config+console; - isolation checks, core-dump hardening:
console; - detectors, mask modes, references:
pii; capped output:render; - error scrubbing:
sanitize; release signature checks:update; - MCP plumbing (server, stdio, profile argument, progress, untrusted
framing, the no-console answer, refusals, large integers):
mcpkit; - agent wiring (MCP entries, skills, rules, instructions sections, Codex
TOML, Claude permissions,
claude mcp add):agentwire. - the secret refusal of a non-interactive
profile add, its credential source, the JSON ofprofile addanddoctor:verbs.
12. MCP server and agent wiring #
MCP server. Your tools are yours (names <name>_*, descriptions,
instructions, texts): declare them with the SDK
(github.com/modelcontextprotocol/go-sdk/mcp, the version lockkit's go.mod
pins), each a thin adapter over client.Call. mcpkit gives the rest:
s := mcpkit.New(P, version, instructions) // Implementation{Name: P.Name}
profiles := mcpkit.Profiles{Product: P, Pinned: pinnedFlag, List: configuredNames}
mcp.AddTool(s, &mcp.Tool{Name: "lockmq_read", Description: "..."}, func(ctx context.Context, req *mcp.CallToolRequest, in ReadIn) (*mcp.CallToolResult, ReadOut, error) {
profile, err := profiles.Resolve(in.Profile) // pinned / unknown / several: errors the model can act on
if err != nil {
return nil, ReadOut{}, err
}
stop := mcpkit.Progress(ctx, req, 0, mcpkit.WaitingMessage(P)) // every 5 s while the human decides
out, err := read(ctx, profile, in)
stop()
if res, info, ok := mcpkit.Refused(err); ok { // err is a *mcpkit.ToolError with a Refusal
return res, ReadOut{Refusal: info}, nil
}
if err != nil {
return nil, ReadOut{}, err
}
return mcpkit.Text(mcpkit.Untrusted("messages", "the broker") + "\n" + text(out)), out, nil
})
return mcpkit.Serve(ctx, s, os.Stdin, os.Stdout) // stdout carries only the protocol
A status tool reports profiles.ForStatus(arg) and, per profile whose
dial fails, mcpkit.NoConsole(P, profile, err) (the exact start command and
what to ask of the user; NoConsoleLine for the text). Run integers that
may exceed 2^53 through mcpkit.Exact/ExactRows; NonNil keeps [] in
structured output.
Protocol revisions. New accepts only mcpkit.ProtocolVersions()
(pinned: an SDK upgrade does not add a revision by itself). A client at
revision 2026-07-28 or later rejects a tools/call result without
resultType, and the SDK sets that field (unexported) on its own results
only. Add your receiving middleware (to rewrite the SDK's argument-
validation text as your refusal, say) before Serve: Serve adds the
outermost one, which gives a result your middleware built the
resultType it lacks.
Hub mode. Put mcpkit.RequesterFromEnv() (an ipc.Requester: lea's
LEA_AGENT and the hash of LEA_AGENT_TOKEN, so the raw token never
reaches the console; empty outside lea; never write Token to the audit
log, a screen or an error) in the params of
your plan and run calls; the console binds the plan to the token and
answers another agent's run with ipc.CodeWrongRequester (a guard against
mistakes, not a boundary: agents of one UID can read each other's token).
When the console answers that the run waits on a hub ticket, return
mcpkit.Pending(P.Name, ticketID): not an error, the fixed text "Pending
human approval: ticket … on {"status":"pending","ticket":…,"lock":…} that lea's hook reads to
suspend the agent. A typed run tool embeds mcpkit.PendingResult in its
output.
Agent wiring. Declare your texts once:
var Wiring = agentwire.Wiring{Product: P, Assets: agentwire.Assets{
MCPCommand: []string{"lockmq", "mcp"},
Skill: skillMD, // .claude/skills/lockmq/SKILL.md (project) and ~/.claude/skills/... (user)
Instructions: agentsMD, // the <!-- lockmq:begin --> section of AGENTS.md / GEMINI.md, the Cursor rule body
CursorFrontMatter: cursorFront, // .cursor/rules/lockmq.mdc
ProjectConfig: exampleConfig, // .lockmq/config.toml when absent (nil: none)
ClaudeAllow: []string{"mcp__lockmq__lockmq_status", "Bash(lockmq status:*)"},
ClaudeAllowNote: "...", // optional: what the ClaudeAllow calls may do
ClaudeNote: "...", // optional end of the project notes
}}
then Wiring.Init(agentwire.ProjectRoot(P, cwd), agent) for <name> init <agent>, Wiring.InitUser(env, agent) / PendingUser for the account
(env, _ := agentwire.SystemEnv()), agentwire.Detect(env) and
Wiring.Unwired(env) for your doctor's "agent wiring" line. Only list in
ClaudeAllow calls the human may run without a prompt: masked or
policy-bounded answers. ClaudeAllowNote is the one sentence the project
notes put before the list, saying what those calls may do (for locklog:
searches return masked lines); without it they say "The calls below answer
within the console's policy and budgets, without a prompt." Never promise
there that the calls touch no data. ClaudeAllow is merged into permissions.allow at user scope by
InitUser; a project Init only lists it in the notes, unless
ClaudeAllowProject is set: then Init(root, "claude") also merges it into
the project's .claude/settings.local.json (personal, never the shared
.claude/settings.json), keeping the file's other keys and entries, and
the notes say what was written. Set it only when every listed call answers
with masked data and every approved operation is approved at the console
or the hub (the human, not the allow list, decides). When .mcp.json
already holds a <name> server that is not the product's exact entry
(action kept: a cloned repository may ship one that runs anything), it is
foreign: Init leaves it, writes nothing to .claude/settings.local.json,
and the notes only warn the human to check what it runs before approving
it, and to remove it and run init again to wire the product. Codex and Gemini get
600 s tool timeouts from agentwire, which cover the console's 5 minute
approval and a queued call.
13. Hub tickets (ticket) #
In hub mode a lock does not ask the human itself: it pushes a signed
ticket.Ticket to lockhub and acts only on a ticket.Decision signed by
the hub key it pinned. The package is the contract with lockhub; its
testdata fixtures are verified by lockhub's CI and do not change within a
ticket.Version.
t := ticket.NewTicket(ticket.Source{Lock: "locksql", Instance: "crm-prod"},
ticket.RiskProdWrite, planHash, policyFP, time.Now(), ticket.ClampTTL(ticket.RiskProdWrite, cfgTTL))
t.Screen = masked // built by render, never by hand
if err := t.Sign(lockKey); err != nil { ... }
body, _ := t.Marshal() // canonical JSON
d, err := ticket.VerifyDecision(pinnedHubKey, data) // schema + signature
err = d.Match(t) // same ticket, instance, key, plan hash
err = d.Usable(t, time.Now(), ticket.ClampTTL(t.Risk, cfgTTL)) // ErrDenied unless approved
// then consume d.Nonce once, recompute the plan hash, execute
- Risk classes are closed:
read,read-pii,write,prod-write,policy-loosen,unlock. Map each operation onto one; an unknown name does not decode.Risk.TTL():readandread-pii1 h (a read left pending ends by itself),writeandpolicy-loosen24 h,grant,prod-writeandunlock4 h;ClampTTLlets an instance shorten it, never lengthen it. - Signatures are Ed25519 over
Context+ NUL + the canonical JSON withoutsig. Contexts keep a ticket, a decision, a key certificate, a hub request (ContextRequest, forLock-Sig) and a reveal key apart: sign other bytes withSignBytes/VerifyBytesand a context of their own. Verification decodes strictly (unknown members, duplicate members, lone surrogates and trailing data are refused), so what verifies is exactly what you read, and only its exact canonical bytes verify.KeyIDnames a key; it never replaces the key you pinned. Dedupe and refuse replays on the ticket id and the decision nonce, not on bytes. - Sealed boxes (
Seal/Open: X25519, HKDF-SHA256, XChaCha20-Poly1305) carry what the hub must not read: anunlocksecret, a clear screen for a device, a reveal, the values a human typed for a plan (SealInputs). The associated data binds a box to its ticket and to its use (ContextSecret,ContextInputs). Seal only to a key whoseKeyCertverifies under an Ed25519 key you pinned, or that a ticket verified under the pinned lock key carries (Subject.SecretTo). Typed values never reach the hub (it relays the box), the agent (the lock runs them, its answers are the product's) or the audit (names only). HashOf(v)is the plan hash or policy fingerprint of any value:sha256:over its canonical JSON.
plans keeps the plans themselves, in the lock's volume (one 0600 file
per plan in a 0700 directory, refused if wider), so a pending plan
survives a restart:
st, err := plans.Open(filepath.Join(home, "plans"))
caller := plans.Caller{UID: peer.UID, Token: requesterToken}
p, err := st.Create(plans.Binding{UID: peer.UID, Agent: agent, TokenHash: plans.HashToken(requesterToken),
PolicyFP: fp, PlanHash: hash}, body, 10*time.Minute)
// run(plan_id) in hub mode:
if q, ok := st.FindPending(caller, hash); ok { /* answer q.Ticket again */ }
err = st.MarkPending(p.ID, caller, t) // the signed ticket, kept for Match
// run(ticket): q, _ := st.ByTicket(id, caller); own, _ := q.LockTicket(lockPub)
// then VerifyDecision, d.Match(own), d.Usable(own, ...), and:
err = st.Check(p.ID, caller, recomputedFP, recomputedHash) // ErrStale: new plan, new ticket
err = st.Begin(p.ID, caller, d.Nonce) // single use; ErrReplay, ErrUsed
// ... execute ...
err = st.Finish(p.ID, caller, ok, reason)
Another peer UID gets ErrWrongPeer, another agent token
ErrWrongRequester (this catches mistakes; agents of one UID can read
each other's token, so it is no security boundary). The token itself is
never written, only its hash. A plan executing when the lock stopped
comes back Failed and never runs again. End records a denial,
withdrawal or staleness, WithdrawAll withdraws every pending plan (a
lock started without its hub), Prune expires and removes final plans
after Retention; consumed nonces are kept.
14. Launcher verbs (verbs) #
lox add sets up every lock of a project through the same three verbs
of each lock's launcher (locksql, lockmq, lockfile, lockhttp). The verbs
are the product's; this section is their contract, and verbs holds the
checks and JSON shapes they share. Old verbs (lockmq setup, lockhttp init --trusted, ...) are removed, not aliased (pre-release).
<lock> init <agent> writes the agent files of the project of the
current directory (§12: Wiring.Init(agentwire.ProjectRoot(P, cwd), agent)), for <agent> one of agentwire.Agents(). It never asks, is
idempotent, and exits 0 when the files are written or already there.
lox add calls <lock> init claude from the project root.
<lock> profile add adds one profile to the project's
.<lock>/config.toml:
<lock> profile add [--non-interactive] [--json] [--project DIR] [--profile NAME]
[--production] [--credentials ask|vault [--vault-path P]]
<target flags>
| Flag | Meaning |
|---|---|
--non-interactive |
never reads the terminal or stdin; a missing required value is a usage error naming its flag |
--json |
stdout carries exactly one line of JSON, verbs.Profile on success, verbs.Error on failure (human text, if any, goes to stderr) |
--project DIR |
the project (default: the project of the current directory) |
--profile NAME |
the profile name (config.CheckProfileName); the lock's default when absent, reported in the JSON |
--production |
a production profile (its name is typed for every approval) |
--credentials ask|vault |
where the console gets the secret: ask, the console asks at start (masked), the default; vault, the console reads it from Vault at --vault-path with the Vault token it asks at its start, masked (never VAULT_TOKEN or ~/.vault-token) |
--vault-path P |
the Vault path, with --credentials vault only (secrets.CheckVaultPath) |
Without --non-interactive, on a terminal, it asks for what the flags
did not give; without a terminal it refuses with a usage error rather
than wait for an answer. No form of profile add takes, asks for or
writes a secret. Every profile add, the interactive one too,
parses and checks its arguments with verbs before it asks or writes
anything: the mode is itself a flag (--non-interactive), so the
arguments are parsed before the mode is known, and an interactive call
with --json would still leak a value through fs.Parse's error:
asJSON := verbs.WantsJSON(args) // known even when args do not parse
if err := verbs.Parse(P, fs, args); errors.Is(err, flag.ErrHelp) { // -h, -help
fs.SetOutput(os.Stderr); fs.Usage(); os.Exit(0)
} else if err != nil { ... } // never fs.Parse: its errors quote values
cred, err := verbs.Credentials(*credentials, *vaultPath) // "ask" or "vault"; keychain and anything else refused
verbs.WantsJSON takes --json, -json and --json=V for any
strconv.ParseBool form of V; the last occurrence wins and -- ends the
flags. verbs.Parse discards the flag package's output, maps its errors
to ones that name a flag fs defines (read from the flag package's fixed
message shapes, never from a Value's own error) and never quote an
argument, and then runs verbs.RefuseSecrets: a flag named like a
secret (config.SecretKey: --password, --vault-token, --api-key,
--authToken, ...) whatever its value; a value that embeds a secret
(config.EmbedsSecret: user:pw@host, password=, Bearer …, an
Authorization: header, https://TOKEN@host) or a control character; a
value that is itself a secret flag (in --user --password X, --user
took --password as its value: refused as secret, naming --user);
and any positional argument (the flag package stops at the first one, so
profile add takes flags only). It inspects each flag's
Value.String(): a repeatable flag's String renders every value.
These errors name the flag, never a value, and wrap verbs.ErrSecret
for a secret. -h and -help (a lock does not define them) come back
as flag.ErrHelp, unchanged: the launcher prints its usage on stderr and
exits 0, as every Go CLI does. RefuseSecrets is a backstop: a lock never
declares a target flag that carries a secret, and none of its own
profile add errors (an unknown engine, a bad host, ...) quotes a flag
value either; name the flag.
A lock whose target needs no secret (lockfile --source local) takes no
--credentials, does not call verbs.Credentials (which defaults to
ask) and leaves verbs.Profile.Credentials empty. The non-interactive
form takes ask or vault only: keychain, and any other source, is
refused, since nothing could put the secret in the keychain without
asking for it. The interactive form may offer the other sources its
console supports, and never asks for the secret itself.
The target flags are the lock's own, named after its config keys,
and the ones lox add passes from its guided form (the defaults come from what
it detected in the project):
| Lock | Target flags lox add passes |
|---|---|
| locksql | --engine postgres|mysql|mariadb|sqlite, --host, --port, --user, --database, --tls MODE; --path FILE for sqlite |
| lockmq | --url (management API, http(s)://host:port), --user, --vhost (repeatable) |
| lockfile | --source local|s3|sftp and the flags of that source |
| lockhttp | none (manual mode only, with lea): profile add --non-interactive --profile NAME adds a profile with the lock's default policy |
A profile already in the config is never rewritten: profile add fails
with code exists and writes nothing. lox add takes exists as "kept":
it goes on with init and doctor and reports that the profile was
already there, whatever target it has (it never compares or edits it).
<lock> doctor checks the setup of the project of the current
directory (init and doctor take no --project: they work on the
current directory) and the account:
<lock> doctor [--fix [--yes]] [--json]
--fix repairs what it can (agent wiring, the project directory's
permissions, ...) and checks again; it asks before each repair unless
--yes, and refuses without a terminal and without --yes. --yes
without --fix is a usage error. --json prints one verbs.Doctor
(verbs.NewDoctor(P, checks, fixed)): ok, the repairs made (fixed)
and every check (check, status ok|warn|fail, detail, fix).
The exit status is 0 exactly when no check fails (a warning does not
fail). A doctor that could not check (a usage error, --fix refused)
prints a verbs.Error instead: lox add tells the two apart by the
checks key (verbs.Doctor) or the code key (verbs.Error). lox add calls <lock> doctor --fix --yes --json.
JSON and exit status. A verb run with --json prints one line of
JSON on stdout, even when it fails. Its exit status is 0 on success and
non-zero otherwise; lox add reads the reason from the JSON
(verbs.Error.code: usage, secret, exists, failed), not from the
exit status, whose non-zero values stay the lock's own. A verb that
cannot parse its arguments still knows --json from verbs.WantsJSON.
A non-zero exit with no JSON line on stdout (an older lock, a crash) is
failed for lox add, which shows the lock's stderr.
What lox add calls, for each lock it sets up, from the project root:
<lock> profile add --non-interactive --json --profile NAME [--production] [--credentials ask|vault --vault-path P] <target flags>
<lock> init claude
<lock> doctor --fix --yes --json
then lox add <lock> <instance> with that profile, and lox run. The
console asks for an ask secret at its start, under lox run, masked.
15. Asking for a lockkit change #
lockkit has one owner. Do not copy a lockkit package into your product
and do not work around it: send a request to the lockkit owner naming the
need, the API you would like, and the product code that needs it. Build
against a local checkout with an uncommitted go.work:
go 1.26.0
use (
.
../lockkit
)
(go.work and go.work.sum stay out of git: add them to
.git/info/exclude.) For CI, pin the pushed commit or tag:
go get github.com/DavidGodefroid/lockkit@<commit or vX.Y.Z>.