diff --git a/src/servers/api/users/provision-os.ts b/src/servers/api/users/provision-os.ts index b8bed2ad..36a869b1 100644 --- a/src/servers/api/users/provision-os.ts +++ b/src/servers/api/users/provision-os.ts @@ -1,6 +1,6 @@ import type { UserRole } from 'officerdb'; import { updateUser } from 'officerdb'; -import { ensureOsUser, osUserHome } from '@@/os-user'; +import { ensureOsUser, hardenOwnerHome, osUserHome } from '@@/os-user'; import { provisionSshAccess } from '@@/os-user-ssh'; import { seedShellConfig } from '@@/os-user-shell'; import { provisionClaudeCli } from '@@/os-user-claude'; @@ -60,6 +60,16 @@ export async function provisionOsAccount(params: { const account = await ensureOsUser({ email: params.email, username: params.username }); if (!account.ok) return { osUser: null, sshPublicKey: null, error: account.error }; + // The owner's own dotfiles, closed the moment somebody else can open a shell. Here rather than in setup + // because ~/.claude and ~/.pm2 are created by USE, not by install — a chmod at install time finds them + // missing and silently does nothing. Deliberately after `ensureOsUser`: before it there is no account to + // hide anything from, and the log line is more useful when it names one. + const hardened = await hardenOwnerHome(); + if (hardened.closed.length > 0) { + console.log(`[provision] closed the owner's ${hardened.closed.join(', ')} to ${account.osUser}`); + } + if (!hardened.ok) console.warn(`[provision] could not close the owner's home: ${hardened.error}`); + // SSH after the account, because everything it writes lives inside a home that is not ours until // `ensureOsUser` has chowned it away. const ssh = await provisionSshAccess({ diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 8e57bc5d..1d91410a 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -2,7 +2,7 @@ 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, toShellUsername } from './data-path'; +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. // @@ -493,6 +493,64 @@ export async function confineUserTree(params: { } } +// ── 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//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. *