> ## Documentation Index
> Fetch the complete documentation index at: https://docs.stackra.figentra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Secrets

> Two kinds of secret, two mechanisms: who sets a value, where it lives and how code or CI reads it.

> [!NOTE]
>
> 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](/constitution/secrets) and the decision is [ADR-0009](/adr/0009-secrets-two-mechanisms).

## The two kinds

| Kind | Where the value lives | Who sets it | How it is read |
| - | - | - | - |
| **Application secret**: Stripe keys, OAuth client secrets, webhook signing secrets, provider API keys the running app uses | The runtime's own secret store, per environment (Encore's secret manager once a service is on Encore) | A human, in that store's own tool | `secret("NAME")` from `encore.dev/config`, called as `key()` |
| **Developer token**: an MCP server token (HeroUI, Context7, GitHub PAT, a Stripe *restricted test* key) | The Infisical vault at `/agents`, nowhere else | The developer, in Infisical | `tools/scripts/mcp.sh` fetches it for the server process; agents never read it |

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.

> [!NOTE]
>
> 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](#the-secrets-inbox), which writes a value without anyone seeing it ([ADR-0257](/adr/0257-secret-manager-agent-and-the-secrets-inbox)).
* **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](/standards/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

> [!NOTE]
>
> 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.

| Command | Prints |
| - | - |
| `pnpm secrets:import --list` | inbox file names |
| `pnpm secrets:import --dry-run [NAME…]` | target, would-overwrite, and for prod the confirm token |
| `pnpm secrets:import [NAME…] [--confirm <token>]` | imported / unchanged / overwritten, or refused + reason |

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

```
Infisical organisation
├── project stackra
│   ├── /agents          developer and MCP tokens          env: dev (read-only machine identity)
│   └── /ci              every token a pipeline fetches    env: dev | staging | prod (OIDC only)
```

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 `exec`s 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

> [!NOTE]
>
> Status: partly live. CI workflows exist (`.github/workflows/`); the release job reads the registry
> token from `/ci` over OIDC once its identity exists ([Release](/runbooks/release)). No other job
> logs in yet. The shape below is what [CI](/runbooks/ci) and [Deploys](/runbooks/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

| Target | Command (human, with the vendor CLI logged in) |
| - | - |
| Encore (the generated app, once deployed) | `encore secret set --type prod,dev NAME`. It prompts; the value is never an argument. |
| Cloudflare Worker | `wrangler secret put NAME --env <env>`; for a bulk file, delete the file straight afterwards. |
| EAS | `eas env:create --environment production --visibility secret --name NAME` |
| Developer token | The Infisical UI, `/agents`. |

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.

| Token | Where | Cadence |
| - | - | - |
| Cloud-provider and hosting API tokens | `/ci` | 180 days, or on exposure |
| `EXPO_TOKEN` (robot token) | `/ci` | 365 days |
| Registry or publish read tokens | `/ci` | 365 days |
| MCP developer tokens | `/agents` | 365 days, by the developer who holds them |
| Apple App Store Connect key, Google Play service-account key | EAS credentials, not Infisical's plain store | On exposure or personnel change; the `.p8` downloads once |
| Application secrets (Stripe, OAuth, webhook signing, Resend) | The runtime store | On exposure; Stripe offers a roll with an expiry window |

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

| Check | Where | State |
| - | - | - |
| `guard-bash` deny rules | `.agents/hooks/guard-bash.mjs` | Live |
| Secrets inbox: import writes blind; hooks guard against accidental reads | `tools/scripts/secrets-import.mjs`, `guard-bash`, `guard-paths`, `.opencode/plugins/guard` | Live |
| gitleaks on staged files and on history | `lefthook.yml`, `pnpm secrets:scan`, `.gitleaks.toml` | Live |
| `secrets-doctor` (names only) | `tools/scripts` | Not built |
| No `.env` or `.env.example` in the tree | `pnpm validate` | Not built |
| CI workflows never use a stored secret or a forbidden command | CI policy check | Not built |

See also [CI](/runbooks/ci), [Mobile release](/runbooks/mobile-release) and [Dependencies](/standards/dependencies).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.