> ## Documentation Index
> Fetch the complete documentation index at: https://comis-feature-matrix-channel.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Credential Broker

> Drive API-key CLIs from the exec sandbox with the key never inside the namespace — injected at the network boundary by an in-daemon MITM broker

## The Problem

Exec tools are powerful — and that power cuts both ways. A process running inside the exec sandbox can read environment variables, iterate `/proc/self/environ`, or `cat` any file its user can reach. Handing a live API key to an exec process — even inside a filesystem sandbox — means the key is accessible to any code that runs in that process, including prompt-injected commands.

The credential broker solves this by keeping the key out of the sandbox entirely. The daemon holds the key in its encrypted secret store; the agent process only ever sees a placeholder. At the network boundary, the broker intercepts the TLS connection, terminates it with its own CA, and swaps the placeholder for the real credential before forwarding the request upstream.

From inside the sandbox: `cat /proc/self/environ` shows `ANTHROPIC_API_KEY=broker-placeholder`. The real key is never a file, an environment variable, or a `/proc` entry inside the namespace.

## Architecture

The broker works as an in-process MITM proxy running inside the daemon. Here is the full flow for a driven-CLI spawn:

1. **Store the key once.** The operator runs `comis secrets set ANTHROPIC_EXECUTOR_KEY` — the key is encrypted and stored in the daemon's SecretManager.

2. **Configure the binding.** An `executor.broker.bindings` entry maps `api.anthropic.com` to the `anthropic` preset and the `ANTHROPIC_EXECUTOR_KEY` secret reference.

3. **Spawn the driven CLI.** When the broker-enabled sandbox spawns the CLI process, the following environment variables are injected:
   * `HTTPS_PROXY` / `HTTP_PROXY` — points to the broker's unix socket
   * `NODE_EXTRA_CA_CERTS` — path to the broker's CA certificate
   * `ANTHROPIC_API_KEY=broker-placeholder` — a non-secret placeholder value

4. **Broker intercepts the request.** The CLI sends its HTTPS request through the proxy. The broker terminates the TLS connection using its own CA (which the CLI trusts via `NODE_EXTRA_CA_CERTS`), then inspects the target host.

5. **Credential injection.** For `api.anthropic.com`, the broker matches the `anthropic` binding, resolves `ANTHROPIC_EXECUTOR_KEY` from SecretManager per-request, strips the `Authorization` header, and injects the real key as `x-api-key`. The modified request is forwarded to the upstream API over a fresh TLS connection.

6. **Audit trail.** Every inject, deny, and blocked-egress event carries `agentId` and `traceId` — providing full observability without ever logging the credential value.

### Broker vs exec secretRefs

Two credential injection paths exist in Comis:

|                          | exec secretRefs                            | Credential broker                           |
| ------------------------ | ------------------------------------------ | ------------------------------------------- |
| **What it does**         | Injects env vars scoped to the exec tool   | Injects credentials at the network boundary |
| **Who holds the secret** | The exec process (env var visible inside)  | The daemon only (placeholder in sandbox)    |
| **Use when**             | Simple scripts needing a key as an env var | API-key CLIs (Claude Code, curl, etc.)      |
| **Network guard**        | None                                       | Broker-only egress (Linux)                  |

Use `secretRefs` for simple key-as-env-var cases. Use the credential broker when you need the key to be unreachable inside the sandbox.

<Info>Source: packages/skills/src/tools/builtin/exec-tool/exec-shared.ts — broker spawn env injected last via `brokerSpawnEnv` merge (guard: only driven-CLI spawns receive it; general exec spawns receive none of these vars)</Info>

## Configuration

<Info>
  The `executor.broker` config is wired into the daemon (`AppConfigSchema` → `setupBroker`): adding an `executor:` block to `config.yaml` starts the broker at boot — a TCP listener plus a `0600` unix socket at `~/.comis/broker.sock`. The examples below are the active config contract.
</Info>

```yaml title="~/.comis/config.yaml" theme={}
# executor.broker.bindings — provider-agnostic; presets are optional sugar
# The broker starts at daemon boot whenever an executor.broker block is present.
executor:
  broker:
    bindings:
      # Option A: built-in preset — Anthropic (header injection)
      - preset: anthropic
        secretRef: ANTHROPIC_EXECUTOR_KEY

      # Option B: built-in preset — Finnhub (query param injection)
      - preset: finnhub
        secretRef: FINNHUB_API_KEY

      # Option C: custom binding — any host, no preset required
      # A binding with no 'inject' defaults to Authorization: Bearer
      - hostRules:
          - pattern: { kind: exact, host: my-internal-api.example.com }
            inject: []    # defaults to Authorization: Bearer
        secretRef: INTERNAL_API_TOKEN

      # Option D: custom binding with explicit header injection
      - hostRules:
          - pattern: { kind: suffix, suffix: .amazonaws.com }
            inject:
              - kind: setHeader
                name: x-amz-security-token
                format: raw
        secretRef: AWS_SESSION_TOKEN
```

### Binding fields

| Key              | Type                     | Required                | Description                                 |
| ---------------- | ------------------------ | ----------------------- | ------------------------------------------- |
| `preset`         | `string`                 | one of preset/hostRules | Built-in preset ID (`anthropic`, `finnhub`) |
| `hostRules`      | `HostRule[]`             | one of preset/hostRules | Custom host rules (provider-agnostic)       |
| `secretRef`      | `string`                 | yes                     | SecretManager key resolved per-request      |
| `credentialRefs` | `Record<string, string>` | no                      | Extra refs for multi-field finalizers       |

<Info>Source: packages/core/src/config/schema-broker.ts — `BrokerBindingConfigSchema` (z.strictObject; unknown keys are rejected)</Info>

### Built-in presets

Two built-in presets ship today. Anthropic and Finnhub today; more presets are on the catalog roadmap.

| Preset ID   | Host                        | Inject                                                 | Path policy |
| ----------- | --------------------------- | ------------------------------------------------------ | ----------- |
| `anthropic` | `api.anthropic.com` (exact) | `setHeader x-api-key raw`, `removeAuthorization: true` | `/v1/*`     |
| `finnhub`   | `finnhub.io` (exact)        | `setParam token`                                       | all paths   |

<Info>Source: packages/core/src/security/provider-catalog/presets.ts — `PRESETS` array, 2 entries</Info>

### Custom bindings

The broker is provider-agnostic. Any host can be covered by a custom `hostRules` entry — no curated preset is required. A binding with `secretRef` only and no explicit `inject` array defaults to `Authorization: Bearer` injection.

### Host pattern kinds

| Kind     | Behavior                                                                                                                                                                               |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `exact`  | Matches the host exactly                                                                                                                                                               |
| `suffix` | Matches hosts ending with the suffix — the suffix itself must start with `.` or `-` to require a domain-boundary separator (prevents `amazonaws.com` from matching `notamazonaws.com`) |

### Injection rule vocabulary

| Kind            | Fields                                                | Behavior                                                                                |
| --------------- | ----------------------------------------------------- | --------------------------------------------------------------------------------------- |
| `setHeader`     | `name`, `format` (raw/bearer), `removeAuthorization?` | Sets the named header unconditionally; optionally removes `Authorization`               |
| `replaceHeader` | `name`, `format` (raw/bearer)                         | Sets the header only if it is already present in the request                            |
| `removeHeader`  | `name`                                                | Removes the named header                                                                |
| `setParam`      | `name`                                                | Appends a query parameter; existing query bytes are preserved verbatim (signature-safe) |

<Info>Source: packages/core/src/config/schema-broker.ts — `InjectionRuleSchema` discriminated union</Info>

## Fail-Closed Guarantees

The broker is fail-closed: no binding, no proxy token, no path access — the request is refused. A missing secret never forwards the request.

| Scenario                                        | HTTP status | `broker:denied` reason                    |
| ----------------------------------------------- | ----------- | ----------------------------------------- |
| Missing or forged proxy token                   | 407         | `bad_token`                               |
| Host not in any binding                         | 403         | `no_binding`                              |
| Host matched but path denied by `pathPolicy`    | 403         | `path_policy`                             |
| SecretManager returns undefined for `secretRef` | 502         | — (emits `broker:credential_unavailable`) |
| Request body exceeds 10 MiB                     | 413         | `body_too_large`                          |
| WebSocket upgrade attempt                       | 501         | `ws_upgrade_not_supported`                |
| Malformed HTTP request                          | 400         | `malformed_request`                       |

The 502 case deserves emphasis: if the secret is unavailable, the broker returns 502 rather than forwarding with a missing or placeholder credential. There is no fallback path that leaks a request upstream without a valid secret.

<Info>Source: packages/infra/src/credential-broker/mitm-broker.ts — fail-closed gates at lines 250, 269, 364, 405, 419, 450, 490, 522, 571</Info>

## Observability

### broker:\* events

Every stage of the broker pipeline emits a typed event on the Comis event bus. The event taxonomy covers seven events:

| Event                           | Key payload fields                                                                |
| ------------------------------- | --------------------------------------------------------------------------------- |
| `broker:session_opened`         | `sessionId`, `agentId`, `host`, `presetId?`, `timestamp`                          |
| `broker:session_closed`         | `sessionId`, `agentId`, `durationMs`, `reason` (teardown/error), `timestamp`      |
| `broker:request`                | `sessionId`, `host`, `path`, `method`, `timestamp`                                |
| `broker:injected`               | `sessionId`, `host`, `ruleKind`, `timestamp`                                      |
| `broker:denied`                 | `sessionId`, `host`, `reason`, `statusCode`, `timestamp`                          |
| `broker:credential_unavailable` | `sessionId`, `secretRef`, `agentId`, `timestamp`                                  |
| `broker:egress_blocked`         | `sessionId`, `targetHostHash` (SHA-256 hex — NOT the plaintext host), `timestamp` |

The `broker:egress_blocked` event deliberately carries `targetHostHash` rather than the plaintext hostname. The SHA-256 hash is computed inside the emit helper — there is no call path that emits the raw host value into the event payload (redaction-by-construction).

### secret:accessed event

A `secret:accessed` event is emitted separately from the broker:\* taxonomy on the success, `denied`, and `not_found` paths. Fields: `secretName`, `agentId`, `outcome` (success/denied/not\_found), `timestamp`. The durable audit trail persists `success` reads (a secret was accessed) and `denied` reads (a blocked access); a routine `not_found` read (an optional-key probe resolving to nothing — no access, re-emitted every turn) is **not** persisted, keeping the trail free of per-turn probe noise.

### Structured logging

Every pipeline stage carries `traceId`, `agentId`, and `step`; every failure log carries `err`, `errorKind`, and a non-empty `hint`. Hosts where credentials are injected via query param (`setParam`) never emit a full URL in logs — only the path prefix is logged, never the query string.

<Info>Source: packages/infra/src/credential-broker/broker-events.ts — typed emit helpers; `emitEgressBlocked` enforces the hash-only invariant structurally</Info>

## Secure Credential Home

When `secureCredentialHome: true` is set on the sandbox options, the following bind mounts are removed for the credentialed sandbox:

* `~/.claude` (read-write bind)
* `~/.claude.json` (read-only bind)
* `~/.local/share/claude` (read-write bind)

This means `cat ~/.claude/.credentials.json` inside the sandbox returns "no such file or directory". The Claude Code CLI cannot read its own credential cache from inside the sandbox.

<Info>Source: packages/skills/src/tools/builtin/sandbox/bwrap-provider.ts:196–218 — credential home bind removal for secure profile</Info>

## Network Isolation (Linux)

On Linux, the credentialed sandbox runs with `--unshare-net`; the broker unix socket is the only bind-mounted network path — so the broker is the only reachable egress destination for driven-CLI traffic. This kernel-enforced isolation is **validated on the Linux production host class** (Ubuntu 24.04 LTS, kernel 6.8, rootless bubblewrap, run as the unprivileged service user): a direct connection to any non-broker host from inside the namespace fails with "network is unreachable", while the bound broker socket stays reachable and carries the injected-credential request end-to-end.

Network modes (`SandboxOptions.network`):

| Mode             | bwrap args                                                     | Description                                                  |
| ---------------- | -------------------------------------------------------------- | ------------------------------------------------------------ |
| `open` (default) | `--unshare-all --share-net`                                    | Standard exec sandbox; full network access                   |
| `broker-only`    | `--unshare-all --unshare-net --bind <socketPath> <socketPath>` | Driven-CLI sandbox; only the broker unix socket is reachable |

**macOS note:** The `broker-only` network mode requires bubblewrap and is Linux-only. The broker still runs on macOS (TLS termination, injection, events) but without network namespace enforcement. On macOS, `sandbox-exec` is used for filesystem isolation and does not support the broker-only network mode.

<Info>Source: packages/skills/src/tools/builtin/sandbox/bwrap-provider.ts — `broker-only` branch at line 234; `SandboxOptions.network` union defined in packages/skills/src/tools/builtin/sandbox/types.ts</Info>

## Troubleshooting

**Broker request fails with 407 (bad\_token):**

```bash theme={}
# Check broker events in logs
grep 'broker:denied' ~/.comis/logs/daemon.log | grep bad_token
# Cause: session token missing or expired — single-use; a new token is issued per driven-CLI spawn
```

**Broker returns 403 (no\_binding):**

The requested host has no matching binding. Add a `hostRules` entry or use a preset. Verify the host matches exactly (including any subdomain).

**Broker returns 502 (credential\_unavailable):**

SecretManager returned undefined for the `secretRef`. Verify the key exists: `comis secrets list`. Check for typos between the `secretRef` value in config and the key name in the secrets store.

**Broker returns 501 (ws\_upgrade\_not\_supported):**

WebSocket credential injection is not supported in this release; it is a future capability. The broker returns 501 with the `ws_upgrade_not_supported` reason code so the error is actionable rather than a silent connection hang.

**Trace a full request by traceId:**

Every broker log entry carries a `step` field. To trace a complete request pipeline:

```bash theme={}
grep '"traceId":"<your-trace-id>"' ~/.comis/logs/daemon.log | jq '{step, event}'
```

**Log a specific agent's broker activity:**

```bash theme={}
grep '"agentId":"<your-agent-id>"' ~/.comis/logs/daemon.log | grep '"broker:' | jq '{event: .msg, host: .host, reason: .reason}'
```

## Known Limitations

* **One broker-issued exec per agent assembly:** The session token is issued once per
  `assembleToolsForAgent` call, not per exec invocation. The broker token is single-use: the first
  exec call in a turn consumes it, so a second exec call in the same agent turn will receive a
  `407 Proxy Auth Required` from the broker rather than a clean execution error.
  **Workaround:** Limit each agent turn to a single exec call when `executor.broker` is
  enabled, or structure multi-step work so successive execs are in separate turns.
  **Resolution:** Per-command token issuance (threading `sessionManager` into `ExecToolDeps` so
  `issueToken()` is called inside `execute()`) is planned as follow-on work.

## Capability lease (jailed script surface)

Separate from the egress broker above, the daemon mints a **capability lease** to authenticate the jailed script surface to the loopback [capability endpoint](/reference/environment-variables#comis_orch_socket). The lease is the authentication boundary — it carries the agent's resolved orchestration capabilities, not a powerful credential. It is minted per root-run, injected as [`COMIS_CAP_LEASE`](/reference/environment-variables#comis_cap_lease) (the bearer) + [`COMIS_ORCH_SOCKET`](/reference/environment-variables#comis_orch_socket) (the `0600` socket path) into the jailed spawn environment, and registered with the OutputGuard at mint so the bearer is never logged.

The lease record:

| Field                                  | Description                                                                                                                                             |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `leaseId`                              | Opaque id (the handle the operator-facing revoke RPC will reference).                                                                                   |
| `agentId`                              | The agent the lease authorizes. Injected as `_agentId` at the endpoint — so a lease-bearing call to a control-plane (admin) method is denied by origin. |
| `caps`                                 | The agent's resolved orchestration capabilities (`orch:*`). Injected as `_capabilities` — each handler's capability gate fires against this set.        |
| `budgetRef`, `sessionKey`, `rootRunId` | The budget / session / root-run the lease is scoped to.                                                                                                 |
| `parentLeaseId?`                       | Present for a child spawn — the child's caps are `parent ∩ requested` (never broadened).                                                                |
| `expiresAt`                            | The soft, per-use validity window. Past it the lease is denied for this use but **renewable**.                                                          |
| `maxExpiresAt`                         | The hard ceiling — `renew` can never push expiry past it (no renew-forever).                                                                            |

The lease is **multi-use** (the jailed child dispatches many calls per run), **revocable** (a revoked lease is denied at both `validate` and `renew`), and **audience-bound** (RFC 8707): each use is checked against the requested method's required capability, so a captured lease cannot be replayed at a foreign method. The lease lives **in memory** (run-scoped) — there is no on-disk lease table, only the ephemeral `cap.sock` socket.

The operator-facing **revoke RPC + cascade** and the endpoint **rate-limit** are a later release; this release ships the lease, the validate path that respects revocation/expiry/audience, and the fail-closed deny matrix.

## Non-Goals (This Release)

* **OAuth / subscription auth** — future
* **AWS SigV4 signing execution** — the `awsSigV4` finalizer interface ships as a tested no-op pass-through (deferred)
* **Full WebSocket credential injection** — future; the fail-closed guard (501) is shipped
* **External vault / on-demand secret fetch** — future; SecretManager is the only resolution path today
* **Operator-facing lease revoke RPC + cascade** — a later release; the `revoked` field + the revocation-respecting `validate`/`renew` paths ship now

## Related

<CardGroup cols={2}>
  <Card title="Exec Sandbox" icon="box" href="/security/exec-sandbox">
    Filesystem isolation for the system.exec tool — the sandbox the broker protects
  </Card>

  <Card title="Secret Manager" icon="key" href="/reference/secret-manager">
    Encrypted credential store that the broker resolves per-request
  </Card>

  <Card title="Audit Log" icon="scroll" href="/security/audit">
    broker:\* events in the full event taxonomy
  </Card>

  <Card title="Defense in Depth" icon="shield-halved" href="/security/defense-in-depth">
    How the credential broker fits into the 22 categorical security layers
  </Card>
</CardGroup>
