@ai-ecoverse/slicc-vault
v1.20.0
Published
The separate-origin vault at vault.sliccy.ai: sealed secrets, OAuth tokens, and model and MCP credentials for SLICC seven, used only by its injector
Readme
slicc-vault
The separate-origin vault at vault.sliccy.ai. It holds the sealed secrets, OAuth tokens, and model and MCP credentials of SLICC seven, so that no value ever reaches the model provider. The design is secrets-tokens-design.md (revision 4) in slicc-agent-plan. This README is the spec of what is built.
- Built: the key store, records, origin namespacing, auto-unlock with an optional passkey or passphrase lock, the embedding API with its
pageandagentroles, the page'sput,seal,remove,restore,cancelandlock, the scrub state, the lock button and its popup, the attested policy (rules,[exec],[exits],[limits]), the injector andforwardwith the response scrub (S1), the agent'sredact,redactStream,endpoints,modelandmcp(S3, S4),records,grantandrefresh, the model and MCP refusal, exec tickets (materializeandmaterializeConsume),sessionMaterializefor kernel-verified TLS sessions,import(backups, KeePass, six'ssecrets.env) andexport, sign-in tokens with their grants and refresh throughegress, and the vault side of the/dev/sealedopsseal,placeholder,list,test,request,status,cancelandmaterialize. - The vault has no network stack. It never fetches anything but its own
config.jsonandembedders.json: bios sends every request the vault prepares.
Documents and hosting
The build (npm run build) writes a flat static tree to dist/site/:
| file | what it is |
|---|---|
| index.html, frame.js | the core frame. bios embeds it as a hidden iframe and talks to it over a MessagePort |
| button.html, button.js | the lock button. A popup can only open from a click inside a vault document, so bios and spectrum embed this wherever the lock is offered |
| popup.html, popup.js | the vault window, a top-level vault.sliccy.ai document, used only for the passkey or passphrase lock |
| vault.css, fonts/ | Spectrum 2 from the CSS-only @spectrum-css packages, and Adobe Clean |
| config.json | { version, embedders }. embedders is vault.sliccy.ai's fallback list, ["https://seven.sliccy.ai"] |
The bundles hold this repo's code, @ai-ecoverse/slicc-scrub, and @ai-ecoverse/jerk-oauth (exact-pinned, for token refresh): its wasm is inlined into a separate chunk under chunks/ (about 970 KB) that loads only on the first refresh or MCP grant, never fetched. The only third-party code is for KeePass imports: kdbxweb (with @xmldom/xmldom and fflate) and hash-wasm's argon2, exact-pinned and bundled into a separate chunk under chunks/ that the core frame loads only when a KeePass import starts; its wasm is inlined, never fetched. The sliccy-ai Worker serves the tree from the network on every load, never from OPFS, with:
Content-Security-Policywithscript-src 'self', no inline scripts or styles, andframe-ancestorslisting the seven origins;Cross-Origin-Embedder-Policy: require-corp,Cross-Origin-Resource-Policy: same-site,Cross-Origin-Opener-Policy: same-origin;Origin-Agent-Cluster: ?1,nosniff,Referrer-Policy: no-referrer,no-cachewith an ETag, andno-storeon the HTML documents.
To stay out of the back/forward cache, index.html and button.html add an unload listener, which keeps an embedder's page out of it too, and all three documents reload on a pageshow with persisted; bfcache.test.mjs proves index.html is never restored.
At start, a vault document that is the top-level document (popup.html, or index.html or button.html opened as a tab) calls navigator.mediaDevices.setCaptureHandleConfig({ handle: 'slicc-vault', exposeOrigin: true, permittedOrigins }), with permittedOrigins the embedders the document loaded that are valid exact origins, serialized (a :* source or an invalid one is left out; none means an empty list, never '*'), so seven sees the handle and refuses to capture a vault tab. A framed document, or a browser without the API, sets none.
GET /embedders.json is answered by the Worker, not by this tree. It returns { "embedders": [...] }, the same sources as frame-ancestors. The vault reads it at start and accepts only those origins. When it's missing or malformed, only vault.sliccy.ai falls back to config.json; on every other host (vault-preview.sliccy.ai, branch previews, local) that means no embedders, so it fails closed. An embedder source is http(s)://host, optionally with :port or :*, matched exactly.
main is served at vault.sliccy.ai, and every other branch at vault-<label>.sliccy.ai. CI is one caller workflow, ci.yml, that runs slicc-shared-web's pipeline.yml at the same v2 tag as the @ai-ecoverse/slicc-shared-web devDependency, with secrets: inherit, on pull requests, on the merge queue (merge_group) and on main: lint everywhere; on pull requests and in the merge queue the integration tests (test/integration/*.test.mjs, split by timings across the pipeline's shards, Chrome for Testing from slicc-cdp install-browser, headed on Xvfb); on pull requests a Claude and a Codex review; on main semantic-release, with no tests. ci / integration and ci / review are the checks to require. The pipeline picks its own runners (its self-hosted pool, a hosted runner for a fork's pull request and for the release), and a pull request from a fork gets no review. Releases run only on main: the pipeline's release job runs only on a push to it, in the production environment, and publishes vault.sliccy.ai there. On a pull request the pipeline's deploy job publishes the branch preview in the staging environment and hands its URL to the tests and reviewers as STAGING_URL; it publishes nothing to npm and makes no tag or release, and the merge queue deploys nothing. ci.yml is the only workflow, so nothing deploys outside the pipeline. No workflow names a self-hosted runner label. workflows.test.mjs keeps all of this so. seven embeds only vault.sliccy.ai.
Storage and keys
Everything lives in the vault origin's IndexedDB, database slicc-vault, version 1:
| store | key | holds |
|---|---|---|
| meta | "keys" | the key state below |
| records | [ns, kind, name], unique index on placeholder | records |
| requests | id | requests that wait for a human |
| audit | seq (auto-increment) | the audit log, per namespace |
| intents | nonce | hand-offs from a button to the popup, valid for 10 minutes |
The data key is one AES-GCM 256 key. Each record's value is encrypted under it with a fresh 12-byte IV and the additional data slicc-vault/1 NUL ns NUL kind NUL name, so a ciphertext can't be moved to another record.
The key state is one of:
auto(the default): the data key is stored as a non-extractableCryptoKey. Every vault document loads it at start, so there's never a prompt.passkey: the data key is stored only wrapped (AES-GCM) under a key-encryption key from WebAuthn PRF. The passkey's RP id is the vault's host, the PRF input is a random 32-byte salt, and the KEK is HKDF-SHA-256 over the PRF output with that salt and the infoslicc-vault/kek/1.passphrase: the same, with the KEK from PBKDF2-SHA-256, 600,000 iterations, over a random 16-byte salt.
Locking:
- While protected, a document starts locked and asks the other open vault documents (over the origin's
BroadcastChannelslicc-vault) for the unwrapped key. Unlocking in the popup unwraps the key as non-extractable and hands it to every open vault document the same way. Lock nowdrops the key from every document. Closing every vault document has the same effect.- Protecting makes a new data key, re-encrypts every record under it, and stores it wrapped, in one transaction. If a record changed meanwhile, protecting fails with
EAGAINand changes nothing. Removing protection stores the current key as non-extractable again. - Every data key has an id, stored with it. A seal checks that id in the same transaction that writes the record, so a value encrypted under a key that was replaced meanwhile is refused with
EAGAINrather than stored unreadable. - While locked,
sealanswersEACCESwith a message startingvault is locked. Ops that need no value (placeholder,list,status, ...) still work.
Records
{ ns, kind: "secret" | "grant" | "model" | "mcp", name, placeholder, sealed: { iv, data },
source, createdAt, updatedAt, usedAt?, expiresAt?, refresh?, oauth?, provider?, server?, settings?, url?, sealedBy?, removedAt?, removedWith? }nsis the embedder's origin, below.namematches^[A-Za-z_][A-Za-z0-9_]*$, at most 128 characters.- A value is at least 8 bytes of UTF-8, without CR, LF or NUL, and contains no placeholder. Anything shorter is refused with
EINVAL"values shorter than 8 bytes can't be protected from the model", because the scrubber couldn't tell it apart from ordinary output. placeholderisfnox_proxy_plus 24 lower-case hex characters fromcrypto.getRandomValues, unique across the whole vault and stable for the record's life. All placeholders have the same length, so none contains another.provider(amodelrecord's pi-ai provider id, required,^[a-z0-9][a-z0-9._-]{0,63}$),server(anmcprecord's MCP server name, optional) andsettings(non-secretNAME = textpairs for amodelormcprecord, at most 16, each at most 256 characters, e.g.AWS_REGION) bind a record to what may use it. Inlist, a grant shows theserverorproviderof the live tokens of the samesourcethat name it as theirrefresh, when they all agree (and none when they don't, whatever the grant stored), for display and lookup only; nothing is copied onto the grant. A reseal without them keeps the stored ones, and one with them rebinds the record; a newmodelrecord without a provider is refused withEINVAL"a model record needs its provider".sourceissettings(entered in spectrum),process,jerk:<provider>@<device>,oauth:<provider>orimport:<kind>.stateis computed, never stored:removedwhenremovedAtis set,activewhen the accepted policy has a rule for the record (below), otherwiseinert.- Removing sets
removedAt. A removed record keeps its placeholder, can be restored withrestore, and is deleted for good 30 days later. Sealing a removed name brings it back with a new value and the same placeholder. Removing a token removes the grant itsrefreshnames (when the grant has the token's ownsource, and no other live token of that source still names it) in the same transaction, and restoring it restores that grant too, only when that grant was removed by this token's removal (the vault records which token took it asremovedWith,<kind>\u0000<name>of that token, cleared on restore) and is still within the 30 days; the oneremoveorrestoreaudit entry names it asgrant. Removing an already removed record, or restoring a live one, succeeds and changes nothing (its 30 days don't restart, and nothing is audited). - Times are epoch milliseconds everywhere.
Backup reminder
status.exportedAt is the time of the namespace's last successful export, or null if it never exported. status.backupReminder is due once the namespace holds a secret record and has never exported, dismissed after dismissBackupReminder, and null otherwise (no secrets yet, or exported).
Policy
The rules come only from two root-owned kernel files. bios reads them with the kernel's page-only kernel.attest(path) (KS-7), at boot and whenever either file changes, and passes the results through unchanged:
port.postMessage({ v: 1, id, op: 'policy', files: [await kernel.attest('/etc/fnox/config.toml'), await kernel.attest('/etc/slicc/secrets-policy.toml')] });An attestation is exactly { path, bytes, uid, gid, mode, ino, mtimeMs, sha256, users, onRootFs, parents }, nothing more or less:
| field | format |
|---|---|
| path | the canonical absolute path the kernel read (it refuses symlinks and mount points) |
| bytes | the file's bytes, a Uint8Array, read in the same step as the stats |
| uid, gid, mode | integers; mode includes the type bits (0o100644) |
| ino, mtimeMs | numbers |
| sha256 | 64 lower-case hex characters |
| users | the kernel's users mode: off, report or enforce |
| onRootFs | true only on the root file system, under no mount |
| parents | every directory from / down to the file's, each exactly { path, uid, gid, mode } |
The vault trusts a file only when:
pathis/etc/fnox/config.tomlor/etc/slicc/secrets-policy.toml, and the object has exactly the fields above in those formats;- the SHA-256 the vault computes over
bytesequalssha256(the field alone is never trusted); uidis 0, andmodeis a regular file withmode & 0o022of 0;parentslists exactly the directories abovepath, in order from/, each with uid 0, a directory type andmode & 0o022of 0;onRootFsis true, andusersisenforce;bytesis UTF-8 and parses.
Otherwise the vault keeps the policy it last accepted for that file, so the rules it would have changed stay as they were (new ones stay inert), and warns, e.g. rules not trusted: /etc/fnox/config.toml: kernel users must be enforce. The reply is { accepted, files: [{ path, sha256, accepted, warnings, absent?, paused? }], rules: [{ secret, domain, header, methods, paths, protocol?, position?, port? }], warnings }, with rules the rules now in effect. status.policy repeats files from the last policy call, so Settings can show why rules aren't trusted.
A missing file is attested by the kernel as exactly { path, absent: true, users, onRootFs, parents }, with no other field (so no bytes, sha256, uid, mode, gid, ino or mtimeMs). The vault trusts it only when path is one of the two files, absent is exactly true, onRootFs is true, users is enforce, and parents lists exactly the directories above path, by the same rules as for a file. A list that stops above a missing directory isn't trusted. Anything else with absent is an untrusted attestation, as above: the last accepted policy, or a pause, stays as it was.
A trusted one clears the accepted policy of that file and pauses it: while either file is paused, forward of a secret, materialize, materializeConsume, sessionMaterialize, sessionAnchors and the test verdict all refuse with policy file missing: paused (EACCES, or a 403 from forward) and cause: 'policy-paused', so deleting secrets-policy.toml never falls back to permissive defaults. forward of an mcp record uses no rule and goes on. The pause is kept with the accepted policy, and ends when a trusted attestation of that file with bytes arrives. Until then files and status.policy show the file with absent: true and paused: true, in every policy reply, whether or not the call named it.
The KS-7 conformance fixtures are test/integration/fixtures/attest/*.json: { description, trusted, reason?, attestation }, with bytes as base64, written by make.mjs next to them. good-* must be trusted, and every bad-* must be refused with a warning containing reason. The vault's own tests run them through the policy op, and the kernel's tests can check that kernel.attest produces the good shape.
/etc/fnox/config.toml, the injection policy, read the way fnox reads it:
[secrets]: entries whoseprovider(or the file'sdefault_provider) isseven, and whosevalueis a placeholder.[[proxy.rules]]:secret(a seven entry of[secrets]),domain(one exact host, lower-cased),header(lower-cased, defaultauthorization),methods(upper-cased; empty means any) andpaths(each starting with/; empty means any). A rule that breaks any of this is dropped with a warning; the rest of the file still counts. Rules are numbered from 1 in file order, dropped ones included; asessionaudit entry names its rule by that number.- Session rules (§3.19) add
protocolandport(an integer from 1 to 65535), and are used only bysessionMaterialize, never byforward; a rule withoutprotocolis used only byforward:protocol = "http1":header,methodsandpathsas above; the position is the header. Apositionis a warning.protocol = "smtp", withposition = "auth-plain"or"auth-login", orprotocol = "pg", withposition = "password-cleartext"or"scram-sha-256"(each allowed only where a rule names it): noheader,methodsorpaths(a warning otherwise). Itsheaderreads''.- Trust anchors (§3.19.6), on any session rule:
anchors(base64 DER certificates, each starting with a SEQUENCE and at most 8 KB),pins(base64 SHA-256 of a public key, 32 bytes) androots(trueorfalse), at most 8 anchors and 8 pins. A rule with anchors or pins accepts only sessions the kernel verified against them, never the public roots, unless it saysroots = true; a rule without them accepts the public roots, androots = falsethere is a warning. The vault checks only their format: the kernel checks that an anchor is a CA (CA:true and keyCertSign) and the chain. Like every rule, they come only from the attested root-owned file, so only an approvedsudowrite sets them: no program, agent or page op can. port,position,anchors,pinsorrootswithoutprotocol, anotherprotocol, or a missing or wrongport,position,anchors,pinsorrootsis a warning, and the rule is dropped.
- A record of kind
secretisactivewhen the file's[secrets]entry of its name holds the record's own placeholder and at least one rule names it. A placeholder from another vault or an old record doesn't activate anything. - Nothing else in the file counts: no imports, profiles or other providers.
/etc/slicc/secrets-policy.toml, read by the vault only:
[exec]
NPM_TOKEN = true
GITHUB_TOKEN = { argv = [["/usr/bin/gh", "release", "upload"]] }
OPENAI_API_KEY = { shell = true }
[exits]
NPM_TOKEN = ["page", "device:7d1e4b0a9c2f5e83"]
[limits]
materialize_per_minute = 10
materialize_per_day = 200
shell_per_day = 5000[exec]:falseis an explicit off,trueisexec: 'any', exactly{ argv = [[…]] }is'argv'(each prefix an absolute program path), and exactly{ shell = true }is'shell'. Anything else, a table with other or extra keys included, is a warning, andexecstaysfalse. VAULT-1d acts on them.[exits]is optional: a secret without a line may use every exit; a line restricts it to the exits listed (page,proxy,tailscale,device:<id>).<id>is the device'sDeviceId, the stable id of its link certificate (a tray's 64 hex characters, orlocal:…for a CLI linked on the same computer), never its display name, which can be renamed or shared by two devices: one or more ofA-Z,a-z,0-9,.,_,:and-. An entry is compared exactly with theexitbios sends. Unknown names are dropped from the line with a warning, which leaves the line narrower, never wider.[limits]overrides the defaults above with positive integers.
Accepted policy is kept per namespace in IndexedDB, and loaded when an embedder says hello. Each file is audited once per SHA-256 and verdict (the vault remembers the last 64 per file) as a policy entry, with accepted, any warning, and its changes against the previously accepted file: rules added or removed, exec modes, exits lines and limits. A pause and its end are always audited: the entry that starts one has absent: true and sha256: null. Any change to the accepted policy, or a new audit entry, pushes changed with secrets, exits and audit, and other vault documents of the namespace reload the policy. Each policy call reads, merges and writes the stored policy in one transaction, so two pages can't undo each other's update.
Scrub state
scrub { needed, version, ready } tells bios and the agent whether output must pass the vault's scrubber before it reaches the model. It's per namespace:
neededis false only when the namespace holds no plaintext at all: no record of any kind or state, removed ones included until they're purged.versioncounts every change to the namespace's set of values: each seal, rotation, removal, restore and purge.readyis false only when values exist, the vault is locked, and this frame has no compiled scrubber for the namespace: a reload with the lock on, before unlock. The scrubber (@ai-ecoverse/slicc-scrub, over every value of the namespace, records of every kind and state) is compiled whenever the frame holds the key and the set of values changed, and is kept in memory for the frame's life, so locking stops value use but never scrubbing.
It's in status, in placeholders replies and in every placeholders push. After put and seal, the push with the new pair goes out before the reply.
Namespaces
Every record, request, audit entry and scrub version belongs to the origin that embeds the vault. The core frame takes it from the event.origin of the hello, and the button from location.ancestorOrigins[0] (or the first message's origin when the browser hides ancestors). A branch preview of bios therefore gets its own empty vault and never sees seven's records. The popup shows the namespace of the button that opened it. The lock itself is one per vault.
The embedding API (core frame)
The embedder (bios) loads https://vault.sliccy.ai/ in an iframe and, after load, posts a hello carrying a port:
iframe.contentWindow.postMessage({ type: 'slicc-vault', v: 1, op: 'hello', role: 'page', sudoGid }, 'https://vault.sliccy.ai', [port2]);- The frame answers only when the message comes from its own parent window, from an allowed origin, matching
location.ancestorOrigins[0]when the browser exposes it. Anything else gets no answer at all. - On
port2it answers{ v: 1, op: 'welcome', role, status }, or{ v: 1, op: 'refused', error }for another protocol version or an unknown role. - Everything after that goes over the port. The namespace and the role are fixed by the hello. Requests are
{ v: 1, id, op, ...fields }; replies are{ v: 1, id, op, ...fields }or{ v: 1, id, error: { code, message, cause? } }. Each port is answered in order (FIFO). - Roles:
page(the default) is bios and spectrum. bios may hand spectrum a page port of its own, or relay; the messages are the same.agentis the port bios hands the agent worker: it may use onlyredact,redactStream,push,idle,end,drop,endpoints,model,mcp,records,grantandrefresh, and anything else isEACCES. The agent port gets no events. Its ops are below (The agent's ops). - Values: only
forward(inside the head it returns, where an accepted rule allows it),materializeConsume(for an[exec]grant),sessionMaterialize(for a session rule, to the kernel's TLS writer, §3.19) andegress(a refresh request) put a value into the page. On the agent port onlymodelandmcpdo: their reply's prepared request carries the provider's or server's credential in its headers or URL, only for that provider's or server's own URLs, for the agent worker's transport to send at once. It must never be logged, stored, retried from storage or shown. No other op, event or reply carries a value, and no value ever reaches the model.
| op (page role) | fields | reply |
|---|---|---|
| sealed | req (the /dev/sealed request object, or its JSON text), stamp (the kernel's { pid, ppid, uid, gid, tag, users, groups?, user? }) | { reply }, the §3.7 reply, below |
| status | — | { status: { version, protocol, capabilities, origin, locked, lock, scrub, waiting, exportedAt, backupReminder, policy } }. capabilities is { forward: 1 }, in every status (the welcome's, this reply's and the status event's): every credentialed forward needs a manual exit, an MCP one a complete scrubbed body, its response is scrubbed, and a trusted missing policy file revokes. Without it, bios must not forward |
| placeholders | — | { placeholders, scrub }: every placeholder of a record that isn't removed, active and inert, sorted |
| list | — | { list: { secrets, mcp, refused, audit, auditMore, exits } } for Settings › Secrets. mcp is [{ name, server, state }], sorted by name, the mcp records bound to a server that can be used now: not removed, and state ready or refreshing (expired with a refresh source). An expired one with no refresh source or with a dead grant, a bridge token (source process), one with no server, one whose mcp:<server> is longer than a request's 128-character provider, and one whose name or server holds a value are left out, and so is every record while the vault is locked before its first unlock |
| audit | before? (a seq), limit? (1–1000, default 100) | { audit, more, next? }, newest first; pass next as before for the next page |
| put | name, value, kind? (secret, the default, model or mcp), provider?, server?, settings?, request? | { name, placeholder, state }. A value typed in Settings (Settings › Accounts puts model keys as kind: 'model' with their provider), with source settings; on an existing name it's a rotation that keeps the placeholder, except over a process record that isn't a secret, which gets a new one (below, seal in /dev/sealed). Without value, for an existing model or mcp record, it only updates provider, server or settings (say, a new Cloudflare account id), keeping the value and placeholder (ENOENT if there's no such live record; like every put, it needs the vault unlocked), audited as a seal entry with rebound: true; a new server clears a first-use pin, and a new provider drops the record's custom base URLs. With request, it answers that value request, which must still be pending and ask for name, and the record must be a secret (else EINVAL, and nothing is stored), so its /dev/sealed status becomes done |
| seal | name, value, source, kind? (default secret), expiresAt?, refresh?, oauth?, provider?, server?, settings?, request?, jerk? | { name, placeholder, state }. bios's, for J1 leases and sign-in tokens, below (Tokens and refresh) |
| answer | request, state (progress or done), progress? (≤ 200), exit?, user?, scopes?, message? (≤ 500), rows?, placeholder? | { state }. The answer to a jerk or unseal request (or a sign-in that failed: for oauth and jerk-seal, done needs a nonzero exit, since only seal completes them); fields merge into the request, and status returns them. rows (for jop: "list") are { provider, name?, env, device?, signedIn?, user?, scopes? }, with any other field dropped, so no token can pass. For a jop: "status" answered done with exit 0, the vault adds the placeholder of the provider's own oauth:<provider> or jerk:<provider>@… record; a placeholder given by bios must be one of those records of the provider |
| refresh | name | { state: 'refreshed', expiresAt? } or { state: 'requested' }, below |
| remove, restore | name, kind? (default secret) | { name, placeholder, state }. A record removed more than 30 days ago can't be restored (ENOENT) |
| cancel | request | { state }; the request's /dev/sealed status becomes cancelled |
| lock | — | {}; drops the key everywhere when the lock is on |
| dismissBackupReminder | — | { backupReminder }; the reminder becomes dismissed for this namespace |
| policy | files | the attested policy files, below (Policy) |
| endpoints | model?: https base URLs, or { name, url }, a custom base URL for the model record name; mcp?: as the agent's; remove?: { model?: (url \| { name, url })[], mcp?: { server, url }[] } | { ok: true }; the user's own registrations, trusted (Model and MCP endpoints). The whole request is checked first and applied in one transaction, so an EINVAL anywhere changes nothing; a field other than model, mcp, remove and unpin is EINVAL too. unpin?: names of mcp records to unpin (needs the vault unlocked). remove takes back the page's own entries, never the agent's; removing the model record drops its custom URLs too. A { name, url } needs a live model record of that name, and a plain https: URL on 443 without * or {…} and not under a page-registered MCP server, else EINVAL |
| forward | head, bodyPresent, body?, owner, exit, rawTraits, with a MessagePort transferred alongside | { mode, head, bodyComplete?, body? }, { needsBody: true } or { refused }, below (Forward) |
| materializeConsume | ticket, stamp, path, resolved, argv, cwd, interpreters, trusted | the values for the child's environment, below (Exec) |
| sessionMaterialize | stamp, session, host, port, protocol, position, verified, handshakeAt, exit, verifiedBy?, anchor?, then placeholders (and head for http1) at every position but scram-sha-256, which takes scram and no placeholders | { values } for a kernel-verified TLS session, below (Sessions) |
| sessionAnchors | stamp, session, host, port, protocol, exit | { anchors, pins }, below (Sessions) |
| import | kind, bytes, password?, pick? | below (Import and export) |
| export | passphrase | { bundle, count }, below |
| anything else | | ENOTSUP |
put and seal need the vault unlocked. They answer EACCES with a message starting vault is locked otherwise.
Events on page ports:
{ v: 1, event: 'status', status }whenever requests, records, the scrub state or the lock change;{ v: 1, event: 'placeholders', placeholders, scrub }whenever either changes;{ v: 1, event: 'changed', parts }, withpartsamongsecrets,auditandexits, so a view knows what to reload;{ v: 1, event: 'refresh', name, source, expiresAt }, when a J1 lease is due (below);{ v: 1, event: 'egress', request }, with aMessagePort, when the vault needs bios to send a request it built (below).
Errors use errno names: EINVAL, ENOENT, EACCES, EEXIST, EAGAIN, ETIMEDOUT, ENOTCONN, and ENOTSUP for ops this version doesn't have.
cause is the machine-readable reason for a refusal; match on it, never on message. Its one value is policy-paused: the policy is paused (see Policy) and the vault may well be unlocked. forward (in refused), materialize, materializeConsume, sessionMaterialize, sessionAnchors (in error) and test (beside verdict) can return it, with EACCES or status 403 and policy file missing: paused. Every other refusal has no cause, a locked vault's included. status.policy[] says which file to restore: its entries { path, sha256, accepted, warnings, absent?: true, paused?: true }, in page status replies and status events, name the missing file in path.
/dev/sealed, the vault side
bios relays each kernel request unchanged as the sealed op, with the kernel's stamp. The vault validates and answers { v: 1, ...fields } or { v: 1, error: { code, message, cause? } }. A request over 64 KiB, one that isn't a single JSON object, one with v other than 1, and an unknown op are EINVAL, as is a missing or malformed stamp.
| op | fields | reply |
|---|---|---|
| seal | name, value, and for an MCP bridge kind: "mcp", server, url | { placeholder, state }. A new name creates a process record. An existing one is rotated and keeps its placeholder, but only when its value came from a process; a value from Settings, jerk, OAuth or an import is replaced only by uid 0, and anyone else gets EEXIST. A bridge token (§3.16.3, e.g. mcpcat's --bearer) is an mcp record bound to server and url, which must be a kernel-loopback URL (http://<port>.kernel.localhost/…, never 9222; anything else is EACCES). Any write that isn't a process's over a process record of another kind than secret (a page put or seal, a sign-in, an import) makes it an ordinary record, without the URL, under a new placeholder, so whoever sealed it keeps no placeholder of the new value; a secret keeps its placeholder, which every uid can read and the rules name. It records the sealing uid, and resealing it, even after it was removed, is allowed only for a process mcp record sealed by the same uid, or by uid 0; anything else, a user-entered record included, is EEXIST |
| placeholder | name, server? | { placeholder, state } of the secret record, including removed; ENOENT when there's no record. For root, server changes the answer, and name is optional, below (Root and mcp records) |
| list | — | { secrets: [{ name, placeholder, state, exec, rules, expiresAt? }] }, records of kind secret that aren't removed, with their rules and [exec] mode |
| test | name, url, method? (GET), header? (authorization), exit? (page) | { verdict: 'inject' \| 'refuse', reason, cause? }: the injector's verdict for Bearer <placeholder> in that header, without sending anything, with cause: 'policy-paused' when the policy is paused; ENOENT for an unknown name |
| request | kind: "value", name, prompt? (≤ 200 characters) | { state: "pending", id } |
| request | kind: "oauth", provider, oauth (an object) | the same |
| request | kind: "jerk-seal", provider | the same |
| request | kind: "jerk", jop (list, status, create), provider? (needed but for list), scope?, params? (an object) | the same, or { state: 'done', id, exit: 2, message } for a J2 refusal |
| request | kind: "unseal", provider | the same, or the J2 refusal |
| status | id | { state }, with placeholder and record once the request is done, jerk for a sign-in or lease that reported it, and the latest answer fields (progress, exit, user, scopes, message, rows, placeholder). A jerk status of mcp:<server> is decided below (Root and mcp records) |
| cancel | id | { state }: cancelled, or the state it had already reached |
| materialize | names (or shellGrants: true), path, argv, cwd, interpreter? | { ticket, expiresAt, real: { NAME: true \| false } }, below (Exec) |
- Requests expire 300 s after they're made. Each uid has at most one pending request per namespace that waits for a human (
value,oauth,jerk-seal, andjerkwithjop: "create"); a second one isEAGAIN.statusandcancelsee only the caller's own requests (uid 0 sees all), and others getENOENT. Only requests that wait for a human show instatus.waiting. - A value request is answered by the page's
putwith itsrequestid. A sign-in or lease (oauth,jerk-seal) is answered bysealwithrequest(Tokens and refresh). Every other kind is answered by the page'sanswer.cancelends any of them and pushes{ v: 1, event: 'cancelled', request, kind }to the page, which passes it on to J1 where it can. - J-8's jerk requests (§3.7.1): the vault only keeps their state; bios learns of them from the relay and answers.
jop: "create"andunsealare for root and cones only (J2): the kernel stamp must haveusers: 'enforce', and either uid 0 or thesudogroup among itsgroups. bios names that group's gid assudoGidin its hello; a page hello without it clears the namespace's gid. A stamp withoutgroups, a vault withoutsudoGid, or any other mode refuses: the request isdonewithexit: 2at once, before bios sees anything. The tag is kept for audit and display only. - Root and
mcprecords: the placeholder of anmcprecord a process didn't seal is for root only, since a process holding one can call that server throughforwardpast a role's tool limits. Whoever seals a bridge token holds its placeholder. Root is a stamp with uid 0 andusers: 'enforce', notmayManage'ssudogroup: a process a human approved (sudo, including standing approvals), or the judge the owner chose to delegate sudo to (slicc-agent'ssudo.approver = agent, whose default isuser, a person). That root can already do more than read a placeholder. A cone or scoop isn't root, and neither is any uid under another mode, where the stamp proves nothing. Records match as kindmcp, source other thanprocess, withserverequal to the name given.placeholderwithserver, for root: only a usablemcprecord bound to that server answers, as inlist.mcp(live,readyorrefreshing, its name and server holding no value, so none while the vault is locked before its first unlock), with{ placeholder, state, kind: 'mcp' }. Given aname, it's the record of that name, elseENOENT"no record named NAME"; asecretof that name or a removed record never answers. Names in these refusals are scrubbed over every value. Without aname, it's the server's one record:EINVAL"several mcp records are bound to SERVER; ask for one by name" for more than one, andENOENTfor none. A record's name is never derived from its server's. Every other caller, and root withoutserver, gets thesecretanswer, withoutkind.placeholderwithnameandserveranswers for root when such a record, not removed, has that name and server. Anyone else, a wrong server, a removed record, a bridge token and a record with noserverget the reply the op gives withoutserver, the same as for no record. Asealunder a taken name still answersEEXIST, as above.statusof adonejerkstatusrequest formcp:<server>with no placeholder from asecretrecord: when both the request's stored stamp and the caller are root, and bios answered exit 0, and exactly one usable record (the rule oflist.mcp) is bound to that server, the reply adds itsplaceholderandrecord. Several usable ones areEINVALfor root ("several mcp records are bound to SERVER; ask for one by name"), as for the agent'srefresh;placeholderby name needs no such rule. When both are root and a record is usable but bios didn't answer exit 0, bios's reply stands. Every other reader, and any reader with no usable record, gets{ state: 'done', exit: 3, message: "no token for mcp:<server> in seven's vault: run jerk mcp:<server> create" }, whatever bios answered. The page and bios never see this rule: bios answers fromlist.mcp, before the caller is checked.- Neither reply carries a value, and neither read is audited, like every read of a placeholder; each use of one is, as
mcp(Model and MCP endpoints). Where the value is filled is Which MCP credential goes where (§3.16.1): for a Settings record, anhttps:URL under the server URL the page registered; with no page server for that name, it's pinned on first use to the agent's URL for that server, until the page registers one; for a sign-in (oauth:mcp), any registeredhttps:URL of that server.
Tokens and refresh
The vault has no network stack, so sign-ins run in seven's page (route (b), §3.11) and the vault keeps only the results.
- A sign-in's tokens: bios runs the sign-in, does the code exchange, and hands the results to
seal, keeping no copy:- the refresh token as
{ kind: 'grant', name, value, source: 'oauth:<provider>', oauth: { tokenUrl, clientId, scope?, resource? } }. A grant is never injected and never exported;tokenUrlmust behttps:, without userinfo (user:pw@,user@, even an empty:@, also when hidden by whitespace or backslashes the URL parser normalises: any@in the authority refuses), and aresourcemust be anhttps:URL (orhttp:on 127.0.0.1, localhost or [::1]) without userinfo or fragment; - the access token as
{ name, value, source: 'oauth:<provider>', expiresAt, refresh: '<grant name>' }. A grant name needs anoauth:source andjerk-sealajerk:one (EINVAL), and a refresh uses the grant only when its source is the token's own (else the grant is missing). - With
request(only for asecret; the id of a pendingoauthrequest for the same provider asoauth:<provider>, or ajerk-sealrequest for the provider ofjerk:<provider>@…) andjerk({ exit, user?, scopes?, expires_at? }),sealalso answers that request, in the same transaction that stores the record (a request that ended meanwhile refuses withEINVAL, and nothing is stored): its/dev/sealedstatusbecomesdone, withjerk, the record'splaceholderandrecordstate.
- the refresh token as
- A J1 lease:
{ name, value, source: 'jerk:<provider>@<device>', expiresAt, refresh: 'jerk-seal' }. - When a token is due (5 minutes before
expiresAt): the core frame checks once a minute, and right after a page says hello, for every connected namespace and forsecret,modelandmcprecords alike, andrefresh { name }asks for it at once. Vault frames of the same storage (several tabs) take a Web Lock per refresh source, so one grant is sent to its IdP by one frame at a time; a frame that finds the token changed while it waited (refreshed or sealed again) sends nothing and answers with the stored token's{ state: 'refreshed', expiresAt? }.- A
jerk-sealtoken is announced once perexpiresAt(kept in IndexedDB, so across frames), and again every 10 minutes while it stays unsealed, in case no live page heard it, with therefreshevent, and bios runs J1sealagain ({ state: 'requested' }). - An OAuth token is refreshed in the vault with jerk-oauth:
refreshTokenRequestBytesbuildsPOST <tokenUrl, serialized>withgrant_type=refresh_token, the refresh token,client_idand anyscopeandresource(form-encoded), from a byte buffer it zeroes, and the vault sends it through bios with theegressevent. bios sends the request through its exits and posts{ status, headers, body }(or{ error }) on the event's port within 30 s. The event goes to the page port of the namespace that connected last. A port that doesn't answer in time (ETIMEDOUT), closed or not, keeps its ops and events but gets no moreegress, and the next refresh goes to the page port before it. The vault checks the response with its own rules below and then with jerk-oauth'sparseRefreshResponse(with the clock as whole seconds; a clock that isn't a safe integer isEACCES: refresh failed: the vault's clock is invalid), readsaccess_token,expires_inand any newrefresh_token, checks them as values, and rotates the records in one transaction with therefreshaudit entry (their placeholders stay; if the token or its grant was changed, removed or restored during the round trip, nothing is written and it answersEAGAIN). Anexpires_inof 0 makes the token due at once, and one that isn't a whole number from 0 to ten years isEACCES: refresh failed: the IdP returned an invalid expires_in(likewise arefresh_tokenthat is present but not a string, atoken_typethat isn'tBearerin any case or is missing, andaccess_token,refresh_token,token_typeorexpires_ingiven twice:EACCES: refresh failed: the IdP returned NAME twice); a response carryingerrorfails even with a token; a response withoutexpires_inleaves the new token without an expiry, so it isn't refreshed again until a new one is sealed. It answers{ state: 'refreshed', expiresAt? }, withoutexpiresAtwhen the token has none. A failed refresh isEACCES: refresh failed: <the IdP's error code>, scrubbed; a code that isn't 1–128 characters of RFC 6749's error charset reads "the IdP sent an unreadable error", anderror_descriptionis never shown. Any other response jerk-oauth refuses isEACCES: refresh failed: the IdP response is invalid. MCPgrantresponses are parsed the same way. Without a connected page it'sENOTCONN.
- A
egresscarries the refresh token to the page, in transit only; the response goes back to the vault, never to the kernel.
Import and export
Spectrum's Settings › Secrets reads the file and the password, and bios hands them over. Both need the vault unlocked. Like every page op, export trusts the page: under rev 4 seven's page is trusted with values in transit (§8), and a compromised seven origin is out of scope.
export { passphrase } returns { bundle: Uint8Array, count }, a backup of the namespace's records of kind secret, model and mcp that aren't removed. Grants aren't included, because they need a fresh sign-in. The passphrase needs at least 12 characters. The bundle is UTF-8 JSON:
{ "format": "slicc-vault-bundle", "v": 1,
"kdf": { "name": "PBKDF2", "hash": "SHA-256", "iterations": 600000, "salt": "<base64url>" },
"cipher": { "name": "AES-GCM", "iv": "<base64url>" },
"data": "<base64url>" }data is AES-GCM 256 (additional data slicc-vault-bundle) over { "records": [{ name, kind, value, placeholder, source, createdAt, expiresAt? }] }. A successful export sets status.exportedAt.
import { kind, bytes, password?, pick? } runs in two steps:
- Without
pickit answers{ stage: 'pick', candidates: [{ name, kind, exists, suggestedRules?, refused? }] }, for the human to choose from.refusedsays why a candidate can't be imported. - With
pickit seals the candidates picked, each given as{ name, kind }or, for kindsecret, as just its name, and answers{ stage: 'done', imported: [{ name, placeholder, state }], skipped }.skippedlists picked names that were refused or not in the file, andreasonsmaps each to a short reason ("not a valid name", "values shorter than 8 bytes can't be protected from the model", "it isn't in the file", …), never a value or part of one.outcomeslists every picked candidate as{ name, kind?, imported, reason? }, which tells two candidates of the same name and different kinds apart (kindis absent only for a picked name that isn't in the file). Each record is stored in its own transaction, which also reserves a bundle's placeholder, so a record either lands whole or not at all; one that can't be stored (its placeholder was taken meanwhile, the vault was re-keyed) is listed inskipped, and the rest still import.
| kind | password | what becomes a candidate |
|---|---|---|
| bundle | the export passphrase | each record, with its kind, source (import:bundle when it isn't a valid source), expiry, provider, server and settings (a model record without a provider is refused with "a model record needs its provider"), and placeholder, so the policy files still match. A placeholder used by another record of the namespace, or by another embedder of this vault, is refused ("this app already has this secret under another name", "another app that uses this vault already has this secret; use it there, or add it here by hand"), as is a name whose record here has another placeholder ("this app already has a different secret with this name") |
| keepass | the master password (KDBX 3 or 4, as kdbxweb reads them; Argon2 through hash-wasm) | each entry with a password, named after its title (upper-cased, other characters as _, a leading digit prefixed with _; a name already taken by an earlier entry gets _2, _3, …), source import:keepass |
| six | — | each NAME=value line of six's secrets.env (export and quotes allowed), source import:six. NAME_DOMAINS becomes suggestedRules: a [[proxy.rules]] stanza per exact domain, for the human to add with sudo, and a note for each wildcard, which isn't carried over |
Values are checked as at put (at least 8 bytes, no CR, LF or NUL, no placeholder). An existing name is rotated, keeping its placeholder. A wrong password or passphrase is EACCES.
Exec
fnox exec (§3.5) asks for a one-shot ticket, and the kernel redeems it when it execs the child.
materialize (a /dev/sealed op) takes names, path (the absolute program fnox will exec), argv (exact, argv[0] included), cwd, and optionally interpreter: fnox's claim of the absolute shebang or binfmt interpreter of path, omitted for a native program. It's only a claim; the kernel's chain at consume decides. shellGrants: true (fnox exec --shell-grants) adds every shell = true secret to names, which may then be omitted. While the caller's stamp doesn't say users: 'enforce', it's EACCES "kernel users must be enforce" in every [exec] mode, shell included (§3.16.2): with users off, every process is effectively root and the stamp proves nothing.
- Each name must be a
secretrecord (ENOENTotherwise) that hasn't expired (EACCES: NAME expired; refreshing).realsays which will get their value: removed records and secrets without an[exec]grant stayfalseand keep their placeholder. true(execany): refused withEACCESwhenpath, orinterpreter, is a shell (sh,bash,dash,zsh,fish,busybox,jsh), an interpreter (python*,node,deno,bun,perl,ruby,php,lua,wasmer,awkandgawk/mawk/nawk,sed,tclsh,expect,R,Rscript,irb) or an env dumper or wrapper (env,printenv,xargs,sudo,su,nohup,timeout,time,nice,strace), by basename.{ argv = [[…]] }:realonly when[path, ...argv[1..]]starts with one of the listed prefixes. A listed shell or interpreter is allowed.{ shell = true }: alwaysreal, with no program refusal.- Limits, per secret and uid, counted from the audit log:
materialize_per_minute(10) andmaterialize_per_day(200), andshell_per_day(5,000, with no per-minute limit) for shell grants. Over a limit isEAGAIN: materialize limit for NAME (10/min). - A locked vault answers
EACCES(vault is locked). - Each real name is audited as
materialize { name, exec, path, argv, argvSha256, cwd, pid, ppid, uid, tag, ticket, consumed: false }.argvis joined with spaces, scrubbed, and cut to 200 characters;argvSha256is the SHA-256 of each argument followed by a NUL byte. - The reply never carries a value. The ticket is 32 hex characters and expires after 30 s.
materializeConsume (a page op, from bios for the kernel at exec) takes { ticket, stamp, path, resolved, argv, cwd, interpreters, trusted }, all from the kernel and relayed unchanged:
stampis the exec'ing process.resolvedis the kernel-normalised file that actually runs:pathwith every symlink resolved.interpretersis the absolute, symlink-resolved shebang/binfmt chain the kernel resolved forpath, empty for a native binary. A plain#!/usr/bin/env X(orenv -S) is reported as the resolvedX;envwith any other option or aNAME=valueassignment is reported asenvitself.trustedis true when the program and every chain entry are root-owned regular files, not group- or other-writable, under root-owned, non-writable parents (the attestation rule). Under usersoffit's always false, and the consume is refused anyway.
The vault burns the ticket, then requires that:
stamp.usersisenforce(else "kernel users must be enforce"), it's unexpired and from this namespace,stamp.ppidis the pid that asked, andpath,argv(element by element) andcwdare exactly the ones it was issued for;resolvedis an absolute path (missing or malformed refuses),interpretersa list of absolute paths andtrusteda boolean; a claimedinterpreterfrom fnox equalsinterpreters[0](without a claim, the kernel's chain is taken as it is);- each real name is still granted by the policy as stored now (reloaded at materialize and at consume): a removed grant, or an
{ argv }grant that no longer lists the command, refuses; - under that name's mode, the chain passes:
true: no entry ofpath,resolvedandinterpretersis on the refusal list, so neither a#!/usr/bin/env nodescript nor a symlink/tmp/l → /bin/bashpasses, andtrustedis true ("the program or its interpreter can be changed by a non-root user" otherwise), so a renamed copy of a shell, which its non-root maker owns, doesn't pass either;{ argv }: the listed program ispath, and whenpathis a link to a refused program the rule must also list thatresolvedprogram by its real path ("list it in [exec] by its real path"), so a listed/tmp/lthat is repointed to/bin/bashrefuses. Whatever chain the kernel resolved for it is allowed, so a rule fornpm publishallows/usr/bin/npm'snode. But whenresolvedor the chain is a refused program andtrustedis false, it refuses ("the program or its interpreter can be changed by a non-root user"), andenvand the other wrappers in the chain always refuse;{ shell = true }: any program and chain, the explicit exception;
- no real record has expired meanwhile.
Any failure is EACCES: ticket refused: …, and the ticket stays burned, except for a policy that is paused or that changes before the values go out: the vault checks that in the transaction that marks the entries consumed, refuses with policy file missing: paused or ticket refused: the policy changed meanwhile, releases nothing, and keeps the ticket for its 30 s. It answers { values: { NAME: value }, placeholders: { NAME: placeholder } } for the names that were real and still exist, and marks their audit entries consumed. The kernel swaps each env var whose value is exactly one of those placeholders, and compiles its own scrubber over these values for the child's output (S2).
Forward
bios's exit router asks the vault to inject a request the kernel forwarded (an owner-bearing request that carries a placeholder), then sends it itself:
const channel = new MessageChannel();
port.postMessage({ v: 1, id, op: 'forward', head, bodyPresent, owner, exit, rawTraits, streamCredit }, [channel.port2]);headis{ method, url, headers: [name, value][] };ownerthe kernel's{ pid, ppid, uid, gid, tag, users, groups?, user? }(usersmust beenforce, else nothing is injected);exitthe exit bios chose (page,proxy,tailscaleordevice:<id>, with the device'sDeviceId);rawTraitsthat exit's{ manualRedirects?, encodedBodies? };bodyPresentwhether the request has a body, sent on everyforward.body(bytes or a string) is read only for MCP requests (below); for every other request it stays in bios, because bodies are never injected.- The reply says which
modethe vault chose; bios never guesses it:{ mode: 'secret', head }: the same head with the values filled in. It has no body, whatever bios sent.{ mode: 'mcp', head, bodyComplete: true, body? }: the same, withbodythe complete request body scrubbed over every value, the only bytes bios may send. A request withbodyPresent: falsegets nobodyfield; withbodyPresent: trueit always does, an emptyUint8Arrayfor an empty body.{ needsBody: true }: an MCP request withbodyPresent: trueand nobody. Nothing else happened: no head, nomcpaudit entry, nousedAt, no pin. Like anyforward, it may run the one-time upgrade of legacy endpoints, with itsendpointsaudit entry. bios collects the complete body and callsforwardagain, with a newMessageChannel.{ refused: { status, reason, cause? } }:403with a one-line reason, andcause: 'policy-paused'when the policy is paused,400for an MCP request whosebodyPresentisn't a boolean ("an MCP request must say whether it has a body") or isfalsewith abody, or502for an exit with encoded bodies. A refused request must not be sent anywhere.- Malformed fields, and an MCP body above 32 MiB, are
EINVAL.
- The port: on every reply but a
modeone, the vault closes the transferred port. - The response goes back over
channel.port1, scrubbed: bios posts{ op: 'response', status, statusText, url, redirected?, headers }, then any number of{ op: 'body', chunk: Uint8Array }, then{ op: 'end' }or{ op: 'error', message }. The vault answers with the same messages, withstatusText,url, every header name and value, the body and the error scrubbed over every value of the namespace. A value in its raw form comes back as its placeholder, anything else as[redacted:NAME]. The body is scrubbed as a stream; after 250 ms without input, a held tail shorter than 16 bytes is let through. - When the vault's values change (a value sealed, rotated, removed or restored, in any vault document), the scrub of a response in flight is out of date, so the response is cancelled. In order for each message from bios and for the idle timer, the vault reads the scrub version and sends what follows from that message inside the same read transaction, so no write can commit between the check and the send. If it moved, nothing derived from that message goes out: any held tail is dropped, and the vault posts one
{ op: 'error', message: "the vault's values changed; send the request again" }and closes the port. No credit follows. bios cancels the send. - Flow control is opt-in: with
streamCredit: true, and only then, the vault follows the scrubbed output of each{ op: 'body', chunk: Uint8Array }it gets with exactly one{ op: 'credit', bytes },bytesbeing that chunk'sbyteLength, even when the output is empty because the scrub holds a tail back. Nothing else earns a credit: not the response head, an idle flush,end,error, or a chunk that isn't aUint8Array.
The injector, fnox's proxy semantics:
- Hosts are compared lower-cased, without a trailing dot, in rules, the refusal list and MCP servers alike.
- Refused before anything is sent: an
ownerwhoseusersisn'tenforce("kernel users must be enforce", §3.16.2; this covers kernel programs calling a registered MCP server, and the/dev/sealedtestverdict, too); a placeholder in the URL or in a header name; credentials in the URL; a scheme other thanhttps:or a port other than 443 (except §3.16.3's kernel servers, below); an ambiguous path (./..segments,\,%2e,%2f,%5c,%25, checked on the raw URL); an exit with encoded bodies; no placeholder of this vault at all; a record that is removed, expired ("NAME expired; refreshing") or not asecret; a locked vault; no rule (in/etc/fnox/config.toml) whose exact domain, header, method and path allow it; a paused policy ("policy file missing: paused"); an exit the secret's[exits]line doesn't list; the same placeholder twice in one header value; any credential,secretormcp, in any header,authorizationincluded, on an exit whose rawmanualRedirectsisn'ttrue("NAME needs an exit that doesn't follow redirects"); a value that contains another stored value ("NAME's value contains another stored value"); CR, LF or NUL after substitution. The/dev/sealedtestverdict goes through the same checks, on an exit it takes to be manual. - Paths in rules are globs:
*stays within a segment,**crosses segments,?is one character. - Injected: each placeholder is replaced inside the rule's header value, so
Bearer …andtoken …templates work. InAuthorization: Basic, the credential is decoded, the user and password parts substituted (a substituted user may not contain:), and re-encoded. A Basic value that holds a placeholder but isn't valid base64 of UTF-8 is refused. - Audited: each injection adds a
forwardaudit entry{ name, domain, method, path, rule, exit, uid, tag }, all its text, the rule's included, scrubbed over every value before it's stored, and sets the record'susedAt. Never headers, bodies or values.
Sessions
sessionMaterialize (§3.19, a page op that bios relays unchanged from the kernel) hands the kernel's TLS writer the secrets for one kernel-terminated TLS session (A2), for protocols forward can't carry: an http1 header on a kernel-made connection, SMTP AUTH, or pg's cleartext password or SCRAM-SHA-256. The kernel asks only after its own handshake verified the host (SNI, chain and hostname, whatever the caller's flags), never on a plaintext leg; it substitutes only at the position, uses each value once within 60 s, keeps it out of every process, fd, file, log and telemetry, and scrubs the session's read side (S2) for the values and their encodings. The vault picks the rules, so the kernel never parses /etc/fnox/config.toml.
port.postMessage({ v: 1, id, op: 'sessionMaterialize', stamp, session, host, port: 587, protocol: 'smtp', position: 'auth-plain', verified: true, handshakeAt, exit, placeholders });The fields are the kernel's session attestation:
stamp: the kernel's stamp of the process that owns the socket;usersmust beenforce(elseEACCES"kernel users must be enforce").session: the kernel's session id, 16 to 128 characters ofA-Z,a-z,0-9,_and-.host: the name the kernel verified the leaf against, compared lower-cased without one trailing dot;port;protocol(http1,smtporpg).position:header:<name>(lower-cased) for http1, elseauth-plain,auth-login,password-cleartextorscram-sha-256(below).verified: the kernel's own verdict on the chain and the name;handshakeAt: when the handshake finished (epoch ms).verifiedBy:roots(the default; the kernel's bundled public roots) oranchored(a rule's anchors or pins, below), withanchor: base64 of the SHA-256 of the whole DER of the anchor that verified the chain, assessionAnchorsgave it, or the pin itself.anchorwithroots, or a missing or malformed one withanchored, isEINVAL.exit: the exit the session leaves by (page,proxy,tailscaleordevice:<id>, with the device'sDeviceId).placeholders(every position butscram-sha-256, which takesscraminstead, below): 1 to 16 placeholders, every one found at the position, in one request.head(http1 only, required there):{ method, path }, the request line's method and target.- A malformed field is
EINVAL, and so is aprotocolother than these or apositionthat isn't one of its protocol's; neither burns the session.
Once the fields check out, the vault burns the session in IndexedDB, in one transaction, so no vault frame of the namespace, now or after a reload, answers it again until 60 s after the later of the request and handshakeAt. Then it refuses the whole request with EACCES "session refused: …" when:
- the session already had a request, answered or refused: "this session already had its request";
verifiedisn't true: "the kernel didn't verify the session's host";handshakeAtis in the future or more than 60 s ago: "the session is past its 60 s deadline";- the vault is locked (
EACCES"vault is locked: …"), at the start or by the point of release (below); - for http1: an ambiguous path (as in Forward), or a URL under a model or MCP endpoint (Model and MCP endpoints).
Each placeholder then gets its value only when it is a secret record's, holding the placeholder [secrets] names, neither removed nor expired, with a session rule of that secret whose protocol, domain, port and position are the session's (and, for http1, whose methods and paths allow head), and whose [exits] line, if any, lists exit. A session can carry several secrets this way, each by its own rule. Just before answering, the records, the accepted policy and the registered endpoints are read again, at a fresh clock, in the transaction that audits the values. A value whose record was removed, expired or rotated meanwhile, or whose rule or [exits] line no longer allows it, is withheld. A session past its deadline by then, or an http1 URL now under a registered endpoint, refuses the whole request. That transaction is the point of release: if the vault locked before it, it writes nothing and refuses (vault is locked).
It answers { values: { [placeholder]: value } } for those, and leaves the others out, so the kernel sends them as written. When none gets a value, it's EACCES with the first placeholder's reason: "NAME isn't a secret", "a placeholder of no record of this vault", "NAME's placeholder isn't the one /etc/fnox/config.toml names", "NAME was removed", "NAME expired", "no session rule in /etc/fnox/config.toml lets NAME be used", "no session rule lets NAME at PROTOCOL POSITION on HOST:PORT" (with " for METHOD PATH" for http1), "NAME isn't allowed on EXIT", or "the secret or its rule changed meanwhile". Each value handed out adds a session audit entry { name, session, domain, port, protocol, position, rule, verifiedBy, anchor?, exit, uid, tag }, rule being the number of the rule that allowed it and verifiedBy and anchor how the kernel trusted the session (anchor only when anchored: the anchor's DER SHA-256 or the pin, never a value; entries written before 1.12.1 have neither, which means unknown, never roots), and sets the record's usedAt. It never includes the value. Stored values never hold CR, LF or NUL (Records), so a value can't break out of its position.
sessionAnchors (§3.19.6) is value-free: when the kernel's
