# Secrets

How Machine stores project credentials and controls where each value is allowed to go.

Canonical page: https://themachine.myapps.ai/docs/project/secrets

A secret is a per-project credential — an API key, token, or connection
string — that a session needs but that must not live in the repository.
Machine stores secrets on the project, never on the account, and encrypts
every value with AES-256-GCM using a key derived per project.

## Identifier and name

Each secret has two names. The `identifier` is the handle you use in the
CLI and in an agent's `secrets` grant. The `name` is the uppercase
environment-variable key injected into the sandbox, for example
`STRIPE_API_KEY`. In most projects the identifier and the name match. They
differ only when a project holds several candidate values for one name —
for example, a primary and a backup Google Maps key can both resolve to
`GOOGLE_MAPS_API_KEY`.

Every access rule uses the identifier. None of them uses the name.

## Shared and personal scope

A secret is either shared or a personal override:

- **Shared** — the project-wide value. Every project member with read
  access sees every shared secret. Machine has no per-member sharing control
  for a single secret.
- **Personal override** — your own value for one name, used instead of the
  shared row for sessions you start. Today Machine uses this only for one
  OAuth login credential.

Machine never lets an LLM provider key, for example `ANTHROPIC_API_KEY`,
become a personal override. The model gateway always reads the shared row.

Which agents receive a secret depends on its delivery mode:

- A **Sandbox** secret reaches every agent in the project. The agent's
  `secrets` list does not narrow it. Only a session's own secrets allowlist
  can hold it back. The platform's own coordinator agent receives none.
- A **Network boundary** or **Machine service** (HTTPS broker) secret reaches
  only an agent whose `secrets` list in `kortix.yaml` names its identifier.
  Omitting `secrets` means none. See [Agents](/docs/project/agents).

## Delivery

Delivery decides where the value is allowed to go. Every secret has one
delivery mode. The default is **Sandbox**.

| Delivery | The sandbox receives | Use it for |
|---|---|---|
| **Sandbox** | The plaintext value, as an environment variable | A local process or script that must read the value |
| **Machine service** | Nothing | The LLM gateway, a connector, Git, or one policy-bound HTTPS request Machine makes for you |
| **Network boundary** | Nothing | An ordinary HTTP client in the sandbox that calls one known host over HTTPS |
| **Disabled** | Nothing | Keeping a value on file without letting anything use it |

**Sandbox** is the only mode that puts plaintext in the sandbox. Agent code
can read, print, and forward it. Every other mode keeps the value outside.

Change the mode in the project's Secrets page, or from the CLI:

```bash
machine secrets delivery STRIPE_API_KEY denied
```

> **Session-scoped delivery needs a named agent grant**
> Network boundary and the HTTPS broker attach a credential to one running
> session. For those two modes an agent's `secrets` list must name the identifier
> explicitly. `secrets: all` does not count, and a project with no `agents:` block
> in `kortix.yaml` never receives one. The Secrets page can write that grant for
> you — see [Grant a secret to an agent](#grant-a-secret-to-an-agent).

## Add a secret

### Set the value from the CLI

```bash
machine secrets set STRIPE_API_KEY=sk_live_...
```

Machine saves it as a shared secret for the project. To store more than one
value under the same name, add `--identifier <id>`. Names can't start with
`KORTIX_` — Machine reserves that prefix for platform values.

### Or use the dashboard

Open **Settings → Secrets**, enter the key and value, and save. Machine
encrypts the value immediately.

### Grant it to an agent, if the mode needs it

A Sandbox secret needs no grant — every agent receives it. For Network
boundary or Machine service delivery, pick an agent on the Secrets page, or
add the identifier to that agent's `secrets` list in `kortix.yaml` yourself.
See [Grant a secret to an agent](#grant-a-secret-to-an-agent).

## Grant a secret to an agent

A grant matters only for Network boundary and Machine service delivery. A
session receives such a secret only when the agent it runs names the
identifier in its `secrets` list. Matching uses the identifier, not the name,
and ignores case.

A Sandbox secret ignores the agent's `secrets` list. Since October 2026 it
reaches every agent in the project, whether or not the agent lists it.

### From the dashboard

The Secrets page marks a Network boundary or Machine service secret that no
agent can receive: **No agent can receive this secret**. Choose an agent there
and confirm. Machine edits `kortix.yaml` and commits it as
`chore(agents): grant <IDENTIFIER> to <agent>`.

The grant works whether or not the manifest already declares that agent. An
agent the manifest does not declare gets a new entry holding this one `secrets`
list. An agent that is already declared keeps every other field — model, tools,
connectors — and the identifier joins its existing list. An agent that already
admits the identifier needs no commit, and Machine makes none.

An agent on `secrets: all` is a special case. `all` cannot carry a
session-scoped secret, so Machine writes an explicit list: every identifier the
project has today, plus this one. Nothing the agent receives today changes. A
secret you add later needs its own grant.

Two cases refuse the grant:

- A project on `kortix_version: 1` (`kortix.toml`) has no agents map to edit.
  The request fails with `400` and `manifest_v1_unsupported`. Edit the manifest
  by hand, or move the project to `kortix_version: 2`.
- A secret whose delivery is **Disabled** has nothing to deliver. The request
  fails with `409` and `secret_not_grantable`. Choose a delivery mode first.

If the project has no `agents:` block yet, read [The first `agents:` block
changes the whole
project](#the-first-agents-block-changes-the-whole-project) before you confirm.

### By hand

The same grant, written directly:

```yaml
kortix_version: 2
agents:
  my-agent:
    secrets: [STRIPE_API_KEY]
```

### The first `agents:` block changes the whole project

> **Sandbox secrets are not affected**
> A project with no `agents:` block — or with no `kortix.yaml` at all — is
> ungoverned. Every agent receives every Sandbox secret, no agent receives a
> Network boundary or Machine service secret, and every agent may use the
> `kortix` CLI with the full permissions of the person who started the session.
>
> Declaring the first agent does not change Sandbox delivery. Every agent still
> receives every Sandbox secret. From that commit on, `agents:` is the project's
> allow-list:
>
> - A listed agent can receive the Network boundary and Machine service secrets
> its `secrets` list names, and gets only the `kortix_cli` actions it declares.
> - An agent the block does not list receives no Network boundary or Machine
> service secret and loses its `kortix` CLI permissions.

The dashboard asks you to confirm the first time it adds an `agents:` block,
because the block also becomes the project's launch roster and governs each
agent's other grants. See the [manifest reference](/docs/project/manifest).

## List your secrets

Run `machine secrets ls` to see which secrets a project declares and which
ones have a value set. An agent runs the same command as `kortix secrets ls`
inside its sandbox.

The list is configuration metadata. It never returns secret values. A scoped
agent token sees every Sandbox secret, plus the other identifiers in its agent
grant. A session-specific
`secrets_allowlist` controls delivery into that session, but it does not hide
configuration metadata that the agent grant permits.

## Rotate a secret

### Set a new value

Run the same command with the new value, or set it again on the project's
Secrets page:

```bash
machine secrets set STRIPE_API_KEY=sk_live_new...
```

### Machine pushes it to running sessions

Machine pushes the new value to every sandbox with an active session for the
project, on a best-effort basis. For model or gateway credentials, Machine
restarts the OpenCode compatibility process.

## Remove a secret

Run `machine secrets unset STRIPE_API_KEY` (or `unset <identifier>`), or
delete it from the project's Secrets page.

> **Removal is immediate, propagation is not**
> Machine deletes a shared secret right away. Push to already-running
> sandboxes is best-effort, the same as rotation.

## Share a value without seeing it

Run `machine secrets request STRIPE_API_KEY` to create a link. Anyone with the
link can enter the value. You never see it. Links stay valid for 7 days by
default; adjust with `--expires <minutes>` (max 30 days). An expired link shows
a clear "expired" page — mint a fresh one with the same command.

## Network boundary

Network boundary is the strictest delivery mode. Machine registers the value
with the Platinum sandbox provider. Platinum adds the value to matching
outbound requests at its own egress edge, after the request has left the
sandbox.

The sandbox never receives the value. It is not an environment variable, it is
not a file, and no alias or placeholder stands in for it. Grepping the sandbox
environment for the identifier returns nothing. The agent writes an ordinary
request with no credential in it, and the header appears in flight.

### Set it up

> **Three things must be true, or the header is silently missing**
> Nothing inside the sandbox can detect a wrong setup. The request leaves without
> the header, and the upstream API answers `401`.

### Pin the project to Platinum

Only Platinum injects at the boundary. Open **Settings → General → Sandbox
provider** and set it to **Platinum**. "Automatic" is not enough: it
follows the platform default, which can place a session on another provider
where nothing injects the header.

New sessions use the pinned provider. A session that is already running keeps
the provider it started on.

### Name the secret in an agent's `secrets` list

```yaml
kortix_version: 2
agents:
my-agent:
secrets: [STRIPE_API_KEY]
```

The list holds identifiers, and matching ignores case. The Secrets page writes
this grant for you from the secret's warning. If the project has no `agents:`
block yet, read [The first `agents:` block changes the whole
project](#the-first-agents-block-changes-the-whole-project) first: the first
block also takes `kortix` CLI permissions away from every agent it does not
list.

`secrets: all` does **not** work here. A network-boundary secret needs an
explicit list that names it; with `all` it behaves exactly as if no grant
existed. A project with
no `agents:` block in `kortix.yaml` delivers nothing here until one exists.
Start the session with an agent whose list names the secret.

### Write the header value template

The template is what Platinum puts in the header. `{{secret}}` is where the
value goes.

| Template | Header sent |
|---|---|
| `Bearer {{secret}}` | `authorization: Bearer sk_live_...` |
| `{{secret}}` | `x-api-key: sk_live_...` |
| *(blank)* | `authorization: sk_live_...` |

A blank template sends the bare value with no scheme. That is correct for
`x-api-key`. It is wrong for a bearer token, and the API answers `401`. Write
`Bearer {{secret}}` when the API expects one.

From the CLI, the same configuration is one command:

```bash
machine secrets delivery STRIPE_API_KEY egress \
  --allow-host api.stripe.com \
  --inject-header authorization \
  --template 'Bearer {{secret}}'
```

### Hosts match exactly

List every host you call, one exact hostname per line. There is no pattern
matching, and the boundary accepts nothing narrower than a host.

| Rejected | Reason |
|---|---|
| A wildcard host, `*.example.com` | The boundary needs exact hosts |
| A path, `api.example.com/v1` | Platinum cannot enforce path restrictions |
| A method filter, `POST` only | Platinum cannot enforce method restrictions |

`api.example.com` does not cover `uploads.api.example.com`. Add the second host
to the same secret, or use a second secret. Machine rejects an unenforceable
policy with `400` when you save it — it never stores a rule it cannot apply.

Use Machine service delivery with the HTTPS broker consumer when you need
wildcards, paths, or method filters. Machine makes that request itself, so it can
enforce controls Platinum cannot.

### One header per host

A host and a header can be claimed by one secret only. Saving a second secret
that targets the same pair fails with `409` and
`secret_boundary_destination_conflict`, naming the secret that already holds it.

Two secrets on the same host with **different** headers are fine. Platinum
injects both.

### HTTPS only

Platinum has to terminate TLS to rewrite a header, so a policy host must be
called over HTTPS. Plain HTTP is refused before the request leaves:

```text
egress to "api.stripe.com" is blocked: this sandbox's policy puts a secret in a
request header for this host, and that requires HTTPS
```

That message is a policy refusal, not a network fault.

### Verify it with two probes

> **An echo service is the worst possible test target**
> If a response would send the secret back into the sandbox, Platinum kills the
> connection. `curl` reports `curl: (52) Empty reply from server`. A correct setup
> therefore looks exactly like a broken one when you test against an endpoint that
> echoes request headers.

Run both probes from inside the sandbox, against a host that is in the policy.
The example below uses `postman-echo.com`, which serves one endpoint of each
kind. Add it as an allowed host for the duration of the test.

```bash
# 1. Reachability — an endpoint that does NOT echo request headers.
curl -s -o /dev/null -w '%{http_code}\n' https://postman-echo.com/status/200
# expected: 200

# 2. Injection — an endpoint that DOES echo request headers.
curl -sS https://postman-echo.com/get
# expected: curl: (52) Empty reply from server
```

Read the pair together. Probe 1 first — it is the only one that tells you the
host is reachable.

| Probe 1 | Probe 2 | Meaning |
|---|---|---|
| `200` | `curl: (52)` | Working. The header was injected and the echo guard blocked the reply. |
| `200` | `200`, and the echoed headers show no credential | The header was not injected. Re-check the three prerequisites. |
| anything else | — | The host is not reachable. Probe 2 proves nothing until this passes. |

Also confirm the value never reached the guest:

```bash
env | grep -c STRIPE_API_KEY
# expected: 0
```

A policy host presents Platinum's per-sandbox certificate, which the sandbox
already trusts:

```bash
curl -sv https://postman-echo.com/status/200 2>&1 | grep 'issuer:'
# issuer: O=Platinum; CN=Platinum egress proxy (sandbox sbx_...)
```

A host that is not in the policy passes through untouched and presents its own
origin certificate.

### End-to-end example

`kortix.yaml`:

```yaml
kortix_version: 2
default_agent: my-agent
agents:
  my-agent:
    secrets: [STRIPE_API_KEY]
```

Secret configuration:

```bash
machine secrets set STRIPE_API_KEY=sk_live_...
machine secrets delivery STRIPE_API_KEY egress \
  --allow-host api.stripe.com \
  --inject-header authorization \
  --template 'Bearer {{secret}}'
```

The agent then calls Stripe with no credential of its own:

```bash
curl -s -o /dev/null -w '%{http_code}\n' https://api.stripe.com/v1/customers
# expected: 200
```

The same request from a session on another provider, or from an agent whose
`secrets` list omits `STRIPE_API_KEY`, returns `401`. Nothing else changes.

## CLI commands

| Command | What it does |
|---|---|
| `machine secrets ls` | List secrets declared and set for the project |
| `machine secrets set KEY=VALUE [--identifier <id>]` | Create or update a secret. `KEY=-` reads the value from stdin |
| `machine secrets unset IDENTIFIER` | Remove a secret |
| `machine secrets delivery IDENTIFIER runtime\|broker\|egress\|denied` | Set the delivery mode and its policy |
| `machine secrets call IDENTIFIER URL` | Send one HTTPS broker request |
| `machine secrets sync` | Re-push project secrets to the project's running sandboxes |
| `machine secrets request NAME [--scope runtime\|connector] [--expires <min>]` | Create a link so someone else can enter a value |
| `machine env push --from <path>` | Upload a `.env` file as secrets |
| `machine env pull [--out <path>] [--force]` | Export secret names, not values, to a `.env` file |

Run `machine secrets --help` for every delivery flag. Inside a session
sandbox, agents run the same commands as `kortix secrets …`.

## Names and permissions

Format rules for the two names:

| Name | Format |
|---|---|
| `identifier` | `^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$` |
| `name` | `^[A-Z_][A-Z0-9_]{0,63}$` |

Reading or writing a secret needs the `project.secret.read` or
`project.secret.write` permission. A custom project role can withhold
either permission, even from a manager.

## REST routes

All routes sit under `/v1/projects/{projectId}`.

| Method | Path | Description |
|---|---|---|
| GET | `/secrets` | List secrets. For a scoped agent token: every Sandbox secret, plus the identifiers in the agent's grant. |
| POST | `/secrets` | Create or update the shared value. Body: `{name, identifier?, value}`, plus an optional delivery policy. |
| PUT | `/secrets/{identifier}/strategy` | Change the delivery mode and its policy. |
| POST | `/secrets/{identifier}/grant` | Add the identifier to one agent's `secrets` list in `kortix.yaml`. Body: `{agent}`. |
| DELETE | `/secrets/{name}` | Delete the shared value. Personal overrides stay in place. |
| PUT | `/secrets/{name}/personal` | Set or turn on the caller's personal override. |
| DELETE | `/secrets/{name}/personal` | Remove the caller's personal override. |

`POST /secrets` rejects names that start with `KORTIX_`. It returns `409`
if the `identifier` already exists with a different `name`. It rejects the
exact name `CODEX_AUTH_JSON` with `400` — Machine manages that secret
through ChatGPT subscription onboarding.

Both write routes return a `delivery_sync` object when the change had to reach
running sandboxes. `ok: false` means the value is saved but at least one live
session still uses the previous one; the listed sessions pick it up on restart.

A secret in the list carries `delivery_blocked_reason`. The value
`no_agent_grant` means no agent can receive this Network boundary or Machine
service secret. `null` means it is granted, the mode needs no grant (Sandbox
and Disabled), or Machine could not read the manifest.

`POST /secrets/{identifier}/grant` clears that reason. It returns
`already_granted: true` when the agent's list already admits the identifier, in
which case Machine commits nothing. It returns `adopted_governance: true` when
the edit added the project's first `agents:` block — the change [described
above](#grant-a-secret-to-an-agent). It answers `400` `manifest_v1_unsupported`
for a `kortix.toml` project and `409` `secret_not_grantable` for a disabled
secret.

## Rotation and propagation

A secret write does not wait for a session restart. Machine pushes the
change to every active sandbox in the project:

1. Machine builds a new environment snapshot. Sandbox secrets go to every
   agent, narrowed only by the session's secrets allowlist.
2. The sandbox writes the snapshot to the live agent environment. New tool
   calls pick up the change right away.
3. If the changed secret is an LLM provider credential, Machine restarts
   OpenCode.

This push is best-effort. The API call that changes the secret returns
before the push finishes. A failed push is only logged, not retried. A
sandbox with a failed push keeps the old value until the next successful
push, or until the session restarts.

Rotating a network-boundary secret updates the Platinum replica in place. The
sandbox does not restart, because it never held the value.

## Model credentials

A project on Machine's managed model access needs no key of its own. To bring
your own, set the provider variables your OpenCode provider config references.

Do not use a generic provider verification result as runtime proof. It cannot
prove the selected model, region, entitlement, and API dialect. Send a real
prompt through the exact model.
