Files
platform/src/servers/os-user.ts
T
pastilhasandClaude Opus 5 4d4606d4a2 close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.

measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.

so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.

it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.

two directories are left open on purpose, and both are the same latent bug:

    /usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
    /usr/local/bin/gh  -> /home/pastilhas/.local/bin/gh

system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.

verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:59:45 +00:00

629 lines
32 KiB
TypeScript

import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { secretStorePath } from 'officerdb/secret-store';
import { DATA_PATH, USER_DIRS, getOwnerHomeDir, 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.
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 login shell a new account gets: zsh where it exists, bash otherwise.
*
* Resolved from `/etc/shells`-style existence rather than from this process's environment. See the call site.
*/
async function defaultShell(): Promise<string> {
for (const candidate of ['/usr/bin/zsh', '/bin/zsh', '/bin/bash']) {
if (existsSync(candidate)) return candidate;
}
return '/bin/sh';
}
/** The account's home as passwd records it, or null if it has none / does not exist. */
async function passwdHome(osUser: string): Promise<string | null> {
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<string, string>;
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 <user>` 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/<email>/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/<owner>` 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<string | null> {
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<EnsureOsUserResult> {
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.
//
// The shell is chosen from what is INSTALLED, not from `process.env.SHELL`. That was the first version and
// it is wrong twice: this process is started by PM2, whose environment has whatever shell PM2 was launched
// from — often `/bin/sh` and sometimes nothing — so the member's shell depended on how the server happened
// to be started. zsh is what the platform's own setup installs and what the shell template targets.
const create = await run([
'sudo',
'-n',
'useradd',
'--home-dir',
home,
'-M',
'--shell',
await defaultShell(),
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/<owner>` 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/<email> 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 — for everyone EXCEPT the members themselves, who are named below.
//
// ── Why "not listable" could not be kept ──
//
// 711 says: pass through, do not read. That is enough to `cd` into a home and not enough for a program
// that READS its ancestors, and at least one in daily use does. `bun run` primes its module-resolution
// cache by walking DOWN from `/` and opening every component of the cwd with `O_RDONLY|O_DIRECTORY`:
//
// openat("/home/pastilhas/officerdev/") = 6
// openat("/home/pastilhas/officerdev/data/") = -1 EACCES
// openat("/home/pastilhas/officerdev/data/<email>/") = -1 EACCES
//
// and dies with `CouldntReadCurrentDirectory` before it ever looks for `package.json`. `getcwd` succeeds;
// it is the read of the ancestors that fails. Traversal alone would do — `O_PATH` needs only `x` — so
// this is arguably Bun's bug, but it is not one this repository can fix, and it presents as a project
// being mysteriously unbuildable from a member's shell.
//
// The cost is stated plainly: a member can now `ls` DATA_PATH and learn the other accounts' email
// addresses. Their CONTENTS stay shut — every `home` is 700 and owned by its member, and every sibling
// is 700 and owned by the service user. What is given up is the account list, not any account's data.
//
// Named ACL entries rather than `chmod 755`, so this reaches members and not every account on the box.
await chmod(DATA_PATH, 0o711);
await chmod(accountDir, 0o711);
// After the chmods, never before — chmod recomputes the ACL mask from the group bits, which for 711 is
// `--x`, and that would clamp every member entry (including ones added by earlier provisions) down to
// traverse-only. Setting `m::rx` explicitly restores them all, so provisioning a second member does not
// silently re-break the first.
const uidEntry = `u:${params.uid}:rx`;
const openUp = await run(['sudo', '-n', 'setfacl', '-m', `${uidEntry},m::rx`, DATA_PATH, accountDir]);
if (!openUp.ok) {
return {
ok: false,
error:
`could not grant ${params.email} read access to ${DATA_PATH}: ${openUp.out}. ` +
`Without it their own tooling cannot resolve paths inside their home.`,
};
}
// 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.`,
};
}
}
// ── One directory where container bind mounts can live ──
//
// Created for every account, not only the ones that get a daemon: it is two `install` calls, and
// `confineUserTree` is the function that places the whole layout rather than the one that knows who is
// a Developer. A member promoted later finds it already correct.
//
// The default ACLs above are inherited by everything created in the home afterwards, including
// `default:other::---`. A rootless container's INNER uid is neither the service user nor the member —
// postgres:18-alpine runs as uid 70, which maps through the member's subuid range to 231141 — so it is
// `other`, and `other` has no `x`. It cannot traverse a directory it otherwise owns.
//
// `3bea46f` stripped defaults from `~/.local/share/docker` and concluded the problem solved. That fixed
// NAMED VOLUMES only. A bind mount lives wherever the member put it, and there it hits the same denial by
// a different route — reported from a real server as `mkdir: can't create directory '…/18/docker'` on a
// directory that already existed. A named volume passes with this bug present, which is exactly how the
// first fix looked complete.
//
// Three ways to fix it, and this is the third:
//
// - extend the strip to wherever the bind source is → unbounded, the member chooses the path
// - `d:other::--x` on the whole home → traverse for every uid, forever, to fix one local case
// - bless ONE directory → scoped, predictable, and already where members work
//
// The cost is that the file browser cannot read inside it, which is the same trade already accepted for
// Docker's internal storage — consistent rather than a new exception. Not enforced: a member can bind
// mount from anywhere and will hit the denial there. This is the documented place that works.
// `.local` FIRST, explicitly, with the member's ownership. `install -d` creates missing parents but
// applies `-o`/`-g`/`-m` only to the FINAL component, so letting it invent `.local` leaves that directory
// root:root — inside the member's own home, unwritable by them.
//
// This is `71589ae` for the second time. That commit found the identical thing for `~/.config` and wrote
// "a single wrong-owner directory in a home is the kind of thing that surfaces weeks later as one tool
// mysteriously failing". It surfaced in twenty minutes: rootless Docker died on
// `mkdir …/.local/share: permission denied`, and the Claude installer targets `~/.local/bin`, so it was
// blocked by the same directory. Grep before adding another `install -d`/`-D` whose parent is implicit.
const localDir = join(home, '.local');
const madeLocalDir = await run([
'sudo',
'-n',
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
'-m',
'700',
localDir,
]);
if (!madeLocalDir.ok) return { ok: false, error: `could not create ${localDir}: ${madeLocalDir.out}` };
const composeDir = join(home, '.local', 'dockers');
const madeComposeDir = await run([
'sudo',
'-n',
'install',
'-d',
'-o',
String(params.uid),
'-g',
String(params.gid),
// 711, not 700, and this is the whole point of the directory. A container's inner uid is `other`, so it
// needs `x` HERE to reach a bind source inside — 700 blocks the path before any ACL matters. `r` stays
// off, so nothing can list it. Safe because the home above is 700: no other account can traverse this
// far in the first place, and the only uids that get here are the member's own containers.
'-m',
'711',
composeDir,
]);
if (!madeComposeDir.ok) return { ok: false, error: `could not create ${composeDir}: ${madeComposeDir.out}` };
// `-b`, not `-k`: remove ACCESS entries as well as defaults, leaving plain POSIX modes.
//
// `-k` alone left `mask::---` behind — inherited named entries with every permission masked off, reading
// as `user:pastilhas:rwx #effective:---`. An ACL that says one thing and means another is worse than no
// ACL, and container storage is the one place in the home that wants ordinary mode bits and nothing else.
//
// AFTER the recursive grant above, or what it just set is re-inherited here.
const stripped = await run(['sudo', '-n', 'setfacl', '-R', '-b', composeDir]);
if (!stripped.ok) {
return { ok: false, error: `could not clear inherited ACLs from ${composeDir}: ${stripped.out}` };
}
return { ok: true };
} catch (ex) {
return { ok: false, error: ex instanceof Error ? ex.message : String(ex) };
}
}
// ── The owner's home, once somebody else has a shell ──
//
// Ubuntu's default umask is 002 with user-private groups, so everything the owner creates lands 775/664.
// Alone on a machine that is harmless. The moment a member has a login it is not: their homes are NESTED
// inside the owner's (`$OFFICER_ROOT/data/<email>/home`), so the owner's home must stay traversable AND
// readable — the ancestor-read requirement that `bun` exposed — and every dotfile in it is then legible to
// every member by default.
//
// `assertSecretsClosed` already refuses to boot on the fatal ones (`.env`, the secret store). This is the
// tier below: nothing that mints a token, but `~/.pm2/logs` is every log the platform has written,
// `~/.claude/projects` names every directory the owner works in, and `~/.config` is whatever any tool put
// there. Measured on a real machine before this existed — all of it was listable by a member.
//
// Two directories are deliberately left open, and it is not an oversight:
//
// ~/.bun — /usr/local/bin/bun is a SYMLINK into it, so every member's `bun` resolves through the
// owner's home. Closing it breaks the runtime for everyone. Verified: 2.6G of package cache
// and no credential file.
// ~/.local/bin — /usr/local/bin/gh points here the same way. `share/` and `state/` underneath ARE closed;
// only the binary directory is reachable.
//
// Both are really the same latent bug — a system-wide tool installed into one user's home — and the honest
// fix is to install them outside it. Until then, this function has to know about them, so it says so.
const OWNER_HOME_PRIVATE = ['.claude', '.pm2', '.config', '.npm', '.opencode', '.cache', '.tmux'];
const OWNER_HOME_PRIVATE_NESTED = ['.local/share', '.local/state'];
/**
* Close the owner's own dotfiles to the accounts this platform hands shells to.
*
* Called from provisioning rather than from setup, because the directories this covers are created by USE,
* not by install — `~/.claude` does not exist until the agent runs once. A chmod at install time would find
* half of them missing and silently do nothing, which is the failure mode that made this necessary in the
* first place. Running it per provision means every member's arrival re-closes whatever appeared since.
*
* Never throws. A member whose account provisioned fine should not be rolled back because a chmod failed;
* the outcome is reported and the caller records it.
*/
export async function hardenOwnerHome(): Promise<{ ok: boolean; closed: string[]; error?: string }> {
// The one caller of this function that legitimately wants the argument ignored: `getOwnerHomeDir` always
// answers the owner, which here is exactly the question being asked.
const home = getOwnerHomeDir('');
const closed: string[] = [];
try {
for (const name of [...OWNER_HOME_PRIVATE, ...OWNER_HOME_PRIVATE_NESTED]) {
const path = join(home, name);
if (!existsSync(path)) continue;
const info = await stat(path);
// Only touch what is actually open, so a re-run is silent and the list means something.
if (!(info.mode & 0o077)) continue;
const done = await run(['sudo', '-n', 'chmod', '700', path]);
if (done.ok) closed.push(name);
}
return { ok: true, closed };
} catch (ex) {
return { ok: false, closed, 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<string[]> {
// The store is not in projectDir — it sits at $OFFICER_ROOT/secrets/, a sibling of the repo — so it is
// checked separately below. Its keys are strictly worse to leak than .env ever was: the 'jwt' purpose
// mints owner tokens, and every other purpose decrypts a credential column in Postgres.
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.
}
}
// The secret store and its directory. The WAL is included deliberately: a freshly written key lives
// there before checkpoint, so a 0600 database beside a world-readable WAL protects nothing.
const storeFile = secretStorePath();
const storeDir = dirname(storeFile);
for (const path of [storeDir, storeFile, `${storeFile}-wal`, `${storeFile}-shm`]) {
if (!existsSync(path)) continue;
try {
const info = await stat(path);
if (info.mode & 0o044) bad.push(path);
} catch {
// Same reasoning as above.
}
}
return bad;
}
/**
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
*
* 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.
*
* Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
* a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
*/
export async function assertSecretsClosed(projectDir: string): Promise<void> {
const readable = await findReadableSecrets(projectDir);
if (!readable.length) return;
throw new Error(
[
'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'),
);
}