lock family docs GitHub

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 onto console.Run is 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). When managed.toml.sigstore.json sits next to it, it is verified with cosign (update.Verify) against the company identity in signer.toml (root-owned: certificate_identity_regexp, oidc_issuer); present and invalid is an error, and [family] require_signature = true makes it mandatory. The signature is re-checked only when the file, the bundle or the identity change.
  • A lock in a lox run container has neither cosign nor the network to check the signature: lox loads the file on the host and writes managed.toml.verified next to it, root-owned, holding the one line managed.VerifiedStamp(f) returns (sha256:<hex> over the file, the bundle and signer.toml). When the bundle is present and the stamp matches, Load counts 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 replace managed.toml and drop require_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_test and call managed.SetTestPath(path) (no ownership check there).
  • File.Decode(&v, "<tool>", managed.KindBounds|KindDefaults) and File.Decode(&v, "<tool>", managed.KindProfiles, name) decode one table strictly; [lox] and [lea] decode whole (File.Decode(&v, "lox")). File.Fingerprint is 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:

  1. decode [<tool>.defaults] into your config struct first, then the user and project files over it (Decode overlays);
  2. 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;
  3. implement console.ManagedBackend so 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, audited refused-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-managed once, never pending, listed by :review as not approvable (Server.PolicyRefused reports 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.Managed to the file managed.Load returned at start and ServerConfig.LoadManaged to managed.Load: the file is re-read at every policy poll, as under Run (nil keeps Managed for 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 wrapping ErrManaged whose console.ManagedLines(err) are the lines to print. Refuse the start and audit console.DecisionRefusedManaged yourself.

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"}) (also be_niss, fr_nir, nl_bsn, us_ssn, and the opt-in phone_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 most DetectLimit bytes.

    • 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, 00 and 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_loose adds 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 (when phone_loose is not enabled; tel, tél, gsm, mobile, phone, telefoon, handy, ...) raises the numbers phone_loose would 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). passport and birth_date find nothing without a context. pii.FindField(field, s, ds) is FindInViews with the field name as the context of every candidate of s (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, a Detector of your own) or ConfidenceLow (person_name, passport, birth_date, a candidate raised by a context, the CutKind rest). 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.xxx or 0xxx 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 a phone.
    • 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/YYYY when 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: Belgian 1-ABC-123 and ABC-123 (not after INV, REF, SKU, CVE, ISO and other reference prefixes), French AB-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, NL 1234 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 of Authorization/Proxy-Authorization (the scheme word stays), Cookie/Set-Cookie (to the end of the line), *-Token/*-Key headers, any key=value/"key": "value"/key => value/--flag value whose name LooksSecret, 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 as sha256), Bearer/Basic/Token credentials 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:pw and mysql -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, SQL IDENTIFIED 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 .ppk bodies, 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's https:\/\/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 -p and mongo* -p are masked; -p elsewhere 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 through auth (--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 after version, build, release, oid or 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) and PWD are not. Check it before LooksPersonal: a secret is redacted, never pseudonymised or unmasked.

  • For text bound for a model, use pii.FindInViews(s, ds): the values found in s and in every detection view of it, as byte ranges of s ([]pii.Match{Start, End, Kind}, sorted, non-overlapping; Kind is 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) is s folded, 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; DetectionViews adds 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 of View.Text back to s, 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[31m inside a value does not split it. Then a view where introducers and final bytes read as spaces, the payload kept; the detection view of s percent-decoded (once, twice), with JSON escapes decoded (\u0040), with HTML character references decoded (&#64;, &commat;), with all of them chained (jean%40example&#46;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, \e and \033 escapes decoded, and form-decoded (+ as a space) then percent-decoded. A view of a long text may stop early: apply only the matches of Find/FindInViews to a text, never ranges you computed on a view yourself.
    • pii.Find(s, ds) is what Detect masks, as ranges: masking each with pii.MaskText gives Detect's output, except that Find masks more where a later detector matched across an earlier mask, and a Detector of 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 kind pii.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 to s itself.
  • Keyed pseudonyms: p.ID(kind, value) is 12 characters of Crockford base32 (60 bits) of HMAC-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). Normalise folds 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 as YYYY-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 with pii.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 until Close (which forgets the keys in memory: ID then fails, and Rotate and Prune fail with ErrClosed; opening creates the .lock file). Current(), Get(epoch) for a reference minted earlier, Rotate(now), RotationDue(now, every) (also true for an epoch created more than ClockSkew in the future), Prune(now, keep) deletes keys replaced keep ago and leftover temporary files (a clock that jumped forward may delete a key early; one set back keeps it at most ClockSkew late, and set back further deletes it at once and makes RotationDue true: 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. ID returns ErrForgottenEpoch once Prune deleted 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 in AllDetectors): 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, TestPersonNameHeldOutRecall and TestPersonNameFalsePositives log 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) and pii.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 as person_name finds 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 columns LooksPersonal leaves out and propose those it reports.

  • pii.RefStore: n := s.Begin() per result, s.PutDistinct(n, value) (or Put(n, "row.col", value)) returns rN.K; show pii.RedactedRef(ref); resolve with Get in the console only. Values never leave the console's memory.

  • The detection bench (TestBench, pii/bench_test.go) scores every detector per language with FindInViews on the synthetic corpus of pii/testdata/bench (written by go run ./pii/internal/benchgen -out pii/testdata/bench), and LooksPersonal / LooksQuasi on its field names; the figures are in pii/testdata/bench/RESULTS.md. A metric below baseline.json fails go 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 (or secrets);
  • 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 of profile add and doctor: 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 . Do not retry. End your turn; you will be told when it is decided." and the structured {"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(): read and read-pii 1 h (a read left pending ends by itself), write and policy-loosen 24 h, grant, prod-write and unlock 4 h; ClampTTL lets an instance shorten it, never lengthen it.
  • Signatures are Ed25519 over Context + NUL + the canonical JSON without sig. Contexts keep a ticket, a decision, a key certificate, a hub request (ContextRequest, for Lock-Sig) and a reveal key apart: sign other bytes with SignBytes/VerifyBytes and 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. KeyID names 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: an unlock secret, 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 whose KeyCert verifies 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>.