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:
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { chmod, mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { chmod, mkdir, mkdtemp, stat, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv } from './os-user';
|
||||
import { findReadableSecrets, osUserNameFor, runAsArgv, secureRuntimeSecrets } from './os-user';
|
||||
|
||||
// The tests that matter here are the two that prove the MECHANISM rather than the plumbing: that Bun
|
||||
// ignores `uid`, and that `setpriv` does not. Everything else in os-user.ts touches the passwd database
|
||||
@@ -167,3 +167,79 @@ describe('findReadableSecrets', () => {
|
||||
expect(await findReadableSecrets(dir)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
The proxy secret IS the owner's Claude Max subscription.
|
||||
|
||||
`officer-anthropic-proxy` swaps a matching `x-api-key` for the owner's OAuth bearer and forwards to
|
||||
Anthropic, on a loopback port every local account can reach. So these assertions are not about tidy file
|
||||
modes — they are the difference between a member's turns being billed to their own account (which
|
||||
`spawn-as-member.ts` goes to considerable lengths to ensure) and a member being able to help themselves
|
||||
to the owner's.
|
||||
|
||||
It shipped world-readable: default umask 002, no mode ever set, verified by reading the live secret as
|
||||
`nobody` on 2026-08-17.
|
||||
*/
|
||||
describe('secureRuntimeSecrets', () => {
|
||||
async function fixture(mode: number) {
|
||||
const root = await mkdtemp(join(tmpdir(), 'officer-data-'));
|
||||
const dir = join(root, 'sidecar');
|
||||
await mkdir(dir, { recursive: true });
|
||||
const file = join(dir, 'claude-state.json');
|
||||
await writeFile(file, JSON.stringify({ proxySecret: 'sk-ant-api03-test', claudeSessions: {} }));
|
||||
await chmod(file, mode);
|
||||
await chmod(dir, 0o755);
|
||||
return { root, dir, file };
|
||||
}
|
||||
|
||||
test('closes a world-readable proxy secret and its directory', async () => {
|
||||
const { root, dir, file } = await fixture(0o664);
|
||||
|
||||
const result = await secureRuntimeSecrets(root);
|
||||
|
||||
expect(result.failed).toEqual([]);
|
||||
expect(result.repaired).toContain(file);
|
||||
expect(result.repaired).toContain(dir);
|
||||
// The mode is the assertion, not the return value: a function that reports success while leaving the
|
||||
// file readable is the exact failure this exists to prevent.
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
expect((await stat(dir)).mode & 0o777).toBe(0o700);
|
||||
});
|
||||
|
||||
test('group-readable counts too — a member’s supplementary groups are not ours to predict', async () => {
|
||||
const { root, file } = await fixture(0o640);
|
||||
const result = await secureRuntimeSecrets(root);
|
||||
expect(result.repaired).toContain(file);
|
||||
expect((await stat(file)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('is idempotent: a second boot repairs nothing and reports nothing', async () => {
|
||||
const { root } = await fixture(0o664);
|
||||
await secureRuntimeSecrets(root);
|
||||
|
||||
// Not merely "no error" — an empty list, so the log on every subsequent restart stays silent and the
|
||||
// warning keeps meaning something when it does appear.
|
||||
expect(await secureRuntimeSecrets(root)).toEqual({ repaired: [], failed: [] });
|
||||
});
|
||||
|
||||
test('covers the per-account copies, not just the proxy’s own', async () => {
|
||||
const { root } = await fixture(0o600);
|
||||
const memberDir = join(root, 'someone@example.com', 'sidecar');
|
||||
await mkdir(memberDir, { recursive: true });
|
||||
const memberFile = join(memberDir, 'claude-state.json');
|
||||
await writeFile(memberFile, '{}');
|
||||
await chmod(memberFile, 0o664);
|
||||
|
||||
const result = await secureRuntimeSecrets(root);
|
||||
|
||||
// These hold no live secret today, but they map session keys to transcript uuids for a NAMED account,
|
||||
// which is not something one member should be able to enumerate about another.
|
||||
expect(result.repaired).toContain(memberFile);
|
||||
expect((await stat(memberFile)).mode & 0o777).toBe(0o600);
|
||||
});
|
||||
|
||||
test('an install with nothing written yet is not an error', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'officer-data-'));
|
||||
expect(await secureRuntimeSecrets(root)).toEqual({ repaired: [], failed: [] });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user