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:
2026-08-11 19:03:00 +00:00
co-authored by Claude Opus 5
parent eda004a46d
commit 4d4a253f72
6 changed files with 199 additions and 34 deletions
+12 -2
View File
@@ -27,13 +27,19 @@ export function startServer() {
const url = new URL(req.url ?? '/', 'http://127.0.0.1');
// Officer-owned routes, reached through the platform's authenticated proxy.
//
// `osUser` scopes both to one account's sessions. The platform sends it for a member and omits it for the
// owner; absent means unscoped. Until this existed these two listed and killed EVERY shell on the box for
// anyone who could reach them, which was safe only because the terminal was owner-only.
const scope = url.searchParams.has('osUser') ? (url.searchParams.get('osUser') || null) : undefined;
if (url.pathname === '/_officer/sessions' && req.method === 'GET') {
return json(res, 200, { sessions: store.list() });
return json(res, 200, { sessions: store.list(scope) });
}
const killMatch = url.pathname.match(/^\/_officer\/sessions\/([^/]+)$/);
if (killMatch && req.method === 'DELETE') {
const killed = store.kill(decodeURIComponent(killMatch[1]));
const killed = store.kill(decodeURIComponent(killMatch[1]), scope);
return json(res, killed ? 200 : 404, { ok: killed });
}
@@ -55,6 +61,10 @@ export function startServer() {
cwd: url.searchParams.get('cwd') ?? undefined,
cols: Number(url.searchParams.get('cols')) || 0,
rows: Number(url.searchParams.get('rows')) || 0,
// Injected by the platform's bridge from the authenticated account, after stripping whatever the
// browser sent. Absent for the owner, whose shell runs as this process.
osUser: url.searchParams.get('osUser') || undefined,
home: url.searchParams.get('home') || undefined,
});
if (!session) {
socket.close(1011, 'failed to start terminal');