close the anthropic proxy secret, and re-close it on every boot

data/sidecar/claude-state.json holds proxySecret, and that string IS the
owner's Claude Max subscription: officer-anthropic-proxy accepts it as
x-api-key, swaps it for the owner's OAuth bearer and forwards to
api.anthropic.com. it listens on 127.0.0.1, which every local account can
reach.

it was 664 in a 775 directory. nothing had ever set a mode - mkdirSync and
Bun.write at ubuntu's default umask of 002. confirmed by reading the live
secret as 'nobody', an account with no relationship to the platform, and by
confirming the proxy 401s without the key so the secret is the only control.

this does NOT mean members shared the subscription. spawnClaudeAsMember runs
their claude through setpriv --reset-env with a six-name allowlist, so
ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY cannot cross into it and their turns
go straight to anthropic on their own OAuth. that design is intact - it could
just be walked around by anyone with a shell.

fixed twice on purpose. state.ts sets 0600/0700 on every write, which is the
authoritative fix, and on every write rather than at creation because
writeFileSync leaves an existing file's mode alone. secureRuntimeSecrets()
sweeps at boot because officer-anthropic-proxy is a separate pm2 process that
can recreate the file hours after officer started, and because every existing
install has the old mode until something repairs it.

it repairs rather than refusing to boot, unlike assertSecretsClosed beside it.
the line is who wrote the file: .env is the installer's and we must not guess
at it; this is our own output at the machine's umask, so refusing to start
would punish the user for our bug.

the per-account copies are swept too. no live secret in them today, but they
map session keys to transcript uuids for named accounts.

tests verified to bite: removing the chmod while still reporting success fails
four of them.

a chmod does not undo exposure - the secret still needs rotating on the
production host. TODO.md has the sequence, including that officer-claude-code
caches it and must be restarted alongside the proxy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 22:27:09 +00:00
co-authored by Claude Opus 5
parent 63f866750b
commit 56ddb6bebf
6 changed files with 280 additions and 5 deletions
+74
View File
@@ -596,6 +596,80 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
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.
*