@criticalbridge/conformance-sidecar
v1.3.2
Published
Language-agnostic conformance sidecar that makes an external A2A agent a fully governed CriticalBridge peer (registers, heartbeats, control-channel polls, proxies /tasks, emits per-task telemetry). Companion to the Brain's /api/brain/agents/v1 ingress (Ep
Maintainers
Readme
@criticalbridge/conformance-sidecar
Language-agnostic conformance sidecar that makes an external A2A agent a fully governed peer of the CriticalBridge Brain — handles registration, heartbeats, control-channel polling, the policy/HITL gate at
/tasks, telemetry emission, and JWT lifecycle. Companion to the Brain's/api/brain/agents/v1ingress.
If you're integrating a Python agent and want a higher-level API (@handler decorator, mint_credential(), etc.), use the criticalbridge Python SDK — it boots this sidecar for you. This package is the lower-level boundary for non-Python customers (Go, Java, Rust, …) or operators who want to manage the sidecar process independently.
Install
The Docker container is the recommended install path — it's language-agnostic, isolated from your agent runtime, and works identically on every host OS. npx and npm install -g are shortcuts for Node-native environments.
Docker (recommended)
docker run --rm -p 8080:8080 \
-v cb-sidecar-sessions:/home/sidecar/.criticalbridge/sidecar/sessions \
-e BRAIN_URL=https://api.criticalbridge.ai \
-e ENTITY_ID=your-entity-id \
-e ENROLLMENT_TOKEN=eyJhbGc.paste.your.token.here \
-e AGENT_VENDOR=langchain \
-e AGENT_BROKER_ID=your-agent-id \
-e AGENT_UPSTREAM_URL=http://your-agent-host:8000 \
-e SIDECAR_PUBLIC_URL=https://sidecar.your-domain.com \
ghcr.io/fburkitt/conformance-sidecar:1.3.2AGENT_UPSTREAM_URL DNS resolution. your-agent-host in the example is a placeholder. Replace with the hostname the sidecar container can reach:
host.docker.internal— if your agent runs on the host machine (Docker Desktop only)- A Docker Compose service name (e.g.
my-agent) — if the sidecar and agent share a network - A fully-qualified DNS name — if the agent runs on a separate machine reachable from inside the container
http:// is fine here (the upstream is private to the sidecar); SIDECAR_PUBLIC_URL still must be HTTPS.
Volume mount matters. The -v flag persists the 30-day registration JWT to a Docker volume. Skip it and container restarts have to re-exchange the (single-use) enrollment token — which fails after the first boot with enrollment_token_already_consumed. The path /home/sidecar/.criticalbridge/sidecar/sessions matches the image's non-root user (sidecar, uid 1001) — that's $HOME/.criticalbridge/sidecar/sessions for the default user, which is where the sidecar's default session directory now resolves (#8665). If you rebuild the image with a different user, adjust the mount path to that user's $HOME/.criticalbridge/sidecar/sessions.
The same directory (or SIDECAR_STATE_DIR, when set) also holds the last-known tool-call screening state (<entity>--<agent>.screening.json). Without the mount, a restarted sidecar can only learn that state from the Brain, so a restart during a Brain outage starts fail-open. See Tool-call screening.
npm
# Run on demand
npx @criticalbridge/[email protected]
# Or install globally
npm install -g @criticalbridge/[email protected]
cb-conformance-sidecarConfigure
All env vars are required; missing ones fail loud at boot. The error message lists every missing var in a single message — no need to iterate.
Required
| Variable | Purpose |
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| BRAIN_URL | CriticalBridge Brain Coordinator base URL (HTTPS, no trailing slash) |
| ENTITY_ID | Entity ID the sidecar registers under |
| ENROLLMENT_TOKEN | One-time enrollment token (mint via Portal /dashboard/managed-agents/enrollment-tokens). Single-use; persisted as a 30-day JWT after first boot |
| AGENT_VENDOR | One of langchain, autogen, crewai, semantic-kernel, haystack, other |
| AGENT_BROKER_ID | Durable per-agent ID within the entity — stable per-deployment; the resulting agent id is derived from (vendor, brokerId) |
| AGENT_UPSTREAM_URL | Where your agent listens (private OK) — /tasks requests get forwarded here |
| SIDECAR_PUBLIC_URL | Where the Brain dispatches to the sidecar (must be HTTPS). Point this at your tunnel / reverse-proxy hostname |
Optional
| Variable | Purpose |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| SIDECAR_PORT | Port the sidecar HTTP server listens on (default 8080) |
| SIDECAR_HITL_HOLDS_ENABLED | true reports each task parked on hitlRequiredFor[] to the Brain as a hold an operator approves or rejects per task (default off). See Per-task approvals |
| SIDECAR_HITL_SUMMARY_FIELDS | Comma-separated top-level request-body fields whose values may be shown to the approving operator, e.g. amount,payee. Unset: no field values leave the sidecar |
| SIDECAR_AGENT_PROXY_TOKEN_ENABLED | true fetches a proxy session token from the Brain and attaches it to MCP calls sent through the loopback agent-proxy forwarder (default off). See Agent proxy token |
| SIDECAR_AGENT_PROXY_URL | HTTPS base URL of the entity's agent proxy gateway. Required when SIDECAR_AGENT_PROXY_TOKEN_ENABLED=true |
| SIDECAR_AGENT_PROXY_HEADERS_TIMEOUT_MS | How long a forwarded MCP call waits for the gateway's response headers before answering 504 agent_proxy_timeout (default 30000). Streamed bodies are not cut |
| AGENT_DISPLAY_NAME | Label shown in the operator fleet view |
| AGENT_CAPABILITY_TAGS | Comma-separated capability strings, e.g. expense.submit,expense.approve |
| AGENT_CATALOG_IDENTIFIER | AI Catalog URN, e.g. urn:air:criticalbridge.ai:acme:support-agent |
| AGENT_CATALOG_MEDIA_TYPE | AI Catalog media type, e.g. application/a2a+json |
| AGENT_REPRESENTATIVE_QUERIES | Pipe-separated (\|) example queries the agent handles — up to 5, up to 256 chars each |
| AGENT_PUBLISHABLE | true | false — whether the agent should appear in federated AI Catalog discovery. Defaults to server-side (typically off) |
| AGENT_CARD_POLL_ENABLED | true | false — enable Agent Card drift detection (polls ${AGENT_UPSTREAM_URL}/.well-known/agent.json, ships hash + structural diff on the heartbeat). Default true |
| AGENT_CARD_POLL_INTERVAL_MS | Poll interval for the Agent Card fetcher, in milliseconds. Clamped to [15_000, 600_000]. Default 60_000 |
What it does
- First boot: exchanges your one-time
ENROLLMENT_TOKENfor a 30-day registration JWT, persists it at~/.criticalbridge/sidecar/sessions/<entity>__<agent>.json(mode 0600), registers the agent with the Brain. - Subsequent boots: reads the persisted JWT from the session file — no exchange needed. Volume-mount the session directory so the file survives container restarts (see the Docker example above).
- Steady state:
- Heartbeats the Brain on a server-tuned cadence (5s–10min)
- Polls the control channel for
executionAllowed+hitlRequiredFor[]posture - Serves
POST /tasks— gates by policy, forwards toAGENT_UPSTREAM_URL - Emits 60s rollups of latency p50/p99/avg + tasks_processed_total + tasks_failed_total
- Polls
${AGENT_UPSTREAM_URL}/.well-known/agent.jsonon a 60s cadence, hashes the card, and piggybacks any structural diff (added/removed/changed skill paths) on the next heartbeat so the Brain surfaces silent card drift — new tools your agent started advertising without operator approval. SetAGENT_CARD_POLL_ENABLED=falseto opt out - Refreshes the JWT inside the 7-day pre-expiry window
- Health:
GET /healthzreturns 200 for Kubernetes / Cloud Run liveness probes.
Per-task approvals
Off by default. With SIDECAR_HITL_HOLDS_ENABLED=true, a task whose skill is in hitlRequiredFor[] becomes an approval request in the operator's Approvals inbox instead of a silent park. It needs a Brain with BRAIN_MANAGED_AGENT_HITL_HOLDS_ENABLED=true and fleet auth in enforce mode; against any other Brain every HITL-required task simply stays parked.
What POST /tasks answers for a HITL-required skill:
| Situation | Response |
| --- | --- |
| Waiting for a decision | 200 {"status":"working","pendingDecisionId":…}. Re-send the same task to check again |
| Approved | The task is forwarded to your agent, once |
| Rejected | 403 hitl_rejected, with the operator's note. Permanent for that task |
| Nobody decided in time | 403 hitl_expired. Re-send the task to ask again |
| The approval was already used | 409 hitl_already_executed |
| No approval request could be opened | 429 hitl_unavailable (the approval queue is full) or 422 hitl_unavailable (the Brain refused the request; retrying will not help) |
Three things to know before turning it on:
- An approval covers one exact request body, once. The request is identified by its
taskIdplus a hash of the body. Changing any byte of the body asks for a new approval, so an agent that re-sends a task must re-send it byte for byte. After the task has been forwarded once, the same request is refused with409; if your agent fails after that point, the task needs a new approval. - Send a
taskId. Without one, two tasks with identical bodies cannot be told apart, and the second is refused with409rather than run on the first one's approval. - By default the operator does not see the request's contents. They see the skill, the body's size and hash, and the names of its top-level fields. List the fields that are safe and useful to show in
SIDECAR_HITL_SUMMARY_FIELDS; only string, number and boolean values are sent, truncated to 256 characters. With nothing listed, approving means approving "this skill, this exact body" unread.
Agent proxy token
Off by default. With SIDECAR_AGENT_PROXY_TOKEN_ENABLED=true and SIDECAR_AGENT_PROXY_URL set, the sidecar asks the Brain for a proxy session token (POST /api/brain/agents/v1/entities/:entityId/agents/:agentId/proxy-token, authenticated with the agent's registration JWT) and keeps it fresh: tokens last at most 15 minutes and are refreshed at 80% of their lifetime or 60 s before expiry, whichever comes first. The Brain needs AGENT_PROXY_TOKEN_ISSUER_ENABLED=true and fleet auth in enforce mode.
Point your agent's MCP client at the sidecar instead of the gateway:
POST http://127.0.0.1:8080/agent-proxy/servers/<serverId>/mcpThe sidecar forwards the call to <SIDECAR_AGENT_PROXY_URL>/v1/entities/<ENTITY_ID>/servers/<serverId>/mcp with x-cb-agent-did and x-cb-agent-token set (any x-cb-* headers your agent sends are dropped; its Authorization for the MCP server passes through). The route is loopback-only. When no token is available it answers 503 agent_proxy_token_unavailable (with Retry-After while backing off). If the Brain refuses the agent (403 agent_not_approved or agent_id_mismatch), the sidecar stops asking, logs an error, and answers 403 agent_proxy_token_refused with the reason until it is restarted. While the cached control-channel posture says the agent is not active or execution is disabled, the forwarder refuses with 403 agent_lifecycle_refusal / 403 execution_disabled, the same codes as /tasks. If the agent disconnects, the upstream call is aborted.
Tool-call screening
Your agent's MCP traffic does not pass through the sidecar, so the agent (or the SDK) asks the sidecar before and after each tool call. Both routes are loopback-only.
POST http://127.0.0.1:8080/mcp/authorize
{ "serverId": "github", "toolName": "search", "toolCallId": "tc-1", "arguments": { "q": "…" } }
→ 200 { "allowed": true, "reason": "unlisted_allowed", "policyVersion": "…", "screening": { … } }
POST http://127.0.0.1:8080/mcp/screen-result
{ "serverId": "github", "toolName": "search", "toolCallId": "tc-1", "result": { … } }
→ 200 { "allowed": true, "reason": "content_allowed", "sanitizedContent": "…" }/mcp/authorize applies the entity's MCP access policy first. When the call is allowed and carries arguments, the Brain then screens them against the entity's guardrail policy. Use sanitizedContent from /mcp/screen-result in place of the raw result when it is present. Screening is controlled per entity by the Brain's sidecar-guardrails capability; while it is off, nothing is screened and calls are allowed.
Once the Brain has said screening is on, the sidecar fails closed: a tool call without arguments is refused (screening_required), a Brain outage is refused (screening_unavailable), and so is a request over 156 KiB (content_too_large). After a restart the sidecar restores that state from the session directory and asks the Brain before the first call. Always send arguments.
What your agent must provide
- An HTTP
/tasksendpoint that accepts the A2A task envelope{ skill, taskId, ... }and returns the result. The sidecar forwards verbatim.
That's the bare minimum. For richer governance (decision lineage, audit events, just-in-time credentials, HITL gating), see the External Agent Integration Guide.
Troubleshooting
[sidecar] Missing required env vars: … — batch fail-loud at boot. The message lists every unset required var. Fix them all at once and retry.
enrollment_token_already_consumed at boot — the enrollment token is single-use, and either (a) you're restarting the container without a volume mount for the session file, or (b) you copy-pasted the token to a second sidecar. Fix (a) with -v cb-sidecar-sessions:/home/sidecar/.criticalbridge/sidecar/sessions; fix (b) by re-minting a fresh token for each sidecar.
401 unauthorized on register — your registration JWT's entity or broker doesn't match the URL/body. Re-mint the enrollment token, making sure the AGENT_BROKER_ID on your sidecar matches the broker registration id in the Portal form.
503 Managed agent registration requires a seat license — the enrollment token you minted didn't carry a seatLicenseId claim (pre-1d token) or your entity has no available seats. Re-mint a fresh token via the Portal after your entity has at least one seat licensed.
Brain never sees the sidecar despite the local log saying register OK — your SIDECAR_PUBLIC_URL isn't reachable from the Brain. Test it: curl https://your-sidecar-public-url/healthz from a machine outside your network should return 200. If it fails, fix your tunnel / ingress before restarting the sidecar.
Enrollment token expired before boot — enrollment tokens are 1h TTL. Mint a fresh one via the Portal.
License
Apache-2.0 © Frank Burkitt and CriticalBridge.AI contributors.
