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>
This commit is contained in:
2026-08-14 21:59:45 +00:00
co-authored by Claude Opus 5
parent a220342b22
commit 4d4606d4a2
2 changed files with 70 additions and 2 deletions
+59 -1
View File
@@ -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/<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.
*