Skip to content

levain 0.7.0

Choose a tag to compare

@phillipclapham phillipclapham released this 10 Oct 13:58
· 5106 commits to main since this release

The entity's own hands: on macOS and Linux, levain setup-isolation runs its bash as a separate user, each command in a fresh shell whose exit status comes from the operating system, and on Linux behind a network boundary of its own. Also the governed action engine (levain.autonomic, library only), the operator state line and the starter jar.

Added

  • On Linux, the entity's user has a network boundary of its own. levain setup-isolation adds an nftables rule on the entity's user id that refuses every IP connection it opens, except to the loopback ports you name with --egress-port (where its model proxy listens) and the network git that levain ws-git starts (push, fetch, pull and ls-remote, which run with a group nothing else of the entity's gets). The rule loads at boot and again whenever nftables.service starts, restarts or reloads, and levain doctor checks it by trying a connection as the entity's user. An administrator who rewrites the firewall (nft flush ruleset, firewalld, ufw) lifts it until it is loaded again; sudo levain setup-isolation --path <entity> puts it back. Setup refuses, by name, on a Linux host without nftables, where the kernel refuses nftables, or without systemd running as init. On macOS the entity's user has no such boundary yet.

  • levain state: a freeform state line beside the focus. One line in your own words (levain state "wiped, keep it light") that every Claude Code and Codex session reads at start, shown with its age in their session-start hooks, the TUI and the web masthead (an OpenHands session does not read it yet). It is shown as you wrote it, except that runs of whitespace, line breaks included, become single spaces. It is never parsed, scored or used to limit what the partner does. It expires after 8 hours (a state whose age cannot be established is dropped too), holds at most 500 characters, and --clear removes it. It lives in .levain/context.json; a web edit is a governed write of kind operator_state.

  • The starter jar on the dashboard masthead. An SVG jar whose level is today's episode count against the entity's own typical day (the median of up to 14 prior days; the dashed mark is a typical day, full is twice that). A bubble rises for each episode that arrived since the last render, up to five at a time. It reads counts only, from the entity's own store; with no store or too little history the jar is drawn empty and says why. prefers-reduced-motion keeps the level still and drops the bubbles.

  • On Linux, bash under the sandbox no longer has your session keyring. Every sandboxed command, yours and the entity's, starts in a new, empty session keyring, so it cannot read keys you hold there. The cost: a credential kept in the session keyring (a krb5 KEYRING: credential cache, a token added with keyctl) does not work inside Levain's bash; file-based credentials are unaffected. Each command also ends when levain does, even if levain is killed.

  • levain setup-isolation: the entity's bash can run as its own user. On macOS any process can read the command line and environment of every other process running as the same user, and no sandbox rule changes that (measured 2026-10-07). After sudo levain setup-isolation, on macOS, headless chat turns, --task runs and scheduled seats execute bash as a dedicated user with no password and no login shell, so the kernel refuses those reads; files only you can read stay out of its reach too. The file editor reads and writes as that user as well, and only inside the entity's workspace; it does not follow a symlink there. The entity gets its own workspace outside your home and its own ssh key; your ssh-agent is not passed to it. On Linux the same sessions run bash as that user too, in an allowlisted view: /usr, /etc and /opt read-only, its workspace and home, fresh /tmp, /var/tmp and /run; your home, /srv, /var/lib, other disks and daemon sockets are absent, and it shares no IPC objects or session keyring with you. A crown jewel with a second name on disk (a hardlink) refuses such a session as it refuses your own bash, even when the jewel itself is outside that view: levain cannot find where the other name is, and it may be inside the view. Linux is therefore stricter than macOS, where the entity's bash still sees what its user may read. Before each command, levain refuses, naming the path, if the entity's user could write a socket, read or write a FIFO, or search an unlistable directory in /usr, /etc or /opt (something root creates there during a command, or a mount added mid-command, is not seen: that stays the administrator's to hold); Linux needs python3, and zsh for the file editor. The interactive REPL is unchanged (levain run and levain doctor say which user bash runs as). If sudo can no longer start that user, bash is refused rather than run as you. One such session of an entity runs at a time (a second is refused), and when it ends every process of the entity's user is stopped, including one a command moved out of its process group; if something cannot be stopped, the session says so and ws-git stays locked until levain exits. A session also stops anything of that user an earlier levain left running before it starts, and refuses to start if it cannot. --undo removes everything it created. levain doctor warns while an entity is not set up.

  • levain ws-git, levain ws-put and levain ws-adopt. The entity's workspace belongs to the entity's user, and you change it through the entity. ws-git runs git there as that user, with hooks and similar settings off, and waits while the entity is running. ws-put copies a file in as data. ws-adopt imports a repository of yours, every branch, and leaves yours where it is.

  • levain.autonomic: the action manifest is frozen once a gate holds it. EfferentGate freezes the ActionManifest it is built with; register then raises ManifestFrozen and the declarations are read-only. A change to them, a tightening included, means building a new manifest and a new gate (today: the next process).

  • levain.autonomic: the governed action engine for work that runs with no session open. Folded in from the vagus project's efferent half (vagus is retiring as a separate package; this is the second piece of that fold, after levain.firing in July). It holds bindings (a standing grant to act when an event matches), the risk and trust postures with floors that no trust level can buy down, the confirm-and-timeout queue, chains, graduation and the kill check. It ships as a library only: no command, server route or default config reaches it yet, so an install behaves exactly as before. It imports nothing outside itself and the standard library.

  • The levain.autonomic binding registry and run journal live in one SQLite database, inside one store directory. BindingStore(dir) and RunJournal(dir) share dir/autonomic.db (WAL journaling, synchronous=FULL, fullfsync on macOS; the database's sidecars and the journal's effect leases stay inside the directory, which is created private). The confinement floor denies that directory, read and write, at its conventional location <levain home>/autonomic (AUTONOMIC_STORE_DIR), as it denies ~/.anneal-memory; it is a directory, so bash is not refused on its account. A change to standing authority and everything it implies commit in one transaction: a pause, revoke, expire, tighten, supersede or remove together with the fence that stops runs admitted before it; an admission together with a one-shot's claim. The store carries a format marker and refuses a database with another, or a database that holds tables and no marker (it is not taken over); a store deleted under a running object is refused rather than started empty. The registry derives every record's identity instead of trusting it: each row's key must be its record's binding_id and the seal recomputed from the record's own sealed core; a record that is not JSON, whose core does not parse, or that seals to another id makes the registry corrupt (reads return no bindings, every write refuses), and BindingStore.integrity() and binding_liveness's registry_corrupt say why, so a corrupt registry is not mistaken for an empty one. Because the key is the content's own address, a revoked record keeps colliding with its grant even if its stored id is rewritten. A record whose unsealed bookkeeping does not parse keeps its identity and is skipped. Migration direction: a registry kept in the earlier JSON file format (this package's object format or the vagus package's list) enters once, through BindingStore.migrate_json(path): it refuses a file that does not read clean (bad JSON, a duplicate key at any depth, two entries for one id, an entry whose identity does not derive) and a store that has been used (it holds or has held a registry, or any run state), both checked before anything is written; then it writes a backup beside the file, imports every record in one transaction, reads them back and compares, and records the source in the store's meta; the file is not read again. add is create-only (False, nothing written, for an existing id; a re-add carrying tightenings raises). add and replace_atomic write only a record that reads back.

  • replace_atomic states what it expects to find. It now takes the old binding as the caller read it, not its id. Under the lock the stored old grant must exist, be live (fireable by the same check the fire view uses) and equal what the caller read, bookkeeping included; otherwise nothing is written. A revoked, expired, paused or barred grant is no longer superseded into a live one (all three reviewers of the previous round flagged it), and a grant paused, tightened or fired since the caller's read is not superseded on stale data. The optional precondition stays as an extra check. It returns a ReplaceResult, truthy when written, whose reason names the check that refused it.

  • A trajectory bound that is present must be a predicate. {"bound": null} (or any non-predicate value) used to be accepted and treated as no bound, which left the prediction monitor off for a grant that appeared to ask for it. It is now refused when a binding is stored or tightened, and if one reaches the fire path anyway the monitor reads it as diverged and stops the fire. Leaving the key out is still how a trajectory says it is descriptive only. When a binding's guards carry several trajectories, every declared bound must hold; a descriptive trajectory earlier in the list no longer hides a bound added after it.

  • levain.autonomic.journal: a durable run journal, wired into the fire path. (New in this release; an earlier file-based design of it never shipped, so there is nothing to migrate from.) Every binding fire is a journaled run, with no other mode: the gate and the binding store take one RunJournal (one store directory), and the gate refuses a binding fire without a run, as FireDispatcher and ChainExecutor refuse a gate without a journal. A run's id is a content address over the binding and the exact event, so delivering the same event again resumes the same run, and each link of a chain is one effect. An effect runs at most once, also across processes delivering the same event at once: a done effect replays its recorded result (no second send, no second graduation count, and a receipt that never landed is written then, from the provenance recorded with the effect, not the new delivery's); an effect whose process died, or whose executor raised or returned something that is not a result, is poisoned and never retried, only listed by poisoned(); whether an effect is still in progress is read from a lock its owner holds across the call, not from a process id; a replay whose bytes differ from the recorded effect cancels the run; a store that cannot be read runs nothing. The journal is the only durable home of a journaled decision. A confirm-class proposal records its sealed pending AS the run's hold, one row that also carries, for a link of a chain, the state that resumes the chain; EfferentGate.open_pendings() and get_pending() read open decisions from the open holds; resolve is one write-once decision (a rejection's cancel of the run commits with it), so of any number of resolvers exactly one decision counts and the rest change nothing and write no receipt. Re-delivering an event while its decision is open finds the same hold and pending, so nothing is asked twice. A refusal that is not a decision (an approval without an enrolled key's signature, an approval nobody may give unattended, a run not yet admitted, a registry that cannot be read or no longer grants the run) is checked before anything is written, records nothing and leaves the decision open; an approval whose effect cannot run yet is reported as held (approved_not_yet_run), never as refused, runs once on the run's next delivery or resolve, and is listed by RunJournal.approved_unrun(); a receipt names whoever made the approval that fired; resolve accepts an approval from anyone but a person only for an expired cooling-off pending whose action is on the auto-fire allowlist. A link of a chain resolves only through its chain (ChainExecutor.resume, which reads the chain state from the hold; ChainExecutor.sweep_timeouts applies the silence default to expired chain links); a plain resolve refuses it. While a decision is open on a binding, no undecided effect of that binding runs, in that run or any other (a dispatch reports it as held, and delivering the event again after the decision resumes it). A decision whose run has been cancelled or fenced is no longer open: it can never fire, so it is not listed and holds nothing back, though a reply to it still resolves, as a stop. A kill or refusal whose cancel of the run cannot be written is reported as held, with no receipt, never as a terminal stop. Pausing, revoking, expiring, tightening, superseding or removing a binding fences its admitted runs, which stop at their next effect even if the grant is live again by then; the fence commits with the change, through any handle on the store, and every effect reads it in its own transaction. Every effect also reads the registry in that transaction and does not run if the registry does not read clean or its binding is absent or no longer fireable (a claimed one-shot counts as live for the run it was claimed for). BindingStore.admit checks the binding is fireable, admits the run at its current generation and claims a one-shot (with the run it was claimed for, so only that run can finish it, and a later revoke fences it) in one transaction. binding_liveness reports open decisions per binding, poisoned effects and approved effects not yet run (a cancelled or fenced run's approval is not listed: it will not run). ConfirmDecision refuses a non-bool approved. A FireDispatcher refuses a store and gate that do not share one journal and a chain executor on a different gate. A binding's pending is sealed with the risk floor it was proposed at (derived from the binding's sealed tools, where its risk comes from) and with the hold it is the decision of, so two runs proposing identical content at one clock reading get two pending ids, and a pending in a hold it does not name is rejected; a resolve re-validates against the highest of that floor, the manifest's current floor for the action name (if the manifest declares it) and the floor of the binding's tools now, re-derived from the sealed binding by the binding's risk resolver, which the gate takes as binding_risk (FireDispatcher and ChainExecutor refuse a gate without one, and derive a binding's risk from it when it fires: they take no risk resolver of their own, so a proposal and its resolve read the same one); a risk that cannot be re-derived refuses the decision, and so does a journaled pending sealed without a floor; a registry that cannot be read at that moment refuses nothing: the decision stays open and silence decides nothing until it reads. When the floor has risen, a reply must meet the raised rung (a signature over the raised rung; no unattended approval) or the decision stays open, silence takes the raised rung's default (a rung above cooling-off drops), and an approval given before the rise ends its run with a receipt if its effect has not started (checked in the cancel's own transaction), whether it is resumed or its event is delivered again. The journal records the rung each approval met, so an approval given at the raised rung still runs after a stop before its effect. A new proposal is made at the same raised rung. Manual (human-authority) pendings keep the pending store, which now writes durably, refuses to rewrite a file it cannot read, and resolves a duplicated id once; a binding's pending found there is refused and never fires. An event source must deliver at least once and give distinct events distinct content (an id): two deliveries with identical content are one run. Library only, like the rest of the package: no command or default config wires it yet; levain.autonomic.db.default_store_dir() names the conventional location, <levain home>/autonomic.

  • levain.autonomic: a person's approval is a signature, verified again where it is used; the risk fence is the risk inputs themselves; and no binding effect runs until an executor can prove confinement. A ConfirmDecision from a person that approves (any decider but on-loop, the silence default; an absent or unknown by counts as a person) is authority only when it carries an SSH signature (signer, signature; namespace levain-confirm) by a key named in the gate's allowed-signers file (EfferentGate(confirm_signers=...)) over the challenge EfferentGate.confirm_challenge(pending_id) gives: the store's identity (a random id the store keeps in its meta, made when it is first opened by this version), the pending's id and its hold's id, a hash of its whole record, the risk fence and the rung the approval must meet. Without one, or with a signature by another key, in another store, for another decision, under another fence or at a lower rung, it is refused and the decision stays open (a manual pending stays in its store). by is a label, not authority, and typed_proof is gone: the signature is the elevated rung's re-authentication. A denial needs no signature. A gate with no allowed-signers file accepts no person's approval. The signed hold: a journaled approval is stored with its signer, signature, the exact challenge it signed and that challenge's fence (RunJournal.decide refuses a person's approval without them: unsigned_approval), and RunJournal.effect asks again at the effect's admission whether the decision recorded on its hold is authority NOW (authorize, which the gate supplies): the rung the effect needs is derived again from the current risk inputs and the decision must have met it (a rung raised by policy alone, with the inputs unchanged, counts); a person's approval must verify over the challenge rebuilt for the fence current then; a decision recorded as the silence default (on-loop) must still be one the gate may give unattended. The check runs outside the journal's write transaction (a verifier can take seconds; writers, pause and revoke among them, do not wait for it) and counts only if the decision and the fence are unchanged when the admission commits, else it is made again. An approval refused there (one with no stored signature, written by an earlier version of this store, which gains the columns in place, or any way but a verified resolve; one signed under another fence or below the rung needed now; an on-loop decision nobody may give unattended) does not run: its hold REOPENS, undecided with its signature cleared, and the resolve reports it pending again (journal:reopened:<why>) at the rung needed now, never approved-not-yet-run. An approval whose check cannot run (the verifier or its files unavailable, the registry unreadable) is held with the approval intact (journal:held:unavailable:<why>). An effect runs only under the decision its resolver acted on (RunJournal.effect(expect=...), from decision_key): one reopened and decided again in between with any other decision is held. The verdict is used only if the risk inputs it derived the rung from are the ones read at admission. Authority is evaluated when the admission checks it: an enrolment removed from the allowed-signers file while an admission is checking does not stop that admission (enrolment is a file, not state the journal's transaction can read; a signed record of enrolments is to replace it). A risk that changes after a person signed therefore asks them again, at the rung the new inputs give, instead of ending the run. The fence: the risk-catalog revision (RunJournal.revise_risk, risk_revision, ActionRequest.risk_revision) is deleted. An effect's fence is a digest of the risk inputs its rung was decided from (the manifest's entry for the action, and the binding's risk for the link as the gate's binding_risk resolver derives it), and RunJournal.effect(fence=..., fence_now=...) reads those inputs again inside the admission's transaction: a different digest is STALE (journal:stale; decided again, nothing recorded), inputs that cannot be read are UNCLASSIFIED (journal:unclassified:..., held, not a crash). A change made any way is fenced; a change to another tool is not a reason to stop this effect. Confinement: the self-declared confined attribute is gone. No executor can prove today that it runs a binding's effect through the confinement floor as the entity's separate hands user, so every binding effect is refused up front (executor_not_confined): the fire path refuses before it admits a run or claims a one-shot, the gate refuses any journaled request before the journal is touched (no hold opens, no proposal is pushed), and a resolve refuses an approval before writing it (a rejection is still written). Binding effects stay refused until the M2 hands wiring exists; then the one function that says so (levain.autonomic.gate._executor_is_confined) changes, and nothing in configuration can change it before. The executor checked is the object called (captured once per call). The verifier: ssh-keygen runs from an absolute path (/usr/bin/ssh-keygen, or EfferentGate(ssh_keygen=...)), never found through PATH, only if it is a regular file owned by root and writable by neither group nor others, with a fixed environment (nothing copied from the process); verify_signature never raises, and verify_signature_status tells a refusal (not_verified) from a check that could not run (unavailable). The allowed-signers file is a trust anchor like the binary: it and every directory above it must be root-owned and writable by nobody else, or no signature verifies against it, so enrolling a key is an act of root. Symlinks are resolved and the resolved path is checked and used; a file or directory the verifying process's uid may write through an ACL is refused (and so is a process whose real and effective uids differ, which that check cannot answer for), and an allowed-signers file it cannot read makes a check that cannot run (the approval stays, held), not a refusal. A signed approval whose risk cannot be re-derived ends with a receipt once the journal has stopped its run for good (fenced or cancelled), and otherwise stays as it is. A manual pending's signature is verified against the record actually claimed from the store, never a separate lookup (integrity:unverified_signature). What the check cannot see, and the operator must hold: that an enrolled key cannot leave its hardware, and whom root lets enrol a key.

Changed

  • Each bash command runs in a fresh shell; the directory and exported variables carry, nothing else does. The working directory, OLDPWD (so cd - works) and exported variables carry from one command to the next, as data levain holds; functions, aliases, set/shopt options, traps, umask, ulimit and unexported variables start fresh in each command, as in a new terminal. Names that make a shell or the loader run code at startup (BASH_ENV, ENV, PROMPT_COMMAND, SHELLOPTS, BASH_FUNC_*, LD_*, DYLD_* and similar) never carry, and neither does a credential-shaped variable the entity exported. exit N now ends only that command, and a command that times out is killed with every process in its session while the shell stays usable (on macOS, a process that starts its own session, with setsid or a double fork, is outside that kill, as from any shell; it keeps the sandbox it inherited, and when bash runs as the entity's own user it is stopped when the session ends).

  • Chat and levain run --task no longer read your standard credential stores by default. ~/.config/gh, ~/.aws/credentials, ~/.netrc, ~/.git-credentials, ~/.config/git/credentials and, on macOS, the Keychain are now denied in every drive except the interactive levain run REPL, as they already were for a scheduled seat. Before, a headless session (a cockpit chat turn, or --task without --unattended) could read them, and reading a file is not an action the gate holds. An entity that needs gh or aws there sets "deny_standard_creds": false in .levain/confinement.json; an explicit true or false is unchanged. Measured 2026-10-07 on macOS under the real sandbox profile: a headless session's shell got ~/.config/gh/hosts.yml before this change and is denied after it.

  • Four more token files are standard credential stores: ~/.pypirc and ~/.npmrc (registry publish tokens), ~/.docker/config.json (registry auths) and ~/.kube/config (cluster credentials). Each held a live token, or can, and was readable by a confined entity in every drive; measured 2026-10-07 under the real profile for the first three. They follow the same default and the same deny_standard_creds switch as the stores above.

  • The cloud CLIs' credential caches are standard credential stores. ~/.aws/sso/cache, ~/.aws/cli/cache, ~/.aws/login/cache and ~/.aws/boto/cache (SSO tokens and temporary role credentials), ~/.config/gcloud (gcloud's credential databases and application-default credentials) and ~/.azure (az's token cache and service principals) now follow the same default and the same deny_standard_creds switch, as do the directories $AWS_LOGIN_CACHE_DIRECTORY, $CLOUDSDK_CONFIG and $AZURE_CONFIG_DIR name. ~/.aws/config and ~/.aws/cli/alias stay readable. macOS denies them whether or not they exist. On Linux an absent one is created empty (0700) for the session, masked, and removed afterwards if it is still empty, so the entity can neither plant a config there nor read what a gcloud auth login or aws sso login you run during the session writes there; one in a place your user cannot create it is skipped, since the entity cannot create it either. The floor also follows each tool's own override of where it keeps credentials, denying the default location as well: AWS_SHARED_CREDENTIALS_FILE, KUBECONFIG (every entry), DOCKER_CONFIG, GH_CONFIG_DIR, NETRC, NPM_CONFIG_USERCONFIG, XDG_CONFIG_HOME (git's credential store and gh), and a credential.helper = store --file in the config GIT_CONFIG_GLOBAL names. The file given to --api-key-file is denied to the entity too.

  • A credential store that is a symlink is denied at the link as well as its target. The floor added each standard store resolved only, so with a dotfile manager's ~/.kube/config -> ~/dotfiles/kube/config the link was in no rule: on macOS the entity could remove it and create a planted ~/.kube/config (a kubeconfig exec entry runs a command the next time you use kubectl). Each store is now denied at its literal path, at the path under your resolved home and at its target, the three spellings the ~/.ssh files already had, and the directory holding the link is protected against rename. On Linux a symlink directly in your home (~/.netrc, ~/.kube, ~/.config, or ~/.anneal-memory / ~/.anneal-projects, which refused bash before) is accepted and what it points at is protected, because inside bash your home's own entries are read-only and the link cannot be replaced; a symlink deeper down that you could replace (~/.config/gh as a link inside a real ~/.config) refuses bash with the reason, because a mount cannot cover a link. A link inside one of the four tool directories below is fine.

  • On Linux, an absent credential file no longer leaves a read-only empty file in your home for good. bwrap masks a file by mounting over it, so an absent cred file got a 0444 placeholder that stayed after the session and then broke npm login or docker login. Inside bash, each of the four tool directories (~/.kube, ~/.docker, ~/.aws, ~/.config/git) is now a read-only copy of its own listing without the credential files: existing subdirectories are still writable and other files are read-only, so an absent credential file there needs no placeholder, cannot be planted, and stays unreadable when your own kubectl or aws creates it during the session. A file added or replaced at the top of one of those directories during a session is not seen inside bash until the next one; an absent tool directory is created (0700) for the session and removed afterwards if it is still empty. Inside bash the entity cannot create or rewrite a top-level file in those directories (aws configure set, kubectl config use-context, git config --global with an XDG git config). Your home folder is the same kind of view inside bash (its own entries read-only, every subdirectory as writable as before, the credential files left out), so an absent ~/.netrc, ~/.npmrc, ~/.pypirc or ~/.git-credentials needs no placeholder either, and one you create during a session (a docker login, an npm login) is never readable inside bash, not even by a command already running. Where the floor still has to create something to mount over (a credential directory under an existing ~/.config, say), levain records it in ~/.levain-runtime/floor/ (denied to the entity) and removes it once the last session using it has fully ended, if it is still what levain made; levain removes only what its own create made, never a file or directory that was merely absent when it looked. A crash's leftovers are removed at the next bash start and by levain doctor, which also names the ones a running session holds.

  • On Linux, every process of a bash command runs in a cgroup of its own, and stopping the command stops all of it. Each command runs in a transient scope of your systemd user manager (systemd-run --user --scope, under levain.slice); levain kills it with cgroup.kill and knows it is gone when the cgroup holds no process, with no process id involved. A command can no longer outlive a levain that was killed while starting it (bwrap's parent-death signal misses that window, bubblewrap #633 and #700), and it cannot move itself into another of your cgroups (bwrap --unshare-cgroup). Requires Linux 5.14 or later, the unified cgroup v2 hierarchy, and a running systemd user manager: on a server or under cron, run sudo levain setup-isolation again (it now enables linger) or sudo loginctl enable-linger $USER. WSL2, containers, older kernels and v1 or hybrid cgroups are refused by name, and levain doctor says which.

  • The entity can no longer read other processes' environments through /proc. On Linux, bash now runs in its own PID namespace (bwrap --unshare-pid), so its /proc lists only the sandbox's processes; before, it could read /proc/<pid>/environ and cmdline of levain and of every other process of your user, where exported tokens live. Any other proc filesystem mounted on the host is hidden too. Cost: inside bash, ps, pgrep and kill see only the sandbox's own processes. The file editor (which runs inside levain itself) refuses any path under /proc (or any other procfs on the host), /dev/fd and /dev/stdin|stdout|stderr, by the path as given and as it resolves, on both platforms: those reached levain's own memory and open files. It also judges each file it opens by the object actually opened, before reading or truncating it, so a link the shell swaps between the check and the open is refused rather than followed. Where bwrap cannot give bash its own PID namespace (typically inside a container whose /proc has masked paths), bash is refused and levain doctor names the cause and the fix. On Linux, levain marks itself not dumpable at startup (as ssh-agent does), so other processes of your user cannot read its memory, environment or open files through /proc or attach to it; attaching py-spy or gdb to levain now needs root, and levain writes no core dumps.

  • Tokens exported in the shell that starts levain are no longer readable from levain's process listing. The kernel reports a process's environment as it was when the program started (ps -E on macOS, /proc/<pid>/environ on Linux), and changing it afterwards does not change that report, so a GH_TOKEN or AWS_SECRET_ACCESS_KEY exported in your shell was readable by every process of your user, the entity's sandbox included, for the whole session. levain now restarts itself once at launch (same process id) with only a fixed list of non-secret variables (PATH, HOME, locale, terminal, XDG_*, LEVAIN_* and similar) in that report, and hands everything else to the new image through a private file descriptor; it is back in levain's environment in memory, so model providers, proxies and CA bundles work as before. Measured 2026-10-07 on macOS: ps -E of a levain-style process launched with a token showed it, and after the restart did not. Every program levain starts (git, pip, anneal, your editor, service managers, the browser it opens) is now given the same list plus only the variables that program needs, for example proxies for a git push; tests/test_launch.py fails on any call in levain's own code that passes none. Programs that libraries inside levain start themselves are not covered by that test. A variable in levain's own namespaces (LEVAIN_*, ANNEAL_*, VAGUS_*) whose name looks like a credential (..._TOKEN, ..._KEY, ..._SECRET and the like) is carried rather than listed. If levain cannot rebuild its command line to restart itself (an unusual interpreter invocation), it refuses to start and says how to start it, rather than run with the launch environment visible. On macOS the hand-over file is created in ~/.levain-runtime/floor/carry (denied to every entity) and removed before anything is written to it. The console script now starts at levain.launch:main; reinstall (pip install -e . or upgrade) for an existing levain command to pick this up.

  • --api-key-file PATH and LEVAIN_API_KEY keep the model API key off the command line for levain run, levain wrap and levain serve --chat. The file is refused if other users can read or write it. --api-key VALUE still works: levain takes it out of its command line at launch and prints a one-line warning, because until then the value was visible to other processes (on Linux, to every user).

  • The file editor no longer follows a link it acts through, and answers about a protected path as if it were missing. Its insert moved a temp file onto the target with a plain move, its directory view listed with a plain directory listing, and its existence, type, size and binary checks ran by name, all in levain's own process and outside the sandbox, so a link the entity repointed between levain's check and the act could write onto, list, or reveal the existence and size of a protected file. Each now walks the path one directory at a time without following links below the workspace, the entity directory and your home (links above those, such as macOS's /tmp, are yours and are resolved once), judges the directory it holds, and acts from there; a protected path reads exactly like a missing one. Cost: the editor refuses a path that runs through a symlink inside those roots, and a path outside them that runs through any symlink.

  • On Linux the entity cannot create or remove files directly in $HOME. Inside bash, your home directory's own entries are read-only: nothing can be created, removed, renamed or replaced at its top level, so no new dotfile can be planted there and no link there (a dotfile manager's ~/.config, say) can be swapped. Everything inside its subdirectories (your repos, the entity's workspace, ~/.cache) stays writable as before. A tool that creates a new top-level file or directory in $HOME the first time it runs fails inside bash. If you remove or rename a top-level directory of your home while a session runs, the entity's shell closes and the next one starts on the new layout.

  • levain update replaces codex's hooks.json only when it is exactly what levain last wrote for this install. A copy that differs from what this version writes and is not what levain last wrote (edited by you, written by another install, or from before levain recorded what it writes) is left untouched, and this release's version is put under .levain/pending/codex-home/hooks.json for you to review. An unedited hooks.json from an install made by 0.5.0 or later matches levain's record and is refreshed as before. An install made before 0.5.0 has no such record: if its hooks.json differs from what this version writes, the first levain update after upgrading lists it as pending once and exits 1 (if it is identical, nothing changes). If it is this install's and you never edited it, copy the pending file over it (that points every codex session on the machine at this install); from then on levain update refreshes it again.

  • Levain now requires anneal-memory 0.9.41 or later (anneal-memory>=0.9.41,<0.10), the version this release is tested against: since 0.9.39 it no longer brings back a team entry you pruned or deleted on the next team-import. No schema or seed change.

Fixed

  • levain init writes files that parse, whatever the install path holds. The install path, the interpreter path and the anneal-memory path were pasted raw into string literals. A path with a \ or " produced a codex config.toml that codex could not read (which disables every MCP server it lists, not only levain's), a codex hooks.json and a claude-code .mcp.json that were invalid JSON, and with a " could add keys of its own; init still exited 0. The same paste put the anneal-memory path into every activation hook's Python source, so a " there, or an ordinary Windows path such as C:\Users\..., left the hooks unable to compile. Each slot is now escaped for the string it sits in (and, in a hook's command, for the shell's double quotes as well, in a form levain doctor and verify-hooks read back as the same path); a path that is not valid UTF-8 is refused by name; and init parses the whole codex config.toml it is about to write and refuses, before changing config.toml or hooks.json, if its change is what would stop the file parsing. A config.toml that does not parse before levain's change and still does not after it is written as before, and levain says so once the write has landed. Reproduced and closed with a real levain init: codex mcp list reads such a path exactly, and both codex hooks fire from it.
  • A command can no longer forge its own completion or exit status. The confined shell read its commands from a pipe and learned "done, with status N" from a line bash printed, so a command could read ahead in that pipe and print a status of its choosing (reproduced). Each command is now its own bash, and levain takes completion and the status from the operating system (waitpid), never from output. The old named command pipe, whose path another process of the same user could open, is gone with it, and no state file or profile file is left where another process could rewrite it: on macOS the sandbox profile is passed as text, never read from a file again between commands.
  • levain refuses to start its macOS floor with a profile that would make the kernel build a syscall mask. A malformed syscall-mask rule in a hand-written Seatbelt profile kernel-panicked a Mac on 2026-10-07. levain never renders such a rule (a test pins it), and its one launch point now also scans the profile it is about to hand to the sandbox and refuses, before anything runs, if it finds one or a form that could compute one. A host-side PATH guard cannot see levain's launches, because levain calls the sandbox by absolute path.
  • The launch banner names every credential store the floor covers. Its "READABLE" warning named three of the five stores (it left out both git credential files); it now reads the floor's own list.

Known open issues

  • On Linux, bash under the sandbox can still look names up through the host's DNS resolver. Bash has no IP network there, but unless it runs as the entity's own user the system D-Bus socket stays reachable, and through it systemd-resolved resolves whatever name bash asks for, on the network; the name asked for can carry data out. Measured 2026-10-10 on an Ubuntu host with this release: resolvectl query of a fresh name, run in that bash, was answered from the network. Bash run as the entity's user after levain setup-isolation gets its own empty /run, so these sockets are not in its view; there the resolver's D-Bus, varlink and nscd routes were closed when last measured (2026-10-09, on the build before this release). This closes before Levain 2.0.
  • Two levain init --adapter codex (or levain update) runs from different codex installs at the same moment can mix their writes to codex's shared configuration. Codex's hooks.json and config.toml are machine-global, and the lock is per install, so interleaved runs can leave hooks.json pointing at one install and config.toml at the other, and an update can replace a hooks.json another install's init wrote a moment after update read it (the replaced copy is kept as hooks.json.bak.*; traced in the code, not reproduced). Run one codex init or update at a time; levain doctor, run from an install, says whether codex's hooks and memory server are wired to it. This closes before Levain 2.0.
  • On Linux, a placeholder can outlive a levain that was killed. The empty files and directories levain creates to mask absent credential paths are released by levain itself, when a session closes or levain exits, not by the kernel. If levain is killed, they stay until the next levain launch or levain doctor removes them.
  • If levain cannot save its record of those placeholders (a full disk, say) as a session ends, the record can name files already removed. The placeholders that session freed are removed before the record is saved, so the record, and the launch banner that reads it, keep naming them until a later session drops them. A session that cannot save the record as it starts is refused, as before.