Skip to main content

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 secretRefsCredential broker
What it doesInjects env vars scoped to the exec toolInjects credentials at the network boundary
Who holds the secretThe exec process (env var visible inside)The daemon only (placeholder in sandbox)
Use whenSimple scripts needing a key as an env varAPI-key CLIs (Claude Code, curl, etc.)
Network guardNoneBroker-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.
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 (AppConfigSchemasetupBroker): 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
# 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

KeyTypeRequiredDescription
presetstringone of preset/hostRulesBuilt-in preset ID (anthropic, finnhub)
hostRulesHostRule[]one of preset/hostRulesCustom host rules (provider-agnostic)
secretRefstringyesSecretManager key resolved per-request
credentialRefsRecord<string, string>noExtra 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 IDHostInjectPath policy
anthropicapi.anthropic.com (exact)setHeader x-api-key raw, removeAuthorization: true/v1/*
finnhubfinnhub.io (exact)setParam tokenall paths
Source: packages/core/src/security/provider-catalog/presets.ts — PRESETS array, 2 entries

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

KindBehavior
exactMatches the host exactly
suffixMatches 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

KindFieldsBehavior
setHeadername, format (raw/bearer), removeAuthorization?Sets the named header unconditionally; optionally removes Authorization
replaceHeadername, format (raw/bearer)Sets the header only if it is already present in the request
removeHeadernameRemoves the named header
setParamnameAppends a query parameter; existing query bytes are preserved verbatim (signature-safe)
Source: packages/core/src/config/schema-broker.ts — InjectionRuleSchema discriminated union

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.
ScenarioHTTP statusbroker:denied reason
Missing or forged proxy token407bad_token
Host not in any binding403no_binding
Host matched but path denied by pathPolicy403path_policy
SecretManager returns undefined for secretRef502— (emits broker:credential_unavailable)
Request body exceeds 10 MiB413body_too_large
WebSocket upgrade attempt501ws_upgrade_not_supported
Malformed HTTP request400malformed_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.
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:
EventKey payload fields
broker:session_openedsessionId, agentId, host, presetId?, timestamp
broker:session_closedsessionId, agentId, durationMs, reason (teardown/error), timestamp
broker:requestsessionId, host, path, method, timestamp
broker:injectedsessionId, host, ruleKind, timestamp
broker:deniedsessionId, host, reason, statusCode, timestamp
broker:credential_unavailablesessionId, secretRef, agentId, timestamp
broker:egress_blockedsessionId, 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 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 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.
Source: packages/infra/src/credential-broker/broker-events.ts — typed emit helpers; emitEgressBlocked enforces the hash-only invariant structurally

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.
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):
Modebwrap argsDescription
open (default)--unshare-all --share-netStandard 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.
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

Troubleshooting

Broker request fails with 407 (bad_token):
# 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:
grep '"traceId":"<your-trace-id>"' ~/.comis/logs/daemon.log | jq '{step, event}'
Log a specific agent’s broker activity:
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.

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

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