Skip to content

levain 0.10.0

Choose a tag to compare

@phillipclapham phillipclapham released this 12 Oct 23:35
· 3782 commits to main since this release

Added

  • The roster kernel computes health per kind and the Fleet card draws it grouped. Each shown roster row carries fleet_group (resident / on_call / roundtable, the roundtable's seats fold into one line) and health: {tone, text, at, memory}: only a resident member whose silence is a signal can be an alarm (no episodes in 24 h, write path unavailable, or a digest.last_wrap_at older than wrap_every, resident + stateful only); an on-call member's last call, failed or not, is a neutral fact. A roster is no longer cut at the list default of 10 rows. Health is derived at render against the cockpit's clock, never stored; a resident whose evidence is missing or unreadable (no wrap time, no liveness data, a bad episodes_24h) reads unknown with the reason, never ok; an unreadable or zero wrap_every and a non-on-call roundtable member are refused.

  • The cockpit contract gains hero, brief and widgets regions, a zone grid and reading tokens (still levain.cockpit/2; a current provider registers and renders unchanged). The hero holds one reading and one ambient; the brief holds lines and briefs; a widget is one answer, so a list there must declare face="count". A zone panel takes size, span and tabs (same-zone panels sharing a tabs title are one card), and the manifest's regions carries cards beside the flat panels. The legacy header region is normalised at registration (a reading or ambient to the hero, a line or brief to the brief, anything else to operate) and regions.header stays as a derived compat key. A reading may send a symbol from a fixed token set and a sub-reading a detail; the band tokens are named in the contract.

  • A list panel may carry compose: {verb, fields}, and the inbox compose draws on the list's own card. ProviderSpec(compose=...) names a verb the panel offers and typed fields of the form kind (text, recipient, date); registration refuses a verb the panel does not offer (or an empty one), an unknown field type, a recipient without its enum or with a blank or repeated id, more than one recipient field, a recipient whose facet or source the panel does not declare, or a compose on anything but a list or on a count face; once the verb registry is attached (at attach, at every later registration and on rediscovery) it also refuses a verb the registry does not hold, a row-bound or job verb, and a field outside the verb's fields. The declaration is stored detached, covered by the panel etag, and replaced (or refused, offering nothing) on rediscovery. The panel head carries the declaration. The page draws recipient fields as chips (none selected sends no value for the field: "none selected = everyone", said in words on the card; a chosen value that trims to nothing refuses the send), sends through the panel's own verb to that panel only (a panel that no longer offers it refuses; another panel's offer of the same verb is never used), reads unavailable on a writable surface whose panel does not offer the compose verb, refuses a field type it cannot draw visibly (the panel reads unavailable with the reason, never a dropped field), and draws every row of a list that composes to recipients as source -> for (everyone when the row names no reader), its meta after the address. The facet for is registered (readers, comma-joined).

  • GateHandle (levain.autonomic.handle). A process holds one handle over its current EfferentGate. swap(manifest, binding_catalog, aggregate) builds a new gate on a frozen copy of the catalog and publishes it in one step; pending holds, receipts and the journal carry across. FireDispatcher and ChainExecutor read the handle once per admission, so an admission in flight completes on the gate it started with.

  • Every cockpit signing key states its custody, and levain cockpit key create makes a laptop-presence key on the hardware you already have. Levain never requires bought hardware; a FIDO key is an optional upgrade. Each enrol link of the authority log carries custody, one of touch-id, passphrase, tpm, fido, windows-hello, fido-attested or unstated, signed with the rest of the record; levain cockpit keys prints it beside the class on every row, and enroll / ledger init take --custody (default: the key's sidecar). It is a declaration by the enrolling key, never a proof, and nothing here proves it: the verifier cannot tell a key made to require a touch from one made without (the Secure Enclave provider macOS ships echoes the presence flag it is handed), so a touch rests on how the key was made. The replay holds each declaration to the key types it can be true of: touch-id, fido, windows-hello and fido-attested need an sk- key, tpm a plain ecdsa-sha2-nistp256, passphrase a plain ssh-ed25519 or ecdsa-sha2-nistp256. A laptop-presence key must state a real custody; unstated is for display-phone and presence keys and for a key ledger init carries over from 0.9.x. fido-attested is refused until attestation at enrol is built, with the governed install; windows-hello is a reserved label (levain runs on macOS and Linux). What each custody guarantees:

    • touch-id: a fingerprint per signature on a key that cannot leave the Secure Enclave, as created by levain cockpit key create. Declared, not proven: a Mac key carries no attestation.
    • passphrase: a typed passphrase per signature, and no defence against same-uid malware, which can keylog it or copy the key file. The lowest class, and the one that works on any machine.
    • tpm: a non-exportable key, and no presence.
    • fido: a touch on a key you plugged in, custody declared.
    • fido-attested (later): the only class proven.

    levain cockpit key create --custody touch-id (macOS) makes a Secure Enclave identity with Touch ID required (sc_auth create-ctk-identity -k p-256-ne -t bio; levain offers no way to make one without), confirms the identity is listed with protection bio before anything else happens, and fetches its key reference with ssh-keygen -K -w /usr/lib/ssh-keychain.dylib; any failure or interrupt once the create has started deletes the identity that run made (told apart by listing the identities before and after, deleted by its hash, never by label, so another run's identity is never touched). --custody passphrase makes an ed25519 key with a passphrase ssh-keygen asks for on the terminal, and refuses the key if the file is not encrypted (an empty passphrase). With no --custody, key create chooses touch-id on a Mac where bioutil -r shows Touch ID configured for unlock and passphrase otherwise, and prints which it chose and why. Keys go in ~/.levain/keys/ ($LEVAIN_HOME/keys, made 0700, or --dir), each beside a sidecar naming its custody and provider, so signing with a touch-id key needs no SSH_SK_PROVIDER (one set in the environment still wins). A passphrase key is always signed from its file, never through an agent, so every signature asks the passphrase. Every signature is checked against the signing key the moment it is made, so a cancelled Touch ID prompt (which lets ssh-keygen exit 0 without a signature by the key) is refused as Touch ID was cancelled or did not complete, and nothing is signed; a link that fails to verify when the log is replayed still reads the signature does not verify. The same check refuses, at signing, an sk- signature without the user-presence flag, which the replay already refused. A hand-made key enrolled with --custody passphrase gets the same encrypted-file check; a touch-id key whose identity is on this Mac must still be bio, and levain cockpit keys re-checks that on its row.

  • levain cockpit ledger init writes the authority log's first links, each signed by --signing-key, a laptop-presence key of any custody (--custody, default the key's sidecar; a touch-id or fido key asks a touch per link, a passphrase key its passphrase). On a new store it enrols that key, signed by itself: trust on first use. On a store upgraded from 0.9.x it lists the keys the store held (fingerprint, class, key type, when enrolled) and re-enrols each one you confirm, the signing key included; a key that was revoked is enrolled and then revoked again, a key whose type its class does not accept (a laptop-presence key of a type no custody admits) is listed and skipped, and the rest are dropped; a carried key is re-enrolled with custody unstated, and the signing key with the custody you state. At a terminal it asks per key; otherwise pass --confirm <fingerprint> for each key to keep and --drop-unlisted for the rest, and with neither it refuses. The store reads initialised only once every link has landed: a run cut off part way leaves every signed act still refused, and running it again finishes the job. Ctrl-C at a prompt or during a touch ends it with ledger init cancelled, saying where the log stands once a link has landed.

  • levain doctor warns while only one laptop-presence key is enrolled in the install's cockpit. Losing that key leaves nothing that can enrol another, so enrol a second laptop-presence key with levain cockpit enroll while you still hold the first. Recovery after losing the only one (levain cockpit ledger re-genesis, a new authority log) is not in this build. It is a warning, not a failure: the exit code is unchanged. A cockpit journal of a format this levain neither reads nor migrates (missing, unknown, or newer than levain-cockpit/3) is a failing row.

Changed

  • The cockpit page draws the KE regions. A manifest that declares hero, brief and widgets is drawn as the hero first (the clock column with the weather under it, the sky beside it, the mode / unit / live / jump strip above the sky), the brief, one equal-width row of 138px widgets, then each zone as a fixed 3-column grid whose rows are as tall as their tallest card (tall 540, medium 460, short 190 px) with span and tabs honoured; side gutters are 64px and the gap 24px, one token each. Verbs are 12px neutral buttons shown on the row you are on, and tags are plain meta text, so the accent is left to the now-line, failed reviews, overdue tags and a compose's one primary button. A reading draws an inline icon for its symbol and a sub-reading with a band as an AQI pill. The operate zone's title is now "Work" (its id is unchanged). A manifest with no KE regions renders as before. Every panel and widget carries data-ck-panel and data-ck-kind.

  • The KE page draws a region's alerts, notes and every contract shape, keeps tabs and panels in step, and clears when the data goes. The hero, brief and widget regions draw a panel's banner (stale, partial, an alert), its note and its markdown, line or typed content; an alert suppresses the empty message. A status-dots widget draws up to 4 dots and 4 figures (a figure may be a whole, a fractional or a written value) and counts the rest in a "+N more" line. A card's tab row and its panels come from the same list, so a panel that cannot be drawn keeps its tab. Each region builds its content off the page and attaches it last, and a region that fails shows one error line in its own place and hands the masthead and the state line back to the static page (a failed hero also empties its weather and sky, so no stale reading sits beside the error), so one failing region no longer blanks the page or strands those nodes (the next good render recovers with no reload), and a null render clears the regions. Row verbs (spore, episode and pending-cancel rows alike) stay in the tab order and show on focus (and always on a touch screen); the edit chip and the expand control show on the card you are on.

  • The levain run REPL no longer reads your standard credential stores by default. They are the stores the README lists under Chat with a sovereign entity from the cockpit (~/.config/gh, ~/.aws/credentials, ~/.netrc and the rest, and on macOS the Keychain). Chat, --task runs and scheduled seats already denied them; the REPL was the one drive that let the entity's shell and file editor read them, on the grounds that you read every turn. You read a turn only after it ran, so a read a prompt injection asked for was already in the model's context. This is the default changing, not a new prohibition: an entity that needs gh, aws or the Keychain in the REPL sets "deny_standard_creds": false in its .levain/confinement.json, which reopens them in every drive, as it did before for the others.

  • The cockpit schema is levain.cockpit/2. The /1 kinds triage-list, metric, visual and prose are accepted as aliases of list, status-dots, ambient and brief, and the manifest and every panel carry the new names. Field names are kept, and v2 validates them strictly: a metric needs a non-empty label and, when it sends a value, a finite number or a string; prose needs markdown or lines, markdown must be a string, and every prose line must be an object with a string text; a visual value's text must be a list. A provider whose /1 value was looser is refused until it sends well-formed values.

  • Cockpit signing keys live in a signed authority log. Who may sign a cockpit write is a verified replay of authority_log in cockpit.db, read once per request; the keys and policy tables are gone. levain cockpit enroll and revoke append a signed link and take --signing-key: an enrolment, which records the key's class and custody (above), is signed by a laptop-presence key, a revocation by a key of at least the revoked key's class. --compromised-since takes an authority seq, which levain cockpit keys lists. A pending's fence names the log head it was issued under, so a pending still waiting when any link is appended ends STALE at its decision.

  • Breaking: the journal format is levain-cockpit/3. A 0.9.x journal migrates at first open: its keys are set aside, waiting pendings end STALE, and every signed act is refused, naming levain cockpit ledger init, until levain cockpit ledger init enrols the keys again, each link signed by a laptop-presence key. A 0.9.x binary refuses a migrated journal. The migration runs only under the cockpit's cockpit.lock: while a levain serve (an old one included) holds it, any levain cockpit command, a read-only levain cockpit keys included, refuses and leaves the journal untouched, so stop levain serve, upgrade, and restart it; the restarted server migrates. A levain cockpit command migrates only a journal started as reported, and checks that before it writes anything.

  • The Claude Code host's self-test refuses an entity whose activation/ is a symlink. The self-test checks that bash cannot rename activation/, and puts the directory back if it was moved, by its own identity read without following a link, so a symlinked activation/ (a link to a directory included, which was accepted before) is refused as not a directory. If the self-test cannot put the directory back, it now fails with an error naming where the directory was left, instead of reporting it only as a failed check.

  • The kernel's default cockpit layout is mock-e's. Operate bands: Tray 10, Open loops 15, Recent episodes 40, Keep 50, Recent edits 60, Recent jobs 70 (16-39 are left for an embedder's inbox, fleet and stream). Mind: the Neocortex tabs card (one tab per heading, ranked from 0), Crystallized patterns 100, and a Cognition card (trace and projection history as tabs, 200-210). Health moved to widgets and the state line to brief; regions.header keeps its shape and now carries Health. An embedder that wants its old position re-ranks against the new bands. Until the KE2 page lands, the current page draws Health in Operate.

Removed

  • ChainExecutor.gate. The executor now holds a GateHandle, exposed as ChainExecutor.handle; read the gate with handle.current(), once per admission. A gate kept across a policy swap goes stale, so the executor no longer hands one out.
  • EfferentGate.sweep_timeouts. The silence-default sweep is GateHandle.sweep_timeouts, which decides each pending on the gate current for it; a sweep held on one gate decided pendings on that gate's policy after a swap had superseded it. A bare gate sweeps with as_handle(gate).sweep_timeouts(now).

Fixed

  • A shell command that fails for want of a credential the floor denied now says it may have been the floor. When the standard credential stores are denied (by default in every drive, the interactive REPL included) and, this turn, a shell command that failed printed a line naming one of those stores with "operation not permitted" (as gh, docker, kubectl, aws, twine and npm report a denied read), or git printed could not read Username for 'https:// (or Password), the turn's activity ends with one note: this may have been denied by the standard credential floor; for git, use an ssh remote if your key is in the ssh agent; otherwise set "deny_standard_creds" to false, which opens every listed store and the Keychain in every drive, a scheduled seat's included. A private or mistyped HTTPS URL makes git print the same, hence "may". The Keychain is denied by its services rather than a path, and a denied read looks like a missing item, so it shows up only through git's prompt. The streamed activity of levain run shows the note too, and the chat panel lists it apart from the actions that ran. The run banner's line for the denied stores now ends "(set deny_standard_creds false to allow)".
  • An unreadable tool call is no longer hidden or reported as an answer. A reply holding a tool call as text and then a finish call ({"name":"terminal",...} then {"name":"finish","arguments":{"message":"Created x"}}) was shown as "Created x", though the terminal call never ran; such a text is now unwrapped to its finish message only when every call in it is think or finish, and otherwise kept whole and flagged. A reply that is nothing but one fenced block holding call markup (<tool_call><function=terminal>...) is now flagged; markup in a fence beside prose is still an answer. So is Qwen3-Coder's <function=name> tag followed by a <parameter=...> tag or its own close, without the <tool_call> wrapper, which glm-5.2:cloud via Ollama sent once the wrapper had been consumed upstream, MiniMax's <invoke name="..."> call, GLM's <tool_call>name</tool_call> for one of the entity's own tools, a GLM call cut after its first <arg_key> tag, and Kimi's functions.name:0{...} once its special tokens are stripped. A function-call JSON object naming one of the entity's own tools now counts among other text too, outside code (it counted only as the whole reply), and an indented code block alone is read like a lone fence. Each agent message of a turn is now checked on its own as well as their join, so a call that is a whole message after a plan (for example after the act-now nudge) is caught, as is a call split across two messages, and so is the text a model sends beside a parsed finish, which was never shown, even when the finish carries no message; the reply now shows that text before the finish message, or as the reply when there is none. Text beside an action that ran is not checked, since it often restates that call. A failure of the check itself now flags the reply instead of passing it, with its own notice (levain could not check the reply, so it is not treated as an answer) rather than one about the model, and the turn result and the chat API carry unreadable_unchecked: true. A finish call whose arguments are a JSON string (the OpenAI wire form) raised out of the turn; it is now unwrapped to its message, as are a finish in the OpenAI {"type": "function", ...} wrapper and an array of think and finish calls. A reply over 200 kB, which was not checked at all, is now searched without the Markdown parser or a JSON decode, so nothing in it counts as code and a large array of calls is not expanded in memory; such a reply that quotes call markup or call JSON in a code block is flagged too, so a headless run exits 7 for it. A JSON call is recognised whatever the order of its keys. levain run --task exits 7 for such a reply (it exited 0) and leaves stdout empty, with --quiet too, where the markup used to be printed as the payload; the notice and the text go to stderr. A turn result with an unreadable call is no longer ok, and the chat API's exit_code for it is 7. The REPL's gate no longer asks approve? over a hold that lists nothing: it can only be rejected, as in the chat panel.
  • The chat panel no longer offers Approve on a hold that can only be rejected. A held action whose bytes the server cannot bind an approval to was shown with an Approve button, though every call in it was displayed in full; clicking it was refused as undecidable and left the turn's outcome to be checked by hand. The server now says whether a hold is approvable (approvable in the turn result and in /chat/session.json), and the panel shows Approve only when that is true; otherwise it shows Reject alone and says why.
  • On Linux, bash can no longer reach your gnupg dirmngr by renaming one of your directories (the 0.7.1 known limit). Requires bubblewrap 0.5.0 or later (its --perms option); an older bubblewrap now reads unavailable, and levain doctor names the version. Your user runtime directory (/run/user/<uid>, and $XDG_RUNTIME_DIR) is now a fresh, empty directory inside bash, holding only your gpg-agent, keyboxd and ssh-agent sockets, bound back as single files. The rename that reached a running dirmngr from the next session now finds nothing to move: measured 2026-10-12 on an Ubuntu host with a packet capture on port 53, the attack put a fresh name on the wire 63 times before this change and 0 times with it, and the host directory was untouched. The same view hides your user service manager and session bus (which bash could ask to run a command outside the sandbox), replacing the masks that did that, and every other daemon socket in that directory, including one started after bash. gpg signing and ssh-add -l through the agents keep working. New setting, runtime_sockets in .levain/confinement.json: sockets of that directory to put back, relative to it (["podman/podman.sock"]), each of which hands bash the daemon behind it. systemd/, bus and S.dirmngr are refused there. A dirmngr whose socket sits in its GNUPGHOME instead (one started where /run/user/<uid> did not exist, such as from cron before linger, and still running) gets the same treatment in bash without network: that GNUPGHOME is a fresh directory holding its files and the agent and keyboxd sockets, never dirmngr's, and the directories above it cannot be renamed. Measured the same day, same host: renaming that GNUPGHOME, or its parent, reached dirmngr from the next session (60 names on the wire each) before, and failed with "Device or resource busy" (0 on the wire) after; gpg listed keys and signed through the host's agent and keyboxd. gpg inside bash cannot --import into such a home, and its lock files there are not shared with gpg outside bash. A GNUPGHOME that holds the entity's workspace, or sits inside ~/.kube, ~/.docker, ~/.aws or ~/.config/git when the standard credentials are denied, is refused with a message naming the dirmngr to stop. A runtime directory that holds the workspace, the entity or your home (XDG_RUNTIME_DIR pointed at one of your own directories) is refused too. On GnuPG 2.4 with use-keyboxd, bash now reaches your keyboxd (it used to start a second one on the same keyring). GNUPGHOME now survives levain run's restart, so these sockets are found for a non-default home. Known limit: an agent restarted during a session is unreachable from that session's bash.