Skip to main content
Status: partly live. tools/scripts/mcp.sh and the guard-bash rules exist. Not built yet: secrets-doctor, a no-.env validator check, the CI OIDC identities and any secret() declaration (there is no Encore app).
This page names keys and steps. It never contains a value, and neither may a file, a test, a fixture, a log, a comment, a commit, a chat message or an agent prompt. The law is Constitution: secrets and the decision is ADR-0009.

The two kinds

There is no third kind. No .env, no .env.example, no CI-provider repository secret, no value in infrastructure state, a manifest, a record, a log, an artifact or a cache.
A Cloudflare Worker secret is an application secret too: same rule, the store is the Worker’s secret bindings. An EAS secret environment variable (licence tokens, store credentials) is also set by a human, with eas env:create --visibility secret.

Rules for everyone, agents included

  • Adding an application secret is an agent’s job; setting it is not. Declare secret("NAME") in the service, then name the key and its purpose in your report.
  • Setting, rotating or deleting a value is a human’s job, or the secret-manager agent’s through the secrets inbox, which writes a value without anyone seeing it (ADR-0257).
  • Adding a developer token: a human adds it to Infisical /agents. Reference it in .mcp.json as a bare $KEY, not ${KEY}: Claude Code expands the braced form itself and silently disables the server when the variable is unset. Say so in your report.
  • Never read a value. .agents/hooks/guard-bash.mjs denies infisical export, infisical secrets get without --silent, encore secret set/get, wrangler secret put/bulk, printenv, a bare env, and reading .env or .dev.vars.
  • Tests and the CI verify stage need zero secrets. A test that needs a real credential is a design finding. Use provider fakes (see Testing).
  • The image never bakes a value. Runtime secrets come from the environment the platform injects.
  • Local overrides for a service go in a gitignored file (target name: services/<name>/.secrets.local.cue once a service uses Encore).

The secrets inbox

Status: live for Infisical (pnpm secrets:import). Runtime-store (Encore) secrets join the same flow once a tool for that store exists.
The owner never pastes a value into a chat, and no agent ever reads one. Instead:
1

The owner drops a file

One file per secret in ~/Secrets-inbox/ (outside every repository, never synced or backed up to a shared place). The file name is the secret name: SCREAMING_SNAKE, one extension stripped, so STACKRA_REVIEWER_PRIVATE_KEY.pem becomes STACKRA_REVIEWER_PRIVATE_KEY. A short value such as an App ID is a file holding just that value; one trailing newline is stripped. Files must be regular (no symlinks, no folders) and at most 64 KB.
2

Optionally, a target

<NAME>.target.yaml next to it with env: (dev, staging, prod; default dev) and path: (/agents or /ci, or a sub-folder; default /agents). Flags --env, --path and --name override it.
3

The agent dry-runs

pnpm secrets:import --dry-run checks the name, target and file, reaches Infisical, and prints per secret NAME env:path ok would-overwrite: yes|no.
4

Production asks first

For prod the dry run also prints a one-time --confirm <token>, valid 30 minutes and bound to the name, env, path and file content. The agent shows the owner the dry-run line and asks yes or no; only on yes does it re-run with the token. The token is spent before the write starts, so it never works twice. It is a process rule, not an enforced yes: the agent that sees it could use it. Where the owner’s yes must be enforced, use the request page (STA-187); there the owner’s own submit is the yes.
5

The import

pnpm secrets:import --actor secret-manager checks the CLI supports NAME=@file, then writes each value with infisical secrets set NAME=@<0600 temp file> (never on argv; the temp file is a dot-folder inside the inbox, removed at once, on Ctrl-C, or at the next start), captures all CLI output without echoing it, reads the value back and compares SHA-256 hashes in-process, then deletes the inbox file and its sidecar. On any failure the file stays and the line says why, in a fixed, value-free phrase (not logged in to Infisical, read-back hash does not match, …).
Every run appends a JSON line to ~/.stackra/secrets-audit.log (outside the repo): timestamp, actor, name, env, path, action, result. Names only. The production tokens are HMACs under a per-machine key in ~/.stackra/secrets-confirm.key, so a token says nothing about the value. Why the script is the boundary: a hook cannot reliably tell which agent is calling, so any caller may run pnpm secrets:import, and the script is written so that no caller can get a value out of it. The hooks are an accident guard, not a security boundary: guard-bash refuses commands that name Secrets-inbox or the confirm key (also by a glob like Secrets-inbo?) or recursively read the home directory; guard-paths refuses Read, Grep (path and glob), Glob, Write and Edit that reach the inbox; the OpenCode guard plugin mirrors the literal and glob checks. A process that builds the path at runtime gets past them; agents are told never to try. A future local request page (the owner types a value into a form on this machine) calls the same core, importSecret({ name, env, path, valueStream, actor }), which returns only { name, ok, reason }.

Infisical layout

Names are SCREAMING_SNAKE as <VENDOR>_<PURPOSE> (CONTEXT7_API_KEY, HEROUI_PERSONAL_TOKEN, EXPO_TOKEN). Never a customer name, never a value-shaped string. .infisical.yaml (tracked; it holds no secret) holds the project id and domain the CLI needs.

How mcp.sh works

.mcp.json passes a secret as a bare $VAR argument, for example "x-heroui-personal-token:$HEROUI_PERSONAL_TOKEN". tools/scripts/mcp.sh:
  1. Checks the infisical CLI and .infisical.yaml exist and that /agents is reachable.
  2. Runs infisical run --path=/agents --silent -- …, expands each $VAR inside that subprocess and execs the server. The value exists only in that process tree.
  3. If the vault is unreachable it fails only when an argument references a variable, and the message names the server and the variable. Servers that need no token launch unchanged.
There is no .env fallback on purpose: a second source is a second place a token goes stale, and the failure it causes is the quiet kind, a server that launches with an empty environment and fails somewhere else. To see which names exist, run node tools/scripts/secrets-doctor.mjs (target: it reports names only, never values; not built yet).

CI reads /ci through OIDC, one step at a time

Status: partly live. CI workflows exist (.github/workflows/); the release job reads the registry token from /ci over OIDC once its identity exists (Release). No other job logs in yet. The shape below is what CI and Deploys assume.
  • Every pipeline job that needs a token logs in to Infisical with the job’s OIDC token, then reads /ci for that one step. No token is stored as a CI variable.
  • One machine identity per environment, each trusting only its own OIDC subject (repository plus environment). production additionally requires main.
  • The token exists as the process environment of that step. It is never written to the job’s env file, a file, an artifact or a cache, and never echoed, not even its length.
  • The verify stage holds no identity at all. Only deploy, promote and release jobs log in.
  • CI never writes a secret value to any store.
What CI may never run (denied locally by guard-bash, to be grepped out of workflow sources by a CI check):
  • infisical export, infisical secrets get without --silent, infisical secrets set
  • printenv, env without a filter, set in a shell step, cat of any .env*
  • encore secret get/set, wrangler secret put/bulk
  • any “print the token length” debug step
  • anything from infisical run stored in the job’s env file, outputs or an artifact

How a human sets an application secret

Use the vendor’s UI where there is one, so the value never sits in shell history. Record the name, environment, date and your handle in the owning member’s AGENTS.md, never the value.

Rotation

Overlap, never revoke-first:
1

Create the new value at the vendor

A second token, key or client secret, with the same minimum scope.
2

Set it where it lives

A human writes it to Infisical /ci or /agents, or to the runtime’s store for an application secret.
3

Let the consumer pick it up

A no-op pipeline run, a redeploy or a rollout. A running service does not see a new value on its own.
4

Prove it works

One real call that uses the new value (whoami, a read-only API call, one test email). Confirm by metadata, never by printing the value.
5

Revoke the old value at the vendor

Only now. Where a vendor cannot hold two values, declare the single-value window before you start and keep it as short as the roll allows.
Record each roll in the member’s AGENTS.md: the name, environment, date, handle, whether the window overlapped, and the call that confirmed it.

A value was exposed

1

Revoke at the vendor first

Not in Infisical first: the leaked value stays valid until the vendor revokes it. Rewriting git history does not un-leak it.
2

Rotate

Follow the rotation steps above. For an application secret, a human re-sets it.
3

Purge

Delete the CI run logs, artifacts and any cache key that held it.
4

Record and close the gap

Note it in the member’s AGENTS.md and open a finding against the step that leaked it. Add the pattern to guard-bash and .gitleaks.toml.

Checks that back this page

See also CI, Mobile release and Dependencies.