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
+23
View File
@@ -1,5 +1,7 @@
import type { Context } from 'hono';
import { createRouter } from '../../create-router';
import { isSuperAdmin } from '@@/super-admin';
import * as errors from '@@/custom-errors';
import * as sidecar from '@@/sidecar-registry';
import { getUserSettings } from 'officerdb';
import {
@@ -30,6 +32,27 @@ import { registerAgentPanelRoutes } from './agent-panels-routes';
export const chatRouter = createRouter();
// ── Chat is grantable, and its machinery is not ready for a member. This is that gap, held open on purpose ──
//
// The `chat` capability moved from `execution` to `confined` so the owner can grant it and the route resolves.
// The agent underneath has NOT moved: `claude-manager.ts` drives turns through the Agent SDK, which spawns
// `claude` itself with no way to hand it a uid, and every transcript path here resolves through the owner's
// home. So a member reaching this router would read the owner's session list and run an agent as the owner —
// which is the whole thing the confinement work exists to prevent.
//
// Refused wholesale rather than per-route, and reads rather than just writes: `listClaudePwds` returns the
// directory names of the owner's projects, which is not a member's business either.
//
// What lifts this is per-user agents: the turn becomes its own process under `runAs`, with the member's own
// HOME so `~/.claude` and their transcripts are theirs. docs/per-user-linux-accounts.md § stage 5. Delete this
// middleware then — it is the only thing standing between a granted member and the owner's agent.
chatRouter.use(async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) {
throw errors.FORBIDDEN('Chat is not available to members yet — the agent still runs as the server owner.');
}
return next();
});
// The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default
// general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read.
// (OpenCode sessions all live in the one fixed server and ignore cwd.)