terminal runs as the member; chat is grantable and still refused
TERMINAL is confined now, and the shell is genuinely theirs. The pty sidecar spawns it through sudo setpriv as their own account, in their own home, with the platform's environment cleared. Verified end to end against the sidecar's own socket: id -u 1001, not 1000 file the shell wrote owned by ptyprobe ps -o user=,args= ptyprobe /bin/zsh -i env | grep -c POSTGRES 0 osUser and home are resolved in upgradeWs from the authenticated account, and whatever the browser sent under those names is DELETED first. The bridge forwards the query string to the sidecar untouched and the sidecar starts a shell from what it finds there, so trusting the client for either would let a member ask for the owner's uid in a query parameter. node-pty does support uid/gid, unlike Bun.spawn, and they are deliberately unused: they set the ids without applying the account's groups or resetting the environment, so the shell would keep the owner's groups and everything Bun loaded from .env. Also closes the pty identity blindness in TODO.md. Sessions record whose they are, list and kill scope to the caller, and re-attaching to a session belonging to another account is refused — otherwise a member resumes someone else's shell by guessing an id that travels in a query string. Measured: member killing the owner's session -> ok:false, owner killing it -> ok:true. CHAT is confined so the owner can grant it and the route resolves, and both execution doors refuse a non-owner: the router wholesale, and the socket in server.tsx. The agent has not moved — the SDK spawns claude itself with nowhere to put a uid, and every transcript path resolves through the owner's home, so a member would read the owner's session list and run an agent as the owner. Reads are refused too, because listClaudePwds returns the names of the owner's projects. A deliberate, temporary gap at the owner's request: permission and route now, function when a turn can be spawned under runAs with the member's own HOME. Both guards say so, and the registry test names them so a future edit cannot move one without the other. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -154,9 +154,10 @@ describe('self-service routes', () => {
|
||||
describe('kinds', () => {
|
||||
test('execution capabilities are never grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
// `files` left this list on 2026-08-11 when it became `confined` — see the test below and
|
||||
// docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home.
|
||||
for (const key of ['terminal', 'chat', 'tasks', 'desktop', 'browser', 'items']) {
|
||||
// `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below and
|
||||
// docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home with no
|
||||
// per-caller resolution at all.
|
||||
for (const key of ['tasks', 'desktop', 'browser', 'items']) {
|
||||
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution');
|
||||
expect(grantable.has(key)).toBe(false);
|
||||
}
|
||||
@@ -166,7 +167,7 @@ describe('kinds', () => {
|
||||
// authorize.ts, which is where the rule can cover routes, sockets and the dock at once.
|
||||
test('confined capabilities are grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
for (const key of ['files']) {
|
||||
for (const key of ['files', 'terminal', 'chat']) {
|
||||
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('confined');
|
||||
expect(grantable.has(key)).toBe(true);
|
||||
}
|
||||
@@ -174,9 +175,14 @@ describe('kinds', () => {
|
||||
|
||||
// The claim `confined` makes is that every path it reaches resolves its directory from the CALLER. That
|
||||
// cannot be asserted from the registry, so this pins the inverse: nothing becomes confined without a
|
||||
// deliberate edit here, and the list is short enough to audit by eye.
|
||||
// deliberate edit here, and the list stays short enough to audit by eye.
|
||||
//
|
||||
// `chat` is on it ahead of its implementation, deliberately and temporarily: the capability is grantable so
|
||||
// the route resolves, while api/chat/chat.ts and the chat socket both refuse a non-owner because a turn
|
||||
// would still spawn the agent as the owner. If you are here because this test failed, check that those two
|
||||
// guards moved together with whatever you changed.
|
||||
test('confined is a short, deliberate list', () => {
|
||||
expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['files']);
|
||||
expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
|
||||
});
|
||||
|
||||
test('admin capabilities are never grantable', () => {
|
||||
|
||||
@@ -255,20 +255,33 @@ export const CAPABILITIES: Capability[] = [
|
||||
},
|
||||
|
||||
// ── execution: never grantable ──────────────────────────────────────────────────────────────────
|
||||
// Confined since 2026-08-11. A member's shell is spawned by the pty sidecar through `sudo setpriv` as their
|
||||
// own Linux account, in their own home, with the platform's environment cleared — so it is their shell, and
|
||||
// the kernel decides what it can reach. The sidecar also records whose each session is, so `list` and `kill`
|
||||
// scope to the caller instead of every shell on the box.
|
||||
//
|
||||
// What this is NOT is a jail. A member with a shell can `cd /` and read whatever the system leaves
|
||||
// world-readable, like any account on any machine. It isolates members from each other and from the owner's
|
||||
// files, which is the promise `confined` makes.
|
||||
{
|
||||
key: 'terminal',
|
||||
label: 'Terminal',
|
||||
description: 'A real shell as the server owner',
|
||||
kind: 'execution',
|
||||
description: 'A shell on this machine, as your own user',
|
||||
kind: 'confined',
|
||||
api: ['/terminal'],
|
||||
ws: ['terminal'],
|
||||
routes: ['/terminal'],
|
||||
},
|
||||
// Confined so the owner can grant it and the route resolves — but the agent underneath still runs as the
|
||||
// OWNER, so `api/chat/chat.ts` refuses a non-owner outright and the chat socket is refused in server.tsx.
|
||||
// A deliberate, temporary gap: the permission exists, the functionality follows when a turn can be spawned
|
||||
// under `runAs` with the member's own HOME. Until then this grant buys a route and a refusal, and the
|
||||
// comments at both guards say so.
|
||||
{
|
||||
key: 'chat',
|
||||
label: 'Chat',
|
||||
description: 'The agent, running unsandboxed as the server owner',
|
||||
kind: 'execution',
|
||||
description: 'The agent',
|
||||
kind: 'confined',
|
||||
api: ['/chat'],
|
||||
ws: ['chat'],
|
||||
routes: ['/chat'],
|
||||
|
||||
Reference in New Issue
Block a user