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
+11 -1
View File
@@ -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({