`context: 'command-hq'` on a chat websocket turn now resolves to
getCommandersDir(email) regardless of any client cwd, and the row gets
written the moment a real transcript id is known — the same
session:claude/result seed chat_session_models already waits on, a sibling
closure (fileAsCommander) hung off the same two triggers.
found while wiring it, not guessed at: a member's commander turn would have
failed to spawn at all. getCommandersDir is a sibling of home, and
confineUserTree confines every sibling of home to 700 owned by the service
user — unreachable "even by name" — which is exactly wrong for a directory a
member's own turn needs to cd into via setpriv. same shape of bug
resolveChatCwd already tells a story about (general_chat_sessions,
abandoned for the same reason). fixed by chowning commanders/ to the member
the same way confineUserTree already chowns home, and excluding it from the
loop that confines every other sibling. no ACL needed, unlike home — command
hq reads a member's transcripts through listTranscriptsAs, the same
as-the-member read /chat already uses, never as the service user directly.
the gap this leaves: an account provisioned before this commit needs one
manual reprovision (provision-linux-route.ts / manage-users.ts, already
idempotent) before its first commander turn works. documented, not silently
left.
pulled COMMS/command-hq/{mobile,watchers-what-broke}.md from the mobile
side's session — apps/commandhq already exists and typechecks against the
router. their finding: mobile cannot create a commander the way the web
composer does (a context flag two independent clients would have to carry
correctly), and decided with the owner on POST /command-hq/commanders/new
instead. not built server-side yet — the real blocker is that claude does
not assign a transcript id until a turn actually runs, so the endpoint has
to run one and wait for session:claude, which means extracting the
turn-starting half of handleClaudeChat rather than a second copy of it.
replied in COMMS/command-hq/platform.md with the two open questions before
building it (prompt omitted, and wanting a second look at that refactor).
docs/command-hq.md updated throughout to say plainly what's built vs planned
per path (web vs mobile) rather than one blended status.
bunx tsgo clean. suite at baseline (same 7 pre-existing failures).
confineUserTree has no unit tests today (sudo/uid-dependent, matching this
file's existing coverage posture) — not inventing a new harness for one
block inside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
729 lines
38 KiB
TypeScript
729 lines
38 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, getCommandersDir, 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, confined 700 to the service user — EXCEPT `commanders`,
|
|
// which gets the same member ownership as `home` itself, just below. Both are excluded from this loop
|
|
// for the same reason: this pass runs unconditionally on every provision, and re-confining a directory
|
|
// a member is meant to reach would silently undo the block that grants it.
|
|
const entries = await readdir(accountDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (entry.name === 'home' || entry.name === 'commanders') 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.`,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Commanders: a second directory the member must be able to reach directly ──
|
|
//
|
|
// Command HQ (docs/command-hq.md) gives every account ONE shared directory for the long-running jobs
|
|
// ("commanders") they start there — `getCommandersDir`, a sibling of `home` rather than something
|
|
// inside it, on purpose: keeping it out of the owner's own home is the whole reason it exists as its
|
|
// own directory rather than reusing `home`. But every OTHER sibling of `home` is confined 700 to the
|
|
// service user, unreachable "even by name" — and a member's commander turn runs as the MEMBER'S own
|
|
// Linux account via `setpriv`, so a sibling with that shape is exactly the `general_chat_sessions`
|
|
// mistake `resolveChatCwd` already tells this story about: a working directory the turn cannot even
|
|
// `cd` into, surfacing as an agent that fails on its own first command.
|
|
//
|
|
// No ACL here, unlike `home` above. `home` needs one because the file browser reads a member's home
|
|
// FROM INSIDE the platform process, as the service user. Command HQ has no equivalent: it reads a
|
|
// member's commander transcripts the same way `/chat` already reads any member's transcripts — through
|
|
// `listTranscriptsAs`, which runs the read AS the member, never as the service user directly. So plain
|
|
// ownership is the whole requirement, the same as `~/.claude/projects` itself.
|
|
const commandersDir = getCommandersDir(params.email);
|
|
if (!existsSync(commandersDir)) await mkdir(commandersDir, { recursive: true });
|
|
const giveCommanders = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, commandersDir]);
|
|
if (!giveCommanders.ok) return { ok: false, error: `chown of ${commandersDir} failed: ${giveCommanders.out}` };
|
|
const closeCommanders = await run(['sudo', '-n', 'chmod', '700', commandersDir]);
|
|
if (!closeCommanders.ok) return { ok: false, error: `chmod of ${commandersDir} failed: ${closeCommanders.out}` };
|
|
|
|
// ── 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 permission 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;
|
|
}
|
|
|
|
/**
|
|
* Close the secrets the PLATFORM ITSELF writes, on every boot.
|
|
*
|
|
* ── Why this repairs where `assertSecretsClosed` refuses ──
|
|
*
|
|
* The two functions cover the same hazard and take opposite actions, and the line between them is who
|
|
* created the file. `.env` and the secret store are the INSTALLER's: Officer cannot know what mode they
|
|
* were meant to have or whether it may touch them, so it names them and refuses to start. Everything here
|
|
* is Officer's own output, written by its own sidecars at whatever umask the machine happens to have — so
|
|
* a wrong mode is Officer's bug, and refusing to boot over it would be punishing the user for our mistake.
|
|
* Fixing it silently and saying so in the log is the honest response.
|
|
*
|
|
* ── What this is protecting ──
|
|
*
|
|
* `sidecar/claude-state.json` holds `proxySecret`, which `officer-anthropic-proxy` accepts as `x-api-key`
|
|
* and exchanges for the owner's Claude Max OAuth bearer. The proxy is on 127.0.0.1, which every local
|
|
* account can reach, so that string IS the owner's subscription. Found world-readable on 2026-08-17 and
|
|
* confirmed by reading it as `nobody` — every per-member protection in `spawn-as-member.ts` was intact and
|
|
* simply walked around.
|
|
*
|
|
* The per-user copies (`<email>/sidecar/`) carry no live secret today but map session keys to transcript
|
|
* uuids for named accounts, which is not something one member should be able to enumerate about another.
|
|
* They are covered by the same sweep rather than a second one, because "platform state, service user only"
|
|
* is one rule.
|
|
*
|
|
* ── Why it repeats on every boot ──
|
|
*
|
|
* `state.ts` now sets these modes at every write, which is the authoritative fix. This exists anyway for
|
|
* two reasons: the proxy is a separate PM2 process that can recreate its file long after Officer booted,
|
|
* and every install that already exists has the old mode right now. A sweep is cheap and idempotent; the
|
|
* alternative is a fix that only reaches machines installed after it.
|
|
*
|
|
* Never throws — a chmod that fails is reported and the platform starts. The one thing worse than a
|
|
* world-readable secret is a platform that will not boot and cannot be used to fix it.
|
|
*/
|
|
export async function secureRuntimeSecrets(dataPath: string = DATA_PATH): Promise<{
|
|
repaired: string[];
|
|
failed: string[];
|
|
}> {
|
|
const repaired: string[] = [];
|
|
const failed: string[] = [];
|
|
|
|
// Every `sidecar/` under DATA_PATH: the proxy's own, plus one per account that has run a turn.
|
|
const dirs = [join(dataPath, 'sidecar')];
|
|
try {
|
|
for (const entry of await readdir(dataPath, { withFileTypes: true })) {
|
|
if (entry.isDirectory() && entry.name.includes('@')) dirs.push(join(dataPath, entry.name, 'sidecar'));
|
|
}
|
|
} catch {
|
|
// No DATA_PATH yet — a first boot. Nothing written means nothing exposed.
|
|
}
|
|
|
|
const targets: { path: string; mode: number }[] = [];
|
|
for (const dir of dirs) {
|
|
targets.push({ path: dir, mode: 0o700 });
|
|
targets.push({ path: join(dir, 'claude-state.json'), mode: 0o600 });
|
|
}
|
|
|
|
for (const { path, mode } of targets) {
|
|
if (!existsSync(path)) continue;
|
|
try {
|
|
const info = await stat(path);
|
|
// Only touch what is actually open, so a repeat boot logs nothing and the list stays meaningful.
|
|
if (!(info.mode & 0o077)) continue;
|
|
await chmod(path, mode);
|
|
repaired.push(path);
|
|
} catch {
|
|
failed.push(path);
|
|
}
|
|
}
|
|
|
|
return { repaired, failed };
|
|
}
|
|
|
|
/**
|
|
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
|
|
*
|
|
* Same posture as `assertPermissionTotality`, 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 permission 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 permission check. Fix with:',
|
|
'',
|
|
...readable.map((p) => ` chmod 600 ${p}`),
|
|
'',
|
|
'Then restart. See docs/per-user-linux-accounts.md → "Hard prerequisite".',
|
|
].join('\n'),
|
|
);
|
|
}
|