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:
@@ -81,6 +81,47 @@ prerequisite that gets skipped.
|
||||
The same applies to `permissions/` (775 today) and to the repo checkout itself: a member can read the
|
||||
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
|
||||
|
||||
### The one this list missed: the Anthropic proxy secret
|
||||
|
||||
Found 2026-08-17, while verifying the claim that members do not share the owner's Claude Max subscription.
|
||||
**They do not** — `spawnClaudeAsMember` runs their `claude` through `setpriv --reset-env` with a six-name
|
||||
allowlist, so neither `ANTHROPIC_BASE_URL` nor `ANTHROPIC_API_KEY` can cross into it and their turns go
|
||||
straight to Anthropic on their own OAuth. That part holds up.
|
||||
|
||||
But the isolation could be walked around:
|
||||
|
||||
| path | mode | consequence |
|
||||
| -------------------------------- | ------- | ------------------------------------- |
|
||||
| `data/sidecar/` | 775 | listable by anyone |
|
||||
| `data/sidecar/claude-state.json` | **664** | **world-readable — holds the secret** |
|
||||
|
||||
`officer-anthropic-proxy` listens on `127.0.0.1`, accepts that string as `x-api-key`, swaps it for the
|
||||
owner's Max OAuth bearer and forwards to `api.anthropic.com`. Every local account can reach loopback, so
|
||||
the file IS the subscription. Confirmed by reading it as `nobody` — an account with no relationship to the
|
||||
platform at all — and by observing the proxy 401 without the key, so the secret is the only control.
|
||||
|
||||
Nothing had ever set a mode: `mkdirSync`/`Bun.write` at Ubuntu's default umask of 002. The same umask
|
||||
reasoning was already written down two sections above for `.env` and in `os-user.ts` for the owner's home;
|
||||
this file was simply on nobody's list.
|
||||
|
||||
Fixed in two places, deliberately:
|
||||
|
||||
- **`state.ts` sets 0600/0700 on every write** — the authoritative fix, at the point that creates the file.
|
||||
On every write rather than at creation, because `writeFileSync` leaves an existing file's mode alone.
|
||||
- **`secureRuntimeSecrets()` sweeps at every boot** (`server.tsx`) — because the proxy is a separate PM2
|
||||
process that can recreate its file long after Officer started, and because every existing install has the
|
||||
old mode until something repairs it.
|
||||
|
||||
It **repairs** rather than refusing to boot, which is the opposite of `assertSecretsClosed` one section
|
||||
above. The line is who created the file: `.env` is the installer's and Officer must not guess at it, while
|
||||
this is Officer's own output at the machine's umask — a wrong mode there is Officer's bug, and bricking the
|
||||
platform over it would punish the user for our mistake.
|
||||
|
||||
**A chmod does not undo exposure.** Any account that read the secret while it was open still holds it, and
|
||||
it does not expire. Rotating it means clearing `proxySecret` and restarting `officer-anthropic-proxy` —
|
||||
and then `officer-claude-code`, which caches the value in `process.env.ANTHROPIC_API_KEY` and will 401 on
|
||||
every turn until it re-reads. Restart both or neither.
|
||||
|
||||
## The mechanism, and the trap in it
|
||||
|
||||
### `Bun.spawn` silently ignores `uid` and `gid`
|
||||
|
||||
Reference in New Issue
Block a user