Layer: boundary. Proof: make prove LEVEL=boundary.
The API key exists in exactly one place, the api/ process. The browser bundle, the git history, the Docker image and Claude's shell never hold it.
A quick fix renames the key to PUBLIC_ANTHROPIC_API_KEY so a browser component can call Claude directly. After npm run build, grep -r sk-ant web/dist finds the key in shipped JavaScript. Separately, asked to "debug the env", Claude runs cat on the secrets file, because a Read deny does not cover Bash.
Do the rename on a scratch branch, build, and grep. The key is public. Then deny Read on the secrets file in .claude/settings.json and ask Claude to print it. The cat still works. That is the trap.
web/is static. It readsPUBLIC_API_URLand nothing else, and has no server adapter.api/src/env.tsparses the environment with zod at boot, fails fast, and never echoes a value.api/src/server.tsenforces a CORS allowlist, a 16 KB body cap, a rate limit, a daily budget and generic error bodies.api/Dockerfilehas no secretARGorENV. The key arrives at runtime..claude/settings.jsondenies Read and Edit on the secrets file andgit push, and turns the sandbox on withdenyRead..claude/hooks/pretooluse-secrets.mjsblocks a Bash command that names the secrets file. Exit 2 stops the call..gitleaks.tomlplusmake hooksscans every commit.
Repeat the "debug the env" prompt. The hook blocks the command and tells Claude to load the file inside a script. Repeat the build and grep. Nothing is found.
- Type
make hooks. Expect the pre-commit hook installed. - Type
make smokeafter putting a key inapi/'s local env file. Expect one clarifying question, then a worksheet. - Open the browser network tab. Expect calls to
/chatonly, never to api.anthropic.com.
make prove LEVEL=boundary greps the built bundle for key shapes, checks that web/ reads only PUBLIC_ variables, checks the Dockerfile, checks the 500 body has no model name or stack, and runs the hook against known bad commands.
Track A: add a second secret to api/src/env.ts and prove the bundle stays clean. Track B: write a hook case for a command that reads the file through base64.
- Settings merge from user, project, local and managed scopes, and a deny rule in any scope wins.
.claude/settings.jsonis the project scope and is committed. Put personal changes in.claude/settings.local.json, which stays out of git. - Start the same risky ask in
default,planandacceptEdits(claude --permission-mode plan) and note which tool calls each mode allowed without asking. The factory picks one per stage in.factory/config.json:planfor reading stages,acceptEditsfor build. - A dev container keeps a mistake off your laptop. Open the repo in one and the sandbox plus the deny rules are a second wall around the same secret.
Besides the secrets guard, five hooks each show one pattern: a prompt router that adds a hint, a session-start hash of the settings, an output scan for leaked keys, a warn-only length check and a fail-safe formatter. hook-catalogue.test.ts feeds each one bad input. docs/managed-settings.example.json shows the deny list an organisation would enforce above every repo.
Where does a secret live in your stack today, and which processes can read it?
