a bot is a chat whose working directory is bots/, armed through the background job any chat can already arm. no scheduler was written and none was needed: handoff already means "poll cheaply, wake the agent only when it fires", which is the expensive half of a commander without any of its ceremony. ONE directory, not one per bot. a commander earns its own because it is a job with procedures and a ledger; a bot is a sentence and a watch, and a directory each would mean a creation step before anything can be said. sharing it makes the list free — every bot is a chat in that cwd, so the header's picker is the list of bots. it earns NO permission of its own. /bots rides `chat`, because a bot adds no surface: same routes, same socket, same confinement. the registry says so rather than leaving the next reader to wonder. the brief is the feature. it tells the agent to arm on the FIRST message and not interview — pick a sensible interval, arm, say what you armed in one line, and let the owner correct it, which is cheaper for them than three questions. it says the owner will never say "notify" or "nudge" and must not have to. it forbids the loop that wakes on a timer to look for itself, which is the expensive mistake available here. and it draws the line: a bot watches and tells, it does not fix — a request for work is a commander, say so and stop. bots joins commanders and plugins in confineUserTree, third time: a member's bot runs as the member and a 700 service-user directory kills the turn on its own cwd. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
808 lines
42 KiB
TypeScript
808 lines
42 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,
|
|
getPluginsDir,
|
|
getBotsDir,
|
|
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`
|
|
// and `plugins`, which get the same member ownership as `home` itself, just below. All 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.
|
|
//
|
|
// `plugins` holds each plugin's chat working directories (`getPluginChatsDir`). A member's chat runs
|
|
// THERE, so leaving it 700-service-user repeats the `general_chat_sessions` failure exactly: the turn
|
|
// dies on its own cwd before running anything, and it reads as the agent being broken.
|
|
const entries = await readdir(accountDir, { withFileTypes: true });
|
|
for (const entry of entries) {
|
|
if (['home', 'commanders', 'plugins', 'bots'].includes(entry.name)) 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}` };
|
|
|
|
// ── And the same for `plugins`, for the same reason ──
|
|
//
|
|
// A plugin's chats run in `$DATA_PATH/<email>/plugins/<appName>/chats` (`getPluginChatsDir`), which is
|
|
// a sibling of `home` exactly as `commanders` is, and a member's turn there runs as the MEMBER. So it
|
|
// needs their ownership or it is the `general_chat_sessions` failure a third time — a working directory
|
|
// the turn cannot `cd` into, presenting as an agent broken on its first command.
|
|
//
|
|
// Recursive, because plugins create their own subdirectories under here and a directory made by the
|
|
// platform process would otherwise belong to the service user inside a tree the member owns.
|
|
const pluginsDir = getPluginsDir(params.email);
|
|
if (!existsSync(pluginsDir)) await mkdir(pluginsDir, { recursive: true });
|
|
const givePlugins = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, pluginsDir]);
|
|
if (!givePlugins.ok) return { ok: false, error: `chown of ${pluginsDir} failed: ${givePlugins.out}` };
|
|
const closePlugins = await run(['sudo', '-n', 'chmod', '700', pluginsDir]);
|
|
if (!closePlugins.ok) return { ok: false, error: `chmod of ${pluginsDir} failed: ${closePlugins.out}` };
|
|
|
|
// And `bots`, for the same reason a third time: a member's bot runs as the member, in this directory.
|
|
const botsDir = getBotsDir(params.email);
|
|
if (!existsSync(botsDir)) await mkdir(botsDir, { recursive: true });
|
|
const giveBots = await run(['sudo', '-n', 'chown', '-R', `${params.uid}:${params.gid}`, botsDir]);
|
|
if (!giveBots.ok) return { ok: false, error: `chown of ${botsDir} failed: ${giveBots.out}` };
|
|
const closeBots = await run(['sudo', '-n', 'chmod', '700', botsDir]);
|
|
if (!closeBots.ok) return { ok: false, error: `chmod of ${botsDir} failed: ${closeBots.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'];
|
|
|
|
/**
|
|
* The exact command, split out so a test can assert it without root or an acl-capable filesystem.
|
|
*
|
|
* `-R` because the same clamp happens to every subdirectory created and closed after the original grant,
|
|
* not just to the home itself. Only the mask — the named entries were never the problem.
|
|
*/
|
|
export const maskArgvFor = (home: string): string[] => [
|
|
'sudo',
|
|
'-n',
|
|
'setfacl',
|
|
'-R',
|
|
'-m',
|
|
'm::rwx',
|
|
'-m',
|
|
'd:m::rwx',
|
|
home,
|
|
];
|
|
|
|
/**
|
|
* Re-assert the ACL mask across a member's home. **Must run after every other provisioning step.**
|
|
*
|
|
* ── The failure this exists for ──
|
|
*
|
|
* A POSIX ACL mask is not independent of the mode bits: `chmod` recomputes it from the GROUP bits, and a
|
|
* directory chmod'd to 700 gets `mask::---`, which clamps every named entry to nothing. The platform's
|
|
* grant then reads `user:pastilhas:rwx #effective:---` — present, and meaningless.
|
|
*
|
|
* `ensureOsUser` already knows this and orders itself correctly (chmod, then setfacl). What it cannot
|
|
* account for is the steps that come AFTER it, each of which creates a directory and then closes it:
|
|
* `.ssh` at 700 from the key setup, `.config` and `.local` from the claude installer. Each one inherits
|
|
* the default ACL at creation and then clamps its own mask a moment later.
|
|
*
|
|
* Observed on a fresh server, 2026-08-19: an Admin holding `files`, `chat` and `terminal` reached none of
|
|
* them. The file browser's `readdir` raised EACCES on a home whose ACL said it could read it, and the
|
|
* grant looked correct in every place anyone thought to check.
|
|
*
|
|
* Only the mask is touched. The named entries are already right — they were never the problem.
|
|
*/
|
|
export async function reassertHomeMask(home: string): Promise<{ ok: boolean; error?: string }> {
|
|
const done = await run(maskArgvFor(home));
|
|
return done.ok ? { ok: true } : { ok: false, error: `could not re-assert ACL mask on ${home}: ${done.out}` };
|
|
}
|
|
|
|
/**
|
|
* 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'),
|
|
);
|
|
}
|