From 1637835f72c1fc1638e823a79a9d09206399a5fd Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:19:09 +0000 Subject: [PATCH] =?UTF-8?q?feat(spec):=20bridge=20protocol=20v0.1=20?= =?UTF-8?q?=E2=80=94=20ops,=20capabilities,=20transports,=20security?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JSON-RPC 2.0 contract (LSP-for-extensions): protocolVersion + spec-version handshake, 8-op closed set, capability model for host env slots, stdio / HTTP-loopback / in-process transports, caller identity + per-caller op scoping, loopback auth + threat boundaries. --- spec/bridge/capabilities.md | 128 +++++++++ spec/bridge/messages.schema.json | 475 +++++++++++++++++++++++++++++++ spec/bridge/operations.md | 353 +++++++++++++++++++++++ spec/bridge/protocol.md | 277 ++++++++++++++++++ spec/bridge/security.md | 126 ++++++++ spec/bridge/transports.md | 216 ++++++++++++++ 6 files changed, 1575 insertions(+) create mode 100644 spec/bridge/capabilities.md create mode 100644 spec/bridge/messages.schema.json create mode 100644 spec/bridge/operations.md create mode 100644 spec/bridge/protocol.md create mode 100644 spec/bridge/security.md create mode 100644 spec/bridge/transports.md diff --git a/spec/bridge/capabilities.md b/spec/bridge/capabilities.md new file mode 100644 index 0000000..56daf98 --- /dev/null +++ b/spec/bridge/capabilities.md @@ -0,0 +1,128 @@ +# AnyHarness Bridge Protocol v0.1 — capability model + +> `Capabilities` is the IR that makes one protocol serve a browser tab and +> a native CLI alike. A client declares its surface at +> `capabilities.negotiate`; the server grants a subset; everything outside +> the grant degrades by rule, never by `#ifdef`. Envelope/handshake: +> `protocol.md`; per-op gating: `operations.md`. +> +> **Feature negotiation ≠ authorization.** `Capabilities` describes what a +> client *can consume*. Whether an authenticated caller *may* invoke an op +> is the identity/scope layer in `transports.md` §3 and `security.md` — +> orthogonally enforced, orthogonal failure (`-32012` vs `-32003`). + +## 1. The `Capabilities` shape (pinned) + +```ts +interface Capabilities { + /** ExtensionKind values this client can consume. */ + kinds: ExtensionKind[]; // subset of "skill|mcp|plugin|hook|command|agent|rule" + /** Canonical hook lifecycle events this client will invoke (§3). */ + hookEvents: string[]; + /** Host-injected environment slots (§2). */ + slots: { + storage: "fs" | "kv" | "none"; + secrets: "host" | "prompt" | "none"; + exec: boolean; + skills: "read-write" | "read" | "none"; + mcp: "managed" | "external" | "none"; + [slot: string]: unknown; // extension slots, namespaced — §4 + }; + experimental?: Record; +} +``` + +Negotiation result (`result.capabilities`) is the **granted** set — the +server MAY narrow any field (fewer kinds, fewer events, weaker slot +values). Slot strengths are ordered: `none < kv < fs`, `none < prompt < +host`, `none < read < read-write`, `none < external < managed`. The client +MUST treat anything absent from the grant as unsupported. + +## 2. Environment slots + +`slots` describes the host environment the client runs in — the same +object the metaharness's capability-injection pattern uses internally +(storage/secrets/exec/skills/mcp). The bridge lets a *remote* client make +the same declarations a linked-in `Bridge` would. + +| Slot | Values | Semantics | +| --- | --- | --- | +| `storage` | `fs` / `kv` / `none` | `fs`: POSIX filesystem, can read `~/.agents/` directly. `kv`: opaque key-value persistence (IndexedDB/localStorage-style) — receives file contents inline, never paths. `none`: no persistence; everything inline, nothing cached. | +| `secrets` | `host` / `prompt` / `none` | `host`: client can resolve secret references itself (env vars, OS keychain). `prompt`: client will surface secret requests to the user interactively. `none`: ops needing secrets fail `-32003`. | +| `exec` | `boolean` | Client may execute/honor executable content (script hooks, command runners). `false` → script-type hook handlers are skipped with `skippedReason: "policy"`. | +| `skills` | `read-write` / `read` / `none` | `read-write`: scans and may write `~/.agents/skills/`. `read`: scans it; materialization still allowed (server writes, client reads). `none`: can't see the dir → `skills.materialize` requires `target: "inline"`. | +| `mcp` | `managed` / `external` / `none` | `managed`: client wants the bridge to run `tools.call` against bridge-managed MCP servers. `external`: client has its own MCP client stack; `tools.call` returns `-32003` (client bypasses). `none`: no MCP at all. | + +Extension slots: a client MAY declare additional namespaced slots under +`slots` (e.g. `"dev.maki/canvas": true`). Unknown slot keys are ignored by +the server and excluded from the grant. + +## 3. Hook events + +`hookEvents` names the canonical lifecycle events the client's agent loop +will emit. Canonical set for v0.1 (harnesses declare the subset they +actually reach): + +| Event | Fires | Input payload carries | +| --- | --- | --- | +| `session.start` | Agent session begins | `{ cwd?, agent?, sessionId? }` | +| `prompt.submit` | User prompt accepted, before model call | `{ prompt: string }` | +| `tool.pre` | Before a tool executes | `{ tool: string, arguments: object }` | +| `tool.post` | After a tool returns | `{ tool, arguments, result }` | +| `response.stop` | Model loop yields/stops | `{ reason: string }` | +| `session.end` | Session teardown | `{ reason: string }` | + +Event names are open-typed (`string`) — harness-specific events are legal +but SHOULD be namespaced (`"dev.maki/pane.open"`). Undeclared events the +server never sees; `hooks.invoke` on an event outside the granted +`hookEvents` → `-32003`. + +## 4. Graceful degradation rules + +Degradation is *filtering at the boundary*, not failure in the loop: + +1. **Kinds**: `extensions.list` filters out extensions whose primary `kind` + (or every `provides` entry) is outside the granted `kinds`, unless + `includeUnsupported: true` — in which case they appear with + `supported: false`. Ops on an unsupported-kind extension → `-32003`. +2. **Events**: the client never invokes undeclared events; the server + answers `-32003` if it does anyway. +3. **Storage**: `none`/`kv` → every file-bearing result is inline + (`files[].content`), `materializedTo` is absent, `target: "store"` + fails `-32003`. +4. **Skills**: `none` → `skills.materialize` with `target: "store"` → + `-32003` (`data.capability: "skills"`); `target: "inline"` still works. +5. **Exec**: `false` → script hooks are reported per-handler as + `status: "skipped", skippedReason: "policy"` in `hooks.invoke` results + — the merged verdict proceeds without them. Script policy is the trust + layer's (`spec/trust.md`), surfaced through this slot. +6. **MCP**: `external`/`none` → `tools.call` → `-32003`. +7. **Secrets**: `none` → any op that would need a secret fails `-32003` + upfront, not mid-operation. + +A browser-like host is simply a client that declares fewer slots: + +```jsonc +// browser metaharness +{ "kinds": ["skill", "rule", "command"], + "hookEvents": ["prompt.submit", "response.stop"], + "slots": { "storage": "kv", "secrets": "prompt", "exec": false, + "skills": "none", "mcp": "none" } } + +// native CLI harness +{ "kinds": ["skill","mcp","plugin","hook","command","agent","rule"], + "hookEvents": ["session.start","prompt.submit","tool.pre","tool.post","response.stop","session.end"], + "slots": { "storage": "fs", "secrets": "host", "exec": true, + "skills": "read-write", "mcp": "managed" } } +``` + +## 5. Authorization scoping (cross-reference) + +Feature grants come from this document's model; **per-caller op +authorization** (which authenticated callers may invoke which methods — +e.g. a sandboxed harness granted `extensions.list`/`extensions.get` but +denied `hooks.invoke`) is configured on the *server* per caller identity, +not negotiated. See `transports.md` §3 for the identity model and +`security.md` §3 for the scope format. Failures surface as `-32012 +forbidden`, never `-32003` — an op can be simultaneously in the negotiated +feature set and denied for this caller. diff --git a/spec/bridge/messages.schema.json b/spec/bridge/messages.schema.json new file mode 100644 index 0000000..733ad89 --- /dev/null +++ b/spec/bridge/messages.schema.json @@ -0,0 +1,475 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://spec.anyharness.dev/bridge/messages.schema.json", + "title": "AnyHarness Bridge Protocol v0.1 — message schemas", + "description": "JSON-RPC 2.0 envelope and per-operation params/result schemas for the AnyHarness bridge protocol. Companion prose: protocol.md, operations.md, capabilities.md, transports.md, security.md. Sibling specs referenced by name: spec/manifest.md (plugin.json documents), spec/store-layout.md (paths), spec/lockfile.md (integrity), spec/trust.md (policy).", + "type": "object", + "$defs": { + "RequestId": { + "type": ["string", "number"], + "description": "JSON-RPC request id; unique among in-flight requests in a session." + }, + "Request": { + "type": "object", + "required": ["jsonrpc", "id", "method"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "method": { + "type": "string", + "enum": [ + "capabilities.negotiate", + "extensions.list", + "extensions.get", + "hooks.invoke", + "commands.resolve", + "skills.materialize", + "tools.call", + "events.notify" + ] + }, + "params": { "type": "object" } + }, + "additionalProperties": false + }, + "Notification": { + "type": "object", + "required": ["jsonrpc", "method"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "method": { "type": "string" }, + "params": { "type": "object" } + }, + "not": { "required": ["id"] }, + "additionalProperties": false + }, + "Response": { + "type": "object", + "required": ["jsonrpc", "id"], + "properties": { + "jsonrpc": { "const": "2.0" }, + "id": { "$ref": "#/$defs/RequestId" }, + "result": {}, + "error": { "$ref": "#/$defs/Error" } + }, + "additionalProperties": false + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { + "type": "integer", + "enum": [ + -32700, -32600, -32601, -32602, -32603, + -32001, -32002, -32003, -32004, -32005, -32006, + -32007, -32008, -32009, -32010, -32011, -32012, + -32800 + ] + }, + "message": { "type": "string" }, + "data": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "version-mismatch", "handshake-required", + "capability-unsupported", "not-found", "manifest-invalid", + "hook-failed", "policy-denied", "trust-violation", + "auth-failed", "transport-unavailable", "conflict", + "forbidden", "request-cancelled", "mcp" + ] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "ExtensionKind": { + "type": "string", + "enum": ["skill", "mcp", "plugin", "hook", "command", "agent", "rule"] + }, + "ManifestRef": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string", "description": "plugin.json `name`" }, + "version": { "type": "string", "description": "Exact installed version (semver string)" }, + "integrity": { "type": "string", "description": "Integrity value per spec/lockfile.md, e.g. sha256-…" } + }, + "additionalProperties": false + }, + "Extension": { + "type": "object", + "required": ["id", "kind", "manifest", "enabled"], + "properties": { + "id": { "type": "string", "description": "Stable id '@'; the extensions.lock key" }, + "kind": { "$ref": "#/$defs/ExtensionKind" }, + "manifest": { "$ref": "#/$defs/ManifestRef" }, + "enabled": { "type": "boolean" }, + "provides": { + "type": "array", + "items": { "$ref": "#/$defs/ExtensionKind" }, + "uniqueItems": true + }, + "supported": { "type": "boolean" } + }, + "additionalProperties": true + }, + "SlotStrength.storage": { "type": "string", "enum": ["fs", "kv", "none"] }, + "SlotStrength.secrets": { "type": "string", "enum": ["host", "prompt", "none"] }, + "SlotStrength.skills": { "type": "string", "enum": ["read-write", "read", "none"] }, + "SlotStrength.mcp": { "type": "string", "enum": ["managed", "external", "none"] }, + "Capabilities": { + "type": "object", + "required": ["kinds", "hookEvents", "slots"], + "properties": { + "kinds": { + "type": "array", + "items": { "$ref": "#/$defs/ExtensionKind" }, + "uniqueItems": true + }, + "hookEvents": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "slots": { + "type": "object", + "properties": { + "storage": { "$ref": "#/$defs/SlotStrength.storage" }, + "secrets": { "$ref": "#/$defs/SlotStrength.secrets" }, + "exec": { "type": "boolean" }, + "skills": { "$ref": "#/$defs/SlotStrength.skills" }, + "mcp": { "$ref": "#/$defs/SlotStrength.mcp" } + }, + "required": ["storage", "secrets", "exec", "skills", "mcp"], + "additionalProperties": true + }, + "experimental": { "type": "object" } + }, + "additionalProperties": true + }, + "HookEvent": { + "type": "string", + "description": "Canonical v0.1 events: session.start, prompt.submit, tool.pre, tool.post, response.stop, session.end. Harness-specific events allowed, SHOULD be namespaced." + }, + "FileEntry": { + "type": "object", + "required": ["path"], + "properties": { + "path": { "type": "string" }, + "size": { "type": "integer", "minimum": 0 }, + "content": { "type": "string" }, + "contentBase64": { "type": "string" } + }, + "additionalProperties": false + }, + "capabilities.negotiate.params": { + "type": "object", + "required": ["protocol", "client", "capabilities"], + "properties": { + "protocol": { + "type": "object", + "required": ["supported"], + "properties": { + "supported": { + "type": "array", + "items": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" }, + "minItems": 1 + } + }, + "additionalProperties": false + }, + "specs": { + "type": "object", + "description": "Sibling L0 spec versions the client understands, newest first (spec/manifest.md versioning policy).", + "additionalProperties": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + } + }, + "client": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "additionalProperties": false + }, + "capabilities": { "$ref": "#/$defs/Capabilities" } + }, + "additionalProperties": false + }, + "capabilities.negotiate.result": { + "type": "object", + "required": ["protocol", "server", "session", "capabilities"], + "properties": { + "protocol": { + "type": "object", + "required": ["version"], + "properties": { "version": { "type": "string", "pattern": "^[0-9]+\\.[0-9]+$" } }, + "additionalProperties": false + }, + "specs": { + "type": "object", + "additionalProperties": { "type": "string" } + }, + "server": { + "type": "object", + "required": ["name", "version"], + "properties": { + "name": { "type": "string" }, + "version": { "type": "string" } + }, + "additionalProperties": false + }, + "session": { + "type": "object", + "required": ["id"], + "properties": { "id": { "type": "string" } }, + "additionalProperties": false + }, + "capabilities": { "$ref": "#/$defs/Capabilities" } + }, + "additionalProperties": false + }, + "extensions.list.params": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { "$ref": "#/$defs/ExtensionKind" }, + "uniqueItems": true + }, + "includeUnsupported": { "type": "boolean", "default": false }, + "enabledOnly": { "type": "boolean", "default": true } + }, + "additionalProperties": false + }, + "extensions.list.result": { + "type": "object", + "required": ["extensions"], + "properties": { + "extensions": { + "type": "array", + "items": { "$ref": "#/$defs/Extension" } + } + }, + "additionalProperties": false + }, + "extensions.get.params": { + "type": "object", + "oneOf": [ + { "required": ["id"], "properties": { "id": { "type": "string" } } }, + { "required": ["manifest"], "properties": { "manifest": { "$ref": "#/$defs/ManifestRef" } } } + ], + "additionalProperties": false + }, + "extensions.get.result": { + "type": "object", + "required": ["extension", "document"], + "properties": { + "extension": { "$ref": "#/$defs/Extension" }, + "document": { + "type": "object", + "description": "plugin.json manifest document per spec/manifest.md at the negotiated spec version" + }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/FileEntry" } + } + }, + "additionalProperties": false + }, + "hooks.invoke.params": { + "type": "object", + "required": ["event", "input"], + "properties": { + "event": { "$ref": "#/$defs/HookEvent" }, + "input": { "type": "object" }, + "context": { + "type": "object", + "properties": { + "cwd": { "type": "string" }, + "session": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "agent": { "type": "string" } + }, + "additionalProperties": false + }, + "extension": { "type": "string", "description": "Extension.id — restrict to one provider" } + }, + "additionalProperties": false + }, + "stream": { "type": "boolean", "default": false }, + "timeoutMs": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + }, + "hooks.invoke.result": { + "type": "object", + "required": ["status", "results"], + "properties": { + "status": { "type": "string", "enum": ["continue", "modify", "block"] }, + "output": { "type": "object" }, + "results": { + "type": "array", + "items": { + "type": "object", + "required": ["extension", "status"], + "properties": { + "extension": { "type": "string" }, + "status": { "type": "string", "enum": ["continue", "modify", "block", "skipped", "error"] }, + "output": { "type": "object" }, + "skippedReason": { "type": "string", "enum": ["policy", "capability", "unsupported-event"] }, + "durationMs": { "type": "number", "minimum": 0 } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "commands.resolve.params": { + "type": "object", + "oneOf": [ + { + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "argv": { "type": "array", "items": { "type": "string" } } + } + }, + { + "required": ["text"], + "properties": { "text": { "type": "string" } } + } + ], + "additionalProperties": false + }, + "commands.resolve.result": { + "type": "object", + "required": ["command", "expansion"], + "properties": { + "command": { + "type": "object", + "required": ["name", "source"], + "properties": { + "name": { "type": "string" }, + "source": { + "type": "object", + "required": ["extension", "manifest"], + "properties": { + "extension": { "type": "string" }, + "manifest": { "$ref": "#/$defs/ManifestRef" } + }, + "additionalProperties": false + }, + "description": { "type": "string" }, + "argv": { "type": "array", "items": { "type": "string" } } + }, + "additionalProperties": false + }, + "expansion": { + "type": "object", + "required": ["prompt"], + "properties": { + "prompt": { "type": "string" }, + "context": { + "type": "object", + "additionalProperties": { "type": "string" } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "skills.materialize.params": { + "type": "object", + "required": ["extension"], + "properties": { + "extension": { "type": "string", "description": "Extension.id" }, + "target": { "type": "string", "enum": ["store", "inline"], "default": "store" }, + "include": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "additionalProperties": false + }, + "skills.materialize.result": { + "type": "object", + "required": ["skills"], + "properties": { + "skills": { + "type": "array", + "items": { + "type": "object", + "required": ["name", "manifest", "files"], + "properties": { + "name": { "type": "string" }, + "manifest": { "$ref": "#/$defs/ManifestRef" }, + "files": { + "type": "array", + "items": { "$ref": "#/$defs/FileEntry" } + }, + "materializedTo": { "type": "string" } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "tools.call.params": { + "type": "object", + "required": ["server", "tool", "arguments"], + "properties": { + "server": { "type": "string", "description": "mcp.json server key" }, + "tool": { "type": "string" }, + "arguments": { "type": "object" }, + "meta": { + "type": "object", + "properties": { + "progressToken": { "type": ["string", "number"] } + }, + "additionalProperties": true + } + }, + "additionalProperties": false + }, + "tools.call.result": { + "type": "object", + "description": "MCP tools/call result, verbatim passthrough", + "required": ["content"], + "properties": { + "content": { "type": "array", "items": { "type": "object" } }, + "isError": { "type": "boolean" }, + "structuredContent": { "type": "object" } + }, + "additionalProperties": true + }, + "events.notify.params": { + "type": "object", + "required": ["kind"], + "properties": { + "kind": { + "type": "string", + "description": "Well-known: extensions.changed, lock.changed, hook.progress, hook.delta, request.cancelled, log, store.warning. Unknown kinds ignored." + }, + "data": { "type": "object" }, + "streamId": { "type": "string", "description": "Correlates a streamed notification to the request id that produced it" } + }, + "additionalProperties": false + } + } +} diff --git a/spec/bridge/operations.md b/spec/bridge/operations.md new file mode 100644 index 0000000..172449e --- /dev/null +++ b/spec/bridge/operations.md @@ -0,0 +1,353 @@ +# AnyHarness Bridge Protocol v0.1 — operations + +> The complete v0.1 operation set. Eight methods: one handshake, five +> resource ops, one streaming-capable invocation, one notification channel. +> Envelope, error codes, and lifecycle rules: `protocol.md`. Capability +> gating: `capabilities.md`. Wire framing: `transports.md`. +> +> Pinned shared types — `Extension`, `ExtensionKind`, `ManifestRef`, +> `Capabilities` — are defined in §1 and in `messages.schema.json`. Manifest +> document fields live in `spec/manifest.md`; store paths in +> `spec/store-layout.md`; integrity values in `spec/lockfile.md`. This doc +> references them by name only. + +## 1. Pinned types (verbatim — parallel workstreams code against these) + +```ts +type ExtensionKind = "skill" | "mcp" | "plugin" | "hook" | "command" | "agent" | "rule"; + +/** Pointer to a plugin.json + version, per spec/manifest.md + spec/lockfile.md. */ +interface ManifestRef { + /** Package name — `plugin.json` `name` field. */ + name: string; + /** Exact installed version (semver string). */ + version: string; + /** Integrity value as recorded in extensions.lock (e.g. "sha256-…"). Optional in-flight; always present server-side. */ + integrity?: string; +} + +/** One installed unit in the store. */ +interface Extension { + /** Stable id: "@" — also the extensions.lock key. */ + id: string; + /** Primary kind (how it was installed). */ + kind: ExtensionKind; + manifest: ManifestRef; + enabled: boolean; + /** All kinds of content this extension contributes — a `plugin` may provide hooks+commands+skills. */ + provides?: ExtensionKind[]; + /** Whether the negotiated client can consume it (absent = true). */ + supported?: boolean; +} + +/** Host-declared feature surface — see capabilities.md for slot semantics. */ +interface Capabilities { + /** ExtensionKind values this client can consume. */ + kinds: ExtensionKind[]; + /** Hook lifecycle events this client will invoke. */ + hookEvents: string[]; + /** Host-injected environment slots. */ + slots: { + storage: "fs" | "kv" | "none"; + secrets: "host" | "prompt" | "none"; + exec: boolean; + skills: "read-write" | "read" | "none"; + mcp: "managed" | "external" | "none"; + [slot: string]: unknown; // forward-compatible extension slots + }; + experimental?: Record; +} +``` + +## 2. Lifecycle overview + +| Phase | Client calls | +| --- | --- | +| Connect | `capabilities.negotiate` (once, first) | +| Session setup | `extensions.list`, `extensions.get`, `commands.resolve`, `skills.materialize` | +| Agent loop | `hooks.invoke` at each declared lifecycle event; `tools.call` for MCP work; `commands.resolve` on slash-command input | +| Steady state | server pushes `events.notify` (`extensions.changed`, `lock.changed`, `log`…) | +| Anytime | client pushes `events.notify` `request.cancelled` | + +## 3. `capabilities.negotiate` + +Handshake. Negotiates protocol version, sibling-spec versions, and the +feature surface. **Negotiates features, not permissions** — caller +authorization is enforced per-request by the transport's identity layer +(`transports.md` §3); an unauthorized op fails `-32012` regardless of what +was negotiated here. + +**Params** + +```ts +{ + protocol: { supported: string[] }; // newest first, e.g. ["0.1"] + specs?: Record; // {"manifest": ["1.0"], "storeLayout": ["0.1"], …} + client: { name: string; version: string }; + capabilities: Capabilities; // declared, not yet granted +} +``` + +**Result** + +```ts +{ + protocol: { version: string }; // negotiated protocolVersion + specs?: Record; // negotiated per-spec version + server: { name: string; version: string }; + session: { id: string }; + capabilities: Capabilities; // granted subset +} +``` + +**Errors**: `-32001` version-mismatch (`data.supported`), `-32602` on a +second negotiate or schema violation. + +**When**: exactly once, as the first message on every session and on every +reconnect. + +## 4. `extensions.list` + +Enumerate installed extensions visible to this client. + +**Params** + +```ts +{ + kinds?: ExtensionKind[]; // filter; matches primary kind OR provides[] + includeUnsupported?: boolean; // default false: kinds outside negotiated + // capabilities are filtered out entirely + enabledOnly?: boolean; // default true +} +``` + +**Result** + +```ts +{ extensions: Extension[] } // each carries manifest: ManifestRef +``` + +**Errors**: none beyond the generic table; `-32003` never fires here — +filtering IS the graceful degradation. + +**When**: after handshake, at session setup; re-call after an +`extensions.changed` notification. + +## 5. `extensions.get` + +Fetch one extension and its manifest document. + +**Params** — exactly one selector: + +```ts +{ id: string } | { manifest: ManifestRef } +``` + +**Result** + +```ts +{ + extension: Extension; + document: object; // plugin.json per spec/manifest.md at the negotiated spec version + files?: { path: string; size: number }[]; // relative to the package dir, per spec/store-layout.md +} +``` + +**Errors**: `-32004` not-found (`data.resource: "extension"`), +`-32005` manifest-invalid (`data.issues`). + +**When**: lazily, for extensions the client actually consumes — do not +prefetch manifests for every list entry. + +## 6. `hooks.invoke` + +Run all installed handlers for one lifecycle event and return the merged +verdict. Streaming form: see `protocol.md` §5.2. + +**Params** + +```ts +{ + event: string; // a negotiated hookEvents entry + input: object; // event payload — shape defined by the event (see capabilities.md §3) + context?: { + cwd?: string; + session?: { id?: string; agent?: string }; + extension?: string; // restrict to handlers from one extension + }; + stream?: boolean; // default false → no progress notifications + timeoutMs?: number; // server default applies when omitted +} +``` + +**Result** + +```ts +{ + status: "continue" | "modify" | "block"; // merged verdict: any "block" wins; else any "modify"; else "continue" + output?: object; // merged modification payload when status === "modify" + results: { + extension: string; // Extension.id + status: "continue" | "modify" | "block" | "skipped" | "error"; + output?: object; + skippedReason?: "policy" | "capability" | "unsupported-event"; + durationMs?: number; + }[]; +} +``` + +**Errors**: `-32003` capability-unsupported (`data.capability` = the +undeclared event), `-32006` hook-failed (`data.results` carries the +per-handler partials), `-32007` policy-denied, `-32800` request-cancelled. +A single handler error never fails the whole call unless `failClosed` is +set by policy — it surfaces as `status: "error"` in `results` instead. + +**When**: at every declared lifecycle event the agent loop reaches — +`session.start`, `prompt.submit`, `tool.pre`, `tool.post`, +`response.stop`, `session.end` (canonical list in `capabilities.md` §3). + +## 7. `commands.resolve` + +Resolve a slash command (or user-typed command string) to its definition +and expanded prompt. + +**Params** — exactly one selector: + +```ts +{ name: string; argv?: string[] } | { text: string } // text is raw input, e.g. "/review src/" +``` + +**Result** + +```ts +{ + command: { + name: string; + source: { extension: string; manifest: ManifestRef }; + description?: string; + argv?: string[]; // declared positional args, if the manifest defines them + }; + expansion: { + prompt: string; // fully-expanded prompt text to feed the agent loop + context?: Record; // named captures the manifest declared + }; +} +``` + +**Errors**: `-32004` not-found (`data.resource: "command"`; `data.name` +echoes the lookup), `-32005` manifest-invalid. + +**When**: whenever user input enters the harness's command namespace — +typically on a leading `/` or the harness's own command syntax. + +## 8. `skills.materialize` + +Make an installed skill usable by this client — written to the shared +store, or returned inline for filesystem-less hosts. + +**Params** + +```ts +{ + extension: string; // Extension.id that provides skills + target?: "store" | "inline"; // default "store" + include?: string[]; // skill names to materialize; default = all provided +} +``` + +**Result** + +```ts +{ + skills: { + name: string; + manifest: ManifestRef; + files: { path: string; content?: string; contentBase64?: string }[]; + materializedTo?: string; // absolute dir when target="store", e.g. "~/.agents/skills/" + }[]; +} +``` + +**Semantics** + +- `target: "store"` — the server writes the skill tree under + `~/.agents/skills/` (rules in `spec/store-layout.md`). The client must + have declared `slots.skills` = `"read"` or `"read-write"` — else the + files land where the client can't see them and the server returns + `-32003` instead of silently succeeding. +- `target: "inline"` — file contents are returned in `files[].content` / + `contentBase64`; nothing is written. This is the browser/KV-host path. +- Writing into `~/.agents/plugins/` is NEVER a valid outcome (Codex's + namespace — see `spec/store-layout.md`). + +**Errors**: `-32003` capability-unsupported, `-32004` not-found +(`data.resource: "skill"`), `-32007` policy-denied, `-32008` +trust-violation (integrity mismatch while reading the package). + +**When**: at session setup for skills the client wants resident, or +on-demand after `extensions.list`/`extensions.get` reveals a skill provider. + +## 9. `tools.call` + +MCP passthrough: invoke a tool on an MCP server the bridge manages +(`mcp.json` declarations per `spec/store-layout.md`). + +**Params** + +```ts +{ + server: string; // mcp.json server key + tool: string; // MCP tool name + arguments: object; // tool arguments, passed through verbatim + meta?: { progressToken?: string | number }; // MCP _meta passthrough +} +``` + +**Result** — the MCP `tools/call` result, verbatim: + +```ts +{ content: object[]; isError?: boolean; structuredContent?: object; [k: string]: unknown } +``` + +**Errors** + +- `-32003` capability-unsupported — negotiated `slots.mcp` was `"none"`, + or the server runs without managed MCP (`data.capability: "mcp"`). +- `-32004` not-found — `data.resource: "server" | "tool"`. +- MCP protocol errors map to `-32603` with `data.kind: "mcp"` and the + original MCP error object in `data.mcpError`. `isError: true` in a + successful result is a *tool-level* failure and stays a result, not a + JSON-RPC error. + +**When**: inside the agent loop, whenever the model selects a tool owned +by a managed MCP server. Hosts with their own MCP client stack +(`slots.mcp: "external"`) MAY bypass the bridge entirely for tools — the +bridge op exists for hosts that don't run MCP themselves. + +## 10. `events.notify` + +Notification channel — no `id`, no response. The only bidirectional op: +server→client for everything, client→server for `request.cancelled` only. + +**Params** + +```ts +{ kind: string; data?: object; streamId?: string } +``` + +**Well-known kinds** (unknown kinds MUST be ignored): + +| Kind | Direction | `data` | +| --- | --- | --- | +| `extensions.changed` | S→C | `{ added: string[]; removed: string[]; updated: string[] }` — Extension.ids; client should re-`extensions.list` | +| `lock.changed` | S→C | `{ path: string }` — extensions.lock rewritten externally | +| `hook.progress` | S→C | streamed intermediate, `streamId` = request id (`protocol.md` §5.2) | +| `hook.delta` | S→C | streamed output chunk, `streamId` = request id | +| `request.cancelled` | C→S | `{ requestId: string }` — cancel an in-flight request | +| `log` | S→C | `{ level: "debug"|"info"|"warn"|"error"; message: string }` — human diagnostics only | +| `store.warning` | S→C | `{ message: string; extension?: string }` — non-fatal store/trust issue | + +**When**: `extensions.changed`/`lock.changed` whenever the store mutates +(including mutations by *other* clients in daemon mode — this is how +multi-harness coherency is announced); `log` at server discretion; +`request.cancelled` whenever the client abandons an in-flight request. diff --git a/spec/bridge/protocol.md b/spec/bridge/protocol.md new file mode 100644 index 0000000..b473edc --- /dev/null +++ b/spec/bridge/protocol.md @@ -0,0 +1,277 @@ +# AnyHarness Bridge Protocol v0.1 — envelope, versioning, errors + +> Status: **draft**. Companion docs: `operations.md`, `capabilities.md`, +> `transports.md`, `security.md`. Machine-readable message definitions: +> `messages.schema.json`. +> +> This document defines the wire contract. It does **not** define the store +> layout (`spec/store-layout.md`), the manifest format (`spec/manifest.md`), +> the lockfile (`spec/lockfile.md`), or the trust layer (`spec/trust.md`) — +> those are referenced by name only. + +The bridge is **LSP-for-extensions**: one versioned JSON-RPC protocol. A +client harness (maki/lua, opencode/TS, fx/zig, hermes/py, crush/go, a +browser bundle) implements the client side once and gets the registry, +install, update, and trust layer "for free". The server side is `harness +serve` (or `@any-harness/sdk` in-process); per-language client SDKs are thin +generated wrappers over this document, not reimplementations. + +## 1. Terminology + +- **Client / host** — the agent harness. Initiates every request; owns the + lifecycle of the server process (stdio) or the session (HTTP, in-process). +- **Server** — the AnyHarness bridge: `harness serve` or an in-process + `Bridge` from `@any-harness/sdk`. +- **Session** — one negotiated conversation, from `capabilities.negotiate` + until transport teardown. +- The key words **MUST**, **MUST NOT**, **SHOULD**, **MAY** are RFC 2119. + +## 2. Message envelope — JSON-RPC 2.0 + +All messages are a single JSON object conforming to JSON-RPC 2.0 +([jsonrpc.org/specification](https://www.jsonrpc.org/specification)), with +MCP/LSP conventions applied: + +```jsonc +// Request +{ "jsonrpc": "2.0", "id": 7, "method": "extensions.list", "params": { "kinds": ["skill"] } } + +// Response (success) +{ "jsonrpc": "2.0", "id": 7, "result": { "extensions": [] } } + +// Response (error) +{ "jsonrpc": "2.0", "id": 7, "error": { "code": -32004, "message": "extension not found", + "data": { "kind": "not-found", "resource": "extension" } } } + +// Notification (no id, no response) +{ "jsonrpc": "2.0", "method": "events.notify", "params": { "kind": "extensions.changed" } } +``` + +Rules: + +- `jsonrpc` MUST be the string `"2.0"`. +- `id` is a string or number chosen by the client, unique within the session + among in-flight requests. Notifications have **no** `id` and MUST NOT be + answered. +- `params` is always a **by-name object**; positional-parameter arrays are + not used. +- `method` names are dot-namespaced (`.`). The complete v0.1 + operation set is defined in `operations.md`; unknown methods → `-32601`. +- **Batch requests are not supported in v0.1.** Each message is one + request, response, or notification. +- Implementations MUST ignore unknown members on every object (forward + compatibility) and MUST NOT depend on member ordering. +- A response carries `result` XOR `error`, never both. + +## 3. Handshake and `protocolVersion` negotiation + +### 3.1 Required first message + +The client's first message on a new session MUST be a +`capabilities.negotiate` request. The server MUST reject every other +request received before negotiation with `-32002` (`handshake-required`). +Notifications from the server likewise MUST NOT be sent before negotiation. + +### 3.2 Negotiation semantics + +```jsonc +// → client +{ "jsonrpc": "2.0", "id": 1, "method": "capabilities.negotiate", + "params": { + "protocol": { "supported": ["0.1"] }, + "specs": { "manifest": ["1.0"], "storeLayout": ["0.1"] }, + "client": { "name": "maki", "version": "1.4.0" }, + "capabilities": { /* declared Capabilities — see capabilities.md */ } + } } + +// ← server +{ "jsonrpc": "2.0", "id": 1, + "result": { + "protocol": { "version": "0.1" }, + "specs": { "manifest": "1.0", "storeLayout": "0.1" }, + "server": { "name": "anyharness", "version": "2.0.0" }, + "session": { "id": "ses_01J…" }, + "capabilities": { /* granted subset — see capabilities.md */ } + } } +``` + +- `params.protocol.supported` is an array of protocol versions the client + can speak, **newest first**. A version is a `"."` string. +- The server selects the **first entry in the client's list that it fully + supports** and returns it in `result.protocol.version`. That value is the + session's negotiated version; both sides MUST conform to it for the rest + of the session. +- `params.specs` declares which versions of the **sibling L0 specs** the + client understands, keyed by spec name: `manifest` (see + `spec/manifest.md`), `storeLayout` (`spec/store-layout.md`), + `lockfile` (`spec/lockfile.md`), `trust` (`spec/trust.md`). Each value is + an array of spec versions, newest first, using the versioning policy + defined in `spec/manifest.md` (referenced, not redefined here). The + server answers with `result.specs` — the single negotiated version per + spec — or omits a key it does not honor. A client that does not declare a + spec key receives manifest/store documents only in the oldest spec + version the server can emit. +- If the server supports **none** of the offered versions it MUST fail the + request with `-32001` (`version-mismatch`) and include its own list: + + ```jsonc + { "code": -32001, "message": "no common protocol version", + "data": { "kind": "version-mismatch", "supported": ["0.1", "0.2"] } } + ``` + + The client MAY retry `capabilities.negotiate` with an intersecting list. +- The capability half of negotiation (which `ExtensionKind`s, hook events, + and env slots both sides honor) is defined in `capabilities.md`. The + returned `capabilities` object is the **granted** set — always a subset + of, or equal to, what the client declared. +- `capabilities.negotiate` is invoked **exactly once** per session. A + second call MUST fail with `-32602` (`invalid-params`). + +### 3.3 Versioning policy + +- Versions are `"."`. **Major** = breaking change; **minor** + = strictly additive (new operations, new optional fields, new enum + values). +- Clients and servers MUST ignore unknown fields, enum values, and + notification kinds — this is what makes additive evolution safe. +- **Message schemas ride the protocol version.** `messages.schema.json` is + published per protocol version and is not separately versioned on the + wire: the negotiated `protocol.version` selects the whole schema bundle. + Additive schema changes (new optional params, new enum members) ship in a + minor bump; a field's meaning or requiredness changes only in a major + bump. +- **Embedded documents version separately.** Payloads that carry sibling- + spec documents (`plugin.json` manifests in `extensions.get`, skill trees + in `skills.materialize`) follow the versioning policy of + `spec/manifest.md` — referenced here by name, not redefined. Which of + those spec versions is in force for the session is negotiated via + `params.specs` / `result.specs` (§3.2), independently of the protocol + version. +- **Pre-1.0 caveat:** while the major version is `0`, a minor bump MAY + break compatibility. Pin the exact negotiated `version`; do not assume + forward compat across `0.x` boundaries. + +## 4. Error model + +### 4.1 Shape + +```jsonc +{ "code": , "message": , "data": { "kind": , ... } } +``` + +- `code` selects the machine-readable class. +- `message` is a short human description; it MUST NOT carry the only copy + of machine-relevant detail (that goes in `data`). +- `data.kind` is a stable, kebab-case error kind string. `data` MAY carry + operation-specific fields (defined per op in `operations.md`). + +### 4.2 Codes + +Standard JSON-RPC codes are used unchanged: + +| Code | Meaning | +| --- | --- | +| `-32700` | Parse error — malformed JSON | +| `-32600` | Invalid request — not a valid envelope | +| `-32601` | Method not found | +| `-32602` | Invalid params — schema violation | +| `-32603` | Internal error | + +Bridge errors live in the `-32000…-32099` implementation-defined range: + +| Code | `data.kind` | Raised when | +| --- | --- | --- | +| `-32001` | `version-mismatch` | No common `protocolVersion`; `data.supported` = server's list | +| `-32002` | `handshake-required` | Any request before `capabilities.negotiate` | +| `-32003` | `capability-unsupported` | Op/capability not granted by negotiation; `data.capability` names the missing slot | +| `-32004` | `not-found` | Named resource absent; `data.resource` ∈ `extension | command | skill | tool | server` | +| `-32005` | `manifest-invalid` | `plugin.json` fails `spec/manifest.md` validation; `data.issues` lists violations | +| `-32006` | `hook-failed` | A hook handler errored/timed out; `data.results` carries partial results | +| `-32007` | `policy-denied` | Trust/script policy refused the operation; `data.policy` names the rule (see `security.md`) | +| `-32008` | `trust-violation` | Integrity check failed (`extensions.lock` mismatch); `data.expected`, `data.actual` | +| `-32009` | `auth-failed` | Caller **authentication** failed (bad/missing bearer token on loopback; unknown peer on a socket) | +| `-32010` | `transport-unavailable` | Requested transport/mode not running (e.g. `tools.call` with no managed MCP) | +| `-32011` | `conflict` | Store mutation raced; retry is safe | +| `-32012` | `forbidden` | Caller **authenticated** but not authorized for this op on this server (`data.op` names the denied method; see `transports.md` §3, `security.md`) | + +`auth-failed` vs `forbidden` mirrors HTTP 401/403: the former means "we +don't know who you are", the latter "we know who you are and the answer is +no". `capability-unsupported` is a third axis — the op is not in the +**negotiated feature set** regardless of identity. + +## 5. Ordering and streaming + +### 5.1 Ordering + +- The stdio transport delivers messages **in order** per direction; + HTTP and in-process transports preserve the same guarantee per session. +- Responses to **concurrent** requests MAY arrive in any order; `id` is + the only correlation. A client MAY pipeline (send request N+1 before + response N). +- The server MAY serialize store-mutating work internally; callers MUST NOT + rely on two concurrent requests observing a consistent store snapshot. + +### 5.2 Streaming hook results + +`hooks.invoke` can be long-lived and multi-handler. When the request sets +`"stream": true`, the server streams intermediate progress as +`events.notify` notifications correlated by `streamId` — the client's own +request `id`, echoed as a string: + +```jsonc +// request +{ "id": 42, "method": "hooks.invoke", "params": { "event": "tool.pre", "stream": true, "input": {…} } } + +// streamed intermediates (any count, any order per handler) +{ "method": "events.notify", "params": { "streamId": "42", "kind": "hook.progress", + "data": { "extension": "linter@1.2.0", "stage": "started" } } } +{ "method": "events.notify", "params": { "streamId": "42", "kind": "hook.delta", + "data": { "extension": "linter@1.2.0", "chunk": "…" } } } + +// terminal result arrives as the normal response +{ "id": 42, "result": { "status": "continue", "results": [ … ] } } +``` + +- The response (or error) for `id` is always the **terminal** message for + that `streamId`; there is no separate "end" notification. +- Notifications correlated to a `streamId` the client did not issue MUST be + ignored. +- When `"stream"` is absent or false, no `hook.progress`/`hook.delta` + notifications are emitted for that request. + +### 5.3 Cancellation + +A client cancels an in-flight request by sending `events.notify` from the +client side — the only client→server use of `events.notify`: + +```jsonc +{ "method": "events.notify", "params": { "kind": "request.cancelled", + "data": { "requestId": "42" } } } +``` + +Cancellation follows the LSP/MCP shape adapted to our closed op set: the +server SHOULD abort the underlying work and MUST still answer the +cancelled request — either with its best-effort partial `result` or with +an error whose `data.kind` is `"request-cancelled"` (code `-32800`, +matching LSP's `RequestCancelled`). + +| Code | `data.kind` | Raised when | +| --- | --- | --- | +| `-32800` | `request-cancelled` | Request aborted via `request.cancelled` | + +## 6. Session lifecycle + +``` +spawn/connect → capabilities.negotiate (once) → steady state + ├─ client requests: extensions.*, hooks.invoke, + │ commands.resolve, skills.materialize, tools.call + └─ server notifications: events.notify + → transport teardown (EOF / disconnect / dispose) +``` + +- Teardown ends the session. A reconnect is a **new** session and MUST + re-negotiate. +- On stdio, server shutdown is stdin EOF; the server SHOULD exit `0` on + clean EOF. +- Unanswered requests at teardown are considered failed transport errors + on the client side, not protocol errors. diff --git a/spec/bridge/security.md b/spec/bridge/security.md new file mode 100644 index 0000000..c9398f7 --- /dev/null +++ b/spec/bridge/security.md @@ -0,0 +1,126 @@ +# AnyHarness Bridge Protocol v0.1 — security model + +> What adopting the bridge does and doesn't change about a harness's trust +> posture. Integrity/script-policy/audit mechanics are owned by +> `spec/trust.md` and `spec/lockfile.md` — referenced by name, not +> redefined. Identity plumbing: `transports.md` §3. + +## 1. Trust boundaries + +``` +┌─────────────┐ bridge protocol ┌──────────────────┐ spec/trust.md ┌─────────────┐ +│ harness │ ───────────────────▶ │ harness serve / │ ────────────────▶ │ store: │ +│ (client) │ ◀─────────────────── │ @any-harness/sdk│ │ ~/.agents/… │ +└─────────────┘ └──────────────────┘ └─────────────┘ + trust this? trusts the trust layer signed/locked +``` + +- **Adopting the bridge = adopting the trust layer.** A harness that speaks + this protocol delegates install-time integrity, script policy, and audit + to the server (`harness` binary or linked SDK). The wire contract carries + *verdicts* (`hooks.invoke` results, materialized files), and the client + trusts them because it chose this binary — same trust decision as running + `harness add`. +- **The client still owns execution.** The server returns *data*: expanded + prompts, skill files, hook verdicts. Clients MUST treat all payloads as + data — never `eval` a `commands.resolve` expansion, never execute a + `skills.materialize` file that didn't come through the trust-checked + store path, never render `hook.delta` chunks as HTML unsanitized. +- **Per-caller boundary.** In daemon mode the server is shared: caller + identity + per-caller op scoping (`transports.md` §3) is what keeps a + sandboxed harness from driving `hooks.invoke`/`tools.call` on someone + else's session. + +## 2. Transport threat boundaries + +The three transports have three different threat models — the protocol +doesn't pretend they're equivalent. + +### stdio / in-process + +- **Boundary = process ownership.** Whoever can spawn/link the server can + already run arbitrary code as the user; the channel adds nothing. No + token, no TLS, nothing to leak. +- Unix-socket variants MAY use peer credentials (`SO_PEERCRED` / + `getpeereid`) for audit attribution; the peer is always the implicit + `"owner"` caller, full scope. +- Residual risk: a hostile *plugin* can't touch the channel — handlers are + files invoked by the server, not processes holding the socket. + +### HTTP loopback — daemon mode + +- **Boundary = same-uid file permissions.** `serve.json` is `0600` owned by + the serving user; the bearer token is readable only by processes running + as that user. Any process that can read it could have ptrace'd the daemon + anyway — token auth matches the existing isolation level. +- Per-caller tokens let *policy* differ per harness even though *UID-level* + isolation doesn't: a guest/sandboxed harness gets a scoped-down token + (`allow: [extensions.list, extensions.get]`), and `-32012`/`forbidden` + enforces it server-side. + +### HTTP loopback — browser contexts + +The dangerous case: a web page reaching loopback. Rules the server MUST +enforce: + +- **Bearer token on every non-`/health` request.** The `Authorization` + header makes every request a non-simple CORS request → preflight → the + server sends **no** `Access-Control-Allow-Origin` by default, so foreign- + origin pages can't read responses even if they somehow learned the token. + `--cors ` is an explicit opt-in for the metaharness origin. +- **Host-header allowlist:** accept only `Host: 127.0.0.1:*` / + `localhost:*` / `[::1]:*`. Rejecting everything else kills + DNS-rebinding attacks. +- **Token hand-off is explicit:** browsers can't read `serve.json` — the + token arrives via `--print-token`, user paste, or an enrollment URL the + user clicks. The server MUST NOT serve the token over HTTP. +- **`serve.json` is never a web asset:** the HTTP server MUST NOT serve + files from `~/.agents/` or anywhere else; only the three defined + endpoints exist. +- **Token rotation** (`--rotate-token`) revokes a possibly-leaked token + without restarting the store. +- **TLS off, deliberately:** loopback traffic never crosses a network + boundary; an on-host attacker who can sniff loopback already owns the + box. TLS would add cert-distribution pain for zero threat reduction. + +## 3. Authorization scoping format + +Configured on the server (`anyharness.toml`, `[[serve.caller]]` entries — +see `transports.md` §3.1), enforced per request before dispatch: + +- `allow` / `deny` are sets of bridge method names; `deny` wins; absent + `allow` = full set. `capabilities.negotiate` and + `events.notify`/`request.cancelled` are always permitted. +- Denied → `-32012` `forbidden`, `data.op` = the method. Distinct from + `-32003` `capability-unsupported` (negotiated features) and `-32009` + `auth-failed` (unknown caller). +- Recommended scope for untrusted/sandboxed callers: read-only + (`extensions.list`, `extensions.get`, `commands.resolve`) — no + `hooks.invoke` (drives per-event handler execution), no `tools.call` + (spends MCP credentials), no `skills.materialize` (writes to the store). + +## 4. Script policy through capabilities + +Hooks/commands whose handlers are *scripts* are governed by the trust +layer's script policy (`spec/trust.md`). The bridge surfaces that policy +through the capability grant, so the harness sees enforcement without +re-implementing it: + +- `slots.exec: false` → script handlers never run; they appear in + `hooks.invoke` `results` as `status: "skipped", skippedReason: "policy"`. +- Policy refusing a specific handler or extension → `-32007` + `policy-denied` (`data.policy` names the rule). +- Store content failing integrity → `-32008` `trust-violation` + (`data.expected`/`data.actual` integrity values). +- Every enforcement decision lands in the audit log (`spec/trust.md`) with + caller name, op, and extension id — daemon mode attribution is exactly + the §3 caller identity. + +## 5. Non-goals (stated honestly) + +- The bridge does not sandbox extension code — script hooks run with the + server's privileges. Isolation is policy + provenance, not containment. +- It does not authenticate *users*, only callers — multi-user auth is a + registry-service concern (M4+), not loopback. +- It does not protect the store from a process running as the same uid; + nothing at this layer can. diff --git a/spec/bridge/transports.md b/spec/bridge/transports.md new file mode 100644 index 0000000..69edd8f --- /dev/null +++ b/spec/bridge/transports.md @@ -0,0 +1,216 @@ +# AnyHarness Bridge Protocol v0.1 — transports + +> Three transports, one message schema (`messages.schema.json`). The +> transport carries bytes; the protocol in `protocol.md` is identical on +> all three — the only transport-visible difference is **how the caller is +> authenticated** (§3). + +| Transport | Shape | Auth | Use | +| --- | --- | --- | --- | +| stdio | `harness serve` spawned per-host, NDJSON on stdin/stdout | OS process ownership | **Primary** — the MCP mental model | +| HTTP loopback | `harness serve --http`, `127.0.0.1`, bearer token | Per-caller token | Daemon mode (N harnesses, one store); **the only transport a browser context can reach** | +| in-process | `@any-harness/sdk` `createBridge()` | none — same address space | TS embeds, metaharness, browser bundle | + +## 1. stdio (primary) + +The host spawns and owns the server process: + +``` +harness serve # or: harness serve --stdio +``` + +- **Framing**: newline-delimited JSON — one message per line, UTF-8, `\n` + terminated. Messages MUST NOT contain literal newlines. This matches + MCP's stdio framing (deliberately *not* LSP's `Content-Length` headers — + NDJSON is greppable and stream-parsable in every language). +- **stdout** carries protocol messages only. **stderr** is free-form server + diagnostics and MUST NOT be parsed. +- **Lifecycle**: one client per process. stdin EOF = shutdown → the server + SHOULD exit `0`. Killing the process is always safe; in-flight requests + just fail on the client side. +- **Environment**: the server inherits the host's environment (cwd, env + vars). Optional overrides: `ANYHARNESS_STORE` (store root, default per + `spec/store-layout.md`), `ANYHARNESS_LOG`. +- **Identity** (§3): the caller is *the parent process* — authentication is + process ownership. No token exists to leak. Where the platform supports + it (unix-domain variant of `harness serve`), peer credentials + (`SO_PEERCRED`/`getpeereid`) MAY be recorded for the audit log + (`spec/trust.md`); the peer is always authorized as the implicit + `"owner"` caller with the full op set. A stdio server therefore does + **not** implement per-caller scoping — one caller, all ops it negotiated. + +This is the default every harness SHOULD implement first: spawn, negotiate, +go. + +## 2. HTTP loopback (daemon + browser) + +`harness serve --http` runs a long-lived server bound to **loopback only**. + +### 2.1 Where it's needed — two distinct contexts + +- **Daemon mode**: several local harnesses (a user's maki + opencode + + crush) share one store through one server. Store mutations are serialized + server-side; `extensions.changed`/`lock.changed` notifications keep the + other clients coherent. This is the multi-tenancy case — callers are + *different harnesses* and get different tokens (§3). +- **Browser contexts**: a web-hosted metaharness cannot spawn a process or + open a unix socket; loopback HTTP is the only transport it can reach. + Callers here are *web pages* — the token is a capability handed in + explicitly (§2.4), and CORS/preflight rules apply. + +The two contexts have different threat boundaries — see `security.md` §2 +for the full analysis: a local socket relies on file-system perms and same- +uid isolation; bearer-token HTTP must additionally withstand DNS-rebinding +and any local process that can read `~/.agents/harness/serve.json`. + +### 2.2 Framing + +- `POST /rpc` — body is a single JSON-RPC message; response body is the + single JSON-RPC response. `Content-Type: application/json`. Batch + requests are not supported in v0.1. +- `GET /events` — optional SSE stream (`text/event-stream`) delivering + `events.notify` notifications: `event: message`, `data:` = the full + JSON-RPC notification object. Without an open `/events` stream, + server→client notifications are silently dropped — request/response ops + are unaffected. +- `GET /health` — unauthenticated `200 {"status":"ok","protocol":"0.1"}`; + everything else requires auth (§2.4). + +### 2.3 Port discovery + +Default bind: `127.0.0.1:0` (ephemeral). Discovery file written on +successful bind: + +``` +~/.agents/harness/serve.json # mode 0600, owned by the serving user +{ + "version": 1, + "pid": 4177, + "port": 55177, + "token": "ahrt_…", // bearer token, 256-bit random + "protocol": "0.1", + "startedAt": "2026-10-08T07:00:00Z" +} +``` + +Read order for local clients: `ANYHARNESS_HTTP` env var +(`http://127.0.0.1:PORT` — token still required), else +`~/.agents/harness/serve.json`, else `--port`/`--token` passed explicitly. +The file is removed on clean shutdown; a stale file (dead `pid`) MUST be +treated as absent. Token rotation = restart with `--rotate-token`, which +rewrites `serve.json` and invalidates prior tokens. + +### 2.4 Authentication + +Every request except `GET /health` requires: + +``` +Authorization: Bearer +``` + +The token is the caller credential — possession *is* identity (see §3 for +how tokens map to callers). Browser clients cannot read `serve.json`; the +token reaches them by explicit hand-off: `harness serve --http +--print-token` output, user paste into the web app, or an enrollment URL +(`http://127.0.0.1:PORT/?token=…`) that the app stores itself. Failed auth +→ HTTP `401` with a JSON-RPC error body `-32009`/`auth-failed`. + +TLS is intentionally **off** on loopback: the channel never leaves the +kernel, and the bearer token carries the security. (Why that's sufficient, +and the DNS-rebinding/`Host`-header rules the server MUST enforce, are in +`security.md` §2.) + +## 3. Caller identity and authorization + +`capabilities.negotiate` negotiates **features**; this section defines +**permissions** — who the caller is and which ops it may invoke. In daemon +mode N harnesses share one store, so the server MUST be able to tell them +apart and scope them differently. + +### 3.1 Callers + +A **caller** is a named principal the server knows: + +```toml +# anyharness.toml (namespaced config per spec/store-layout.md) +[[serve.caller]] +name = "maki" # human label, audit log key +token = "ahrt_…" # bearer credential (stored hashed) +allow = ["extensions.list", "extensions.get", "commands.resolve", + "skills.materialize", "tools.call", "events.notify", + "capabilities.negotiate"] +# deny = ["hooks.invoke"] # explicit denies also legal +``` + +- `allow`/`deny` are sets of bridge method names; `deny` wins on overlap. + Omitted `allow` = full op set (the common single-user case). +- Wildcard `"*"` allowed in `allow` only (e.g. `allow = ["*"], deny = + ["tools.call"]`). + +### 3.2 Identity per transport + +| Transport | Caller identity | Default scope | +| --- | --- | --- | +| stdio | `"owner"` — the spawning process; unix-socket variant MAY verify via peer creds | full op set (no scoping on stdio) | +| in-process | `"embed"` — same address space, no auth at all | full op set | +| HTTP loopback | one caller per bearer token in `serve.caller`; unknown/absent token → `-32009` | per-caller `allow`/`deny` | + +- HTTP callers SHOULD also send `client.name` in `capabilities.negotiate`; + when present the server SHOULD require it to match the token's `name` + (prevents a leaked token silently impersonating a different harness in + audit logs). Mismatch → `-32009`. +- Tokens are generated 256-bit random (`ahrt_` + base62). The server never + echoes them in `events.notify`/`log`. + +### 3.3 Enforcement + +Authorization is checked **per request**, before dispatch: + +- Method in the caller's `deny` or outside its `allow` → `-32012` + `forbidden` with `data.op` naming the denied method. This is independent + of capability negotiation: a sandboxed harness may negotiate a feature + and still be refused the op. +- Caller-scoping errors are worth logging server-side; the response body + reveals only that the op is denied, never the scope table. +- `capabilities.negotiate` itself MUST be in every caller's `allow` (the + server auto-grants it); `events.notify` `request.cancelled` likewise. + +Example sandboxed caller — a harness that may browse the store but never +run hooks or tools: + +```toml +[[serve.caller]] +name = "sandboxed-guest" +token = "ahrt_…" +allow = ["extensions.list", "extensions.get"] +``` + +## 4. In-process binding + +TS embeds link `@any-harness/sdk` directly — no serialization: + +```ts +import { createBridge } from "@any-harness/sdk"; + +const bridge = await createBridge({ capabilities /* declared Capabilities */ }); + +const { extensions } = await bridge.call("extensions.list", { kinds: ["skill"] }); +// or the typed facade: +const skills = await bridge.skills.materialize({ extension: "pkx@1.0.0", target: "inline" }); + +for await (const n of bridge.notifications()) { /* events.notify params */ } +await bridge.dispose(); +``` + +- Method names, params, and results are the `messages.schema.json` types + (exported as TS types from `@any-harness/spec`). +- JSON-RPC errors surface as thrown `BridgeError` carrying the same + `code`/`data.kind`. +- Notifications arrive on `bridge.notifications()` (async iterator) or a + callback — same `events.notify` shape. +- Negotiation still happens: `createBridge` performs the equivalent of + `capabilities.negotiate` internally — same pins, no wire. + +This binding is what the metaharness and any TS host (opencode-class) uses; +the same code path compiled into a browser bundle consumes the loopback +transport instead — same ops, different framing.