Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ Two integration paths:
1. **Proxy only**: Point your MCP client at Helio instead of your MCP server. Zero code changes. Immediate governance.
2. **Proxy + SDK**: Add the thin Python SDK to annotate tool calls with evidence context and action dependencies. Richer governance, under 500 lines of code.

### Enforcement grades

Helio governs at the strongest grade each path physically allows, and records it per call:

- **Structural** (stdio MCP) — Helio owns the only path to the tool; the agent cannot route around it.
- **Network** (HTTP MCP) — structural given you control the upstream's egress.
- **Host-enforced** (hook adapters via the [adapter API](docs/adapter-api.md), e.g. OpenClaw) — for frameworks that run tools in-process and expose hooks rather than an MCP transport. The framework's hook gate enforces; Helio decides. This is a cooperative, lower grade than the proxy path, and Helio labels it as such rather than overclaiming. Helio's decisions still cannot be evicted from the agent's context or prompt-injected, and any attempt to route around them is visible in the audit trail.

## Quick Start (5 minutes)

### 1. Install
Expand Down
151 changes: 151 additions & 0 deletions docs/adapter-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Adapter Governance API (sideband)

> **Status: experimental.** These four endpoints are how hook-based agent frameworks (OpenClaw first) drive Helio's policy engine without an MCP transport to interpose on. The contract may change in a breaking way until a second adapter validates its neutrality. Pin your adapter to a Helio minor version.

The governance API lives on the **SDK sideband** — the local server on `127.0.0.1:3200` (configurable via `sdk.*`), the same server the Python SDK uses for evidence/context. It is **not** the dashboard sideband (`:3100`, documented in [Sideband API Reference](./sideband-api.md)); the two are different servers with different jobs. Endpoints here:

| Route | Purpose |
| ---------------------------- | ------------------------------------------------------------------------------ |
| `POST /evaluate` | Decide a tool call. **Side-effect-free** on rate/spend counters. |
| `POST /audit` | Record the outcome of an evaluated call; **consumes** counters. Idempotent. |
| `POST /install-scan` | Evaluate a package/skill install. Observational until install-time rules ship. |
| `POST /approval/:id/resolve` | Record the resolution of a natively-handled approval. |

## Why this exists, and what it does not promise

Helio's headline guarantee is **structural** enforcement: an agent speaking MCP physically cannot reach a tool except through the proxy. Hook-based frameworks run their tools in-process, so there is nothing to proxy — the framework's hook dispatcher is the enforcement point, and Helio supplies the decision. This is the standard policy-decision-point / policy-enforcement-point split.

Helio classifies every governed call by **enforcement grade**, surfaced via the audit `origin` column:

| Grade | Path | Guarantee |
| --------------- | ------------------------ | ------------------------------------------------------------------ |
| `structural` | stdio MCP | Helio owns the only path to the tool. |
| `network` | HTTP MCP | Structural given the operator controls egress. |
| `host-enforced` | hook adapters (this API) | Enforcement by the host framework's hook gate; decisions by Helio. |

The host-enforced grade is **cooperative**: it works only if the adapter faithfully calls `/evaluate`, honors the decision, and reports `/audit`. A malicious in-process skill that bypasses the hook is outside what this API can prevent (`/install-scan` exists to gate exactly that vector). Helio does not market the hook path as proxy-grade, and neither should you.

### Normative adapter requirements

An adapter built on this API **MUST**:

1. **Fail closed.** If `/evaluate` is unreachable, times out, or returns 5xx, **block** the tool call. Never proceed on a failed decision — this is the property that couples tool execution to Helio's liveness.
2. **Resolve before auditing.** For a `require_approval` decision, call `/approval/:id/resolve` before `/audit` (see [Approvals](#approvals)).
3. **Carry tool definitions where it can** (`tool.input_schema`, `tool.annotations`, …) so adapter-origin tools get the same rug-pull / drift guard as MCP tools.
4. **Authenticate** with the adapter-scope bearer token (`HELIO_ADAPTER_TOKEN`), never the SDK token.

## Authentication

The governance routes require `Authorization: Bearer <HELIO_ADAPTER_TOKEN>`. This is a **separate token** from the SDK's `HELIO_SDK_TOKEN`: an SDK client cannot drive policy decisions, and an adapter cannot write evidence. Both are generated per boot (and printed to stderr) unless set in the environment. Requests carrying an `Origin` header are refused (browser-forgery guard), and bodies over 1 MiB are rejected with 413.

If you embed `GovernanceService` directly (instead of running `helio start`), wire an `ApprovalRouter` whenever the policy can emit `require_approval` (explicit rules, `flag_destructive: require_approval`, or `on_tool_drift: require_approval`), otherwise construction and hot-reload fail closed by throwing `GovernanceConfigError` (exported from `@gethelio/proxy`).

## `POST /evaluate`

```jsonc
// Request
{
"origin": "openclaw", // optional; default "sideband"; ^[a-z0-9_-]{1,64}$
"adapter_version": "0.1.0", // optional, ≤64 chars (per-origin liveness)
"agent_id": "main", // optional
"session_id": "oc-session-1", // optional; required for evidence/dependency rules
"tool": {
"name": "send_message", // required
"description": "…", // optional ┐ full definition enables the drift guard
"input_schema": { }, // optional ┤
"annotations": { "destructiveHint": false } // optional ┘
},
"arguments": { "channel": "#general", "text": "hi" }, // optional (≤64 KiB)
"metadata": { "channel_id": "C1", "sender_id": "U7" } // optional (≤4 KiB)
}

// Response 200
{
"evaluation_id": "5f2…", // correlate with /audit; present even for terminal decisions
"decision": "allow", // allow | deny | require_approval | rate_limited | spend_limited | dry_run
"reason": "Matched \"allow-chat\" → allow",
"matched_rule": "allow-chat", // null when the default policy applied
"matched_rule_index": 2,
"feedback": { "message": "…" }, // present on blocking decisions
"approval": { "id": "…", "timeout_ms": 300000, "resolve_path": "/approval/…/resolve" }, // require_approval only
"limits": { "rate": { } }, // present when a limit rule matched
"dry_run": { "would_forward": true }, // dry_run only
"tool_drift": { "changes": [ ] } // present when the drift gate fired
}
```

The `decision` is an **outcome**, not Helio's internal rule action: a `rate_limit` rule that still has headroom returns `"allow"` with a `limits.rate` block; only when the bucket is exhausted does it return `"rate_limited"`. There is no `modify` decision — argument rewriting has no engine support today.

**Errors:** `400` validation / invalid JSON, `401` wrong-or-missing adapter token, `403` Origin header, `413` oversized `metadata`/`tool_input`/body, `400 origin_limit_exceeded` / `400 tool_baseline_limit` / `503 evaluation_backlog_full` (memory budgets), `503 governance_unavailable` (sideband running without the service).

## `POST /audit`

```jsonc
// Request
{
"evaluation_id": "5f2…",
"status": "success" | "error" | "not_executed",
"error": "…", // optional, when status == "error"
"duration_ms": 412, // optional
"result": { }, // optional outcome summary
"actual_amount": 0.42 // optional, finite ≥0 — true post-execution spend; overrides the arg-derived amount
}

// Response 201 (fresh) — replays return 200
{ "ok": true, "audit_record_id": "…" }
```

Counters are consumed here (not at `/evaluate`), and only when the call actually ran (`success`/`error`, not `not_executed`). `/audit` is **idempotent on `evaluation_id`**: an identical replay returns `200 { already_finalized: true }` with no double-consumption, so a network retry after a lost response is safe. A different payload under the same id is an adapter bug → `409 evaluation_conflict`.

**Decision finalization.** `deny`, `rate_limited`, `spend_limited`, and `dry_run` are **terminal at `/evaluate`** — their audit record is written immediately, so completeness never depends on the adapter calling `/audit`. A later `/audit` for such an evaluation returns `200 { finalized_by: "evaluate" }` and accepts any payload, so adapters may audit unconditionally.

`actual_amount` must be finite and `>= 0` (`400 invalid_actual_amount` otherwise) and only applies to evaluations whose decision carried a spend rule (`400 no_spend_rule` if sent for any other evaluation).

**Other responses:** `404 evaluation_unknown`, `404 evaluation_expired` (the decision aged out — see below), `409 approval_unresolved` (resolve the approval first; **retryable** with short backoff).

### The crash-TTL and TOCTOU caveats

- An evaluation that is never audited expires after `sdk.evaluation_ttl` (default `10m`) into an audit record with `record_kind: "evaluation_expired"`. This is a **bypass/tamper signal**, not a normal block — surface it in monitoring.
- Because decision and execution are separate calls, two concurrent `/evaluate`s can both peek the last limit slot and both execute. Counters stay truthful after the fact (both `/audit`s record), but the host-enforced tier cannot close this window from the proxy side.

## `POST /install-scan`

Observational until install-time policy ships: always returns `decision: "allow"` with `reason: "no install-time rules defined"`, and writes an audit record with `record_kind: "install_scan"`. The request/response shape is final now so adapters and the dashboard build against a stable contract.

```jsonc
// Request
{ "origin": "openclaw", "package": { "name": "left-pad", "version": "1.3.0", "source": "npm" } }
// Response 200
{ "evaluation_id": "…", "decision": "allow", "reason": "no install-time rules defined", "matched_rule": null }
```

## Approvals

A `require_approval` decision creates a **native ticket** (`channel_name: native:<origin>`): Helio does not block, start timeout timers, or notify a channel, because the adapter runs the approval in its own UI (e.g. a Telegram dialog). The dashboard shows the ticket but its approve/deny buttons return `409 native_ticket` — only the adapter can resolve it, via:

```jsonc
// POST /approval/:id/resolve
{ "resolution": "approved" | "denied" | "timeout" | "cancelled",
"resolved_by": "telegram:@oli", // required for approved/denied
"reason": "…", "scope": "once" | "always" }
// Response 200
{ "ok": true }
```

The resolution does **not** write the audit record; the subsequent `/audit` does, copying the approval status. A native ticket times out at `min(rule timeout, evaluation TTL)`; deadlines are enforced on access, so a late resolve deterministically returns `409 already_resolved`.

## Audit record additions

Sideband activity shares the audit schema with the MCP path, plus three columns (also used by the dashboard):

- `record_kind` — `tool_call` | `drift_event` | `install_scan` | `evaluation_expired`.
- `origin` — `mcp` for the proxy path, or the adapter origin string.
- `metadata` — the adapter-supplied context object (reserved keys `channel_id`, `sender_id`, `sender_name`, `conversation_id`).

See [Audit Trail](./audit.md) for the full record reference.

## See also

- [Sideband API Reference](./sideband-api.md) — the dashboard sideband (`:3100`), a different server.
- [Configuration](./configuration.md) — the `sdk.*` block (`enabled`, `port`, `host`, `evaluation_ttl`).
- [Policy Guide](./policies.md) — the rules these endpoints evaluate.
3 changes: 3 additions & 0 deletions docs/audit.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ Each audit record contains the following fields:
| `proxy_compute_ms` | number | Proxy compute time excluding approval wait and upstream processing. |
| `flagged_destructive` | boolean | Whether the tool was flagged as potentially destructive (`destructiveHint: true`). |
| `dry_run` | boolean | Whether this record was produced in dry-run mode. |
| `record_kind` | string | Record category: `tool_call` (default), `drift_event`, `install_scan`, or `evaluation_expired` (a sideband decision that was never audited). |
| `origin` | string | Enforcement origin: `mcp` for the proxy path, or an adapter origin string (e.g. `openclaw`) for [sideband-governed](./adapter-api.md) calls. |
| `metadata` | object \| null | Adapter-supplied context (reserved keys `channel_id`, `sender_id`, `sender_name`, `conversation_id`). Null for MCP-origin records. |
| `created_at` | string | ISO 8601 timestamp of when the record was persisted to the database. |

## Tool Definition Drift Records
Expand Down
19 changes: 11 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -298,25 +298,28 @@ Audit rows also include:

### sdk

Configuration for the Python SDK sideband API, used for evidence grounding.
Configuration for the SDK sideband API, used for evidence grounding (Python SDK) and the [adapter governance API](./adapter-api.md) (hook-based adapters such as OpenClaw).

| Field | Type | Required | Default | Description |
| --------- | ------- | -------- | ----------- | ------------------------------------ |
| `enabled` | boolean | No | `false` | Enable the SDK sideband HTTP server. |
| `port` | integer | No | `3200` | Sideband server port (1–65535). |
| `host` | string | No | `127.0.0.1` | Sideband server bind address. |
| Field | Type | Required | Default | Description |
| ---------------- | ------- | -------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | No | `false` | Enable the SDK sideband HTTP server. |
| `port` | integer | No | `3200` | Sideband server port (1–65535). |
| `host` | string | No | `127.0.0.1` | Sideband server bind address. |
| `evaluation_ttl` | string | No | `10m` | How long a governance `/evaluate` decision waits for its `/audit` before being finalized as `evaluation_expired`. |

#### Sideband authentication

When `sdk.enabled` is `true`, Helio generates a fresh 32-byte hex Bearer token on every `helio start` and prints it to stderr:
When `sdk.enabled` is `true`, Helio generates two fresh 32-byte hex Bearer tokens on every `helio start` (unless set in the environment) and prints them to stderr:

```
SDK sideband listening on http://127.0.0.1:3200
SDK token (pass as HELIO_SDK_TOKEN env var to your SDK clients):
<hex>
Adapter token (governance routes; pass as HELIO_ADAPTER_TOKEN to your adapter):
<hex>
```

The token is also written into `process.env.HELIO_SDK_TOKEN` so child processes spawned by the proxy inherit it. Every sideband request except `GET /healthz` must carry `Authorization: Bearer <token>`; mismatches return `401`. The sideband additionally rejects any request that carries an `Origin` header (including `Origin: null`) and blocks `OPTIONS` preflights with `403`, so a malicious local HTML file cannot talk to it through a browser.
The tokens are scoped: `HELIO_SDK_TOKEN` authorizes the evidence/context routes, and `HELIO_ADAPTER_TOKEN` authorizes the governance routes (`/evaluate`, `/audit`, `/install-scan`, `/approval/:id/resolve`). An SDK client cannot drive policy decisions, and an adapter cannot write evidence. Both are written into `process.env` so child processes inherit them. Every sideband request except `GET /healthz` must carry the matching `Authorization: Bearer <token>`; mismatches return `401`. The sideband rejects any request carrying an `Origin` header (including `Origin: null`), blocks `OPTIONS` preflights with `403`, and rejects request bodies over 1 MiB with `413`.

Operators who need a stable token across restarts can set `HELIO_SDK_TOKEN` explicitly in the proxy's environment — the proxy respects a pre-set value instead of generating one. Rotation, revocation, and key management are not part of the v0.1.0 trust model; a restart with a new token is the rotation primitive.

Expand Down
2 changes: 2 additions & 0 deletions docs/sideband-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ The dashboard sideband is a read/write REST + SSE surface served on a separate p

> **Why a separate port?** The sideband is deliberately isolated from the `/mcp` port. This is what prevents an agent speaking `/mcp` from self-approving its own pending tickets — the approval REST API is mounted exclusively on this sideband, not on the MCP port.

> **Not the same as the SDK sideband.** This document covers the **dashboard sideband** (`:3100`), the operator read/write surface. There is a second, separate **SDK sideband** (`:3200`, `sdk.*`) that serves the Python SDK's evidence routes and the [adapter governance API](./adapter-api.md) (`/evaluate`, `/audit`, `/install-scan`, `/approval/:id/resolve`) for hook-based adapters like OpenClaw. Different server, different port, different token.

## Overview

- **Default port:** `127.0.0.1:3100` (configurable via `dashboard.port`).
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src/components/ApprovalStatusBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const COLORS: Record<ApprovalStatus, string> = {
break_glass: 'bg-purple-50 text-purple-700 ring-purple-600/20',
client_disconnected: 'bg-gray-100 text-gray-700 ring-gray-500/20',
shutdown_cancelled: 'bg-slate-100 text-slate-700 ring-slate-500/20',
cancelled: 'bg-slate-100 text-slate-700 ring-slate-500/20',
}

export const ApprovalStatusBadge = memo(function ApprovalStatusBadge({
Expand Down
1 change: 1 addition & 0 deletions packages/dashboard/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type ApprovalStatus =
| 'break_glass'
| 'client_disconnected'
| 'shutdown_cancelled'
| 'cancelled'

export interface ApprovalTicket {
readonly id: string
Expand Down
Loading
Loading