Skip to content

fix: scope a confined session's lich token and hide lich from the sandbox - #1068

Merged
omartelo merged 4 commits into
mainfrom
fix/confined-session-token
Nov 9, 2026
Merged

omartelo merged 4 commits into
mainfrom
fix/confined-session-token

Conversation

@omartelo

@omartelo omartelo commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

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 call services.Save + services.Start, which run an arbitrary command on the machine with no sandbox at all, store.SetSetting (the sandbox rung itself), terminal.Write into any session, and every other RPC. Even with the token scoped, ~/.config is mounted read-only as a whole, so ~/.config/lich/runtime.json (the launch token), lich.db and every account's profile were readable, and ~/.local/share brought in every other session's worktree. Reproduced from a real bwrap child before any change: all of it readable.

What changed

  • Scoped token (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 own session_id only. /rpc/ takes it through a deny-by-default allowlist; on the methods that name their caller (from, a browser's id) the name must be the token's session. /events, /ws, /blob, /drop, /restart, /ping and /debug/goroutines refuse it. A refusal answers in the RPC envelope so the lich CLI 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.
  • Allowlist: relay.* except SetShown, SetSchedule, AnnounceMerge; all of spawn.*; the agent's own browser.* (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 against api.Methods() by a test.
  • Opened from the sandbox, confined (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.
  • Hidden mounts (internal/sandbox): Spec.Hidden blanks $XDG_CONFIG_HOME/lich and $XDG_DATA_HOME/lich/worktrees after the toolchain reads, following a symlinked directory to its target; Spec.Session mounts back the hook scripts under plugin/, the session's dropped files and the shell-integration dir after the blank. The profile and plugin store (Write), GitCommon and Cwd already mount after it. The seatbelt profile gets the same deny between its read allowance and the session's own paths.
  • Relay held to confined targets (scope.Target, called where each method resolves its target in internal/relay and internal/spawn): relay.Send, SendPrivate, SendSubagent, Insert, Submit, spawn.Control and spawn.Ask from 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.Reply reaches any ticket. spawn.Close refuses force from the sandbox (scope.Force); a plain close or park stays.
  • Docs: docs/ceilings.md Sandbox 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 with wayland-info inside a bwrap child), SECURITY.md, docs/hooks/README.md, docs/cli.md, CHANGELOG.md under Security.

Providers

All eight untouched: every hook and lich mcp read LICH_TOKEN from the PTY environment (docs/hooks/) and report for their own session; none reads runtime.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 in api.
  • internal/terminal: scoped token refused on services.Start, store.SetSetting, terminal.Write with the envelope; accepted on relay.Send, relay.Wait, spawn.Open, browser.Open as its session with the body intact; refused when from/id names another session; refused on /events, /ws, /ping, /debug/goroutines, /restart; /hook and /mod/commands for 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.json gone from its disk, curl to services.Start and store.SetSetting answering 403, relay.Send 200 as the session, a forged from 403.
  • internal/sandbox: Describe hides 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 vet clean, native/ gofmt, vet and test clean.

@omartelo
omartelo force-pushed the fix/confined-session-token branch from f644773 to b608714 Compare November 9, 2026 00:25
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.
@omartelo
omartelo force-pushed the fix/confined-session-token branch from b608714 to fc095f1 Compare November 9, 2026 00:30
@omartelo
omartelo merged commit 53f0fba into main Nov 9, 2026
8 checks passed
@omartelo
omartelo deleted the fix/confined-session-token branch November 9, 2026 00:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant