Gateway API
The defenseclaw-gateway sidecar API — its registered route patterns, authentication and CSRF model, inspection verdict shape, the /api/v1/sandbox REST API, and the OpenShell sandbox hook ingress and egress proxy. The handler code is authoritative.
defenseclaw-gateway exposes a sidecar management API used by hook scripts,
connector plugins, the CLI, and the TUI. This page lists every static route
pattern registered by APIServer.Run, the two provider routes installed by
registerProviderRoutes, and every built-in connector hook route.
This is not an OpenAPI specification. The canonical request and response
shapes are the handlers in
internal/gateway/api.go
and the files referenced from its mux.HandleFunc registrations.
This page covers the sidecar API on gateway.api_port. The separate guardrail
proxy listener on guardrail.port has its own OpenAI-compatible and provider
routes in internal/gateway/proxy.go. The routes that manage OpenShell
sandboxes are under Sandbox API. The two listeners that
sandboxes themselves reach, the hook ingress and the egress proxy, are
described under Sandbox listeners.
Bind address
| Knob | Config default | Effective value |
|---|---|---|
gateway.api_port | 18970 | Sidecar API port |
gateway.api_bind | empty | 127.0.0.1 when empty, except on an uncleaned legacy sandbox host (below) |
The API binds to loopback by default. The one exception is a host that still
carries the removed legacy standalone sandbox: while the config still says
openshell.mode: standalone, an empty gateway.api_bind inherits
guardrail.host when that value is non-empty and is not the literal
localhost (for example 10.200.0.1), so upgrade health checks keep
reaching the gateway. Running defenseclaw sandbox legacy-cleanup
resets that config and returns the API to loopback. An explicit
gateway.api_bind always takes precedence. A non-loopback value expands access
to the authenticated management surface; protect that listener with host
firewalling and a deployment-specific network design.
gateway.host and gateway.port describe the upstream agent gateway, while
guardrail.host/guardrail.port control the separate guardrail proxy
listener.
Auth
Every request except GET /health must authenticate. The shared authentication
middleware accepts these header forms; all three are constant-time compared
with the resolved gateway token:
Authorization: Bearer <token>
X-DefenseClaw-Token: <token>
X-DC-Auth: Bearer <token>PATCH /v1/guardrail/config performs a second, endpoint-local check and accepts
only Authorization: Bearer or X-DefenseClaw-Token; X-DC-Auth alone is
rejected there. The endpoint also rejects every patch in
managed_enterprise deployment mode.
Loopback connector hook routes can instead use the matching connector's narrow
hook token. The Codex token also authorizes /api/v1/codex/notify. A
connector-scoped token on a generic /api/v1/inspect/* route is accepted only
over loopback and only when X-DefenseClaw-Connector names the same registered
connector.
OTLP credentials are source-specific for Codex and Claude Code. They send their scoped
bearer to /v1/{logs,metrics,traces} together with
X-DefenseClaw-Source: codex|claudecode; that header selects and must match the
scoped credential. Once a scoped token exists for one of those sources, the
master gateway token is rejected for that source's scoped route. Copilot CLI
upstream documents optional OTel traces and metrics, but DefenseClaw does not
configure, authenticate, ingest as a Copilot-native source, or certify that
surface. Copilot connector telemetry therefore reaches this API through its
authenticated hook route only. OmniGent's separately documented optional
native source currently authenticates loopback /v1/{signal} requests with
the master gateway token.
The token is resolved from one of, in priority order:
- The env var named by
gateway.token_env(custom name — operator intent wins) DEFENSECLAW_GATEWAY_TOKEN(canonical)OPENCLAW_GATEWAY_TOKEN(legacy fallback for existing installs)gateway.tokendirectly in~/.defenseclaw/config.yaml
There is no standalone ~/.defenseclaw/gateway-token file. On first boot
the sidecar creates DEFENSECLAW_GATEWAY_TOKEN in
~/.defenseclaw/.env when neither a supported environment nor dotenv source
already supplies a token. Rotate a locally managed token with
defenseclaw setup rotate-token --yes; that transaction restarts the gateway
and refreshes active connector credentials. An externally managed custom
gateway.token_env must be rotated by its owning secret system.
A request that carries an OpenShell sandbox binding credential (a dcsb_
value in Authorization, X-DefenseClaw-Token, X-DC-Auth, or an OTLP path
token) gets 401 on every authenticated route of this API, before any other
check. Those credentials are valid only on the hook ingress.
CSRF protection
Every non-GET/HEAD request also requires X-DefenseClaw-Client (any
non-empty caller identifier such as cli, openclaw-plugin, or
inspect-hook/1.0). POST, PUT, PATCH, and DELETE requests require
Content-Type: application/json; OTLP requests instead require
application/json or application/x-protobuf. OPTIONS requires the client
header but no content type. The only narrow client-header exception is a
loopback scoped-token OTLP path, because some native exporters cannot add
arbitrary headers; that path still requires an OTLP content type and rejects a
non-local browser origin.
All ordinary mutations also reject a non-local Origin; cross-site requests
are rejected when Sec-Fetch-Site identifies them. A shared middleware caps
ordinary POST, PUT, and PATCH bodies at 1 MiB. Exact authenticated OTLP-HTTP
ingest routes use a separate 64 MiB receiver limit so exporter batches can
follow the protocol's recommended bound without increasing the limit for the
rest of the API.
X-DefenseClaw-Client: cliEndpoints
Health & status
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /health | exempt | Liveness + uptime + version. Used by k8s probes and defenseclaw doctor. |
GET | /status | required | Health, process identity, provenance, an optional connected-gateway hello, legacy singular connector_mode, and connector_modes (one entry per active connector). Used by defenseclaw status and the TUI. |
GET | /v1/connectors | required | Lists every connector registered in the gateway (name, description, source, hook capabilities, tool inspection mode). |
When openshell.enabled is true, /health includes a sandbox subsystem
for the OpenShell sandbox runtime:
running, with the hook ingress and egress proxy addresses indetails.ingressanddetails.egress;degraded, with alast_errorthat names each failing part:ingress,egress,manager, oropenshell(the connection to the OpenShell gateway). Sandbox hooks fail closed while a part is down;disabled, with the reason, on a platform or in a deployment that can't run sandboxes: any platform but Linux and macOS, or amanaged_enterpriseinstall;error, with the cause inlast_error, when the sandbox runtime can't be set up at all, for example because its binding store or image store can't be opened. The sandbox routes then answer 503unavailable, and sandbox hooks fail closed;stoppedwhile the API listener restarts.
On a host that still carries the removed legacy standalone sandbox (see
Bind address), /health includes a sandbox subsystem with
state degraded and a last_error of "legacy standalone install detected",
directing the operator to run defenseclaw sandbox legacy-cleanup. On a host
with neither, where openshell.enabled is false and no legacy install is
left, the sandbox subsystem is absent from /health.
GET /status carries both a singular and a plural connector field so old and new clients keep working on a multi-connector install:
{
"connector_mode": {
"connector": "codex",
"mode": "observability",
"policy_mode": "action",
"guardrail_mode": "action",
"hook_fail_mode": "closed",
"enabled": true,
"hook_enforcement": true,
"enforcement_surface": "agent_lifecycle_hooks",
"telemetry": ["hooks", "otel", "notify"],
"proxy_intercept": false
},
"connector_modes": [
{
"connector": "codex",
"mode": "observability",
"policy_mode": "action",
"guardrail_mode": "action",
"hook_fail_mode": "closed",
"enabled": true,
"hook_enforcement": true,
"enforcement_surface": "agent_lifecycle_hooks",
"telemetry": ["hooks", "otel", "notify"],
"proxy_intercept": false
},
{
"connector": "claudecode",
"mode": "observability",
"policy_mode": "observe",
"guardrail_mode": "observe",
"hook_fail_mode": "closed",
"enabled": true,
"hook_enforcement": false,
"enforcement_surface": "agent_lifecycle_hooks",
"telemetry": ["hooks", "otel"],
"proxy_intercept": false
}
]
}connector_mode(singular) is the same summary object for the primary connector, retained for clients written before multi-connector existed. On a fan-out install it is not the authoritative roster view; useconnector_modesinstead.connector_modes(plural) is the authoritative per-connector view: one entry per active connector.modeis the backward-compatible data-path value (guardrailorobservability);policy_modeandguardrail_modereport the effectiveobserve/actionposture;hook_fail_modeis the effective hook fail mode;enabledis the connector's effective guardrail enablement;hook_enforcementis true for a non-proxy connector in action mode;enforcement_surfaceidentifies the proxy, lifecycle-hook, or OmniGent policy API path;telemetrylists reported wiring channels; andproxy_interceptis true only for proxy connectors.mode,enforcement_surface, andproxy_interceptare derived from the connector's declared LLM traffic mode, through the same predicate the sidecar uses to decide whether to bind the proxy listener, so this array cannot disagree with what is actually in the data path.defenseclaw-gateway statusrenders its per-connector "Connector Mode" section directly from this array.
Connector hooks
Built-in connectors that implement the hook endpoint contract register the following routes. Proxy connectors such as OpenClaw and ZeptoClaw do not register a connector hook route.
| Method | Path | Connector | Source |
|---|---|---|---|
POST | /api/v1/claude-code/hook | Claude Code | internal/gateway/connector/claudecode.go |
POST | /api/v1/codex/hook | Codex | internal/gateway/connector/codex.go |
POST | /api/v1/cursor/hook | Cursor | internal/gateway/connector/hook_only.go |
POST | /api/v1/devin/hook | Devin | same; native CLI lifecycle events only |
POST | /api/v1/copilot/hook | GitHub Copilot CLI | same |
POST | /api/v1/openhands/hook | OpenHands | same |
POST | /api/v1/antigravity/hook | Antigravity | same |
POST | /api/v1/hermes/hook | Hermes | same |
POST | /api/v1/opencode/hook | OpenCode | same |
POST | /api/v1/amp/hook | Amp | internal/gateway/connector/amp.go |
POST | /api/v1/omnigent/hook | OmniGent | internal/gateway/connector/omnigent.go |
All hook endpoints share a per-IP rate limiter (20 RPS, burst 40 — see hookLimiter in api.go). Loopback callers (the on-host hook scripts) are exempt; the limit only applies to remote callers.
Inspection (rate-limited)
The /api/v1/inspect/* family is the hot path that connector hooks and the OpenClaw plugin call to score a single tool call, prompt, or completion. Mounted under a sub-mux that wraps the per-IP rate limiter.
| Method | Path | Used by | Purpose |
|---|---|---|---|
POST | /api/v1/inspect/tool | Shared hook script; OpenClaw extension | Inspect a tool call (tool + args), or inspect message content when tool: "message" supplies content and direction. Returns the verdict envelope below. |
POST | /api/v1/inspect/request | Shared hook script | Inspect an outbound LLM request (prompt). |
POST | /api/v1/inspect/response | Shared hook script | Inspect a model response. |
POST | /api/v1/inspect/tool-response | Shared hook script | Inspect a tool's output before it returns to the agent. |
/api/v1/inspect/tool is the existing trusted tool-call entry point; there is
no client-supplied is_tool_call switch and no additional semantic endpoint.
The authenticated route and its server-side connector identity establish the
boundary. A request with tool: "message" stays in the message lane and is not
eligible for structured tool-call CEL rules.
Single-call semantic findings are available on this inspect route and on
native pre-tool hooks. Ordered tool chains are stricter: they use durable
SQLite state only on authenticated /api/v1/{connector}/hook events with
canonical connector and session correlation. Inspect, proxy, router, and OTLP
traffic never advance a chain. Only a matching authoritative successful result
can promote an existing pending proposal into a durable successful predecessor.
A chain can deny only its final step on an enforcement-safe synchronous
pre-tool event. Post-tool and tool-result events never make the final blocking
decision.
See the stateful connector lifecycle for
the pairing, state-level, and cutoff contracts.
Chain matches are HIGH severity and retain the connector's effective action
matrix: default and permissive profiles alert, strict blocks, and eligible
human-in-the-loop configurations confirm. A durable deny receipt is created
only when that effective action is block.
| Ordered chain | Wall-clock window | Event horizon |
|---|---|---|
| Guardrails disabled → external egress | 30 minutes | 64 events |
| Permission denied → runtime bypass | 5 minutes | 16 events |
| Privilege discovery → elevation | 15 minutes | 32 events |
| Secret-manager read → external egress | 30 minutes | 64 events |
| Sensitive secret read → external egress | 30 minutes | 64 events |
| Workload identity access → lateral execution | 15 minutes | 32 events |
Chain state contains bounded masks and fingerprints rather than raw commands, arguments, paths, endpoints, or tool payloads. Durable deny receipts replay the same final decision without duplicating finding telemetry.
Code & network scanning
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/scan/code | Run the built-in CodeGuard scanner on the local filesystem path supplied in the JSON body. |
GET | /api/v1/network-egress | List persisted egress audit records. Supports limit, hostname, session_id, agent_id, root_agent_id, user_id, blocked, and RFC3339 since filters. |
POST | /api/v1/network-egress | Ingest one observed egress event into the audit logger; this is not an allow/block decision endpoint. |
Asset scanning
| Method | Path | Purpose |
|---|---|---|
POST | /v1/skill/scan | Run the skill scanner against the local target directory named in the request. |
POST | /v1/plugin/scan | Run the plugin scanner against the local target directory named in the request. |
POST | /v1/mcp/scan | Run the MCP scanner against a target URL or local path. |
POST | /v1/skill/fetch | Stream a tar.gz of an existing local directory after resolving it beneath a configured skill or plugin root; this does not fetch from a registry. |
POST | /scan/result | Persist a scanner result through the canonical audit logger. |
Asset enable / disable
| Method | Path | Purpose |
|---|---|---|
POST | /skill/disable | Ask the connected upstream agent gateway to disable a skill by key (skills.update). |
POST | /skill/enable | Ask the connected upstream agent gateway to enable a skill by key (skills.update). |
POST | /plugin/disable | Ask the connected upstream/OpenClaw gateway to disable a plugin through its config RPC. |
POST | /plugin/enable | Ask the connected upstream/OpenClaw gateway to enable a plugin through its config RPC. |
These routes return 503 when no upstream client object is present and 502
when an attempted upstream RPC fails, including a disconnected client. They do
not perform the CLI's filesystem quarantine/restore workflow.
Inventory
| Method | Path | Purpose |
|---|---|---|
GET | /skills | Return the connected upstream agent gateway's skills.status RPC payload. An absent client returns 503; an RPC/disconnection failure returns 502. |
GET | /mcps | Return MCP server entries for the singular active connector from DefenseClaw configuration; unavailable or malformed input returns an empty list. |
GET | /tools/catalog | Return the one connected upstream agent gateway's tools.catalog RPC payload. An absent client returns 503; an RPC/disconnection failure returns 502. |
Provider registry
| Method | Path | Purpose |
|---|---|---|
GET, HEAD | /v1/config/providers | Return the merged built-in and operator provider registry plus overlay status. |
POST | /v1/config/providers/reload | Reload the built-in registry and custom-providers.json overlay. |
Policy
| Method | Path | Purpose |
|---|---|---|
POST | /policy/evaluate | Debug the admission policy with {domain?: "admission", input: {target_type, target_name, path?, scan_result?}}. DefenseClaw injects its current allow/block lists and returns {ok: true, data: <AdmissionOutput>}; arbitrary OPA domains/input are rejected. |
POST | /policy/evaluate/firewall | Evaluate the firewall sub-policy in isolation. |
POST | /policy/evaluate/audit | Evaluate the audit sub-policy. |
POST | /policy/evaluate/skill-actions | Evaluate the skill_actions sub-policy by severity. |
POST | /policy/reload | Atomically reload .rego modules plus optional data.json from configured policy_dir (or its rego child), invalidate the judge cache, and return {"status":"reloaded","policy_dir":"..."}. |
Guardrail control plane
| Method | Path | Purpose |
|---|---|---|
POST | /v1/guardrail/event | Emit canonical telemetry for an already-decided guardrail event. Requires evaluation_id, direction (prompt or completion), action (allow, alert, or block), and severity (NONE through CRITICAL); latency/token values must be finite and nonnegative. Returns {"status":"ok"}. No production caller is wired in this repository. |
POST | /v1/guardrail/evaluate | Combine caller-supplied, precomputed local_result / cisco_result values with required evaluation_id, direction, and mode, plus optional model/scanner/content/timing metadata. It does not scan arbitrary content and returns a GuardrailOutput with action, severity, reason, and scanner_sources. |
GET | /v1/guardrail/config | Return the active guardrail config snapshot. |
PATCH | /v1/guardrail/config | Patch supported guardrail runtime fields into the active DefenseClaw config file, validate the full YAML file, and apply it through the central reloader. Body is JSON, not raw YAML. Supported fields: mode, scanner_mode, block_message, connector, hilt_enabled, hilt_min_severity. Managed-enterprise deployments reject this API mutation. |
Changing connector uses restart semantics so listener and hook state are rebuilt; it is not accepted as an in-place connector swap.
Enforcement logging
These endpoints manage the durable enforcement allow/block lists.
| Method | Path | Purpose |
|---|---|---|
POST, DELETE | /enforce/block | Add a block, or remove it with DELETE. |
POST | /enforce/allow | Add an allow and re-enable a disabled skill/plugin when required. |
GET | /enforce/blocked | List blocked entries. |
GET | /enforce/allowed | List allowed entries. |
Audit
| Method | Path | Purpose |
|---|---|---|
POST | /audit/event | Append one audit-event JSON object to the configured audit logger/store. The production OpenClaw extension client uses this route; managed connector hook routes audit internally. |
GET | /alerts | Return recent alert rows. limit defaults to 50 and is capped at 500. |
POST | /api/v1/alerts/disposition | Preview or apply an audited alert acknowledgement/disposition operation. |
There is no general audit-history or streaming HTTP route. Read/export history through the CLI:
defenseclaw-gateway audit export --output audit.jsonl # JSONL of audit_events
tail -f ~/.defenseclaw/gateway.jsonl | jq # Optional configured v8 JSONL destination
defenseclaw alerts # Recent alerts (paginated)
defenseclaw tui # Live dashboardAudit export includes the legacy details text and, when present, first-class structured JSON from audit_events.structured_json. Connector hook rows use schema: "defenseclaw.hook.v1"; older parsers can still read the mirrored details_json= token inside details.
Config
| Method | Path | Purpose |
|---|---|---|
POST | /config/patch | Bridge {path, value} to the connected upstream agent gateway's WebSocket config.patch RPC. It does not write DefenseClaw's config.yaml; an absent client returns 503, while an RPC/disconnection failure returns 502. |
Runtime control and internal observability bridges
These authenticated routes are narrow process/CLI integration seams rather than general-purpose ingestion APIs.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/admin/shutdown | Request graceful shutdown. Restricted to loopback and requires the current PID and data directory identity. |
POST | /api/v1/telemetry/canary | Emit a trace canary through the active observability-v8 runtime. |
POST | /api/v1/watchdog/recovery | Record a watchdog recovery metric. Restricted to loopback. |
POST | /api/v1/observability/destination-test/activity | Persist destination-test compliance activity. Restricted to loopback and the exact python-cli client marker. |
POST | /api/v1/observability/cli | Hand validated CLI events to the process-owned observability-v8 runtime. |
AI usage / discovery (AIBOM)
The continuous AI Discovery surface — see AI Discovery for the operator workflow.
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/agents/discovery | Receive an agent-discovery report from the on-host scanner. |
GET | /api/v1/ai-usage | Current continuous-discovery snapshot (summary plus agent, process, endpoint, component, and local-model signals). Local responses retain each model's dedicated model block. |
POST | /api/v1/ai-usage/scan | Trigger an on-demand AI usage scan. |
POST | /api/v1/ai-usage/discovery | Ingest a validated external AI-discovery report. |
GET | /api/v1/ai-usage/components | Aggregated components across the active workspace. |
GET | /api/v1/ai-usage/components/{ecosystem}/{name}/locations | Where the component was detected. |
GET | /api/v1/ai-usage/components/{ecosystem}/{name}/history | Detection history for one component. |
GET | /api/v1/ai-usage/confidence/policy | Show the active confidence-scoring policy. |
POST | /api/v1/ai-usage/confidence/policy/validate | Dry-run validation of a candidate policy file. |
Correlation ledger
Each read route accepts exactly one supported identity anchor (for example
record_id, evaluation_id is not a supported query key here,
semantic_event_id, session_id, trace_id, or tool_invocation_id) and
supports bounded cursor pagination.
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/correlation/graph | Return the evidence-backed identity graph around an anchor. |
GET | /api/v1/correlation/explain | Explain how records are linked to an anchor. |
GET | /api/v1/correlation/timeline | Return the correlated event timeline. |
GET | /api/v1/correlation/conflicts | Return identity conflicts associated with an anchor. |
Codex bridge
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/codex/notify | Codex agent-turn-complete notifier. The Codex notify-bridge.sh shim posts each turn's JSON arg here so the gateway can audit turn counts and completion reasons. |
OTLP receiver
The gateway accepts OTLP-HTTP from configured connectors. Both OTLP JSON and
protobuf content types are accepted; decoding is implemented in
internal/gateway/otel_ingest.go. Request bodies are bounded at 64 MiB; larger
batches receive HTTP 413 and are recorded as content-free body_too_large
rejections.
| Method | Path | Purpose |
|---|---|---|
POST | /v1/logs | Ingest OTLP logs. |
POST | /v1/metrics | Ingest OTLP metrics. |
POST | /v1/traces | Ingest OTLP traces. |
POST | /otlp/{source}/{token}/v1/{signal} | Connector-scoped logs, metrics, or traces for a native exporter that cannot set an auth header. The path token is accepted only over loopback and is removed from route telemetry. |
See internal/gateway/otel_ingest.go for the parsing details and Local observability for the operator setup.
Sandbox management
| Method | Path | Purpose |
|---|---|---|
GET, POST, DELETE | /api/v1/sandbox/... | Create, inspect, and control OpenShell sandboxes, answer their asks, lift egress blocks, explain their policy, and stream their activity. See Sandbox API. |
Verdict envelope
All four /api/v1/inspect/* endpoints serialize the same
ToolInspectVerdict:
{
"action": "block | confirm | alert | allow",
"raw_action": "block",
"severity": "CRITICAL | HIGH | MEDIUM | LOW | INFO | NONE",
"confidence": 0.93,
"reason": "DefenseClaw policy blocked this action (rule CMD-RM-RF: Recursive force delete from critical root path). Do not retry it in another form.",
"findings": ["CMD-RM-RF:Recursive force delete from critical root path"],
"detailed_findings": [
{
"rule_id": "CMD-RM-RF",
"title": "Recursive force delete from critical root path",
"severity": "CRITICAL",
"confidence": 0.95,
"evidence": "rm -rf /",
"tags": ["destructive", "shell"]
}
],
"mode": "action | observe",
"would_block": true,
"approval_timeout_ms": 45000
}Notable behaviours:
reasonis the text the agent shows. For a block or confirmation by a policy rule it says that DefenseClaw policy decided and names the rule by ID; a title is kept only for DefenseClaw's built-in rules, and rule-pack titles and scanner text stay in the audit record, which keeps the full source reason. A block also tells the agent not to retry the action in another form. In the standalone enterprise profile the text names the organization's policy and tells the user to contact the administrator.actionis the effective verdict, not the raw policy decision.applyMode()downgradesblock,confirm, andalerttoallowoutside action mode and preserves the original inraw_action. It setswould_block: trueonly for a downgradedblock; downgradedconfirmandalertleave that field false/omitted.confirmis surface-dependent. A native OpenClaw tool-approval caller can receiveconfirmand resolve it in the plugin. A non-native caller cannot safely pause, so the inspect handler fails the confirmation closed toblock. Proxy-lane confirmations may be resolved by the in-processHILTApprovalManager; unsupported proxy surfaces degrade toalert. There is no/v1/hilt/*HTTP API.evaluation_idis the runtime join key in telemetry. Inspect calls stamp it on structured JSONL and audit/scan rows, but the currentToolInspectVerdictHTTP schema does not exposeevaluation_idorrule_ids. Connector-hook responses do expose both fields, as shown below. See Observability §1.4 for the per-surface contract.- Pattern-rule and CodeGuard decisions populate both representations for
backward compatibility. Clean verdicts can omit details, and static, MCP, or
AID merge paths can add string
findingswithout synthesizing matchingdetailed_findings. - Optional fields are omitted when empty. In particular,
approval_timeout_msis absent when its value is zero. A structured finding can additionally includetool_capability_class; it has noremediationfield. - Empty request/response content takes a fast allow path. Those two
handlers return the same verdict type, but before
applyMode()runs; the serialized non-optionalmodeis therefore an empty string andraw_actionis absent.
Other endpoints return their own shapes — /v1/skill/scan returns a scanner-specific envelope, /audit/event returns {"status": "ok"}, and /skill/disable returns {"status": "disabled", "skillKey": "..."}. Always confirm against the handler code before assuming a shape.
Connector hook responses
A connector hook response can carry evaluation_id and the top rule_ids
when the evaluation produced correlation data:
{
"action": "allow | block | deny | …",
"reason": "rule X fired",
"findings": ["rule.id.one:First finding", "rule.id.two:Second finding"],
"evaluation_id": "eval-c1d0…",
"rule_ids": ["rule.id.one", "rule.id.two"]
}Connector-hook responses expose a string findings list, not the inspect
endpoint's detailed_findings objects. Pattern-rule entries normally use
rule_id:title; some scanner-specific paths emit bare finding IDs.
rule_ids is the canonical bare-ID list populated with evaluation_id when
the hook evaluation produced correlation data.
For /api/v1/inspect/*, evidence is redacted by default.
X-DefenseClaw-Reveal-PII: 1 requests raw response evidence. The tool-inspect
handler records an inspect-reveal audit event; the request, response, and
tool-response handlers currently reveal without that extra audit event.
Sandbox API
The routes under /api/v1/sandbox/ manage NVIDIA OpenShell sandboxes. The
defenseclaw sandbox CLI, the TUI Sandboxes panel, and the macOS app call
them. The daemon is the only writer of sandbox bindings, approvals, and the
sandboxes' OpenShell policy rules, and it creates and changes the OpenShell
sandboxes and providers. Two CLI commands also write to the OpenShell
gateway directly: sandbox teardown deletes the sandboxes and providers the
daemon doesn't delete (it is down, or doesn't know them) and removes unused
DefenseClaw provider profiles, and sandbox setup imports the hook ingress
provider profile when the gateway lacks it. The CLI keeps the terminal, the
copy-mode workspace, the image builds, and the installer; see
Sandbox CLI. The wire types are in
internal/openshell/sandboxapi,
and the router is internal/gateway/api_sandbox.go.
Auth and CSRF. The routes sit on the main API port behind the same
middleware as every other route. They need the master gateway token (see
Auth); a sandbox binding credential gets 401. Mutating requests carry
X-DefenseClaw-Client (the CLI sends defenseclaw-sandbox) and
Content-Type: application/json (see CSRF protection).
Bodies are decoded strictly: an unknown field, trailing data, or malformed
JSON gets 400 invalid_request, and a body over 256 KiB gets 413. Where the
table marks a body optional, an empty body means the defaults.
When sandboxes are off. Until the sandbox runtime runs, every route except
GET /api/v1/sandbox/status answers 503: code disabled when
openshell.enabled is false, and unavailable when it is true but the
runtime isn't running (see the /health sandbox subsystem above).
GET /api/v1/sandbox/status then still answers 200, with enabled and the
reason.
| Method | Path | Request | Response |
|---|---|---|---|
GET | /api/v1/sandbox/status | — | Status: enabled, available and reason, the OpenShell gateway (with the compute driver it runs: docker, or vm for OpenShell's MicroVM driver), ingress_addr and egress_addr, the configured pack and profile, admin (see below), counts of sandboxes, running, and pending_approvals, last_reconcile, started_at (when the sandbox runtime started: nothing keeps the sandboxes' hook and egress counters across a restart, so they count from then), and daemon_uid (the uid the daemon runs as; absent on Windows). |
GET | /api/v1/sandbox/sandboxes | — | {"sandboxes": [Sandbox]} |
POST | /api/v1/sandbox/sandboxes | CreateRequest | Sandbox |
GET | /api/v1/sandbox/sandboxes/{name} | — | Sandbox |
DELETE | /api/v1/sandbox/sandboxes/{name} | Optional {"keep_snapshot": bool} | DeleteResponse: name, deleted, the removed providers, warnings |
POST | /api/v1/sandbox/sandboxes/{name}/stop | Optional {} | Sandbox |
POST | /api/v1/sandbox/sandboxes/{name}/start | Optional {"no_snapshot": bool, "new_snapshot": bool}; 409 conflict when the sandbox is already running | Sandbox |
POST | /api/v1/sandbox/sandboxes/{name}/undo | Optional {"preview", "keep_refs", "stop", "restart"} (all bool) | UndoResponse: the workspace result, whether it stopped and restarted the sandbox, a summary, and a review |
POST | /api/v1/sandbox/sandboxes/{name}/review | Optional {"diff": bool} | ReviewResponse: the report, a summary, a risk_line, and the diff when asked |
POST | /api/v1/sandbox/sandboxes/{name}/accept | Optional {"snapshot_created_at": time, "session": int}; 409 conflict when the sandbox is running, its undo point was undone, it is another one than snapshot_created_at names, or the sandbox was started again since the session named; 400 invalid_request for a copy-mode sandbox; 404 not_found without an undo point | Sandbox, whose snapshot carries accepted_at |
GET | /api/v1/sandbox/sandboxes/{name}/logs | Query lines (optional, 0 or absent for all of it); 404 not_found when no log was kept | RunLog: name, state (exited or interrupted), exit, started_at, kept_at, log |
POST | /api/v1/sandbox/sandboxes/{name}/workspace | WorkspaceReport | {"status": "recorded"} |
GET | /api/v1/sandbox/approvals | Query sandbox (optional) | {"approvals": [Approval]} |
POST | /api/v1/sandbox/approvals/{id} | ApprovalDecision | ApprovalResult: the approval, whether it was persisted, a message |
POST | /api/v1/sandbox/egress/unblock | UnblockRequest | UnblockResponse: host, sandbox, scope (sandbox or always), persisted, message |
GET | /api/v1/sandbox/policy/explain | Query, see below | Explain |
GET | /api/v1/sandbox/activity | Query sandbox, since, follow | {"events": [ActivityEvent]}, or a server-sent event stream |
Any other path under /api/v1/sandbox/ gets 404 not_found.
Sandbox requests and responses
POST /api/v1/sandbox/sandboxes creates and starts a sandbox. The CLI sends
it for sandbox run.
| Field | Meaning |
|---|---|
harness | Required. The connector to run, such as claudecode or codex. |
project | Required. The absolute host path of the launch folder. In mount mode the daemon checks and mounts it. In copy mode it only labels the sandbox: the CLI stages and uploads the copy after the create, then reports it with POST …/{name}/workspace. |
name | Optional. At most 19 lowercase letters, digits, and - (OpenShell 0.1.1 refuses longer names). Empty picks <folder>-<random>, the folder name cut to fit. |
pack, profile, copy, safe, yolo, context, unmask, host_ports, no_mcp, learn, cpu, memory | The run options that take part in policy resolution. |
no_snapshot | Skip the pre-session snapshot of a mounted project, and so undo. |
no_build | Refuse, with 409 image_unavailable, instead of building and hook-checking a missing harness image inside the request. A build can take minutes. |
llm | The model credential: profile (a provider profile the harness supports, such as defenseclaw-anthropic), credentials (its environment variable names and secret values), and bedrock_region. |
credentials | --credential bindings: name, value, host, and port (default 443). |
env | Non-secret environment variables. DefenseClaw, proxy, and loader variables, and variables the harness image pins, are refused. |
Secret values in llm.credentials and credentials go to OpenShell as
provider credentials. The daemon never stores them or returns them.
A Sandbox carries its name and OpenShell id, the harness, its
phase (lowercase OpenShell phase such as ready or stopped, or missing
when OpenShell no longer has it), and its policy: pack, pack_digest,
profile, network_mode, approvals, and yolo. It also carries
workdir_mode (mount or copy), project, and workdir, the image
(image, image_id, and on a MicroVM gateway the run_image and
run_image_id it boots, which carries its run's harness settings) and
tamper_tier, hooks (hook traffic, blocked tool calls, the last block's
plain reason, tamper counts, the verdicts per hook event under the
harness's own event names in events, at most 48 names with the rest in
other_events, and whether hooks are silent or unreachable),
endpoints (the last result of each credentialed endpoint),
egress totals, pending_approvals, the
workspace and mcp banner views, snapshot, session (a count that goes
up each time the daemon sees the sandbox become ready), violations (settings the
policy clamped), warnings, orphaned, and nested_repos (repositories the
nested-repository guard found in a mounted project). launch holds what the
CLI needs to start the harness. credentials lists the sandbox's
--credential bindings (name, host and port, never the value) and
host_ports the host ports it may ask to reach: what a resume keeps.
Undo point and detached runs
A mounted sandbox's snapshot is its undo point: kind, ref,
created_at, undone_at once undo ran, and accepted_at once the user
kept the changes made on top of it. Every start takes a fresh snapshot
unless the folder still holds changes an earlier session made that nobody
undid or accepted, which keeps the undo point so undo still reverts them,
whoever starts the sandbox. POST …/{name}/accept records the acceptance of
a stopped sandbox's changes (the CLI sends it when the user keeps them at the
end of a session, with the created_at of the snapshot it reviewed them
against and the sandbox's session then, so an accept after another start,
whose changes nobody reviewed, is refused). The next start then takes a fresh
snapshot and drops the acceptance; a start with no_snapshot keeps the old
snapshot but drops the acceptance too. A start that fails while OpenShell
still reports the sandbox stopped keeps the acceptance for the next one.
new_snapshot on a start accepts the changes too.
Every stop of a running sandbox (…/stop, …/undo with stop, and a
tamper stop under hooks.on_tamper: stop) looks at the sandbox's latest
detached run (sandbox run --detach) before it ends the harness. A run
still going is marked interrupted, and the feed gets a sandbox.lifecycle
event with reason run_interrupted. Once the harness has exited and the
sandbox reads stopping, the daemon keeps the last 1 MiB of the run's log on
the host (under <data_dir>/sandboxes/<name>/runlog/), which GET …/{name}/logs serves while the sandbox is stopped, and which sandbox logs
of a stopped sandbox prints. A tamper stop keeps no log: it waits on nothing
the workload controls, and its run_interrupted event says the log was not
kept. The next stop that finds a run replaces it; a delete removes it.
Bytes of the log that are not UTF-8 come back replaced.
Approvals and unblocks
An Approval is one of the rare asks: an OpenShell draft proposal that triage
would not decide on its own. It has an id, sandbox, kind
(network_rule, or host_port for a port on this machine), host, port,
endpoints, the binary that connected, whether it is risky, the triage
reason, and the OpenShell advisor's rationale and security_notes. Its
status is pending, queued, approved, rejected, or failed.
POST /api/v1/sandbox/approvals/{id} takes
{"decision": "approve"|"reject", "always": bool, "reason": text}. An
approval is usually answered queued: approvals are applied in batches when
the sandbox's hooks are quiet, because every OpenShell policy change closes
the sandbox's open connections. always keeps the decision for future
sandboxes: an approval adds the hosts to openshell.egress.unblocked, a
rejection to openshell.egress.block.
POST /api/v1/sandbox/egress/unblock takes {"host", "sandbox", "always"}.
sandbox scopes the unblock to one sandbox. Without it, always is
required, and the host is saved to openshell.egress.unblocked for every
sandbox. Only refusals the policy marks unblockable can be lifted; see
How a destination is decided.
Policy explain
GET /api/v1/sandbox/policy/explain resolves the sandbox policy with its
provenance. The query names either sandbox (the policy that sandbox runs
with, against the current config) or the run options harness, pack,
profile, project, copy, safe, yolo, and unmask (repeatable).
The Explain response has the pack, pack_source, and pack_digest, the
profile, network_mode, and approvals, the admin status, settings,
and violations. Before a create on a MicroVM gateway, vm_first_boot says
the image the sandbox would boot has no prepared MicroVM disk yet, so its
first start takes about a minute.
For a harness whose run files depend on the create request (Claude Code,
Codex), run=true describes that request with the following parameters:
run_env=KEY=VALUE(repeatable): theenvvariables the run files read.run_env_withheld=KEY(repeatable): such a variable whose value is not sent because it may be a secret. The answer then counts as a first boot.run_credential=NAME(repeatable): the credential names, never their values.run_llm_profileandrun_bedrock_region: the model profile and region.
Without run, the daemon uses the newest sandbox of the harness.
Each setting has a key, value, source (pack, user, flag, admin,
gateway, or default), origin (such as openshell.admin.min_profile),
and, for a clamped setting, the value requested. gateway is the compute
driver of the OpenShell gateway (origin openshell.gateway.compute_driver):
OpenShell's MicroVM driver mounts no host folders, so it runs every project
on a copy. admin has configured,
authority (authoritative in managed_enterprise, otherwise advisory),
and detail.
Activity stream
GET /api/v1/sandbox/activity returns the buffered activity feed as
{"events": [...]}. With follow=true, or Accept: text/event-stream, it
streams server-sent events instead. The stream first replays the buffered
events after since, or after the Last-Event-ID header, which wins over
since. Then it sends each new event as:
id: 42
event: activity
data: {"seq":42,"time":"2026-09-27T10:15:03Z","kind":"egress.blocked","sandbox":"myapp-7f3a","host":"webhook.site","port":443,"source":"dc-egress-proxy","category":"webhook_catcher","unblockable":true}A : keepalive comment follows the replay and repeats every 15 seconds.
sandbox limits the feed to one sandbox. Too many open streams get 429.
Each ActivityEvent has a seq (one higher per event), time, kind, and
sandbox, plus the fields of its kind: host, port, method, source
(dc-egress-proxy or openshell), category, rule, unblockable,
bytes_up, bytes_down, threshold, approval_id, tool, event,
phase, severity, reason, and message.
kind | Meaning |
|---|---|
egress.allowed, egress.blocked | A destination the egress proxy or OpenShell allowed or blocked. An upload the large-upload block cut, and later refused uploads to that host, are egress.blocked with category: "large_upload". |
egress.large_upload | A large upload to a destination the sandbox hadn't contacted before, which only the report saw (the block was off, or the host is exempt). It is reported as it crosses the sandbox's threshold, before it ends: bytes_up is what had gone up then, and message says more than the threshold (⚠ large upload to first-seen files.example.net (more than 25 MiB)). |
egress.unblocked | An unblock was applied. |
approval.requested, approval.resolved | An ask was raised or answered. |
tool.blocked | DefenseClaw blocked a tool call. reason is plain and never quotes matched content. |
sandbox.lifecycle | A sandbox changed phase. |
finding | A finding. Its reason is, for example, hook_tamper (a tool ran without a DefenseClaw verdict), hook_silence, hooks_unreachable (hooks do not reach DefenseClaw, so every tool call is blocked), hooks_restored, or nested_repo. |
workspace | An undo, or a copy-mode upload or pull. |
dropped | A slow subscriber missed events. |
Sandbox API errors
Every error answer is JSON:
{"code": "…", "error": "…", "detail": "…", "violation": {…}}. error is
the sentence for people, detail is optional, and violation names the
refusing policy decision when there is one. A refusal by openshell.admin
starts with "blocked by your organization's DefenseClaw policy".
| Status | code | When |
|---|---|---|
| 400 | invalid_request, pack_invalid | A malformed request, or a pack that fails to load. |
| 403 | admin_violation | openshell.admin refused the request. |
| 403 | policy_violation | The pack, the profile, or a DefenseClaw invariant refused it. |
| 404 | not_found | No such sandbox, approval, or route. |
| 409 | conflict | The sandbox exists already or is in the wrong phase, another sandbox already mounts the folder (or one inside or around it) live, or an undo would revert a folder another running sandbox mounts. |
| 409 | needs_copy | The project cannot be mounted live (a linked worktree, .git as a symbolic link, a git directory outside the folder, or a gateway whose compute driver mounts no host folders, when the request staged no copy); detail says why. sandbox run then creates the sandbox in copy mode. |
| 409 | image_unavailable | No hook-checked harness image, and building one was refused or failed. |
| 422 | policy_rejected | OpenShell rejected the sandbox configuration, or the sandbox it made does not run as DefenseClaw prepared it (the check after ready on the MicroVM driver: the identity the workload runs as, its capabilities, and DefenseClaw's hook and settings files). The sandbox is deleted, or stopped again after a start. |
| 429 | unavailable | Too many activity streams are open. |
| 502 | upstream_error | An OpenShell call failed. |
| 503 | disabled, unavailable | Sandboxes are off, or the runtime or the OpenShell gateway is not available, or (MicroVM driver) the volume of its image cache lacks the room to prepare the disk of an image the sandbox is the first to boot: the message names what is free and what is needed. |
| 504 | unavailable | The request was cancelled or timed out. |
| 500 | internal | Anything else. |
Sandbox listeners
A sandboxed agent cannot connect out directly. The host-networked OpenShell
supervisor carries the connections its policy allows, and it relays
connections to host.openshell.internal to host loopback, so every sandbox
request reaches DefenseClaw from 127.0.0.1. Neither listener trusts
loopback for that reason: each request must carry a credential issued to one
sandbox. Inside the sandbox, both listeners are addressed as
host.openshell.internal:<port>.
| Listener | Config key | Default port | Listens on |
|---|---|---|---|
| Hook ingress | openshell.ingress_port | gateway.api_port + 1 (18971) | 127.0.0.1 only, never the main API address |
| Egress proxy | openshell.egress_port | gateway.api_port + 2 (18972) | 127.0.0.1 only |
The gateway starts both listeners, and the sandbox manager behind the
Sandbox API, with the API when openshell.enabled is true
on Linux or macOS. In a managed_enterprise install it starts neither, and
/health reports the sandbox subsystem as disabled. A listener that
cannot start marks the subsystem degraded (sandbox hooks then fail closed),
but never stops the API.
The gateway checks the openshell: block every time it loads the config and
refuses a config that fails; the error starts with config: openshell:. An
explicit port that is not between 1 and 65535, or both keys set to the same
port, always fails. When openshell.enabled is true, the effective ports
must also be at most 65535 and differ from gateway.api_port,
guardrail.port and each other. On a config reload, turning
openshell.enabled on or off, or moving either port while it is on, restarts
the main API listener. A sandbox keeps the ingress port its image and policy
were built for, so re-create existing sandboxes after moving it.
Hook ingress
The hook ingress (internal/gateway/api_sandbox_ingress.go) is a second API
listener that serves only what a sandboxed harness needs. Each sandbox has a
binding: a record of its credential (stored only as a SHA-256 hash), the
one connector it runs, and the route classes it may call. By default a
binding may call its hook route and OTLP, plus notify for Codex; inspect is
added only when asked for.
| Route class | Paths | Extra condition |
|---|---|---|
| Hook | The connector's hook route, for example POST /api/v1/claude-code/hook or POST /api/v1/codex/hook | The route belongs to the binding's connector. |
| Notify | POST /api/v1/codex/notify | The binding is for Codex. |
| Inspect | POST /api/v1/inspect/{tool,request,response,tool-response} | X-DefenseClaw-Connector names the binding's connector. |
| OTLP | POST /v1/{logs,metrics,traces} | X-DefenseClaw-Source, when sent, names the binding's connector. |
For an authenticated sandbox, every other path returns 404. That includes
all management routes and the /otlp/{source}/{token}/v1/{signal} path-token
form, which would put a credential in the URL.
Authentication. A request must send exactly one
Authorization: Bearer dcsb_… header with the sandbox's binding credential.
The master gateway token, connector hook tokens and OTLP path tokens are
never accepted here. The sandbox manager mints the credential when it creates
the sandbox and revokes it when the sandbox is deleted.
openshell.token_delivery says how the credential reaches the sandbox:
provider(the default): as an OpenShell provider credential inDEFENSECLAW_SANDBOX_TOKEN. The sandbox sees only a placeholder, which OpenShell swaps for the real value on the way to the ingress. Each ingress listener has its own OpenShell provider profile,defenseclaw-ingress-<ingress_port>, so DefenseClaw daemons on different ports never re-point each other's sandboxes. The credential is rotated every time the sandbox starts, so a credential from an earlier session stops working.env: as a plainDEFENSECLAW_SANDBOX_TOKENvariable. The agent can read the real value, and it is kept for the sandbox's life, because OpenShell can't change a sandbox's environment after it is created. The sandbox's policy then gets its own rule to reach the ingress.
| Response | When |
|---|---|
| 401 | The credential is missing, malformed, unknown, expired or revoked. Auth-failure events are rate-limited to about ten per second, because every sandbox shares one source address. |
| 400 | The credential also appears somewhere other than Authorization (another header, the host, the path or the query), where it could be echoed back or written to audit records. |
| 403 | The route exists, but the binding may not call it, or the request acts for another connector. |
| 429 | The sandbox is over its budget. The response carries Retry-After: 1. |
Default per-sandbox limits.
| Traffic | Rate | Burst | Concurrent requests |
|---|---|---|---|
| Hook, notify and inspect | 25/s | 100 | 32 |
| OTLP | 10/s | 50 | 4 per sandbox, 16 across all sandboxes |
Hook and inspect bodies are capped at 1 MiB, like the main API. OTLP bodies are capped at 4 MiB, far below the main receiver's 64 MiB.
Other differences from the main API.
- Identity comes from the binding: records are attributed to the host user
who launched the sandbox. Identity headers such as
X-User-Idare dropped. - Client-supplied request IDs are ignored. The gateway always mints the
X-DefenseClaw-Request-Idit returns. - A hook or notify post may send
X-DefenseClaw-Hook-Idempotency-Key. A retry with the same key and the same request within two minutes gets the original response, markedX-DefenseClaw-Idempotent-Replay: true, instead of a second evaluation. The same key on a different request gets 422. - A blocked tool call's verdict carries a plain reason for the agent: the rule and what to do instead, never the matched content.
- The CSRF rules are the same as on the main API.
Egress proxy
The egress proxy (internal/openshell/egress) is the sandbox's general path
to the web. The harness launcher and the sandbox's shells set HTTPS_PROXY
and HTTP_PROXY to http://host.openshell.internal:<egress_port>, with a
per-sandbox username and password in the URL. NO_PROXY lists
host.openshell.internal and the model provider hosts, so hook traffic and
LLM traffic do not go through the proxy. The proxy refuses a sandbox whose
profile is strict, even one raised to strict after it was created: such a
sandbox has no web egress.
Authentication. Clients send the credential as
Proxy-Authorization: Basic. A request without it, or with a wrong one, gets
407 Proxy Authentication Required with
Proxy-Authenticate: Basic realm="DefenseClaw egress". The proxy keeps only
SHA-256 digests of the passwords, and issuing a new credential for a sandbox
revokes the old one. The credential does not grant extra reach: it
attributes each connection to a sandbox and applies that sandbox's policy and
limits.
What it forwards.
CONNECTtunnels for HTTPS. TLS is never terminated, so certificate pinning keeps working. The proxy reads only the server name in the TLS handshake and closes a tunnel whose name it would block.- Absolute-form
http://requests, with hop-by-hop headers and the proxy credential removed. - Only destination ports on the sandbox's port list: 80 and 443 in the
openandbalancedpacks, replaced byopenshell.egress.portswhen you set it. On 80 and 443 the proxy relays only web traffic; other protocols such as SSH pass only on ports you add.
What it decides. Each sandbox is decided by its own resolved policy: its
pack, your openshell keys, its run options, and the admin constraints.
Another sandbox's pack, block list, ports, or unblocks never apply to it. The
unblock and approval checks and the triage of OpenShell connection requests
ask the same decider, so they never disagree with the proxy. In short:
- This machine is never reachable: loopback, its own addresses and names, link-local, cloud metadata, and reserved addresses.
- Private networks are reachable only when an allow entry names them: an
openshell.egress.allowentry, the pack'segress.allow, oropenshell.admin.egress_allow_only. An unblock never opens them. Withopenshell.admin.allow_unblock: false, your own entries and those of a custom pack you picked are ignored, so onlyegress_allow_onlyor a required pack's list can open one; thehow_to_unblockhint says so. openshell.admin.egress_block(a host name there covers its subdomains), a host outsideopenshell.admin.egress_allow_only, and the block list (the pack's andopenshell.egress.block) are refused, and can't be unblocked.- Under
open, host names are allowed unless the built-in feed of exfiltration and abuse destinations lists them (paste sites, file drops, webhook catchers, tunnels, anonymizers). A destination given as an IP address is blocked, because it would get around the name-based feed. An unblock, or an allow entry that names it, lifts the block. - Under
balanced, only hosts on the allow list are reachable. The rest can be unblocked. - Names are resolved by the proxy, and each answer is checked just before the connection, so a DNS answer that changes between check and connect cannot redirect it.
The full order, with what an allow entry, an unblock, and
openshell.admin.allow_unblock: false change, is under
How a destination is decided.
POST /api/v1/sandbox/egress/unblock refuses what can't be unblocked: the
administrator's lists and allow_unblock: false answer admin_violation
("blocked by your organization's DefenseClaw policy"). The block list,
private networks, this machine, and the strict profile answer
policy_violation.
A blocked request gets 403 with a JSON body: error: "egress_blocked", a
message, the host, port, category and reason, the matching rule
or feed entry, whether it is unblockable, and a how_to_unblock sentence
written for the agent to pass on to you, such as the
defenseclaw sandbox unblock command to run.
Policy changes reach open connections too. When a sandbox's policy changes
(an administrator's constraint, for example), the proxy decides its open
tunnels and in-flight requests again and closes the ones it now blocks. When
a sandbox loses its proxy credential (its policy can no longer be resolved,
or it moves to strict), all of its open tunnels close.
The proxy also counts bytes per destination and reports an upload of more
than the configured threshold to a destination the sandbox had not contacted
before. The threshold is the sandbox's own: openshell.egress.large_upload_mb,
or else the egress.large_upload_mb of the pack the sandbox resolved to
(25 MiB in open, 10 MiB in balanced; 0 in a pack turns the report off).
It follows configuration changes with the rest of the sandbox's egress
policy. The report is an
egress.large_upload activity event and a finding; the upload itself is not
cut off.
With the large-upload block on (the pack's egress.block_large_uploads,
openshell.egress.block_large_uploads, or
openshell.admin.block_large_uploads), the proxy also cuts the tunnel or
request before the chunk that crosses the threshold, and answers the
sandbox's later requests to that host (or to other first-seen hosts under
its domain or at its address) with a 403 of category: "large_upload"
whose reason names the threshold. The cut is an egress.blocked activity
event instead of egress.large_upload: category is large_upload,
reason is the proxy's sentence ("This sandbox tried to send more than 10
MiB to a destination it had not contacted before."; a later refusal, of a
request that may send nothing, says "This destination is blocked since this
sandbox tried to send more than 10 MiB to it, a destination it had not
contacted before."; a report without the block says "More than 10 MiB was
sent to a destination this sandbox had not contacted before."), bytes_up is what
reached the destination (at most the threshold), severity is HIGH, and unblockable says whether
POST /api/v1/sandbox/egress/unblock lifts the block for the host. Each later
refusal is an ordinary egress.blocked event with that category. Hosts an
unblock, an allow entry or openshell.admin.egress_allow_only names are
exempt: their uploads are only reported.
Default per-sandbox limits. 256 open connections, 256 concurrent tunnels or requests, and 50 new tunnels per second (burst 200), with 1024 connections across all sandboxes.
Headers
| Header | Direction | Purpose |
|---|---|---|
X-DefenseClaw-Token, Authorization: Bearer ..., or X-DC-Auth: Bearer ... | request | Master gateway authentication. Every route except GET /health requires either this credential or a route-appropriate scoped token. |
X-DefenseClaw-Client | request | CSRF marker — any non-empty caller identifier (cli, openclaw-plugin, inspect-hook/1.0). Required on non-GET/HEAD requests except the constrained loopback path-token OTLP case described above. |
X-DefenseClaw-Connector | request | On loopback generic inspect routes, selects the registered connector whose scoped hook token is being presented. |
X-DefenseClaw-Source | request | On loopback /v1/{logs,metrics,traces}, selects and must match a header-scoped Codex or Claude Code OTLP token. |
X-DefenseClaw-Request-Id | request (optional), response | Caller-supplied correlation id. The gateway also accepts X-Request-Id and X-Correlation-Id; if none is supplied, it mints one. The chosen ID is always returned as X-DefenseClaw-Request-Id. The hook ingress ignores caller-supplied IDs and always mints one. See requestctx.go. |
X-DefenseClaw-Hook-Idempotency-Key | request (optional) | Hook ingress only: a retry-stable key on a sandbox hook or notify post. |
X-DefenseClaw-Idempotent-Replay | response | Hook ingress only: true when the response replays an earlier one for the same key. |
Last-Event-ID | request (optional) | Sandbox activity stream only: resume after this event sequence number. It wins over ?since. |
Sandbox CLI
Every defenseclaw sandbox command and flag for NVIDIA OpenShell sandboxes, with an example for each. Covers setup and doctor, run and connect, the activity feed and asks, undo, review and pull, policy and packs, images, wrappers, and teardown.
Configuration
~/.defenseclaw/config.yaml schema, environment variables, on-disk layout, and per-connector source-of-truth files. The single source of truth for "where does this setting live?"