# Secret Injection

Keep API keys out of Git. Inject tokens into MCP server configs at sync time from your environment, 1Password, Vault, AWS, GCP, Azure, or Keychain.

**When you need this:** an MCP server needs a token, password, or API key, and you don't want to commit it into the pack you share.

**What you'll learn:**

- How `${kst_...}` placeholders are written and resolved
- Where values come from, and how to pin a specific source
- What happens when a secret is missing, and how rotation works

## Placeholders

Reference a secret anywhere inside an MCP server definition: a header, an `env` value, a URL. At sync time Kasetto resolves it and writes the value into the agent's settings file. There are two forms.

The **chain form** `${kst_<name>}` resolves against an ordered fallback (env, then credentials):

```json
{
  "mcpServers": {
    "vercel": {
      "url": "https://mcp.vercel.com",
      "type": "http",
      "headers": {
        "Authorization": "Bearer ${kst_vercel_token}"
      }
    }
  }
}
```

The lowercase `kst_` (or `kst:`) sentinel is the opt-in. A bare `${HOME}` or `${PATH}` is left untouched, so placeholders the agent or shell expands at launch time pass straight through. Kasetto only resolves its own sentinel.

### Nested Keys

In the chain form, a double underscore (`__`) descends into a nested credentials path. A single underscore stays within one segment.

| Placeholder              | `credentials.yaml` path |
| ------------------------ | ----------------------- |
| `${kst_vercel_token}`    | `kst_vercel_token` (flat) |
| `${kst_vercel__token}`   | `vercel.token` (nested) |
| `${kst_org__ci__token}`  | `org.ci.token` (nested) |

Keys match case-insensitively, so `vercel.token` resolves `${kst_vercel__token}`, and the chain also tries the env var uppercased: `${kst_vercel_token}` reads `KST_VERCEL_TOKEN` from the environment.

## The Chain (env -> credentials)

The chain form resolves against an ordered set of sources. The first that has the value wins:

1. **Environment variables**: the placeholder name as written, then uppercased, e.g. `${kst_vercel_token}` → `kst_vercel_token`, then `KST_VERCEL_TOKEN`.
2. **`credentials.yaml`**: `$XDG_CONFIG_HOME/kasetto/credentials.yaml` (`~/.config/kasetto/credentials.yaml`), plus any extra files listed in `secrets.files`.

Environment wins over the file, so one shared pack works in CI (env vars) and on a laptop (`credentials.yaml`) without editing.

```yaml
# ~/.config/kasetto/credentials.yaml (never commit this)
kst_github_token: ghp_xxx        # flat key
vercel:
  token: xxxxxxxx                # nested → ${kst_vercel__token}
```

<Callout type="warn">

`credentials.yaml` holds plaintext secrets. Keep it out of version control and lock it down with `chmod 600`. Kasetto warns when the file is group- or world-readable.

</Callout>

## Pinning a Source

When you want to bypass the fallback and name exactly one source, use the **tagged form** `${kst:<source>:<ref>}`. Each tag pins exactly one provider.

### `env` - A Specific Environment Variable

```text
${kst:env:VERCEL_TOKEN}
```

Looks up `VERCEL_TOKEN` **verbatim** (no `kst_` prefix, no uppercasing). Point it at any env var you already export.

### `crd` - a credentials.yaml path

```text
${kst:crd:vercel/token}
```

Descends the `/`-separated path in `credentials.yaml` (`vercel.token`), case-insensitively. Unlike the chain form, this never falls back to the environment.

### `op` - 1Password

```text
${kst:op:<vault>/<item>/<field>}
${kst:op://<vault>/<item>/<field>}
```

Both forms run `op read op://<vault>/<item>/<field>` (the [1Password CLI](https://developer.1password.com/docs/cli/)). Example: `${kst:op:Private/GitHub/token}`.

### `vault` - HashiCorp Vault

```text
${kst:vault:<kv-path>#<field>}
```

Runs `vault kv get -field=<field> <kv-path>` (the [Vault CLI](https://developer.hashicorp.com/vault/docs/commands)). Example: `${kst:vault:secret/myapp#token}`.

### `kp` - KeePass

```text
${kst:kp:<entry>#<attr>}
```

Runs `keepassxc-cli show -s -a <attr> <database> <entry>` (the [KeePassXC CLI](https://keepassxc.org/docs/KeePassXC_UserGuide#_command_line_tool)). The attribute defaults to `Password` when `#<attr>` is omitted. Example: `${kst:kp:GitHub/PAT}` or `${kst:kp:GitHub/PAT#Token}`.

Unlike `op`/`vault`, KeePass has no ambient session, so it needs the database location in config:

```yaml
secrets:
  keepass:
    database: ~/secrets.kdbx
    # key_file: ~/secrets.keyx   # optional
```

The database is unlocked with a **key-file** (`secrets.keepass.key_file`, if set) and/or a **master password** read from the `KST_KEEPASS_PASSWORD` environment variable. With neither, `kst` adds `--no-password` and closes stdin, so the CLI fails fast instead of waiting on a prompt.

### `aws` - AWS Secrets Manager

```text
${kst:aws:<secret-id>#<json-key>}
```

Runs `aws secretsmanager get-secret-value` (the [AWS CLI](https://aws.amazon.com/cli/)) against your active profile/region. AWS secrets are often JSON, so an optional `#<json-key>` extracts a top-level field. Example: `${kst:aws:prod/db}` or `${kst:aws:prod/db#password}`.

### `gcp` - Google Cloud Secret Manager

```text
${kst:gcp:<name>#<json-key>}
```

Runs `gcloud secrets versions access latest --secret=<name>` (the [gcloud CLI](https://cloud.google.com/sdk/gcloud)) against the active project. An optional `#<json-key>` extracts a top-level field when the secret is a JSON document. Example: `${kst:gcp:db-password}` or `${kst:gcp:db#password}`.

### `az` - Azure Key Vault

```text
${kst:az:<vault>/<name>#<json-key>}
```

Runs `az keyvault secret show --vault-name <vault> --name <name>` (the [Azure CLI](https://learn.microsoft.com/cli/azure/)). An optional `#<json-key>` extracts a top-level field when the secret is a JSON document. Example: `${kst:az:my-vault/db-password}` or `${kst:az:my-vault/db#password}`.

### `pass` - pass / gopass

```text
${kst:pass:<path>}
```

Runs `pass show <path>` (the [pass](https://www.passwordstore.org/) password store; gopass is compatible) and returns the first line (the password, by the store's convention). Example: `${kst:pass:work/vercel}`.

### `keychain` - macOS Keychain

```text
${kst:keychain:<service>#<account>}
```

Runs `security find-generic-password -s <service> [-a <account>] -w` against the macOS Keychain; the `#<account>` is optional. Example: `${kst:keychain:vercel-token}`.

<Callout type="info">

`op`, `vault`, `kp`, `aws`, `gcp`, `az`, `pass`, and `keychain` all shell out to the provider's own CLI at sync time and inherit whatever session and auth you already have, so no tokens live in any Kasetto file. If the CLI is missing or the lookup fails (item not found, not authenticated, locked database), the entry is a hard failure with the CLI's error, regardless of `--allow-missing-secrets`.

</Callout>

## Missing Secrets

By default a missing required secret is a hard failure: the MCP entry is marked broken, **nothing is written** for it, and `kst sync` exits non-zero. Other assets still sync.

```bash
kst sync --allow-missing-secrets
```

With `--allow-missing-secrets`, Kasetto warns instead and leaves the literal placeholder in place. The same policy can be set in config:

```yaml
secrets:
  on_missing: warn   # error (default) | warn
```

## Rotation

<Callout type="warn">

**Rotating a secret requires `--update`.** A plain `kst sync` will report the server `unchanged` and leave the **old** value in place. It does not re-resolve secrets on its own. When a secret-bearing server is skipped, kasetto prints a tip reminding you to run `--update`.

</Callout>

A plain `kst sync` is stable: an MCP server already present in the agent's settings is left exactly as is, so a rotated secret in `credentials.yaml` or the environment does **not** propagate on its own. This is the same zero-churn guarantee the lock provides everywhere else.

To push a rotated secret, run an update. It re-resolves and replaces the managed server block:

```bash
kst sync --update            # rotate everything
kst sync --update vercel     # rotate one server
```

<Callout type="info">

`--update` overwrites only the servers Kasetto installed. Servers you added by hand, and your edits to other entries, are never touched.

</Callout>

## What Never Gets Stored

Injection happens in memory, only on the value written to the agent destination. The plaintext secret never reaches:

- **`kasetto.lock`**: the lock hashes the placeholder source file, not the resolved value.
- **The source cache** and stage directory: they hold the pack exactly as authored.

So `kasetto.lock` stays commit-safe even when the synced MCP config carries a live token.

## Config Reference

The `secrets:` block is optional and carries no values, so it is safe to commit.

| Key          | Required | Description                                                                 |
| ------------ | -------- | --------------------------------------------------------------------------- |
| `on_missing` | no       | `error` (default) to fail, or `warn` to leave the placeholder and continue.  |
| `files`      | no       | Extra credential files (relative to the config dir, or absolute), searched after the default `credentials.yaml`. |
| `keepass`    | no       | KeePass database for `${kst:kp:...}` refs: `database` (path) and optional `key_file`. No password (that comes from `KST_KEEPASS_PASSWORD`). |

```yaml
secrets:
  on_missing: error
  files:
    - ./team-credentials.yaml
  keepass:
    database: ~/secrets.kdbx
    key_file: ~/secrets.keyx
```

<Callout type="info">

Secret injection currently applies to **MCP configs**. An unknown tag (anything other than `env`, `crd`, `op`, `vault`, `kp`, `aws`, `gcp`, `az`, `pass`, or `keychain`) errors clearly rather than silently passing through.

</Callout>
