Skip to content

levain 0.11.4

Choose a tag to compare

@phillipclapham phillipclapham released this 17 Oct 14:59
· 3191 commits to main since this release

Added

  • levain consent away [show|on|off] [--agent claude_code|codex|all] [--hold SECONDS] sets away mode without editing a file. Away mode is what lets a signed answer reach the agent: off (what levain init writes), the consent hook answers only by a standing grant and otherwise leaves the agent's own dialog to ask you at once, so levain consent answer and the phone have nothing to answer; on, the hook holds each request no grant answers for up to the hold (more than 0 and at most 840 seconds, the longest the hook waits; default the file's hold, else 300 for Claude Code and 120 for Codex) so you can answer it. Claude Code shows its own dialog during the hold as well (an allow from the hook dismisses it); Codex shows no dialog until the hold ends, so for Codex away means away. on refuses when the install's cockpit has no enrolled key to sign an answer (or the settings name no cockpit, or it does not replay). Before this the only way to turn it on was editing <levain home>/consent/claude_code.json or codex.json. The verb changes only away_mode and away_hold_seconds in those files, keeps every other key, never creates one (levain init does, naming the install's cockpit), refuses a symlinked file or a queue directory that is not 0700 and yours, and replaces the file atomically; show (the default) prints each agent's mode and hold as its hook reads them, and whether an answer could verify, and writes nothing. Away mode grants nothing: every answer still verifies as a signature. Both hooks and this verb now read the settings file through one reader: a hold written as an integer too large for a number, or a file nested too deep to parse, reads as the defaults instead of stopping the hook (which then gave no decision). Known edges: two away runs at the same instant can each write, the last one winning; a Claude Code request you answer in the dialog stays listed on the phone until its hold ends, and an answer to it there does nothing; on checks that some enrolled key can sign, not that one covers T3, so with only a key that cannot sign T3 an edit of agent config waits out its hold; a hook installed with a shorter --max-hold than the default waits less than the hold show prints.

Fixed

  • A standing consent grant on an MCP tool could answer an edit of agent config or hooks when the server names its path in a field levain did not know. Edits of agent config and hooks (Claude Code and Codex settings and hook files, .mcp.json, ~/.claude.json, plugin hooks, CODEX_HOME, levain's own installed code) go to you, never to a grant; for an MCP tool levain read only the fields file_path, path, paths, notebook_path, source and destination, so a grant on, say, a write tool taking filepath allowed a write to .claude/settings.json (reproduced with a real signed grant through the Claude Code hook). Every string key and value in an MCP tool's input (an object or not), at any depth, is now checked as a path, as given and also cut at a NUL, trimmed of whitespace, percent-decoded, read as a file: URI (its host, query and fragment dropped), these in any combination, and with ./, .. and repeated slashes collapsed (as a server's own path resolving would), and a string naming a directory that holds agent config or the code the consent hook runs (a .claude, .codex or .claude-plugin directory, your home directory, a levain install's activation/hooks, levain's own package, the directory holding the cockpit's authority database, or this Python's site directories, where a .pth file runs at every interpreter start), or a file called .claude.json, also goes to you (a server can take the directory and the file name in two fields). An input with more than 4096 strings or 16384 values, nested more than 64 deep, or whose spellings total more than 256 KiB goes to you. A request whose text merely names such a file (a search for .mcp.json) now also goes to you.
  • Known edges: this check is best effort, because MCP gives a client no way to know which of a tool's fields are paths (a tool's annotations are untrusted and access control is the server's): levain cannot see a path an MCP server builds from something no input string holds whole: a root directory the server was configured with (except for .claude.json, matched by its name anywhere), or an encoding other than the ones above, and a string padded past 64 KiB that is still that long once ./, .. and slashes are collapsed is not decoded (one carrying an encoded dot or slash or a file: scheme goes to you). A directory in one field and a path below it (not just a file name) in another, such as activation and hooks/x.py, is not joined.

Changed

  • A standing consent grant can no longer name an MCP tool that may write. A grant's workspace bound reads the six path fields above, and MCP gives levain no way to learn which of a tool's fields are paths, so since 0.11.0 a grant on an MCP tool taking its path in any other field (such as filepath) answered that tool's writes anywhere, ~/.zshrc or a workspace's .git/hooks included. levain consent grant create now refuses an MCP tool unless it is one of the filesystem server's read tools (read_file, read_text_file, read_media_file, read_multiple_files, list_directory, list_directory_with_sizes, directory_tree, search_files, get_file_info, list_allowed_directories, under any server name), and any other tool name containing __, before anything is signed; a grant already signed on any other MCP tool answers nothing and cannot be renewed (levain consent grant list marks it), so those requests come to you. A request on a granted read tool is answered only when its input is one the filesystem server's tool accepts (its required fields, no others, each of its type: path, paths, and head, tail, sortBy, pattern or excludePatterns where it takes them), so another server's read_file taking its path in another field goes to you. Known edges: a server that names a writing tool after one of those read tools and takes the same fields is believed (it is your own install); a relative path is checked against the session's working directory, while the server resolves it against its own.
  • On Linux, bash can no longer connect to a unix socket outside its sandbox unless you granted it. levain reads the running kernel's Landlock version and applies the strongest policy it supports: on Landlock ABI 9 and later, pathname and abstract unix-socket connects to endpoints created outside the sandbox are refused, and so are signals to processes outside it; on ABI 6 to 8 abstract sockets and signals are closed while pathname sockets stay reachable; below ABI 6 (or where the kernel refuses Landlock) there is no socket-level claim, and levain run and levain doctor say so. Before bash starts, a self-test checks the policy against a canary socket, and a launch whose policy does not hold is refused by name.
  • Socket access is now something you declare. The sockets bash may reach are listed in <levain home>/confinement/grants.json (owner-only, outside the entity's reach); each one is checked at launch (a real socket you own, in a directory bash cannot change) and printed by levain run and levain doctor as a named waiver. /dev/log is granted by default. The automatic bind-backs are gone: your gpg-agent, keyboxd and ssh-agent sockets no longer come back into bash, and runtime_sockets in .levain/confinement.json no longer grants anything (doctor reports it as ignored). gpg signing from bash now goes through the gpg-agent's extra socket, which you declare in the grant file. The ssh agent: on Linux each session's bash gets its own ssh-agent, started by levain outside the sandbox, holding only keys you add from your own shell with levain ssh-add <key> -h <host> (hosts checked against <levain home>/ssh/known_hosts, a lifetime of 8 hours unless you set -t); levain run and levain doctor print the agent and each key with its hosts and time left. Your own agent is never passed to bash. A grant of your ssh agent is refused: a path that is the same socket as the one your $SSH_AUTH_SOCK names or gpg-agent's ssh socket (under any name, a hardlink included), or, on Linux, any socket an ssh-agent, gnome-keyring or gpg-agent is listening on, except gpg-agent's extra socket at the exact path gpgconf names (to read what listens, levain doctor and each launch open one connection to every granted socket and close it without sending anything). Known edge: this refusal guards against a grant made by mistake, not against a process running as you outside the sandbox, which can replace sockets in your own gpg directory. Known edge, off Linux only: an agent is recognised only as the socket $SSH_AUTH_SOCK names in levain's own environment or as gpg-agent's ssh socket, so an agent at any other path, or yours when levain was started without $SSH_AUTH_SOCK, is not. macOS is unchanged in this release, and its grant lines say the grants are not applied there yet.
  • gnupg's secret keys are now behind the floor, read and write, in every gnupg home levain knows, on Linux and macOS. On Linux, inside bash, private-keys-v1.d lists as empty rather than failing, and gpg without a granted agent socket reports "No secret key". A gnupg home levain knows is the default one and the one in levain's own $GNUPGHOME when that is an absolute path; a gnupg home elsewhere that nothing names is not covered. If that home still holds an unmigrated legacy secring.gpg, gpg in bash exits 2 even when the signature is made (so git commit -S fails); one gpg -K outside bash migrates it.
  • Known limits of the session ssh agent: on macOS bash still receives your $SSH_AUTH_SOCK and no session agent starts, until levain's macOS socket rule lands; a levain that is killed leaves its agent running until the next levain start removes it; a key added with --no-destination-limit can sign non-login data and works from any host it is forwarded to (a -h key signs only logins to its hosts, so there is no ssh commit signing through one); the session's keys can be listed, removed or locked from bash; plain ssh in bash (not git) still reads ~/.ssh/known_hosts; if the agent dies or is restarted during a session, bash ends for that session (every later command and respawn is refused until the session is started again); the OpenSSH version check reads ssh -V, so an older ssh-agent elsewhere on PATH refuses the first -h add with OpenSSH's own error.
  • Known limit (tracked for 1.0): files a confined session writes in your home, such as a shell startup file on macOS or a program under ~/bin, ~/.local/bin, a user service unit or an autostart entry, can run outside the sandbox the next time you start a shell or log in. This closes before 1.0 with the same deny-by-default approach; separate-user hands (levain setup-isolation) avoid it today.