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:-
Store the key once. The operator runs
comis secrets set ANTHROPIC_EXECUTOR_KEY— the key is encrypted and stored in the daemon’s SecretManager. -
Configure the binding. An
executor.broker.bindingsentry mapsapi.anthropic.comto theanthropicpreset and theANTHROPIC_EXECUTOR_KEYsecret reference. -
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 socketNODE_EXTRA_CA_CERTS— path to the broker’s CA certificateANTHROPIC_API_KEY=broker-placeholder— a non-secret placeholder value
-
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. -
Credential injection. For
api.anthropic.com, the broker matches theanthropicbinding, resolvesANTHROPIC_EXECUTOR_KEYfrom SecretManager per-request, strips theAuthorizationheader, and injects the real key asx-api-key. The modified request is forwarded to the upstream API over a fresh TLS connection. -
Audit trail. Every inject, deny, and blocked-egress event carries
agentIdandtraceId— 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) |
secretRefs for simple key-as-env-var cases. Use the credential broker when you need the key to be unreachable inside the sandbox.
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)Configuration
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.~/.comis/config.yaml
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 |
Source: packages/core/src/config/schema-broker.ts —
BrokerBindingConfigSchema (z.strictObject; unknown keys are rejected)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 |
Source: packages/core/src/security/provider-catalog/presets.ts —
PRESETS array, 2 entriesCustom bindings
The broker is provider-agnostic. Any host can be covered by a customhostRules 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) |
Source: packages/core/src/config/schema-broker.ts —
InjectionRuleSchema discriminated unionFail-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 |
Source: packages/infra/src/credential-broker/mitm-broker.ts — fail-closed gates at lines 250, 269, 364, 405, 419, 450, 490, 522, 571
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 |
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
Asecret:accessed event is emitted separately from the broker:* taxonomy on both the success and not_found paths. Fields: secretName, agentId, outcome (success/not_found), timestamp. This enables auditing which secrets an agent touched regardless of whether the broker injection succeeded.
Structured logging
Every pipeline stage carriestraceId, 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.
Source: packages/infra/src/credential-broker/broker-events.ts — typed emit helpers;
emitEgressBlocked enforces the hash-only invariant structurallySecure Credential Home
WhensecureCredentialHome: 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)
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.
Source: packages/skills/src/tools/builtin/sandbox/bwrap-provider.ts:196–218 — credential home bind removal for secure profile
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 |
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.
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.tsTroubleshooting
Broker request fails with 407 (bad_token):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:
Known Limitations
- One broker-issued exec per agent assembly: The session token is issued once per
assembleToolsForAgentcall, 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 a407 Proxy Auth Requiredfrom the broker rather than a clean execution error. Workaround: Limit each agent turn to a single exec call whenexecutor.brokeris enabled, or structure multi-step work so successive execs are in separate turns. Resolution: Per-command token issuance (threadingsessionManagerintoExecToolDepssoissueToken()is called insideexecute()) is planned as follow-on work.
Non-Goals (This Release)
- OAuth / subscription auth — future
- AWS SigV4 signing execution — the
awsSigV4finalizer 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
Related
Exec Sandbox
Filesystem isolation for the system.exec tool — the sandbox the broker protects
Secret Manager
Encrypted credential store that the broker resolves per-request
Audit Log
broker:* events in the full event taxonomy
Defense in Depth
How the credential broker fits into the 22 categorical security layers
