import { chmod, mkdir, readdir, stat } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path'; // Real Linux accounts for members, so the surfaces that execute code can run as them. // // Design, prerequisites and the staging plan: docs/per-user-linux-accounts.md. Read it before changing // anything here — several of the choices below look arbitrary and are not. // // ── The one thing to know ── // // `Bun.spawn` SILENTLY IGNORES `uid` and `gid`. Verified on bun 1.3.10: from uid 1000, // `Bun.spawn(['id','-u'], { uid: 65534 })` exits 0 and prints 1000. No throw, no warning. So a privilege // drop written the obvious way would look like it worked while every member's process ran as the owner — // an isolation boundary that is silently absent, which is worse than none at all because it is believed. // // Everything here goes through `setpriv`. os-user.test.ts pins Bun's behaviour so that if it is ever // fixed, a test tells us we may simplify, rather than someone assuming it and being wrong. /** Off unless explicitly enabled: this needs root, and a light install has no sudoers entry. */ export const OS_USERS_ENABLED = process.env.OFFICER_OS_USERS === 'true' || process.env.OFFICER_OS_USERS === '1'; const MAX_USERNAME = 32; /** * The Linux account name for a platform account: **the username the owner chose**, verbatim. * * This carried an `officer_` prefix until 2026-08-11. The prefix bought two things — no collision with a * system account, and a greppable record of what we created — and cost the thing the owner actually wants, * which is that `whoami` in a member's terminal says who they are. Measured before removing it: `useradd` * on this host accepts everything `validateUsername` permits, including dots, hyphens, underscores and * uppercase. * * What replaced the prefix's safety is a stricter ADOPTION rule in `ensureOsUser`: an existing Linux account * is only reused when its passwd home is already the home we expect. Without that, a platform account named * `root` would have adopted root. See there. * * `toShellUsername` still handles the fallback when there is no username, since the email local part can * contain things a Linux name cannot. */ export function osUserNameFor(params: { username: string | null; email: string }): string { const chosen = params.username?.trim(); if (chosen) return chosen.slice(0, MAX_USERNAME); return toShellUsername('', params.email).slice(0, MAX_USERNAME); } /** The account's home as passwd records it, or null if it has none / does not exist. */ async function passwdHome(osUser: string): Promise { const result = await run(['getent', 'passwd', osUser]); if (!result.ok) return null; return result.out.split(':')[5] ?? null; } export type RunAsOptions = { /** Passed through to the wrapped command. `setpriv --reset-env` means nothing else survives. */ env?: Record; cwd?: string; }; /** * The argv that runs `command` as `osUser`. Pure, so the shape is testable without spawning anything. * * sudo -n REQUIRED, and not merely for the uid. Measured 2026-08-11: `--init-groups` fails * with "initgroups failed: Operation not permitted" for an unprivileged caller even * when reuid'ing to its OWN account — setgroups(2) is root-only, full stop. So this * cannot be done without privilege, and `-n` makes a missing sudoers entry an * immediate error instead of a process blocking on a password prompt nobody will see. * --reuid/--regid the REAL ids, not merely effective — there is nothing to switch back to. * --init-groups apply the account's supplementary groups. Without it the process keeps the OWNER'S * groups, which quietly retains access we just took away. * --reset-env drop the inherited environment, then set HOME/SHELL/USER/LOGNAME/PATH from the * target's passwd entry. Both halves matter: the parent's env carries the owner's HOME * and — in a PM2 process started in the platform directory — everything Bun auto-loaded * from `.env`. Verified: `POSTGRES_URL` is unset on the far side, and HOME arrives as * the member's own. * * `sudo -u ` alone would also work and would be shorter. It is not used because its environment * handling is sudoers policy (`env_reset`, `env_keep`, `always_set_home`) rather than something this file * states — and "which variables cross into a member's shell" is exactly the question that must not depend * on a config file somebody may have edited. */ export function runAsArgv(osUser: string, command: string[]): string[] { if (!osUser) throw new Error('runAsArgv: no OS user'); if (!command.length) throw new Error('runAsArgv: empty command'); return [ 'sudo', '-n', 'setpriv', `--reuid=${osUser}`, `--regid=${osUser}`, '--init-groups', '--reset-env', '--', ...command, ]; } /** * Run a command as another Linux account. * * Deliberately does NOT accept a `uid` option. The only supported way to change user in this codebase is * this function, precisely because the option that looks like it would work does nothing. */ export function runAs(osUser: string, command: string[], options: RunAsOptions = {}) { return Bun.spawn(runAsArgv(osUser, command), { cwd: options.cwd, // Reaches sudo and setpriv, NOT the command — `--reset-env` clears it on the way through. Anything // the command needs beyond the passwd-derived HOME/SHELL/USER/LOGNAME/PATH has to be stated inside // `command` itself (`env FOO=bar cmd …`). That asymmetry is deliberate: it means a variable can only // cross into a member's process because someone wrote it there. env: options.env, stdout: 'pipe', stderr: 'pipe', }); } async function run(command: string[]): Promise<{ ok: boolean; out: string }> { const proc = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' }); const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const code = await proc.exited; return { ok: code === 0, out: `${out}${err}`.trim() }; } /** uid/gid from the passwd database, or null when the account does not exist. */ export async function lookupOsUser(osUser: string): Promise<{ uid: number; gid: number } | null> { const uid = await run(['id', '-u', osUser]); if (!uid.ok) return null; const gid = await run(['id', '-g', osUser]); if (!gid.ok) return null; return { uid: Number(uid.out), gid: Number(gid.out) }; } /** The member's home: `DATA_PATH//home`, where `provisionUserDirs` already put it. */ export const osUserHome = (email: string): string => join(DATA_PATH, email, 'home'); /** * The first directory on the way to `path` that `osUser` cannot traverse, or null if the whole chain is fine. * * Exists because of a real and thoroughly confusing failure. A member's home is under `DATA_PATH`, which on * a normal install is under the OWNER'S home — and `/home/` is 750 on Debian and Ubuntu. So every * mode bit we set on the account tree was correct, the directory existed, and the member still could not * reach it, because they had no `x` on an ancestor four levels up. What surfaced was * `ssh-keygen: Could not stat …/.ssh: Permission denied`, which points at exactly the wrong place. * * `x` without `r` is the ask: traversal, not listing. Nobody gains the ability to enumerate the owner's home. */ export async function firstUntraversableAncestor(osUser: string, path: string): Promise { const parts = path.split('/').filter(Boolean); const chain = parts.map((_, i) => `/${parts.slice(0, i + 1).join('/')}`); // One spawn rather than one per level: this runs on every account creation, and the chain is ~6 deep. const proc = runAs(osUser, [ 'sh', '-c', 'for p in "$@"; do [ -x "$p" ] || { printf %s "$p"; exit 0; }; done', 'sh', ...chain, ]); const out = (await new Response(proc.stdout).text()).trim(); await proc.exited; return out || null; } export type EnsureOsUserResult = | { ok: true; osUser: string; uid: number; gid: number; created: boolean } | { ok: false; error: string }; /** * Create the Linux account if it does not exist, then place the ownership and mode bits. * * Idempotent in both halves: an existing account is adopted rather than recreated, and the modes are * re-applied every time, so a directory the platform added later is confined without needing a * migration. * * Never throws. Account creation is a side effect of creating a platform account, and a `useradd` that * failed must not leave a half-made user behind — the caller records the error and the platform account * simply has no OS account yet. */ export async function ensureOsUser(params: { email: string; username: string | null }): Promise { const osUser = osUserNameFor(params); const home = osUserHome(params.email); let created = false; let ids = await lookupOsUser(osUser); // ── Adoption, and why it is this strict ── // // An account that already exists is REUSED, which is what makes this function re-runnable. While names // carried an `officer_` prefix that was safe by construction: nothing else creates those. Now that the // name is whatever the owner typed, adoption is the dangerous path — a platform account named `root` // would find root in passwd, and every `runAs` for that member would then be a root shell. // // The test is the account's own home. If passwd already points it at exactly the directory we are about // to confine, it is ours (or a previous run's). Anything else is somebody else's account that happens to // share a name, and the answer is to refuse rather than to touch it. // // The uid floor is belt and braces: a system account below 1000 could in principle be created with a // matching home, and none of them should ever be handed to a member. if (ids) { const existingHome = await passwdHome(osUser); if (ids.uid < 1000) { return { ok: false, error: `'${osUser}' is a system account on this machine. Choose another username.` }; } if (existingHome !== home) { return { ok: false, error: `'${osUser}' is already a user on this machine, with its home at ${existingHome ?? 'an unknown path'}. ` + `Refusing to take it over — choose another username.`, }; } } if (!ids) { // `-M` because provisionUserDirs already made the directory, and letting useradd create it would // copy /etc/skel in as root-owned. `-s` explicitly: /etc/default/useradd here says /bin/sh, and a // member opening a terminal should get the same shell everyone else gets. const create = await run([ 'sudo', '-n', 'useradd', '--home-dir', home, '-M', '--shell', process.env.SHELL ?? '/bin/bash', osUser, ]); if (!create.ok) return { ok: false, error: `useradd failed: ${create.out}` }; created = true; ids = await lookupOsUser(osUser); if (!ids) return { ok: false, error: `useradd reported success but ${osUser} is not in passwd` }; } const confined = await confineUserTree({ email: params.email, uid: ids.uid, gid: ids.gid }); if (!confined.ok) return { ok: false, error: confined.error }; // Checked before anything tries to USE the home, so the error names the actual problem. Every mode bit on // the account tree can be right while the member still cannot get there, because `DATA_PATH` normally // lives under the owner's home and `/home/` is 750 on Debian and Ubuntu. const blocked = await firstUntraversableAncestor(osUser, home); if (blocked) { return { ok: false, error: `${osUser} cannot traverse into ${blocked}, so it cannot reach its own home. ` + `Fix with: chmod o+x ${blocked} ` + `(that grants traversal only — the directory stays unlistable.)`, }; } // A new home is EMPTY, deliberately. This used to create Downloads/Documents/Music/Videos/Pictures — a // habit inherited from the file browser, which did the same lazily for the owner. Nothing needs them: // guessing at somebody's folder layout is a decision the platform has no standing to make, and an empty // home is honest about being new. return { ok: true, osUser, uid: ids.uid, gid: ids.gid, created }; } /** * Place the mode bits described in docs/per-user-linux-accounts.md § "The layout". * * DATA_PATH 711 service user traverse only — a member cannot enumerate the members * DATA_PATH/ 711 service user traverse only — a member cannot list their OWN siblings * …/home 700 the member their home * every sibling 700 service user platform-written, unreachable even by name * * 711 on the account directory is the load-bearing one. The member needs `x` to reach `home`, and must * not have `r`, or `ls` would show them the platform's private tree beside it. Because every sibling is * 700 and owned by the service user, guessing a name gains nothing either. * * `chown` on the home is done with sudo: the service user owns the directory but cannot give it away — * `chown` to another user is a root-only operation on Linux regardless of who owns the file. */ export async function confineUserTree(params: { email: string; uid: number; gid: number; }): Promise<{ ok: true } | { ok: false; error: string }> { const accountDir = join(DATA_PATH, params.email); const home = join(accountDir, 'home'); try { if (!existsSync(home)) await mkdir(home, { recursive: true }); // Traversable, not listable. Applied to DATA_PATH itself too: without it a member can read the // directory and learn every other member's email address. await chmod(DATA_PATH, 0o711); await chmod(accountDir, 0o711); // Every sibling of `home` is the platform's. 700 means traversal alone does not open them. const entries = await readdir(accountDir, { withFileTypes: true }); for (const entry of entries) { if (entry.name === 'home') continue; if (!entry.isDirectory()) continue; await chmod(join(accountDir, entry.name), 0o700); } // And any of the standard set that does not exist yet, so a directory created later starts confined // rather than at the process umask. for (const dir of USER_DIRS) { if (dir === 'home') continue; const path = join(accountDir, dir); if (!existsSync(path)) await mkdir(path, { recursive: true, mode: 0o700 }); } // The home goes through sudo for BOTH operations, and that is the only form that is idempotent. // `chmod` requires ownership, so: // - chmod then chown, unprivileged: works once, then fails EPERM forever after, because the home now // belongs to the member. Re-running an install would report failure on a correct tree. // - chown then chmod, unprivileged: fails immediately, for the same reason. // Both were observed. Root does not care about either ordering, so both go through sudo and the // function can be run any number of times. const give = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, home]); if (!give.ok) return { ok: false, error: `chown of ${home} failed: ${give.out}` }; const close = await run(['sudo', '-n', 'chmod', '700', home]); if (!close.ok) return { ok: false, error: `chmod of ${home} failed: ${close.out}` }; // ── And then let the PLATFORM in, by ACL ── // // A 700 home owned by the member locks out the service user, which is correct for a shell and fatal for // the file browser: it runs inside the platform process, so `readdir` returned EACCES and `/ls` reported // "This folder is empty" over five directories that were sitting right there. Observed 2026-08-11. // // These are two different doors and they need different boundaries. The terminal and the agent RUN AS the // member, and there the kernel is the boundary. The file browser acts on the member's behalf from inside // the platform, which already applies its own containment (`resolveUserPath`) and which is the owner's // process on the owner's machine — it can read anything via sudo regardless. Giving it access is not a // hole, it is the honest description of who is doing the work. // // Why ACLs and not mode bits or a group. It has to work in BOTH directions: a file the platform writes // must be editable by the member, and a file the member writes must be editable by the platform. Mode // bits cannot express that — whichever of the two is neither owner nor group ends up as "other", and // widening "other" would open the home to every account on the box. A shared group fails the same way // once you notice both parties would have to be in it, which would put every member in a group that can // read every other member's home. Named ACL entries grant exactly two users, and the `d:` defaults are // inherited by everything created afterwards, by either party, whatever their umask. const serviceUid = process.getuid?.(); if (serviceUid !== undefined) { const entries = [ `u:${serviceUid}:rwx`, `u:${params.uid}:rwx`, `d:u:${serviceUid}:rwx`, `d:u:${params.uid}:rwx`, ].flatMap((entry) => ['-m', entry]); // After the chmod, never before: chmod recomputes the ACL mask and would clamp entries set earlier. const acl = await run(['sudo', '-n', 'setfacl', '-R', ...entries, home]); if (!acl.ok) { return { ok: false, error: `could not set access control lists on ${home}: ${acl.out}. ` + `The file browser cannot read a member's home without them. ` + `Install the acl package (apt install acl) and retry.`, }; } } return { ok: true }; } catch (ex) { return { ok: false, error: ex instanceof Error ? ex.message : String(ex) }; } } /** * Refuse to enable OS users while a secret in the project tree is readable by them. * * `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing * secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would * leave the capability model intact and entirely bypassed. * * Checked at boot rather than documented, because a prerequisite that is only written down is one that * gets skipped. Returns the offending paths; the caller decides whether that is fatal. */ export async function findReadableSecrets(projectDir: string): Promise { const candidates = ['.env', '.env.local', '.env.production']; const bad: string[] = []; for (const name of candidates) { const path = join(projectDir, name); if (!existsSync(path)) continue; try { const info = await stat(path); // Anything readable by group or other. 0o044 covers both read bits. if (info.mode & 0o044) bad.push(path); } catch { // Unreadable to us is not a leak to them; nothing to report. } } return bad; } /** * Refuse to boot with OS users enabled while a secret in the project tree is readable by them. * * Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that * silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a * member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and * bypass every capability check in the codebase. * * A no-op when the feature is off, so an existing install is unaffected until the owner opts in. */ export async function assertSecretsClosed(projectDir: string): Promise { if (!OS_USERS_ENABLED) return; const readable = await findReadableSecrets(projectDir); if (!readable.length) return; throw new Error( [ 'OFFICER_OS_USERS is enabled, but these files are readable by other accounts on this machine:', '', ...readable.map((p) => ` • ${p}`), '', 'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which', 'bypasses every capability check. Fix with:', '', ...readable.map((p) => ` chmod 600 ${p}`), '', 'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".', ].join('\n'), ); }