From 75de2ed7644ce52b0b24d0ce9872ecd5e2272b51 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:39:29 -0700 Subject: [PATCH 01/11] docs(adr): ADR-031 seat OS-user isolation (Proposed, post-11/1; AX-85/#2427) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Durable fix for the 2026-10-22 keychain-token exposure — a dsh seat read an operator's gh token because seats run as the same macOS user as the operator. Proposes running seats under a separate macOS user. Records the in-incident finding (partition lists do not close a go-keyring -A ACL; only -T re-add does, and it is fragile) and the interim mitigations. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nju36GWpimrACHLKahbB4L --- docs/adr/ADR-031-seat-os-user-isolation.md | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/adr/ADR-031-seat-os-user-isolation.md diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md new file mode 100644 index 0000000000..c6b777b19c --- /dev/null +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -0,0 +1,49 @@ +# ADR-031: Seat OS-user isolation + +- **Status:** Proposed (post-11/1 scope — not decided this sprint) +- **Date:** 2026-10-22 +- **Motivation:** AX-85 / issue #2427 (seat read an operator's gh token from the login keychain) +- **Related:** [ADR-008](ADR-008-agent-environment-primitive.md) (`sandbox.mode: bwrap` — the Linux equivalent) + +## Context + +On this macOS fleet host, every seat runs as the **same macOS user as the operator** +(`xcjsam`), with no OS-level sandbox. The harness adapters self-isolate at the +*application* level only — dsh with a per-seat `DSH_HOME` + `DSH_PERMISSION_MODE=workspace-write`, +codex with `shell_environment_policy` — but not at the OS level. + +On 2026-10-22 the dsh seat **dune** ran `security find-generic-password` against +`~/Library/Keychains/login.keychain-db`, read Lily's `gh:github.com` token **with no prompt** +(the go-keyring item carried a permissive "allow all applications" ACL), and used it as +`GH_TOKEN` with repo-admin — bypassing `seat-gh-guard` entirely (merge, branch delete, no +credit line). The token entered the seat's model (DeepSeek) context, so it was treated as +exposed and rotated. + +**Finding (verified in-incident):** the per-item keychain-ACL mitigation is fragile. +`security set-generic-password-partition-list` does **not** close a go-keyring `-A` ACL +(tested: the item stayed readable). Only delete + re-add with `-T ` locks it, which +must reproduce go-keyring's exact `go-keyring-base64:` storage format and be re-applied after +every re-auth and re-derived after every binary upgrade (gh is ad-hoc signed, so there is no +stable team id to pin). App-managed items (Claude, Codex, browser "Safe Storage") cannot be +re-created without risking the owning app. The root cause is unchanged by any per-item fix: +**any credential readable by `xcjsam` is reachable by a seat running as `xcjsam`.** + +## Decision (proposed) + +Run seats under a **separate macOS user** (e.g. `commonly-seat`) that has no access to the +operator's login keychain, SSH keys, or home directory. Operator credentials become +unreadable to seats at the OS boundary rather than per keychain item. This is the macOS +counterpart to ADR-008's `sandbox.mode: bwrap` on Linux. + +## Consequences + +- Seats get their own keychain, home, and `gh`/git identity; shared resources (workspaces, + the CLI, the gh dispatcher, launchd jobs, file permissions) are provisioned for the seat + user. Non-trivial migration — hence post-11/1 scope. +- Until then, the interim mitigations stand and are explicitly **mitigations, not the fix**: + - `~/.commonly/bin/lock-gh-keychain.sh` — locks the `gh:github.com` item to the gh binary + (`-T`), run at each gh re-login; it self-verifies that a `gh auth switch` does not drop + the lock. + - verify-by-attack (read-only) for app-managed keychain items; lock only the ones that are + actually silently readable. + - a `lily-watch` fence check to detect the lock silently evaporating on a token refresh. From dd3e4c7490d5c378d086cda9b2c36f017442c01f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Thu, 22 Oct 2026 10:41:51 -0700 Subject: [PATCH 02/11] =?UTF-8?q?docs(adr):=20ADR-031=20=E2=80=94=20state?= =?UTF-8?q?=20only=20the=20-T=20re-add=20recipe=20(partition=20lists=20don?= =?UTF-8?q?'t=20lock=20go-keyring)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile the finding: `set-generic-password-partition-list` (teamid or cdhash) does NOT close a go-keyring -A ACL; only delete + re-add with -T locks it. Removes the earlier cdhash/teamid-pin ambiguity so the doc carries one recipe. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01Nju36GWpimrACHLKahbB4L --- docs/adr/ADR-031-seat-os-user-isolation.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index c6b777b19c..b584029663 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -19,13 +19,16 @@ On 2026-10-22 the dsh seat **dune** ran `security find-generic-password` against credit line). The token entered the seat's model (DeepSeek) context, so it was treated as exposed and rotated. -**Finding (verified in-incident):** the per-item keychain-ACL mitigation is fragile. -`security set-generic-password-partition-list` does **not** close a go-keyring `-A` ACL -(tested: the item stayed readable). Only delete + re-add with `-T ` locks it, which -must reproduce go-keyring's exact `go-keyring-base64:` storage format and be re-applied after -every re-auth and re-derived after every binary upgrade (gh is ad-hoc signed, so there is no -stable team id to pin). App-managed items (Claude, Codex, browser "Safe Storage") cannot be -re-created without risking the owning app. The root cause is unchanged by any per-item fix: +**Finding (verified in-incident):** the per-item keychain-ACL mitigation is fragile, and +only one recipe works. `security set-generic-password-partition-list` — whether by team id or +by cdhash — does **not** close a go-keyring `-A` ("allow all applications") ACL: tested on a +throwaway, the item stayed readable after it. The **only** `security`-CLI recipe that locks it +is delete + re-add trusting just the gh binary (`-T `, no `-A`), re-storing the value +byte-identical so gh (go-keyring) still decodes its `go-keyring-base64:` format. Because gh is +ad-hoc signed, `-T` pins to the binary's code hash, which changes on upgrade — so the lock must +be re-derived after a gh upgrade and re-applied after any re-auth that rewrites the item. +App-managed items (Claude, Codex, browser "Safe Storage") cannot be re-created without risking +the owning app. The root cause is unchanged by any per-item fix: **any credential readable by `xcjsam` is reachable by a seat running as `xcjsam`.** ## Decision (proposed) From 983e1f0106ac44fa8a5929d4326f42c02963637a Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:54:49 -0700 Subject: [PATCH 03/11] docs(adr): ADR-031 seat OS-user isolation, full decision text Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 279 +++++++++++++++++---- frontend/src/v2/landing/V2LandingPage.tsx | 2 +- 2 files changed, 236 insertions(+), 45 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index b584029663..df11627b0b 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -1,52 +1,243 @@ # ADR-031: Seat OS-user isolation -- **Status:** Proposed (post-11/1 scope — not decided this sprint) -- **Date:** 2026-10-22 -- **Motivation:** AX-85 / issue #2427 (seat read an operator's gh token from the login keychain) -- **Related:** [ADR-008](ADR-008-agent-environment-primitive.md) (`sandbox.mode: bwrap` — the Linux equivalent) +- **Status:** **Proposed.** Nothing here is ratified, and the interim mitigations below stay in + force until it is. Post-11/1 scope. **Four unknowns stay open, and neither Proposed nor + Accepted settles them** (§Unknowns): (U1) whether Claude Code subscription auth works for a + user with no GUI login session, or such seats must move to API keys; (U2) whether any seat + CLI insists on a login keychain that is locked in a LaunchDaemon context; (U3) which GitHub + identity the seat user's token belongs to (tied to ADR-030); (U4) whether a ChatGPT/Codex + device-auth by a second macOS user on this host invalidates the operator's session. +- **Date:** 2026-10-22 (stub); full decision text 2026-10-25 +- **Motivation:** AX-85 / issue #2427 (a seat read an operator's gh token from the login keychain) +- **Related:** [ADR-008](ADR-008-agent-environment-primitive.md) (sandbox modes; `bwrap` on + Linux), [ADR-005](ADR-005-local-cli-wrapper-driver.md) (local wrapper seats), + [ADR-030](ADR-030-agent-identity.md) (agent identity) + +> **Scope boundary: read before implementing.** +> - **ADR-008** owns the per-spawn *sandbox* (`sandbox.mode`: `bwrap` on Linux, Seatbelt via +> `workspace`/`read-only` on macOS) and the env spec a driver honours. This ADR does not change +> any sandbox mode, profile, or the Linux path. It adds one layer *under* the sandbox on the +> macOS fleet host: which OS user the whole seat process tree runs as. +> - This ADR governs the operator-run fleet host only. A BYO user running `commonly agent run` +> on their own laptop is not required to create a second user. ## Context -On this macOS fleet host, every seat runs as the **same macOS user as the operator** -(`xcjsam`), with no OS-level sandbox. The harness adapters self-isolate at the -*application* level only — dsh with a per-seat `DSH_HOME` + `DSH_PERMISSION_MODE=workspace-write`, -codex with `shell_environment_policy` — but not at the OS level. - -On 2026-10-22 the dsh seat **dune** ran `security find-generic-password` against -`~/Library/Keychains/login.keychain-db`, read Lily's `gh:github.com` token **with no prompt** -(the go-keyring item carried a permissive "allow all applications" ACL), and used it as -`GH_TOKEN` with repo-admin — bypassing `seat-gh-guard` entirely (merge, branch delete, no -credit line). The token entered the seat's model (DeepSeek) context, so it was treated as -exposed and rotated. - -**Finding (verified in-incident):** the per-item keychain-ACL mitigation is fragile, and -only one recipe works. `security set-generic-password-partition-list` — whether by team id or -by cdhash — does **not** close a go-keyring `-A` ("allow all applications") ACL: tested on a -throwaway, the item stayed readable after it. The **only** `security`-CLI recipe that locks it -is delete + re-add trusting just the gh binary (`-T `, no `-A`), re-storing the value -byte-identical so gh (go-keyring) still decodes its `go-keyring-base64:` format. Because gh is -ad-hoc signed, `-T` pins to the binary's code hash, which changes on upgrade — so the lock must -be re-derived after a gh upgrade and re-applied after any re-auth that rewrites the item. -App-managed items (Claude, Codex, browser "Safe Storage") cannot be re-created without risking -the owning app. The root cause is unchanged by any per-item fix: -**any credential readable by `xcjsam` is reachable by a seat running as `xcjsam`.** - -## Decision (proposed) - -Run seats under a **separate macOS user** (e.g. `commonly-seat`) that has no access to the -operator's login keychain, SSH keys, or home directory. Operator credentials become -unreadable to seats at the OS boundary rather than per keychain item. This is the macOS -counterpart to ADR-008's `sandbox.mode: bwrap` on Linux. +On this macOS fleet host every seat runs as **the operator's own macOS user**. Seats are +LaunchAgents in the operator's `~/Library/LaunchAgents` (`me.commonly.seat-.plist`), each +running a per-seat `~/.commonly/seat-launch//run.sh`, logging to `~/.commonly/logs/`, +reading their runtime token from `~/.commonly/tokens/.json`. Seat GitHub calls go through +the `gh` dispatcher on the Homebrew path, which routes seat calls to +`~/.commonly/bin/seat-gh-guard`; operator calls bypass it. + +Confinement today is per spawn and partial: + +- **Seatbelt** (`cli/src/lib/sandbox/seatbelt.js`, selected by `sandbox/mode.js` for + `public`/`workspace` trust on darwin) wraps public seats in a deny-default `sandbox-exec` + profile. Its only home-directory read allows are the seat workspace, the adapter's state + home, the CLI binary and a per-spawn temp dir. A decoy attack run on 2026-10-25 against the + opencode public seat showed reads denied on `~/.claude`, `~/.ssh`, `~/.config`, + `/private/tmp`, `~/.commonly` and every private repo directory. +- **Env/HOME scrubbing** (`packages/seats/src/valet/env.js`) gives a run a fresh HOME and an + allowlisted env. Its own header says what it is not: a process that knows an absolute path + can still open it, and "macOS keychain items are reached through the user's security + session, not through HOME." +- **Internal seats** (dsh, codex, pi and others) run with application-level isolation only. + +Seatbelt does not close the keychain. The baseline profile admits `mach-lookup` to +`com.apple.SecurityServer` and `com.apple.securityd`, because `/usr/bin/security` and TLS need +them, and the Claude public-seat path additionally symlinks the operator's `Library/Keychains` +into seat state and admits `login.keychain-db` read so Claude can find its OAuth credential. +Any keychain item whose ACL allows the caller is therefore one securityd round-trip away from a +process running as the operator, sandboxed or not. + +On 2026-10-22 the unconfined dsh seat **dune** ran `security find-generic-password` against the +login keychain, read the operator account's `gh:github.com` token **with no prompt** (the +go-keyring item carried an "allow all applications" ACL), and used it as `GH_TOKEN` with +repo-admin, bypassing `seat-gh-guard` entirely (merge, branch delete, no credit line). The token +entered the seat model's context, so it was treated as exposed and rotated. + +Per-item locking is fragile. `security set-generic-password-partition-list`, by team id or by +cdhash, does **not** close a go-keyring `-A` ACL (tested on a throwaway item). The one recipe +that works is delete + re-add trusting only the gh binary (`-T `), byte-identical so +go-keyring still decodes its `go-keyring-base64:` value. gh is ad-hoc signed, so `-T` pins a code +hash that changes on every upgrade, and any re-auth rewrites the item. App-managed items +(Claude, Codex, browser "Safe Storage") cannot be re-created without risking the owning app. The +root cause is unchanged by any per-item fix: **any credential readable by the operator's user is +reachable by a seat running as that user.** + +## Decision + +**Seats run as a separate, non-admin macOS user.** Operator credentials become unreadable at the +Unix-permission and keychain-session boundary, not per item. Seatbelt stays and confines inside +that user. + +### D1. One shared seat user now; per-seat users deferred + +Create one hidden, non-admin standard user (working name `_commonlyseat`) for all seats. + +*Trade-off.* One user separates seats from the operator, which is the boundary the incident +broke. It does not separate seats from each other: an unconfined internal seat can read a +sibling's token file and workspace, because 0600 means nothing between processes of the same +uid. Per-seat users would close that too, at roughly N times the cost: N keychains-or-credential +stores, N CLI states, N home directories, N sets of file ACLs for every shared resource. All +seats today hold credentials of the same tier and push through the same guard, so seat-to-seat +reach is a smaller harm than seat-to-operator reach. Public seats, the ones reading untrusted +input, are still kept off sibling files by Seatbelt (D5). + +*Reactivation trigger for per-seat users:* a seat is issued a credential its siblings must not +hold (for example a customer-scoped connector grant), or a public seat is shown able to read a +sibling's token under its Seatbelt profile. + +### D2. What the seat user owns, and what stays operator-only + +| Seat user owns (in its own home, mode 0700) | Operator only | +|---|---| +| Seat workspaces and clones | Login keychain and every item in it | +| `~/.commonly/tokens/.json` for seats only | `~/.ssh`, `~/.claude`, `~/.codex` | +| Seat sessions, adapter state homes, per-spawn temp | gh operator auth (`~/.config/gh`, keychain item) | +| Seat logs | Operator `~/.commonly` (operator tokens, notes, ledger, `bin/` tooling) | +| Seat provider key files (D3) | Every private repository checked out on the host | + +**Code is not seat-owned.** The pinned CLI, the MCP package, `seat-gh-guard`, each `run.sh` and +each launchd plist are installed root-owned and seat-readable under a fixed prefix. A seat must +not be able to rewrite the code that frames its own spawn. Seats already pin their CLI rather +than following the global install, so this is a location change, not a behaviour change. + +The operator's home directory is set to 0700 (macOS creates it traversable by others), and +nothing secret is left world-readable in `/private/tmp`. Both are checked by D7, not assumed. + +### D3. Credentials reach the seat user without the operator keychain + +The seat user has **no login session**, so its login keychain is locked in a daemon context. +Decision: seat credentials are **0600 files in the seat user's home**, not keychain items. + +- **GitHub.** The seat user holds its own token, distinct from every operator token, with the + narrowest scope that covers the seat lane: no admin, no `workflow`. gh reads it from the + seat's config with keychain storage disabled. `seat-gh-guard` keeps its job (credit line, + merge/close/delete fence), but becomes a guard against habit again: the boundary is now that + the only token the seat can reach cannot do what the guard forbids. Which account that token + belongs to is U3. +- **Model providers.** Where the adapter supports it (opencode, qwen and the other + OpenAI-compatible adapters), keys go through the provider proxy in + `packages/seats/src/wire/provider-proxy.js`: the wrapper validates a 0600 non-symlink key file, + starts a per-spawn loopback proxy on 127.0.0.1 with a random 32-byte token, and the CLI sees + only that token, with provider echoes of the key redacted. Under one seat user the key file is + readable by that uid; the proxy keeps it out of the model's context, not out of the uid. That + is accepted for seat-tier keys. +- **Claude and Codex seats.** The Claude public-seat path that links the operator's + `Library/Keychains` into seat state does not cross the boundary and is retired for migrated + seats. Such seats get their own credential under the seat user (U1); Codex seats get their own + `CODEX_HOME` login (U4). +- **Commonly runtime tokens** (`cm_agent_*`) move with their seat into the seat user's + `~/.commonly/tokens/`. + +*Deferred:* a broker running as a third user that holds provider keys and serves only the +loopback proxy, so no seat uid can read a key. Trigger: per-seat users (D1), or a provider key +whose blast radius exceeds one seat's quota. + +### D4. launchd: LaunchDaemons with `UserName`, operated through a root-owned wrapper + +Seats become `/Library/LaunchDaemons/me.commonly.seat-.plist`, root-owned, with +`UserName`/`GroupName` set to the seat user. A per-user LaunchAgent is rejected: the +`gui/` domain exists only while that user is logged in, and a background-only user domain +is not a supported place for KeepAlive jobs. Daemons also start at boot without anyone logging +in, which a host reboot showed the fleet needs. + +The operator does not become the seat user to operate seats. A root-owned `seatctl` script +with fixed verbs (`status`, `restart`, `stop`, `start`, `logs`) taking a seat name validated +against the installed plists is permitted to the operator through one passwordless sudoers +entry for that script only. The fleet supervisor and reviver call `seatctl` instead of +`launchctl` on user LaunchAgents. Seat logs and state are group-readable by an ops group the +operator belongs to; the seat user is in no operator group, so the read direction is one-way. + +### D5. Seatbelt is layered under, not replaced + +Every Seatbelt and env-scrubbing rule stays. Inside the seat user, Seatbelt still confines a +public seat to its workspace and state, and is the only thing keeping one seat off a sibling's +files (D1). `homedir()` inside a seat now resolves to the seat user's home, so profile paths +follow without code changes; the `securityd` lookups remain allowed and now reach a keychain +session holding no operator item. The OS-user boundary also does not depend on `sandbox-exec`, +which Apple has deprecated as a public API. + +### D6. Migration: one canary, an attack gate, then one seat at a time + +1. **Prepare.** Create the user (non-admin, hidden, no shell login), set the operator home to + 0700, install the root-owned code prefix, `seatctl` and its sudoers entry. Run D7 tests 1–4 + from a bare `sudo -u` shell of the seat user before any seat moves. +2. **Canary.** Migrate one internal opencode-adapter seat: its credential path is already the + provider proxy, so it isolates the user boundary from the U1/U4 credential questions. Copy + (do not move) its token, sessions and workspace; disable its operator LaunchAgent by rename; + bootstrap the daemon. +3. **Gate.** The full D7 suite must refuse from inside the seat's real spawn, every positive + control must succeed as the operator, and the seat must complete one real board turn and one + guarded PR comment. Any failure stops the rollout. +4. **Roll one seat at a time**, each through the same gate: unconfined internal seats first + (largest gain, the dune class), then Claude and Codex seats once U1/U4 are answered, then + Seatbelt-confined public seats. dune stays disabled until it runs as the seat user. +5. **Rollback (per seat).** `seatctl stop`, boot out the daemon, rename the operator LaunchAgent + back. Seat state was copied, so the old copy is intact; reset sessions on return. Revoke the + seat-user credentials only if the rollback is permanent. + +`lock-gh-keychain.sh`, read-only verify-by-attack on app-managed items, and the `lily-watch` +fence check stay until the last seat has migrated and D7 is green fleet-wide. The gh lock is then +kept as cheap defence in depth; the watch check is retired with the last operator-user seat. + +### D7. Verification: attack tests that must refuse + +Each probe runs **twice**: as the seat, through its real spawn path (a probe script in the +seat's argv, not a hand shell), and from a bare `sudo -u` shell of the seat user, because +unconfined seats have a shell. A pass is an **OS refusal** (`EACCES`, `EPERM`, or a securityd +"item not found"). A model declining to run the probe is not a pass. Each probe has a positive +control run as the operator, so a refusal proves the boundary and not a missing file. + +1. `security find-generic-password -s gh:github.com -w` returns no operator item; the same with + the operator's `login.keychain-db` path given explicitly fails to open it. +2. `security dump-keychain` on the operator's keychain path is refused. +3. Reads of decoy files in the operator's `~/.ssh`, `~/.claude`, `~/.codex`, `~/.config/gh`, + `~/.commonly/tokens`, `~/.commonly/state` and each private repo directory are refused. +4. An operator-written 0600 decoy in `/private/tmp` is refused. +5. `gh auth status` as the seat shows only the seat token; an admin-scoped API call (reading + branch protection, deleting a protected ref) and a workflow-file push are refused by GitHub. +6. `kill -0` against an operator process returns `EPERM`; `launchctl print gui/` + is refused. +7. Writing the seat's own `run.sh`, its plist, the CLI prefix or `seat-gh-guard` is refused. +8. From a public seat under Seatbelt, reading a sibling seat's token file is refused. + +The suite lives beside the existing decoy harness and is re-run after any macOS upgrade, gh +upgrade, CLI pin change or change to the seat user's groups. + +## Unknowns + +- **U1.** Claude Code on macOS stores its subscription credential in the keychain. Whether it + falls back to a file for a user with no login session is unmeasured. If not, Claude seats move + to API keys through the proxy, which changes their cost. +- **U2.** Whether any seat CLI hard-requires an unlocked login keychain and fails in a daemon + context. Each adapter is checked during its own migration step. +- **U3.** Whether the seat token is a second, narrower token on the existing shared GitHub account + or belongs to a machine account or GitHub App. That is an identity decision and belongs with + ADR-030; D3 only requires it to be distinct and narrower than any operator token. +- **U4.** ChatGPT OAuth sessions are bound to where device-auth completed. Whether a second + macOS user on the same host logging in to the same account disturbs the operator's session is + untested. Test on a non-rotation account first. ## Consequences -- Seats get their own keychain, home, and `gh`/git identity; shared resources (workspaces, - the CLI, the gh dispatcher, launchd jobs, file permissions) are provisioned for the seat - user. Non-trivial migration — hence post-11/1 scope. -- Until then, the interim mitigations stand and are explicitly **mitigations, not the fix**: - - `~/.commonly/bin/lock-gh-keychain.sh` — locks the `gh:github.com` item to the gh binary - (`-T`), run at each gh re-login; it self-verifies that a `gh auth switch` does not drop - the lock. - - verify-by-attack (read-only) for app-managed keychain items; lock only the ones that are - actually silently readable. - - a `lily-watch` fence check to detect the lock silently evaporating on a token refresh. +- Operator credentials leave the seat's reach as a class. The next unlisted keychain item, app + token or dotfile does not need its own lock. +- `seat-gh-guard` stops being the only thing between a seat and repo-admin. +- Cost: a second user to provision, code installed twice (operator and seat prefix), a sudoers + entry to audit, separate seat credentials to issue and rotate, and Claude seats possibly moving + to metered keys (U1). +- Operator sessions read seat logs through group permission or `seatctl logs`, not by `cat` in + their own home. +- Seats are still not isolated from each other outside Seatbelt (D1). + +## Out of scope + +- Linux confinement. `bwrap` and `sandbox.mode` remain ADR-008's; a Linux fleet host would run + seats as a dedicated user by ordinary means and needs no new decision here. +- Cluster runtimes (moltbot gateway, cloud-codex). They already run in their own containers. +- Locking app-managed keychain items. With seats off the operator user, those items are no longer + in a seat's reach, so locking them stops being a security requirement. +- BYO laptops (scope note above). diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index 29113bca94..a3f5f36c1b 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -34,7 +34,7 @@ const X_HANDLE = 'https://x.com/sam_commonly'; // before install.sh runs; the earlier one-liner skipped both, so anyone who // pasted it got a bare clone and no started stack (TASK-152). const SELF_HOST_COMMAND = 'git clone https://github.com/Team-Commonly/commonly.git && cd commonly && ./install.sh'; -const ADR_COUNT = 31; +const ADR_COUNT = 32; // A count on a marketing page drifts silently: nothing on the page notices a // new ADR, and docs/ is outside this image's build context so the page cannot // count at build time. The TASK-167 row D test counts docs/adr/ADR-*.md and From 89b7a9840682de8d924f14a36badb46ebc0c746f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:57:08 -0700 Subject: [PATCH 04/11] docs(adr): ADR-031 Accepted (D77); opencode canary in scope now, Claude seats wait on U1 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index df11627b0b..bc285f546f 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -1,8 +1,9 @@ # ADR-031: Seat OS-user isolation -- **Status:** **Proposed.** Nothing here is ratified, and the interim mitigations below stay in - force until it is. Post-11/1 scope. **Four unknowns stay open, and neither Proposed nor - Accepted settles them** (§Unknowns): (U1) whether Claude Code subscription auth works for a +- **Status:** **Accepted** 2026-10-25 (Cofounder, under D77). The interim mitigations below stay in + force until the whole fleet passes D7. In scope this week: the opencode canary (D6 step 2) is pulled + forward from post-11/1, because it is what lets DeepSeek writer seats run. **Four unknowns stay open, and + Accepted does not settle them** (§Unknowns): (U1) whether Claude Code subscription auth works for a user with no GUI login session, or such seats must move to API keys; (U2) whether any seat CLI insists on a login keychain that is locked in a LaunchDaemon context; (U3) which GitHub identity the seat user's token belongs to (tied to ADR-030); (U4) whether a ChatGPT/Codex @@ -173,8 +174,10 @@ which Apple has deprecated as a public API. control must succeed as the operator, and the seat must complete one real board turn and one guarded PR comment. Any failure stops the rollout. 4. **Roll one seat at a time**, each through the same gate: unconfined internal seats first - (largest gain, the dune class), then Claude and Codex seats once U1/U4 are answered, then - Seatbelt-confined public seats. dune stays disabled until it runs as the seat user. + (largest gain, the dune class), then Codex seats once U4 is answered, then Seatbelt-confined + public seats. **Claude seats stay out of the migration until U1 is answered**, and moving them + to metered API keys needs a cost decision first; it is not a default. dune stays disabled + until it runs as the seat user. 5. **Rollback (per seat).** `seatctl stop`, boot out the daemon, rename the operator LaunchAgent back. Seat state was copied, so the old copy is intact; reset sessions on return. Revoke the seat-user credentials only if the rollback is permanent. From 724f7d134c24a5e475166ec3a1910c5d08bf7d6d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:00:56 -0700 Subject: [PATCH 05/11] docs(adr): ADR-031 drop private tool name and unresolvable log references Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index bc285f546f..38252edc7c 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -1,6 +1,6 @@ # ADR-031: Seat OS-user isolation -- **Status:** **Accepted** 2026-10-25 (Cofounder, under D77). The interim mitigations below stay in +- **Status:** **Accepted** 2026-10-25 (ratified by the project's cofounder). The interim mitigations below stay in force until the whole fleet passes D7. In scope this week: the opencode canary (D6 step 2) is pulled forward from post-11/1, because it is what lets DeepSeek writer seats run. **Four unknowns stay open, and Accepted does not settle them** (§Unknowns): (U1) whether Claude Code subscription auth works for a @@ -9,7 +9,7 @@ identity the seat user's token belongs to (tied to ADR-030); (U4) whether a ChatGPT/Codex device-auth by a second macOS user on this host invalidates the operator's session. - **Date:** 2026-10-22 (stub); full decision text 2026-10-25 -- **Motivation:** AX-85 / issue #2427 (a seat read an operator's gh token from the login keychain) +- **Motivation:** issue #2427 (a seat read an operator's gh token from the login keychain) - **Related:** [ADR-008](ADR-008-agent-environment-primitive.md) (sandbox modes; `bwrap` on Linux), [ADR-005](ADR-005-local-cli-wrapper-driver.md) (local wrapper seats), [ADR-030](ADR-030-agent-identity.md) (agent identity) @@ -182,8 +182,8 @@ which Apple has deprecated as a public API. back. Seat state was copied, so the old copy is intact; reset sessions on return. Revoke the seat-user credentials only if the rollback is permanent. -`lock-gh-keychain.sh`, read-only verify-by-attack on app-managed items, and the `lily-watch` -fence check stay until the last seat has migrated and D7 is green fleet-wide. The gh lock is then +`lock-gh-keychain.sh`, read-only verify-by-attack on app-managed items, and the operator's +auto-press watcher's fence check stay until the last seat has migrated and D7 is green fleet-wide. The gh lock is then kept as cheap defence in depth; the watch check is retired with the last operator-user seat. ### D7. Verification: attack tests that must refuse From 5fddbce1a3e15a6e09f026a1dc1989021ff9db1f Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:06:12 -0700 Subject: [PATCH 06/11] docs(adr): ADR-031 deletion fence rests on the main ruleset; probe 8 runs at the first public seat Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index 38252edc7c..b667ab3188 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -118,7 +118,12 @@ Decision: seat credentials are **0600 files in the seat user's home**, not keych seat's config with keychain storage disabled. `seat-gh-guard` keeps its job (credit line, merge/close/delete fence), but becomes a guard against habit again: the boundary is now that the only token the seat can reach cannot do what the guard forbids. Which account that token - belongs to is U3. + belongs to is U3. Its effective permissions are repository contents and pull requests, read and + write, on the public repo only. A contents-write token can delete refs, so the deletion fence + does not rest on the token: the `main` ruleset blocks deletion, force-push and direct push for + every actor outside its bypass list, and no seat token is on that list. A seat can still delete a + branch it pushed, which costs nothing it could not push again. Closing a PR stacked on a deleted + base is the remaining harm, and the guard keeps fencing `--delete-branch` for that reason. - **Model providers.** Where the adapter supports it (opencode, qwen and the other OpenAI-compatible adapters), keys go through the provider proxy in `packages/seats/src/wire/provider-proxy.js`: the wrapper validates a 0600 non-symlink key file, @@ -170,9 +175,9 @@ which Apple has deprecated as a public API. provider proxy, so it isolates the user boundary from the U1/U4 credential questions. Copy (do not move) its token, sessions and workspace; disable its operator LaunchAgent by rename; bootstrap the daemon. -3. **Gate.** The full D7 suite must refuse from inside the seat's real spawn, every positive - control must succeed as the operator, and the seat must complete one real board turn and one - guarded PR comment. Any failure stops the rollout. +3. **Gate.** D7 probes 1–7 must refuse in both of their contexts, every positive control must + succeed as the operator, and the seat must complete one real board turn and one guarded PR + comment. Probe 8 runs when the first public seat migrates. Any failure stops that rollout. 4. **Roll one seat at a time**, each through the same gate: unconfined internal seats first (largest gain, the dune class), then Codex seats once U4 is answered, then Seatbelt-confined public seats. **Claude seats stay out of the migration until U1 is answered**, and moving them @@ -188,11 +193,13 @@ kept as cheap defence in depth; the watch check is retired with the last operato ### D7. Verification: attack tests that must refuse -Each probe runs **twice**: as the seat, through its real spawn path (a probe script in the +Probes 1–7 run **twice**: as the seat, through its real spawn path (a probe script in the seat's argv, not a hand shell), and from a bare `sudo -u` shell of the seat user, because unconfined seats have a shell. A pass is an **OS refusal** (`EACCES`, `EPERM`, or a securityd "item not found"). A model declining to run the probe is not a pass. Each probe has a positive -control run as the operator, so a refusal proves the boundary and not a missing file. +control run as the operator, so a refusal proves the boundary and not a missing file. Probe 8 +runs only through a public seat's real spawn path under Seatbelt, since a bare `sudo -u` shell +is not confined by Seatbelt. 1. `security find-generic-password -s gh:github.com -w` returns no operator item; the same with the operator's `login.keychain-db` path given explicitly fails to open it. @@ -201,7 +208,8 @@ control run as the operator, so a refusal proves the boundary and not a missing `~/.commonly/tokens`, `~/.commonly/state` and each private repo directory are refused. 4. An operator-written 0600 decoy in `/private/tmp` is refused. 5. `gh auth status` as the seat shows only the seat token; an admin-scoped API call (reading - branch protection, deleting a protected ref) and a workflow-file push are refused by GitHub. + branch protection), deleting `main` (refused by the ruleset) and a workflow-file push are + refused by GitHub. 6. `kill -0` against an operator process returns `EPERM`; `launchctl print gui/` is refused. 7. Writing the seat's own `run.sh`, its plist, the CLI prefix or `seat-gh-guard` is refused. From 0812c9d0cca08dc706ff770d160c289f491cc2ac Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:10:24 -0700 Subject: [PATCH 07/11] docs(adr): ADR-031 states live main protection and makes the push ruleset a precondition Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index b667ab3188..c489303090 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -119,11 +119,16 @@ Decision: seat credentials are **0600 files in the seat user's home**, not keych merge/close/delete fence), but becomes a guard against habit again: the boundary is now that the only token the seat can reach cannot do what the guard forbids. Which account that token belongs to is U3. Its effective permissions are repository contents and pull requests, read and - write, on the public repo only. A contents-write token can delete refs, so the deletion fence - does not rest on the token: the `main` ruleset blocks deletion, force-push and direct push for - every actor outside its bypass list, and no seat token is on that list. A seat can still delete a - branch it pushed, which costs nothing it could not push again. Closing a PR stacked on a deleted - base is the remaining harm, and the guard keeps fencing `--delete-branch` for that reason. + write, on the public repo only. A contents-write token can delete refs and push to any branch + it is not stopped from, so the fence cannot rest on the token. Measured 2026-10-26: classic + branch protection on `main` refuses deletion and force-push, but does not apply to admins and + has no push restriction, and the ruleset that carries those rules is disabled. Any write token + can push to `main` directly today. **Precondition for the first seat token:** an active ruleset + on `main` that refuses deletion, force-push and direct push for every actor outside its bypass + list, with the merge queue as the only path in. No seat token is on that list. A seat can still + delete a branch it pushed, which costs nothing it could not push again. Closing a PR stacked on + a deleted base is the remaining harm, and the guard keeps fencing `--delete-branch` for that + reason. - **Model providers.** Where the adapter supports it (opencode, qwen and the other OpenAI-compatible adapters), keys go through the provider proxy in `packages/seats/src/wire/provider-proxy.js`: the wrapper validates a 0600 non-symlink key file, @@ -208,8 +213,8 @@ is not confined by Seatbelt. `~/.commonly/tokens`, `~/.commonly/state` and each private repo directory are refused. 4. An operator-written 0600 decoy in `/private/tmp` is refused. 5. `gh auth status` as the seat shows only the seat token; an admin-scoped API call (reading - branch protection), deleting `main` (refused by the ruleset) and a workflow-file push are - refused by GitHub. + branch protection), deleting `main`, a direct push to `main` (both refused by the D3 ruleset) and a workflow-file + push are refused by GitHub. 6. `kill -0` against an operator process returns `EPERM`; `launchctl print gui/` is refused. 7. Writing the seat's own `run.sh`, its plist, the CLI prefix or `seat-gh-guard` is refused. From a1e7deece32c8468e8ace80e848dee02ea87ccf7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:37:59 -0700 Subject: [PATCH 08/11] chore(landing): ADR_COUNT 33 after ADR-033 landed Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- frontend/src/v2/landing/V2LandingPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/v2/landing/V2LandingPage.tsx b/frontend/src/v2/landing/V2LandingPage.tsx index a3f5f36c1b..b46f1acc52 100644 --- a/frontend/src/v2/landing/V2LandingPage.tsx +++ b/frontend/src/v2/landing/V2LandingPage.tsx @@ -34,7 +34,7 @@ const X_HANDLE = 'https://x.com/sam_commonly'; // before install.sh runs; the earlier one-liner skipped both, so anyone who // pasted it got a bare clone and no started stack (TASK-152). const SELF_HOST_COMMAND = 'git clone https://github.com/Team-Commonly/commonly.git && cd commonly && ./install.sh'; -const ADR_COUNT = 32; +const ADR_COUNT = 33; // A count on a marketing page drifts silently: nothing on the page notices a // new ADR, and docs/ is outside this image's build context so the page cannot // count at build time. The TASK-167 row D test counts docs/adr/ADR-*.md and From 7e4aa526c9f45cb2045bed5f0398f8f3ba0df06d Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:45:56 -0700 Subject: [PATCH 09/11] docs(adr): ADR-031 D3 requires a repo-restricted seat token and a negative probe Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index c489303090..24f0ba7536 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -119,7 +119,11 @@ Decision: seat credentials are **0600 files in the seat user's home**, not keych merge/close/delete fence), but becomes a guard against habit again: the boundary is now that the only token the seat can reach cannot do what the guard forbids. Which account that token belongs to is U3. Its effective permissions are repository contents and pull requests, read and - write, on the public repo only. A contents-write token can delete refs and push to any branch + write, on the public repo only, and the provider must enforce that: the token is a fine-grained + token or a GitHub App installation restricted to that one repository, never a classic or + account-wide token. Before the first seat token is issued, record its repository allowlist and + run a negative probe: a write to a second repository the same account can reach must be refused. + A contents-write token can delete refs and push to any branch it is not stopped from, so the fence cannot rest on the token. Measured 2026-10-26: classic branch protection on `main` refuses deletion and force-push, but does not apply to admins and has no push restriction, and the ruleset that carries those rules is disabled. Any write token From eb3dac628a08cf1f063003956c8f5e4b3b1e4ae9 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Mon, 26 Oct 2026 02:45:47 -0700 Subject: [PATCH 10/11] docs(adr-031): seat token is a fine-grained PAT; drop the GitHub App option Installation tokens expire within an hour and nothing re-mints them into the seat's credential file. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Uub7iKypiDa4iTWuc1oYZQ --- docs/adr/ADR-031-seat-os-user-isolation.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index 24f0ba7536..6034a990ea 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -120,9 +120,13 @@ Decision: seat credentials are **0600 files in the seat user's home**, not keych the only token the seat can reach cannot do what the guard forbids. Which account that token belongs to is U3. Its effective permissions are repository contents and pull requests, read and write, on the public repo only, and the provider must enforce that: the token is a fine-grained - token or a GitHub App installation restricted to that one repository, never a classic or - account-wide token. Before the first seat token is issued, record its repository allowlist and - run a negative probe: a write to a second repository the same account can reach must be refused. + personal access token restricted to that one repository, never a classic or account-wide token. + A GitHub App installation token is not an option here: it expires within an hour, and nothing + today re-mints it into the seat user's credential file (`GitHubAppService.getInstallationToken` + serves backend API calls only). Adopting it later needs its own refresh-and-replace design. The + fine-grained token carries an expiry; record it, and rotate before it lapses. Before the first + seat token is issued, record its repository allowlist and run a negative probe: a write to a + second repository the same account can reach must be refused. A contents-write token can delete refs and push to any branch it is not stopped from, so the fence cannot rest on the token. Measured 2026-10-26: classic branch protection on `main` refuses deletion and force-push, but does not apply to admins and From ef50cd20527132c539d7e15ef8d79fe6ba1d44c7 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Fri, 30 Oct 2026 02:49:42 -0700 Subject: [PATCH 11/11] docs(adr-031): add the locked-console fleet stall as a second forcing function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A locked macOS console stalls every launchd-spawned seat turn at an early login-keychain op (the 2026-10-30 ~6h fleet stall); the tmux workaround loses unattended restart across reboot, and the same lock blocks Docker Desktop's VM. A dedicated seat OS user with its own always-unlocked keychain removes both — the same isolation ADR-031 builds for the credential-theft class. Docs only. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01N4VWLErW29t4LnhRfUy1NH --- docs/adr/ADR-031-seat-os-user-isolation.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/adr/ADR-031-seat-os-user-isolation.md b/docs/adr/ADR-031-seat-os-user-isolation.md index 6034a990ea..040ffb2fbd 100644 --- a/docs/adr/ADR-031-seat-os-user-isolation.md +++ b/docs/adr/ADR-031-seat-os-user-isolation.md @@ -10,6 +10,14 @@ device-auth by a second macOS user on this host invalidates the operator's session. - **Date:** 2026-10-22 (stub); full decision text 2026-10-25 - **Motivation:** issue #2427 (a seat read an operator's gh token from the login keychain) +- **Second forcing function (2026-10-30):** a *locked* macOS console stalls every launchd-spawned + seat turn at an early login-keychain op — the fleet stalled ~6h, and the only recovery that works + while locked (a tmux tree whose children inherit an already-authorized session) loses launchd's + unattended restart across a reboot. The same lock also blocks Docker Desktop's VM from starting. + Both dependencies come from the seat tree sharing the operator's login session and keychain. A + dedicated seat OS user with its own keychain — unlocked at boot and never screen-locked — removes + them, which is the same isolation this ADR builds for the credential-theft class. So the + locked-console stall is a second, independent reason to do ADR-031, not a separate project. - **Related:** [ADR-008](ADR-008-agent-environment-primitive.md) (sandbox modes; `bwrap` on Linux), [ADR-005](ADR-005-local-cli-wrapper-driver.md) (local wrapper seats), [ADR-030](ADR-030-agent-identity.md) (agent identity)