Repository navigation
levain 0.7.0
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-isolationadds 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 thatlevain ws-gitstarts (push,fetch,pullandls-remote, which run with a group nothing else of the entity's gets). The rule loads at boot and again whenevernftables.servicestarts, restarts or reloads, andlevain doctorchecks 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--clearremoves it. It lives in.levain/context.json; a web edit is a governed write of kindoperator_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-motionkeeps 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 withkeyctl) 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). Aftersudo levain setup-isolation, on macOS, headless chat turns,--taskruns 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,/etcand/optread-only, its workspace and home, fresh/tmp,/var/tmpand/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,/etcor/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 needspython3, andzshfor the file editor. The interactive REPL is unchanged (levain runandlevain doctorsay 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 andws-gitstays 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.--undoremoves everything it created.levain doctorwarns while an entity is not set up. -
levain ws-git,levain ws-putandlevain ws-adopt. The entity's workspace belongs to the entity's user, and you change it through the entity.ws-gitruns git there as that user, with hooks and similar settings off, and waits while the entity is running.ws-putcopies a file in as data.ws-adoptimports a repository of yours, every branch, and leaves yours where it is. -
levain.autonomic: the action manifest is frozen once a gate holds it.EfferentGatefreezes theActionManifestit is built with;registerthen raisesManifestFrozenand 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, afterlevain.firingin 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.autonomicbinding registry and run journal live in one SQLite database, inside one store directory.BindingStore(dir)andRunJournal(dir)sharedir/autonomic.db(WAL journaling,synchronous=FULL,fullfsyncon 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'sbinding_idand 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), andBindingStore.integrity()andbinding_liveness'sregistry_corruptsay 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, throughBindingStore.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'smeta; the file is not read again.addis create-only (False, nothing written, for an existing id; a re-add carrying tightenings raises).addandreplace_atomicwrite only a record that reads back. -
replace_atomicstates 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 optionalpreconditionstays as an extra check. It returns aReplaceResult, truthy when written, whosereasonnames the check that refused it. -
A trajectory
boundthat 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 oneRunJournal(one store directory), and the gate refuses a binding fire without a run, asFireDispatcherandChainExecutorrefuse 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 bypoisoned(); 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()andget_pending()read open decisions from the open holds;resolveis 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 byRunJournal.approved_unrun(); a receipt names whoever made the approval that fired;resolveaccepts 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_timeoutsapplies the silence default to expired chain links); a plainresolverefuses 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.admitchecks 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_livenessreports 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).ConfirmDecisionrefuses a non-boolapproved. AFireDispatcherrefuses 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 asbinding_risk(FireDispatcherandChainExecutorrefuse 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. AConfirmDecisionfrom a person that approves (any decider buton-loop, the silence default; an absent or unknownbycounts as a person) is authority only when it carries an SSH signature (signer,signature; namespacelevain-confirm) by a key named in the gate's allowed-signers file (EfferentGate(confirm_signers=...)) over the challengeEfferentGate.confirm_challenge(pending_id)gives: the store's identity (a random id the store keeps in itsmeta, 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).byis a label, not authority, andtyped_proofis 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.deciderefuses a person's approval without them:unsigned_approval), andRunJournal.effectasks 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; anon-loopdecision 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=...), fromdecision_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'sbinding_riskresolver derives it), andRunJournal.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-declaredconfinedattribute 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-keygenruns from an absolute path (/usr/bin/ssh-keygen, orEfferentGate(ssh_keygen=...)), never found throughPATH, 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_signaturenever raises, andverify_signature_statustells 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(socd -works) and exported variables carry from one command to the next, as data levain holds; functions, aliases,set/shoptoptions, traps,umask,ulimitand 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 Nnow 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, withsetsidor 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 --taskno longer read your standard credential stores by default.~/.config/gh,~/.aws/credentials,~/.netrc,~/.git-credentials,~/.config/git/credentialsand, on macOS, the Keychain are now denied in every drive except the interactivelevain runREPL, as they already were for a scheduled seat. Before, aheadlesssession (a cockpit chat turn, or--taskwithout--unattended) could read them, and reading a file is not an action the gate holds. An entity that needsghorawsthere sets"deny_standard_creds": falsein.levain/confinement.json; an explicittrueorfalseis unchanged. Measured 2026-10-07 on macOS under the real sandbox profile: a headless session's shell got~/.config/gh/hosts.ymlbefore this change and is denied after it. -
Four more token files are standard credential stores:
~/.pypircand~/.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 samedeny_standard_credsswitch as the stores above. -
The cloud CLIs' credential caches are standard credential stores.
~/.aws/sso/cache,~/.aws/cli/cache,~/.aws/login/cacheand~/.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 samedeny_standard_credsswitch, as do the directories$AWS_LOGIN_CACHE_DIRECTORY,$CLOUDSDK_CONFIGand$AZURE_CONFIG_DIRname.~/.aws/configand~/.aws/cli/aliasstay 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 agcloud auth loginoraws sso loginyou 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 acredential.helper = store --filein the configGIT_CONFIG_GLOBALnames. The file given to--api-key-fileis 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/configthe link was in no rule: on macOS the entity could remove it and create a planted~/.kube/config(a kubeconfigexecentry runs a command the next time you usekubectl). Each store is now denied at its literal path, at the path under your resolved home and at its target, the three spellings the~/.sshfiles 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/ghas 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 loginordocker 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 ownkubectlorawscreates 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 --globalwith 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,~/.pypircor~/.git-credentialsneeds no placeholder either, and one you create during a session (adocker login, annpm 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 bylevain 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, underlevain.slice); levain kills it withcgroup.killand 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, runsudo levain setup-isolationagain (it now enables linger) orsudo loginctl enable-linger $USER. WSL2, containers, older kernels and v1 or hybrid cgroups are refused by name, andlevain doctorsays 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/proclists only the sandbox's processes; before, it could read/proc/<pid>/environandcmdlineof levain and of every other process of your user, where exported tokens live. Any otherprocfilesystem mounted on the host is hidden too. Cost: inside bash,ps,pgrepandkillsee 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/fdand/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/prochas masked paths), bash is refused andlevain doctornames 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/procor attach to it; attachingpy-spyorgdbto 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 -Eon macOS,/proc/<pid>/environon Linux), and changing it afterwards does not change that report, so aGH_TOKENorAWS_SECRET_ACCESS_KEYexported 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 -Eof 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 agit push;tests/test_launch.pyfails 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,..._SECRETand 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 atlevain.launch:main; reinstall (pip install -e .or upgrade) for an existinglevaincommand to pick this up. -
--api-key-file PATHandLEVAIN_API_KEYkeep the model API key off the command line forlevain run,levain wrapandlevain serve --chat. The file is refused if other users can read or write it.--api-key VALUEstill 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
insertmoved 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$HOMEthe 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 updatereplaces codex'shooks.jsononly 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.jsonfor you to review. An uneditedhooks.jsonfrom 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 itshooks.jsondiffers from what this version writes, the firstlevain updateafter 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 onlevain updaterefreshes 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 nextteam-import. No schema or seed change.
Fixed
levain initwrites files that parse, whatever the install path holds. The install path, the interpreter path and theanneal-memorypath were pasted raw into string literals. A path with a\or"produced a codexconfig.tomlthat codex could not read (which disables every MCP server it lists, not only levain's), a codexhooks.jsonand a claude-code.mcp.jsonthat were invalid JSON, and with a"could add keys of its own;initstill exited 0. The same paste put theanneal-memorypath into every activation hook's Python source, so a"there, or an ordinary Windows path such asC:\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 formlevain doctorandverify-hooksread back as the same path); a path that is not valid UTF-8 is refused by name; andinitparses the whole codexconfig.tomlit is about to write and refuses, before changingconfig.tomlorhooks.json, if its change is what would stop the file parsing. Aconfig.tomlthat 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 reallevain init:codex mcp listreads 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-resolvedresolves 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 queryof a fresh name, run in that bash, was answered from the network. Bash run as the entity's user afterlevain setup-isolationgets 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(orlevain update) runs from different codex installs at the same moment can mix their writes to codex's shared configuration. Codex'shooks.jsonandconfig.tomlare machine-global, and the lock is per install, so interleaved runs can leavehooks.jsonpointing at one install andconfig.tomlat the other, and an update can replace ahooks.jsonanother install's init wrote a moment after update read it (the replaced copy is kept ashooks.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 doctorremoves 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.