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
+98 -21
View File
@@ -28,12 +28,21 @@ const SHELL = { command: process.env.SHELL ?? '/bin/zsh', args: ['-i'] };
/** @type {Map<string, Session>} */
const sessions = new Map();
const resolveCwd = (cwd) => {
if (!cwd || cwd === '~') return HOME_DIR;
if (cwd.startsWith('~/')) return join(HOME_DIR, cwd.slice(2));
/**
* Where a shell opens. `base` is the requesting account's home; without one this is the owner's HOME_DIR, as
* it always was.
*
* An absolute `cwd` is honoured as given, for the owner and for a member alike. That is not a hole and not an
* oversight: the member's shell runs as their uid, so the kernel decides what they can see once they get
* there. A path check here would be theatre — `cd /` is one keystroke away in any shell.
*/
const resolveCwd = (cwd, base) => {
const home = base || HOME_DIR;
if (!cwd || cwd === '~') return home;
if (cwd.startsWith('~/')) return join(home, cwd.slice(2));
if (cwd.startsWith('/')) return cwd;
// Relative paths have no meaning here — this process's cwd is the repo, not the user's folder.
return HOME_DIR;
return home;
};
const appendBuffer = (session, data) => {
@@ -58,11 +67,59 @@ const broadcast = (session, msg) => {
}
};
/** Attach a client to a session, spawning the shell if this is the first time we've seen the id. */
export function attach(sessionId, client, { cwd, cols, rows } = {}) {
/**
* The argv that opens a shell as `osUser`, or as this process when there is none.
*
* Mirrors `runAs` in src/servers/os-user.ts, and cannot import it: this sidecar runs under node (node-pty
* binds a native addon against node's ABI) while that file is Bun/TypeScript. If you change one, change both
* — the flags are load-bearing and the reasoning for each is documented there.
*
* node-pty does support `uid`/`gid` options, unlike Bun.spawn. They are deliberately NOT used: they set the
* ids without applying the account's supplementary groups or resetting the environment, so the shell would
* keep the OWNER's groups and the platform's entire env — including everything Bun loaded from `.env`.
*
* `--reset-env` keeps TERM (per setpriv(1)) and sets HOME/SHELL/USER/LOGNAME/PATH from the target's passwd
* entry. COLORTERM does not survive it, so it is re-stated through `env` — it is how programs decide they may
* emit 24-bit colour.
*/
function shellArgv(osUser) {
if (!osUser) return { command: SHELL.command, args: SHELL.args };
return {
command: 'sudo',
args: [
'-n',
'setpriv',
`--reuid=${osUser}`,
`--regid=${osUser}`,
'--init-groups',
'--reset-env',
'--',
'env',
'COLORTERM=truecolor',
SHELL.command,
...SHELL.args,
],
};
}
/**
* Attach a client to a session, spawning the shell if this is the first time we've seen the id.
*
* `osUser` and `home` come from the PLATFORM, which resolves them from the authenticated account — never from
* anything the browser sent. This socket binds loopback and only the platform's bridge reaches it, which is
* the same trust model as the `X-Officer-User` header on the HTTP sidecars.
*/
export function attach(sessionId, client, { cwd, cols, rows, osUser, home } = {}) {
let session = sessions.get(sessionId);
if (session) {
// A session belongs to whoever started it. Re-attaching is only allowed for the same account, or this is
// how one member resumes another's shell by guessing a session id — and the id is in a query string.
if ((session.osUser ?? null) !== (osUser ?? null)) {
client.send({ type: 'output', data: '\r\n[Terminal error] that session belongs to another account\r\n' });
client.send({ type: 'exit', exitCode: 1 });
return null;
}
session.clients.add(client);
// Scrollback goes out as `replay`, not as ordinary output: the client may already be showing some of
// it, so it resets and rebuilds from this rather than appending a second copy.
@@ -76,12 +133,16 @@ export function attach(sessionId, client, { cwd, cols, rows } = {}) {
let term;
try {
term = pty.spawn(SHELL.command, SHELL.args, {
const { command, args } = shellArgv(osUser);
term = pty.spawn(command, args, {
name: 'xterm-256color',
cols: spawnCols,
rows: spawnRows,
cwd: resolveCwd(cwd),
// COLORTERM is how programs decide they may emit 24-bit colour — TERM only advertises 256.
// A member's shell starts in THEIR home, not the owner's. `resolveCwd` resolves `~` and relative paths
// against HOME_DIR, which is the owner's — so the base has to be passed in for anyone else.
cwd: resolveCwd(cwd, home),
// For a member this env reaches `sudo` and `setpriv`, and `--reset-env` clears it before the shell:
// see shellArgv. For the owner it is the shell's environment as before.
env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor' },
});
} catch (err) {
@@ -101,6 +162,9 @@ export function attach(sessionId, client, { cwd, cols, rows } = {}) {
lastActivityAt: now,
title: '',
pid: term.pid,
/** Whose shell this is, or null for the owner. Read by `list` and `kill` so one account cannot see or
* end another's — the gap TODO.md called the pty sidecar's identity blindness. */
osUser: osUser ?? null,
clients: new Set([client]),
};
sessions.set(sessionId, session);
@@ -145,9 +209,18 @@ export function resize(sessionId, cols, rows) {
}
}
export function kill(sessionId) {
/**
* End a session. `osUser` scopes it: pass an account name and only that account's sessions can be killed;
* pass nothing and it is the owner, who may kill any.
*
* Undefined and null mean different things here, which is why the check is explicit. `undefined` is "the
* caller did not scope this" (the owner). `null` is "the caller is the owner's own shell", which is a real
* value a member must not match.
*/
export function kill(sessionId, osUser) {
const session = sessions.get(sessionId);
if (!session) return false;
if (osUser !== undefined && (session.osUser ?? null) !== osUser) return false;
try {
session.term.kill();
} catch {
@@ -157,17 +230,21 @@ export function kill(sessionId) {
return true;
}
export function list() {
return [...sessions.entries()].map(([sessionId, s]) => ({
sessionId,
cols: s.cols,
rows: s.rows,
createdAt: s.createdAt,
lastActivityAt: s.lastActivityAt,
title: s.title || undefined,
pid: s.pid,
clients: s.clients.size,
}));
/** Sessions, scoped the same way as `kill`: an account name sees only its own, nothing sees everything. */
export function list(osUser) {
return [...sessions.entries()]
.filter(([, s]) => osUser === undefined || (s.osUser ?? null) === osUser)
.map(([sessionId, s]) => ({
sessionId,
cols: s.cols,
rows: s.rows,
createdAt: s.createdAt,
lastActivityAt: s.lastActivityAt,
title: s.title || undefined,
pid: s.pid,
clients: s.clients.size,
osUser: s.osUser ?? undefined,
}));
}
export function killAll() {