Repository navigation
fix: scope a confined session's lich token and hide lich from the sandbox - #1068
Merged
Merged
Conversation
omartelo
force-pushed
the
fix/confined-session-token
branch
from
November 9, 2026 00:25
f644773 to
b608714
Compare
TestDescribeHidesLichFromTheSession and TestDescribeHidesThroughASymlink asserted something the contract never promised: that Spec.Hidden spells a directory as the test's TempDir spells it. The contract is the resolved path, since the mount lands on the directory and not on a link to it, and on macOS TempDir sits under /var (a link into /private/var) while on Windows it is an 8.3 short name. The expected paths now go through filepath.EvalSymlinks too. Reproduced on Linux with a symlinked TMPDIR; no product code changed.
…e anything is marked
omartelo
force-pushed
the
fix/confined-session-token
branch
from
November 9, 2026 00:30
b608714 to
fc095f1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A confined session (bubblewrap on Linux, seatbelt on macOS) kept the host's loopback and was handed the launch-wide
LICH_TOKEN. With it an agent inside the sandbox could callservices.Save+services.Start, which run an arbitrary command on the machine with no sandbox at all,store.SetSetting(the sandbox rung itself),terminal.Writeinto any session, and every other RPC. Even with the token scoped,~/.configis mounted read-only as a whole, so~/.config/lich/runtime.json(the launch token),lich.dband every account's profile were readable, and~/.local/sharebrought in every other session's worktree. Reproduced from a real bwrap child before any change: all of it readable.What changed
internal/terminal/scoped.go,internal/scope): a confined spawn gets a token minted for that session alone, held in the backend's memory, replaced on respawn and dropped when the process ends. The launch token never enters a sandbox. The hook endpoints (/hook,/session-*,/mod/*) take it for the token's ownsession_idonly./rpc/takes it through a deny-by-default allowlist; on the methods that name their caller (from, a browser'sid) the name must be the token's session./events,/ws,/blob,/drop,/restart,/pingand/debug/goroutinesrefuse it. A refusal answers in the RPC envelope so thelichCLI shows the reason instead of trying to reissue the token; a stale scoped token after a backend restart gets a message that says to start the session again, since the record it would fall back on is hidden.relay.*exceptSetShown,SetSchedule,AnnounceMerge; all ofspawn.*; the agent's ownbrowser.*(not the window's pane methods);services.List,Output,LiveOutput;terminal.Ready;store.CostTotals;system.Diagnostics;themes.List,Selection,Select. Everything else is refused. Every allowed name is checked againstapi.Methods()by a test.internal/spawn): a session opened by a scoped caller (open_session,lich open, subagent cards, handoff) has its row marked confined before the spawn reads it and the caller is told, whatever the rung says. A row write that fails refuses the open rather than starting an unconfined session.internal/sandbox):Spec.Hiddenblanks$XDG_CONFIG_HOME/lichand$XDG_DATA_HOME/lich/worktreesafter the toolchain reads, following a symlinked directory to its target;Spec.Sessionmounts back the hook scripts underplugin/, the session's dropped files and the shell-integration dir after the blank. The profile and plugin store (Write),GitCommonandCwdalready mount after it. The seatbelt profile gets the same deny between its read allowance and the session's own paths.scope.Target, called where each method resolves its target ininternal/relayandinternal/spawn):relay.Send,SendPrivate,SendSubagent,Insert,Submit,spawn.Controlandspawn.Askfrom a scoped caller reach only a session whose row is confined, since the receiving agent carries the text out as its task; an unconfined target is refused by label with the reason.relay.Replyreaches any ticket.spawn.Closerefusesforcefrom the sandbox (scope.Force); a plain close or park stays.docs/ceilings.mdSandbox bullets (what a confined session lost, the relay kept as the deliberate gap, the token's life, and a new bullet on what the display socket hands over per compositor family, measured withwayland-infoinside a bwrap child),SECURITY.md,docs/hooks/README.md,docs/cli.md,CHANGELOG.mdunder Security.Providers
All eight untouched: every hook and
lich mcpreadLICH_TOKENfrom the PTY environment (docs/hooks/) and report for their own session; none readsruntime.json. No README table change, since there is no gap.Tests
internal/scope: the escapes pinned as denied, the legitimate calls as allowed, every allowed name declared inapi.internal/terminal: scoped token refused onservices.Start,store.SetSetting,terminal.Writewith the envelope; accepted onrelay.Send,relay.Wait,spawn.Open,browser.Openas its session with the body intact; refused whenfrom/idnames another session; refused on/events,/ws,/ping,/debug/goroutines,/restart;/hookand/mod/commandsfor its own session only; revoked and replaced tokens; Bearer header; the launch token swapped out of a confined spawn's environment and kept in an unconfined one. End to end on Linux: a real bwrap child holding only its scoped token,runtime.jsongone from its disk,curltoservices.Startandstore.SetSettinganswering 403,relay.Send200 as the session, a forgedfrom403.internal/sandbox:Describehides both directories and mounts the hook scripts and drops back, hides only what exists, follows a symlink; bwrap argument order (blank after~/.config, before every path mounted back); real bwrap:runtime.json,lich.db,profiles/and a sibling worktree gone, hook scripts, drops and the own checkout present and writable; seatbelt rule order.internal/relay: a scoped sender refused on Send, SendPrivate, Insert and Submit to an unconfined session with nothing written to it, allowed on Insert and Send to a confined one, Reply from the sandbox answering an unconfined sender's ticket.internal/spawn: Control prompt and Ask from a scoped caller refused on an unconfined session with nothing handed to the mod, allowed on a confined one; Close with force refused from the sandbox and nothing removed, plain close allowed; open from a scoped context marks the row confined and reports it, refuses to start when the write fails, follows the rung for an unscoped caller.internal/cli: a 403 in the envelope is shown and not retried; the stale-token message with no record.Local gate, each step in the memory jail:
gofmt -l .clean,go vet ./...clean,go test ./...green at 86.8% total coverage,GOOS=linux|darwin|windows go build && go vetclean,native/gofmt, vet and test clean.