From 2328dd138700b147b04ab9ae0728ee0bc3982623 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 18:05:21 -0700 Subject: [PATCH 01/12] docs(exploration): explore OpenClaw/Hermes integration, signed agent audit trails, and text control plane (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 708 ++++++++++++++++++ 1 file changed, 708 insertions(+) create mode 100644 docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md new file mode 100644 index 000000000..a78f95764 --- /dev/null +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -0,0 +1,708 @@ +# OpenClaw / Hermes Integration: Signed Agent Audit Trails And A Text Control Plane + +> You already text an agent — OpenClaw or Hermes — on WhatsApp, Telegram, or +> Signal. Can xNet's primitives (DIDs, UCANs, the signed hash-chained change +> log, schemas) turn that agent from an unauditable root shell into a scoped, +> attributable, tamper-evidently logged collaborator — and make "work on your +> hub remotely via text" safe enough to actually do? + +## Problem Statement + +Exploration [0175](0175_[_]_XNET_AS_A_SUBSTRATE_FOR_OPENCLAW.md) and +[`docs/guides/openclaw-integration.md`](../guides/openclaw-integration.md) +answered *how an agent drives xNet*: via the MCP server +(`xnet mcp serve`, stdio or hardened loopback HTTP on :31416), behind the +mutation-plan guardrail. That work treats the agent as a **client**. Two +questions remain open, and they are the ones that matter once the connection +exists: + +1. **Track and audit.** When an always-on agent has standing write access to + your workspace, *what did it actually do last Tuesday at 3am?* OpenClaw's + native answer is per-session JSONL transcripts in + `~/.openclaw/agents//sessions/` — plain files, mutable by any process + with filesystem access, with documented blind spots (cron jobs, sub-agents, + and heartbeats log to isolated contexts). Gateway-level audit logging is an + open upstream feature request (openclaw#13131). xNet's kernel is a signed, + hash-chained, per-author change log — structurally the thing OpenClaw is + missing. The question is how to aim it: agent identity, action schemas, an + author-scoped audit query, and an audit console. + +2. **Work on your hub remotely via text.** The agent's superpower is *reach*: + it already sits in WhatsApp/Telegram/Signal/iMessage. Routed through xNet's + MCP surface, a text message becomes a hub operation ("archive last week's + inbox items", "who changed the Q3 doc?", "how much disk is the hub + using?"). But chat channels are weak authorization surfaces (no E2EE for + Telegram bots, SIM-swap risk, platform-visible content), and the agent + relaying your "yes" can also forge your "yes". The question is which + ceremonies make text-triggered operations safe, and how approvals get + recorded in the same tamper-evident log as the actions they authorize. + +This exploration designs both halves and shows they are the same feature: the +**audit trail is a side effect of making the agent a first-class, scoped +identity in the data model**, and the **approval ceremony is just another +signed node** in that trail. + +## Executive Summary + +- **Give the agent its own DID; never lend it yours.** Every xNet change + carries `authorDID`, a per-author `parentHash` chain, and an Ed25519 + signature (`packages/sync/src/change.ts`). If the agent signs with its own + `did:key` — delegated a *scoped* UCAN from the operator via `createUCAN` + (`packages/identity/src/ucan.ts`) — then attribution, tamper-evidence, and + non-repudiation of agent actions fall out of the existing kernel for free. + Today the integration path shares the operator's client identity, which + makes agent and human writes indistinguishable. This is the single + highest-leverage change in the whole design. +- **The audit trail lane is genuinely open.** Prior-art research: every + mainstream agent-observability stack (OTel GenAI conventions, LangSmith, + Langfuse, AgentOps) exports traces to a *mutable, operator-controlled* + backend. The closest thing to signed agent actions is the early-stage + "Agent Receipts" spec (Ed25519-signed, hash-chained action receipts with a + **reversibility field**). Nobody ships a datastore where the audit trail + *is* the data layer. xNet can — the change log already is one. +- **Three small additions close the audit gap**: an `AgentSession` / + `AgentAction` schema pair (template: `DebugReport`'s deterministic-id + + lane/status pattern, `packages/data/src/schema/schemas/debug-report.ts`); a + **per-author index over the change log** (today "all changes by author X" + is a scan — real gap, flagged in section D below); and an MCP middleware + that writes one `AgentAction` node per guarded tool call. +- **Don't build messaging bridges; own the data plane.** OpenClaw (~383k + stars) and Nous Research's Hermes Agent (~216k stars, Feb 2026) each + maintain 10+ channel integrations (WhatsApp via Baileys, Telegram, Signal, + iMessage, Discord…). Both consume MCP and both read the same + AgentSkills-spec `SKILL.md` format xNet already emits + (`packages/plugins/src/ai-surface/skill.ts`). Let them own channels; xNet + owns identity, governance, and the audit log. One MCP server serves both. +- **Risk-tier the text control plane.** Reads and low-risk writes execute + straight from chat. Medium-risk writes require an in-chat typed + confirmation bound to an expiring nonce (Slack's timestamped-signature + + typed-confirm pattern). High/critical-risk and outward-facing operations + require approval in an xNet surface (app/push), *never* in-chat — because + the agent that relays the request can forge the in-chat reply. Every + approval or denial is itself a signed `AgentApproval` node, so the ceremony + lands in the same hash-chained log as the action it gates. No surveyed + product does this end-to-end. +- **The audit log is detective, not preventive.** The lethal trifecta + (private data + untrusted content + exfiltration channel) is not solved by + logging. Prevention stays where it already is: scoped UCANs (never the + `{with:'*', can:'*'}` anonymous wildcard — 0307's known weakness), + `toolFilter` least-privilege, the mutation-plan guardrail, and hub-side + quotas. The trail makes compromise *visible and provable*, which is the + part nothing else in the agent's stack provides. + +**Recommendation in one line:** *ship an "Agent Passport" — a per-agent DID + +attenuated UCAN + `AgentSession`/`AgentAction`/`AgentApproval` schemas + a +per-author change-log index — and risk-tiered chat ceremonies on top of the +existing MCP surface; build zero messaging bridges.* + +## Current State In The Repository + +### What already works (from 0175 / 0194 / 0196) + +- **MCP substrate**: `packages/plugins/src/services/mcp-server.ts` + (`createMCPServer`, `startStdio()`) exposing `xnet_search`, + `xnet_read_page_markdown`, `xnet_plan_page_patch`, `xnet_create/update/ + delete/query`, behind `AiSurfaceService` and the mutation-plan guardrail + (`packages/plugins/src/ai-surface/types.ts` — `AiMutationPlan` with + `risk`, `requiredScopes`, plan → validate → apply → audit → rollback). + HTTP transport: `packages/plugins/src/services/mcp-http.ts` via + `xnet mcp serve --http` (`packages/cli/src/commands/mcp.ts`). +- **Agent bridge daemon** (`packages/devkit/src/bridge-server.ts`, + `DEFAULT_BRIDGE_PORT = 31416`): loopback-only, Host-header validated, + Origin-allowlisted, pairing-token gated (`timingSafeEqual`). Endpoints: + unauthenticated `GET /health`, OpenAI-compatible `POST /v1/chat/completions` + backed by the user's own `claude`/`codex` CLI, opt-in `POST /run` + (worktree → gate → checkpoint/rollback). CLI: `xnet bridge serve` + (`packages/cli/src/commands/bridge.ts`). +- **Connector fabric** (`packages/plugins/src/connectors/define-connector.ts`, + `.../actions/define-action.ts`): broker-held secrets (hub-side, + `packages/hub/src/features/broker.ts`), `guardStore` capability proxy + (`packages/plugins/src/ecosystem/capability-guard.ts:191`), SSRF guard + `assertPublicUrl` (`packages/plugins/src/actions/ssrf.ts`) enforced in the + action runner *even for allowlisted hosts*. +- **Inbound webhook seam**: `packages/hub/src/features/webhook-inbox.ts` + mounts `POST /hooks/:token` — path token is the credential; deliveries + materialize as nodes stamped with the route's `space` and `schema` + (default `ExternalItem`). This is the shortest inbound "message → node" + path for setups without a full MCP loop. +- **Chat as data**: `ChatMessage`/`Channel` schemas + (`packages/data/src/schema/schemas/chat-message.ts`, `channel.ts`) and + `sendMessage` in `packages/comms/src/chat/chat-service.ts` — "send" is + just a node create, so **an agent posts into a channel by creating a + `ChatMessage` node; no special API**. Deterministic DM ids + (`packages/comms/src/chat/dm.ts`), structured mentions + (`mentions.ts`, never parsed from text). +- **Telemetry-to-nodes template**: `DebugReportSchema` + (`packages/data/src/schema/schemas/debug-report.ts`) — deterministic + fingerprint ids (repeat events LWW-upsert one node and bump + `occurrences`), `lane` and `status` lifecycle, `spaceCascadeAuthorization`. + The cleanest existing pattern for "system events as queryable nodes". + +### The kernel primitives the audit trail rides on + +`packages/sync/src/change.ts` — every change record carries: + +| Field | Audit meaning | +| --- | --- | +| `authorDID` | **who** — the signing identity (this must become the *agent's* DID) | +| `signature` | Ed25519 over the content hash — non-repudiation | +| `hash` / `parentHash` | content-addressed, per-author hash chain — tamper-evidence, gap detection | +| `lamport` + `wallTime` | **when**, in both causal and human time | +| `type` / `payload` | **what** — the mutation itself | + +`verifyChange` / `verifyChangeHash` (`change.ts:321,379`) already run on the +hub before `storage.appendNodeChange` +(`packages/hub/src/services/node-relay.ts`), so a forged or resigned agent +history is rejected at ingest. + +### The gaps (verified, not vibes) + +1. **No per-author audit index.** `authorDID` is on every change and the + store keys LWW by `{lamport, author}` + (`packages/data/src/store/store.ts` ~1939, sqlite column + `lamport_author`), but there is no index or query surface for "all + changes by author X" — hub-side it's a scan over the append-only log + (`packages/hub/src/storage/sqlite.ts`). An audit console needs this. +2. **Agent identity doesn't exist as a concept.** The MCP server executes + with the operator's store identity. Agent writes are attributed to *you*. +3. **Wildcard UCAN weakness (0307).** `createAnonymousSession()` in + `packages/hub/src/auth/ucan.ts` grants `{with:'*', can:'*'}`, and + client-side self-issued tokens are effectively root. Delegating an + *attenuated* UCAN to an agent is meaningless until scoped tokens are the + norm — this exploration adds a consumer that forces the issue. +4. **`assertPublicUrl` blocks loopback dispatch.** A `defineAction` that + notifies a *local* OpenClaw/Hermes gateway (`127.0.0.1:18789`) will be + rejected by the SSRF guard — correctly for marketplace actions, but it + means hub→agent notification needs the bridge/webhook lane, not the + action runner (see Options). +5. **No approval primitive.** `AiAgentApproval` + (`packages/plugins/src/ai/runtime.ts`) is an in-process gate; approvals + are not recorded as durable, signed data. + +## External Research + +### OpenClaw (as of 2026-07) + +- MIT, TypeScript; ~383k GitHub stars, 500+ contributors; community-run + after Peter Steinberger joined OpenAI (Feb 2026). Single Node **gateway** + daemon on loopback `127.0.0.1:18789` (typed WebSocket API + web UIs); + channels: WhatsApp (Baileys), Telegram, Signal, iMessage, Discord, Slack, + Matrix, more via plugins. Agent runtime is **Pi** (`createAgentSession()` + in-process, ~4 tools), model-agnostic (Anthropic/OpenAI/OpenRouter/Ollama). +- **Extension points**: Markdown `SKILL.md` skills (AgentSkills spec — same + family xNet emits), ClawHub registry (~1,700+ skills, with a documented + malicious-skill problem), **native MCP client** (`openclaw mcp add`, + stdio/SSE/streamable-http, per-server tool filters), cron/webhook + automation, config at `~/.openclaw/openclaw.json`. +- **Security posture**: threat model is explicitly "one trusted operator, + not a hostile multi-tenant boundary"; sandboxing opt-in; credentials + plaintext on disk; prompt injection acknowledged unsolved. Jan–Feb 2026: + tens of thousands of publicly exposed gateways (Censys tracked 21k+; + independent counts up to ~42k), CVE-2026-25253 (CVSS 8.8 one-click token + exfiltration → RCE, fixed v2026.1.29), an infostealer targeting + `~/.openclaw`. **Audit gap**: session JSONL transcripts are mutable local + files; cron/sub-agent/heartbeat sessions log to isolated contexts; + gateway-wide audit logging is an open feature request (openclaw#13131). +- **Anthropic Agent SDK credits change (June 15, 2026)** closed the + flat-rate-subscription arbitrage that fueled the viral wave; the sticky + core (personal automation over messaging) remains. + +### Hermes Agent (Nous Research, the "or Hermes" referent) + +- Released Feb 25 2026, MIT; ~216k stars by July — structurally an OpenClaw + peer: one self-hosted gateway, channels for Telegram/Discord/Slack/ + WhatsApp/Signal/Email/CLI, provider-agnostic models (Nous Portal, + OpenRouter, OpenAI, custom — not locked to Hermes-4 weights), six exec + backends (local, Docker, SSH, Modal, Daytona, Singularity). +- Differentiator: a **learning loop** — after solving hard tasks it + *autonomously writes reusable skill documents* (agentskills.io-compatible), + plus agent-curated persistent memory and Honcho-based user modeling. + **Audit implication**: an agent that rewrites its own skills over time is + *more* in need of a tamper-evident action history, not less — "why did it + do that?" increasingly means "which self-written skill fired?". +- Because both agents consume MCP and the same skill format, **one xNet + integration covers both** — "OpenClaw or Hermes" is a single engineering + target with two logos. + +### Agent audit prior art (the lane check) + +- **Observability stacks** (OTel GenAI semantic conventions — + `invoke_agent`/`chat`/`execute_tool` spans; LangSmith; Langfuse; + AgentOps): all export to mutable operator-controlled backends; content + capture is opt-in; no tamper-evidence, no user custody, no offline + verification. Claude Code / Agent SDK ship OTel instrumentation with + session/user attribution — same mutability caveat. +- **Signed action logs**: Certificate Transparency (RFC 9162) and + Sigstore/Rekor prove the Merkle/hash-chain + inclusion-proof model at + internet scale. Applied to agents: **Agent Receipts** spec (each action a + W3C Verifiable Credential, Ed25519-signed, SHA-256 hash-chained, RFC 3161 + timestamps, and a **reversibility field** declaring undo-ability — the + one idea worth harvesting directly); Microsoft Agent Governance Toolkit's + "verifiable compliance receipts" proposal; a handful of early vendors. + The AIP survey (arXiv 2603.24775) concludes **no implemented protocol yet + combines offline attenuable delegation + provenance-aware completion + records** — the field is unclaimed, and xNet's kernel is most of it. +- **Capability delegation**: IETF OAuth WG drafts for agent tokens + (attenuating-agent-tokens, identity-assertion-authz-grant with RFC 8693 + `act` delegation chains); MCP authorization now mandates OAuth 2.1 + PKCE + + resource indicators for remote servers; W3C DID v1.1 at Candidate + Recommendation. xNet's UCAN + `did:key` stack is ahead of, and compatible + with, where this is heading; blockchain-resolved DIDs are explicitly + called out as too slow for agent delegation (xNet's `did:key` is not). +- **Chat-as-control-plane**: ChatOps' enduring lesson is that *chat history + becomes the shared audit log* — and its enduring pitfall is that basic + bots ship no ACLs and no durable audit. Home Assistant × Telegram uses + chat-ID allowlists only, no replay protection, hand-rolled confirmations; + Telegram bot traffic is **not E2EE** (platform sees your control plane). + The robust pattern is Slack's: signed requests with timestamps (reject + >5 min — replay defense) plus typed-confirmation ceremonies for + destructive ops. xNet can go one better: make the approval itself a + signed record in the same log the action commits to. +- **Lethal trifecta** (Willison): private data + untrusted content + + external comms = exploitable, and guardrail classifiers don't hold + (adaptive red-teaming bypassed 12/12 published defenses). Architecture + answers: CaMeL-style capability-tracked data flow, taint-then-approve + policies, and *removing a leg by design*. For this integration: the agent + channel is untrusted content by definition, so the leg to cut is + unattended authority — attenuated UCANs and risk-tiered ceremonies. + +## Key Findings + +1. **Attribution is the keystone.** Every downstream feature — audit query, + console, revocation, "undo everything the agent did since 3am" — reduces + to *the agent signs with its own DID*. The kernel already indexes, + verifies, and chains by author; integration work is issuance (mint a + `did:key` per agent), delegation (operator-signed UCAN naming spaces, + schemas, actions, TTL), and enforcement (hub session capabilities derive + from the delegation, not the anonymous wildcard). +2. **The change log *is* the audit trail, but it needs an action-level + view.** Change records capture writes; they don't capture *reads*, tool + calls that touched nothing, or the natural-language instruction that + triggered a write. The `AgentAction` schema fills that gap at the + semantic layer (one node per tool call, linking instruction → plan → + resulting change ids), while the raw change log remains the + tamper-evident substrate underneath. Two layers, one log. +3. **Approvals belong in the log, not in process memory.** Recording + `AgentApproval` nodes (who approved, over which surface, binding nonce, + expiry) makes the *authorization* as auditable as the action — and + because approval nodes are signed by the **operator's** DID while action + nodes are signed by the **agent's** DID, the log structurally proves the + human was (or wasn't) in the loop. +4. **In-chat approval is forgeable by the agent that relays it; tier it.** + The gateway sits between you and the hub, so a compromised agent can + fabricate your "APPROVE". Acceptable for medium risk (defense in depth: + nonce + TTL + notification fan-out to a second surface), unacceptable + for high/critical — those confirm in an xNet surface where the operator's + own key signs the approval node. This is the CaMeL/taint lesson applied + with xNet's own signing machinery. +5. **Hub→you notification should ride the agent's channels, via the + outbox.** Instead of teaching the hub to speak WhatsApp (or punching the + SSRF guard), let the agent *poll/subscribe* to an `AgentNotification` + lane it already has read capability for — the same "everything is a + node" move as chat. The agent's heartbeat turns new notification nodes + into texts. Zero new transport, works identically for OpenClaw and + Hermes, and the notification history is itself durable and synced. +6. **This is a marketing-grade differentiator, not just plumbing.** "Your + agent, on a leash you can prove" — signed action receipts, scoped + capability, operator-signed approvals, offline-verifiable history — is + exactly what the OpenClaw security discourse (exposed gateways, CVE, + malicious skills) primed the market to want, and what no observability + vendor structurally can offer on a mutable backend. + +## Options And Tradeoffs + +### Where does the integration live? + +| Option | Shape | Pros | Cons | Verdict | +| --- | --- | --- | --- | --- | +| **A. Build native channel bridges** (xNet speaks WhatsApp/Telegram itself) | New `packages/comms` transports | No third-party agent needed | Enormous maintenance (Baileys churn, ToS risk), duplicates OpenClaw/Hermes's core competency, xNet becomes the thing running an unsandboxed messaging surface | **Reject** | +| **B. Agent owns channels, xNet owns data + governance + audit** | MCP substrate (exists) + Agent Passport + audit schemas + notification outbox | Rides two ~200k–400k-star ecosystems; one integration, every MCP agent; xNet's differentiators (signing, UCAN, guardrail) do the work | Depends on agent's gateway security for channel leg; in-chat approvals capped at medium risk | **Recommended** | +| **C. Hub-hosted agent** (run Pi/Hermes runtime inside the hub) | New hub feature | Single deployable; no local gateway | Violates both agents' "one trusted operator, not multi-tenant" threat model; hub inherits exec/sandboxing risk; heavy | Defer (labs, if ever) | + +### Agent identity + +| Option | Pros | Cons | Verdict | +| --- | --- | --- | --- | +| Share operator's DID (status quo) | Zero work | No attribution, no revocation, no audit — the agent *is* you | Reject | +| **Per-agent `did:key` + operator-delegated UCAN** | Attribution + attenuation + revocation by expiry/rotation; uses `createUCAN`/`verifyUCAN` as-is | Requires hub to honor scoped capabilities (forces the 0307 fix — a feature, not a bug) | **Recommended** | +| Hub-issued ephemeral session DIDs per conversation | Fine-grained forensics | Explodes author cardinality in the LWW tiebreak/index; harder UX | Overkill now; revisit for multi-agent fleets | + +### Approval ceremony (risk-tiered, from the mutation-plan `risk` field) + +| Risk | Ceremony | Where recorded | +| --- | --- | --- | +| `low` / reads | Execute immediately | `AgentAction` node (agent-signed) | +| `medium` | In-chat typed confirm: agent sends plan summary + 6-char nonce; operator replies `APPROVE ` within TTL (default 5 min, Slack-style staleness rejection) | `AgentAction` + `AgentApproval` (agent-signed, `surface: 'chat'` — marked lower-assurance) | +| `high` / `critical` / outward-facing / destructive | Push/app approval in an xNet surface; chat gets a "pending approval" link only | `AgentApproval` signed by the **operator's** DID (`surface: 'app'`) — structurally unforgeable by the agent | + +### Hub → operator notification path + +| Option | Pros | Cons | Verdict | +| --- | --- | --- | --- | +| `defineAction` dispatch to local gateway webhook | Reuses action runner | `assertPublicUrl` correctly blocks loopback; punching it is an SSRF regression | Reject | +| Hub pushes to agent's public webhook (VPS setups) | Works for cloud-hosted agents | Splits behavior by deployment; exposes agent endpoint | Optional later | +| **`AgentNotification` outbox nodes; agent polls/subscribes via MCP** | No new transport; durable, synced, auditable; identical for OpenClaw + Hermes + any future client | Latency = agent heartbeat interval (both agents heartbeat natively) | **Recommended** | + +## Recommended Architecture + +```mermaid +flowchart LR + subgraph Channels["Messaging (agent-owned)"] + WA[WhatsApp] --- TG[Telegram] --- SG[Signal] + end + subgraph Gateway["OpenClaw / Hermes gateway (loopback)"] + AG[Agent runtime
Pi / Hermes] + SK[xnet SKILL.md] + end + subgraph XNet["xNet (operator's machine or hub)"] + MCP["xnet mcp serve
:31416, pairing token"] + GUARD["Mutation-plan guardrail
risk / scopes / approval"] + AUDIT["Audit middleware
AgentAction nodes"] + STORE["NodeStore
signed change log"] + end + subgraph Hub["Hub"] + RELAY["node-relay
verifyChange + UCAN caps"] + IDX["Per-author change index (new)"] + end + Channels --> AG + SK -.instructs.-> AG + AG -- "MCP tool call
(agent DID + UCAN)" --> MCP + MCP --> GUARD --> AUDIT --> STORE + STORE -- "changes signed by agent DID" --> RELAY --> IDX + STORE -- "AgentNotification outbox" --> AG + AG -- "text you" --> Channels +``` + +The remote-work loop, with the medium-risk ceremony: + +```mermaid +sequenceDiagram + actor Op as Operator (WhatsApp) + participant GW as OpenClaw/Hermes gateway + participant MCP as xnet mcp serve + participant ST as NodeStore (change log) + participant HB as Hub + + Op->>GW: "archive everything in Inbox older than 30d" + GW->>MCP: tools/call xnet_plan_page_patch (agent DID) + MCP->>MCP: guardrail: risk=medium → approval required + MCP->>ST: create AgentAction (status: pending-approval, nonce, TTL) + ST-->>GW: notification node + GW->>Op: "Plan: archive 47 nodes. Reply APPROVE 8F2KQ1 (5 min)" + Op->>GW: "APPROVE 8F2KQ1" + GW->>MCP: xnet_approve {nonce} + MCP->>ST: create AgentApproval (surface: chat) + apply plan + ST->>HB: changes signed by agent DID + HB->>HB: verifyChange + capability check + author index + GW->>Op: "Done — 47 archived. Receipt: xnet://audit/aa-8F2KQ1" +``` + +Audit data model: + +```mermaid +erDiagram + AgentPassport ||--o{ AgentSession : "authorizes" + AgentSession ||--o{ AgentAction : "contains" + AgentAction ||--o| AgentApproval : "gated by" + AgentAction }o--o{ Change : "produced (change ids)" + AgentPassport { + string agentDID PK + string operatorDID + string ucan "delegated capabilities" + date expiresAt + string runtime "openclaw | hermes | claude-code | other" + } + AgentSession { + string id PK "deterministic: session key" + string channel "whatsapp | telegram | app" + string peer + date startedAt + } + AgentAction { + string id PK "deterministic: session + seq" + string tool + string instruction "operator text (verbatim)" + string risk "low | medium | high | critical" + string status + string reversibility "reversible | compensatable | irreversible" + json changeIds + } + AgentApproval { + string id PK "deterministic: action + nonce" + string surface "chat | app | push" + string approverDID "operator for high-risk" + string nonce + date expiresAt + } +``` + +`AgentAction.status` lifecycle: + +```mermaid +stateDiagram-v2 + [*] --> proposed + proposed --> applied : risk=low + proposed --> pendingApproval : risk >= medium + pendingApproval --> approved : APPROVE nonce (chat) / app confirm + pendingApproval --> denied : DENY / timeout(TTL) + approved --> applied + applied --> rolledBack : xnet_undo (uses reversibility) + denied --> [*] + applied --> [*] + rolledBack --> [*] +``` + +## Example Code + +### 1. The `AgentAction` schema (template: `debug-report.ts`) + +```ts +// packages/data/src/schema/schemas/agent-action.ts +import { defineSchema } from '../define' +import { text, select, json, date, createdBy } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' + +export const AgentActionSchema = defineSchema({ + name: 'AgentAction', + namespace: 'xnet://xnet.fyi/', + properties: { + session: text(), // AgentSession id (deterministic) + tool: text(), // e.g. 'xnet_apply_page_markdown' + instruction: text(), // operator's message, verbatim + risk: select(['low', 'medium', 'high', 'critical']), + status: select([ + 'proposed', 'pending-approval', 'approved', + 'denied', 'applied', 'rolled-back', + ]), + // harvested from the Agent Receipts spec — enables undo tooling + reversibility: select(['reversible', 'compensatable', 'irreversible']), + changeIds: json(), // kernel change ids this action produced + createdBy: createdBy(), // = the agent's DID (attribution) + at: date(), + }, + authorization: spaceCascadeAuthorization(), +}) +``` + +Deterministic ids (`agent-action::`) follow the +`DebugReport` fingerprint pattern so retries LWW-upsert instead of +duplicating. Registration in `packages/data/src/schema/schemas/index.ts`; +the devtools Tier-2 auto-generator seeds it with no seeder edit +(`packages/devtools/src/seed/auto-generator.ts`). + +### 2. Minting the Agent Passport + +```ts +import { createUCAN } from '@xnetjs/identity' + +// Operator delegates a *narrow* capability set to the agent's own did:key. +const passport = await createUCAN({ + issuer: operatorDID, issuerKey: operatorSigningKey, + audience: agentDID, // generated once per agent, stored by the gateway + capabilities: [ + { with: `xnet://space/${inboxSpaceId}`, can: 'node/create' }, + { with: `xnet://space/${inboxSpaceId}`, can: 'node/update' }, + { with: `xnet://schema/AgentAction`, can: 'node/create' }, + // note: no delete, no share, no admin, no other spaces + ], + expiration: Date.now() + 7 * 24 * 3600 * 1000, // rotate weekly +}) +``` + +The hub session (`packages/hub/src/auth/ucan.ts` → `AuthSession +.capabilities`) derives from this delegation instead of the anonymous +wildcard — which is precisely the 0307 remediation, given its first real +consumer. + +### 3. Audit middleware around the MCP tool dispatch + +```ts +// packages/plugins/src/services/mcp-audit.ts (sketch) +export function withAudit(callTool: CallTool, store: NodeStore, + session: AgentSessionRef): CallTool { + return async (name, args) => { + const action = await store.create(AgentActionSchema, { + id: actionId(session, name), + session: session.id, tool: name, + instruction: session.lastOperatorMessage, + risk: planRisk(name, args), status: 'proposed', + reversibility: toolReversibility(name), + }) + const result = await callTool(name, args) // guardrail runs inside + await store.update(action.id, { + status: result.applied ? 'applied' : result.status, + changeIds: result.changeIds ?? [], + }) + return result + } +} +``` + +### 4. What texting your hub looks like + +``` +you → "what changed in Q3 Planning this week?" +agent→ "12 changes: 9 by you, 3 by agent:openclaw-home. + Latest: 'Budget v3' edited yesterday 21:04." + +you → "archive inbox items older than 30 days" +agent→ "Plan: archive 47 nodes in Inbox (risk: medium). + Reply APPROVE 8F2KQ1 within 5 minutes." +you → "APPROVE 8F2KQ1" +agent→ "Done. Receipt xnet://audit/aa-8F2KQ1 — 47 changes, + signed by agent DID, reversible via undo." + +you → "delete the old Clients space" +agent→ "That's destructive (risk: critical). I can't take chat + approval for this — confirm in the xNet app: [link]. + Nothing happens until you do." +``` + +## Risks And Open Questions + +- **The gateway is still the weak link.** xNet cannot fix OpenClaw's + plaintext credentials or exposed-gateway hygiene; it can only bound the + blast radius (scoped UCAN, weekly expiry, tool filters) and make + compromise evident (audit trail). The hardening guidance in + `docs/guides/openclaw-integration.md` remains mandatory reading. +- **Instruction text is sensitive.** `AgentAction.instruction` stores your + messages verbatim in the workspace. OTel GenAI's default is + content-*off* for a reason. Mitigation: store instructions in a + dedicated agent-audit Space with tight read authorization, and offer a + redacted mode (hash of instruction only) — decide the default before + shipping. +- **Author-index cost.** A per-author index over the hub change log adds a + write-path index on a hot table (0318's cliffs apply). Scope it to an + index on the existing author column + a paginated query route, not a + materialized view. +- **Nonce approval is only as strong as session isolation.** If the + operator's WhatsApp thread is bridged into a group, `dmScope` / + binding config on the agent side determines who can type `APPROVE`. + Document: approvals only honored from the paired peer id, and the + `AgentApproval` node records the channel peer for forensics. +- **UCAN revocation.** Expiry + rotation is the near-term answer; true + revocation lists are an open kernel question (ucan-wg revocation spec is + young). A stolen passport is live until expiry — keep TTLs short. +- **Does `xnet_approve` belong in the MCP tool surface?** Exposing the + approval tool to the agent means the agent *mechanically* can call it; + the nonce (never included in the pending-approval payload the agent can + read — delivered only via the operator-visible message) is the control. + Verify the nonce never transits a context the model can read back. + Alternative: approval endpoint on the local API (:31415) instead of MCP. +- **Two-agent households.** Multiple passports (OpenClaw + Hermes + Claude + Code) are natively supported by per-agent DIDs — but the LWW tiebreak is + `authorDID`-ordered, so agent DIDs participate in conflict resolution + identically to humans. Expected, but worth a test. + +## Implementation Checklist + +Phase 1 — Agent Passport (identity + capability): + +- [ ] `xnet agent enroll --runtime openclaw|hermes|other` CLI: + generates agent `did:key`, mints operator-signed scoped UCAN, + prints gateway config snippet (extends + `packages/cli/src/commands/mcp.ts` pairing output) +- [ ] Store passports as `AgentPassport` nodes (schema in + `packages/data/src/schema/schemas/`, registered in `schemas/index.ts`) +- [ ] MCP server accepts agent-scoped auth: tool calls execute against a + store identity = agent DID (writes signed by agent key held locally + by `xnet mcp serve`, never by the gateway) +- [ ] Hub: derive `AuthSession.capabilities` from presented UCAN instead of + anonymous wildcard when a passport token is presented + (`packages/hub/src/auth/ucan.ts`) — 0307 remediation, first consumer + +Phase 2 — Audit trail: + +- [ ] `AgentSession` / `AgentAction` / `AgentApproval` schemas + (deterministic ids, `spaceCascadeAuthorization`, reversibility field) +- [ ] Audit middleware wrapping `AiSurfaceService.callTool` (one + `AgentAction` per call, linked `changeIds`) +- [ ] Per-author index + query: hub storage index on change author, + `GET /audit/authors/:did/changes?since=` (paginated), UCAN-gated +- [ ] Workbench audit console: table view over `AgentAction` filtered by + agent DID, with per-action change diffs (reuse DebugReport console + patterns from 0315) +- [ ] `xnet_undo ` honoring `reversibility` (compensating + changes, not history rewrite) + +Phase 3 — Text control plane: + +- [ ] Risk-tiered approval ceremony in the guardrail: low=auto, + medium=chat nonce (TTL 5 min, staleness-rejected), + high/critical=xNet-surface only; `AgentApproval` node written for + every decision, operator-signed for high-risk +- [ ] `AgentNotification` outbox schema + MCP subscription/poll tool; ship + notification → text relay instructions in the skill +- [ ] Update the ClawHub skill + publish a Hermes-compatible skill + (`docs/integrations/openclaw/xnet-workspace-skill.md` — same + AgentSkills spec covers both) with the ceremony script +- [ ] Update `docs/guides/openclaw-integration.md`: enrollment flow, + approval tiers, audit console pointer; add a Hermes section + +Housekeeping: + +- [ ] Changesets for touched publishable packages (`data`, `identity`, + `plugins`, `hub` are in the fixed core — bump from the diff; new + schemas + new exports = minor) +- [ ] New surface lands in scoped sub-barrels per the 0276 policy (e.g. + `packages/data/src/schema/schemas/index.ts`, not root barrel churn) + +## Validation Checklist + +- [ ] Enroll a real OpenClaw gateway with a passport; verify a tool-call + write lands with `authorDID = agent DID` and `verifyChange` passes + hub-side +- [ ] Attempt a write outside the delegated capability set (other space, + `delete`) → rejected at hub with capability error, `AgentAction` + records the denial +- [ ] Tamper test: mutate an agent session transcript on disk *and* attempt + to replay an altered change → hub rejects (hash/signature); audit + console still shows the true history +- [ ] Medium-risk ceremony end-to-end over Telegram: nonce expires after + TTL; stale/wrong nonce rejected; `AgentApproval` node present with + `surface: 'chat'` and correct peer id +- [ ] High-risk op requested via chat → refused in-chat, approvable only in + app; resulting `AgentApproval` is operator-signed +- [ ] Author-index query returns the agent's full change history in + paginated order on a 100k-change log without a full scan (explain + plan / timing) +- [ ] `xnet_undo` on a reversible action produces compensating changes and + flips status to `rolled-back` +- [ ] Same skill + passport flow works against a Hermes Agent gateway + (MCP streamable-http) +- [ ] Seed coverage test green (auto-generator covers the new schemas); + full `vitest` from root + +## References + +- Repo: `docs/guides/openclaw-integration.md`; + `docs/explorations/0175_[_]_XNET_AS_A_SUBSTRATE_FOR_OPENCLAW.md`; + 0194 (agent bridge), 0196 (agent-native connectors), 0252 (AI chat box), + 0304 (schema authz CRUD split), 0307 (node/change flow security), + 0315 (first-party telemetry), 0161 (token-efficient agent interfaces) +- Code: `packages/sync/src/change.ts`, `packages/identity/src/ucan.ts`, + `packages/hub/src/auth/ucan.ts`, `packages/hub/src/features/ + webhook-inbox.ts`, `packages/plugins/src/services/mcp-server.ts`, + `packages/plugins/src/ai-surface/types.ts`, + `packages/data/src/schema/schemas/debug-report.ts`, + `packages/devkit/src/bridge-server.ts` +- OpenClaw: https://docs.openclaw.ai/concepts/architecture · + https://docs.openclaw.ai/gateway/security · + https://docs.openclaw.ai/tools/skills · + https://github.com/openclaw/openclaw/issues/13131 (audit-log request) · + CVE-2026-25253 writeups (SOCRadar, runZero) · + https://www.microsoft.com/en-us/security/blog/2026/02/19/running-openclaw-safely-identity-isolation-runtime-risk/ +- Hermes Agent: https://github.com/nousresearch/hermes-agent · + https://hermes-agent.nousresearch.com/docs/ +- Audit prior art: https://agentreceipts.ai/specification/overview/ · + https://opentelemetry.io/blog/2026/genai-observability/ · + https://microsoft.github.io/agent-governance-toolkit/proposals/verifiable-compliance-receipts/ · + AIP survey https://arxiv.org/pdf/2603.24775 · RFC 9162 (CT v2) · + https://openssf.org/blog/2025/10/15/announcing-the-sigstore-transparency-log-research-dataset/ +- Control-plane/security patterns: + https://simonwillison.net/2025/Jun/16/the-lethal-trifecta/ · + https://simonwillison.net/2025/Apr/11/camel/ (CaMeL, arXiv 2503.18813) · + https://stackstorm.com/2015/12/10/chatops_pitfalls_and_tips/ · + https://www.home-assistant.io/integrations/telegram_bot/ · + IETF draft-ietf-oauth-identity-assertion-authz-grant · + https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization From 1f1393fcaaed64668737d91db13fd505e06066aa Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 18:54:04 -0700 Subject: [PATCH 02/12] =?UTF-8?q?feat(data):=20agent=20schema=20pack=20?= =?UTF-8?q?=E2=80=94=20AgentPassport,=20AgentSession,=20AgentAction,=20Age?= =?UTF-8?q?ntApproval,=20AgentNotification=20(0337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 4 +- .../data/src/schema/schemas/agent.test.ts | 120 +++++++ packages/data/src/schema/schemas/agent.ts | 301 ++++++++++++++++++ packages/data/src/schema/schemas/index.ts | 60 +++- 4 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 packages/data/src/schema/schemas/agent.test.ts create mode 100644 packages/data/src/schema/schemas/agent.ts diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index a78f95764..5bedb86f6 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -602,7 +602,7 @@ Phase 1 — Agent Passport (identity + capability): generates agent `did:key`, mints operator-signed scoped UCAN, prints gateway config snippet (extends `packages/cli/src/commands/mcp.ts` pairing output) -- [ ] Store passports as `AgentPassport` nodes (schema in +- [x] Store passports as `AgentPassport` nodes (schema in `packages/data/src/schema/schemas/`, registered in `schemas/index.ts`) - [ ] MCP server accepts agent-scoped auth: tool calls execute against a store identity = agent DID (writes signed by agent key held locally @@ -613,7 +613,7 @@ Phase 1 — Agent Passport (identity + capability): Phase 2 — Audit trail: -- [ ] `AgentSession` / `AgentAction` / `AgentApproval` schemas +- [x] `AgentSession` / `AgentAction` / `AgentApproval` schemas (deterministic ids, `spaceCascadeAuthorization`, reversibility field) - [ ] Audit middleware wrapping `AiSurfaceService.callTool` (one `AgentAction` per call, linked `changeIds`) diff --git a/packages/data/src/schema/schemas/agent.test.ts b/packages/data/src/schema/schemas/agent.test.ts new file mode 100644 index 000000000..ed6c022c1 --- /dev/null +++ b/packages/data/src/schema/schemas/agent.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { getAuthMode } from '../../auth' +import { + AGENT_ACTION_SCHEMA_IRI, + AGENT_ACTION_STATUSES, + AGENT_APPROVAL_SCHEMA_IRI, + AGENT_APPROVAL_SURFACES, + AGENT_NOTIFICATION_SCHEMA_IRI, + AGENT_PASSPORT_SCHEMA_IRI, + AGENT_REVERSIBILITIES, + AGENT_SESSION_SCHEMA_IRI, + AgentActionSchema, + AgentApprovalSchema, + AgentNotificationSchema, + AgentPassportSchema, + AgentSessionSchema, + agentActionId, + agentApprovalId, + agentNotificationId, + agentPassportId, + agentSessionId, + redactInstruction +} from './agent' + +const PACK = [ + [AgentPassportSchema, AGENT_PASSPORT_SCHEMA_IRI], + [AgentSessionSchema, AGENT_SESSION_SCHEMA_IRI], + [AgentActionSchema, AGENT_ACTION_SCHEMA_IRI], + [AgentApprovalSchema, AGENT_APPROVAL_SCHEMA_IRI], + [AgentNotificationSchema, AGENT_NOTIFICATION_SCHEMA_IRI] +] as const + +describe('agent schema pack (exploration 0337)', () => { + it('every schema has a canonical versioned IRI matching its constant', () => { + for (const [schema, iri] of PACK) { + expect(schema.schema['@id']).toBe(iri) + } + }) + + it('every schema declares a real authorization block (space cascade)', () => { + for (const [schema] of PACK) { + expect(getAuthMode(schema.schema), schema.schema['@id']).not.toBe('legacy') + } + }) + + it('passport requires agent DID, operator DID, and the delegated UCAN', () => { + const required = AgentPassportSchema.schema.properties + .filter((p) => p.required) + .map((p) => p['@id'].split('#')[1]) + expect(required).toEqual(expect.arrayContaining(['agentDID', 'operatorDID', 'ucan'])) + }) + + it('action lifecycle covers the ceremony states from the exploration', () => { + expect(AGENT_ACTION_STATUSES.map((s) => s.id)).toEqual([ + 'proposed', + 'pending-approval', + 'approved', + 'denied', + 'applied', + 'rolled-back', + 'failed' + ]) + }) + + it('reversibility enumerates the Agent Receipts triple', () => { + expect(AGENT_REVERSIBILITIES.map((r) => r.id)).toEqual([ + 'reversible', + 'compensatable', + 'irreversible' + ]) + }) + + it('approval surfaces distinguish forgeable chat from operator-signed app/push', () => { + expect(AGENT_APPROVAL_SURFACES.map((s) => s.id)).toEqual(['chat', 'app', 'push']) + }) + + it('approval stores a nonce hash, never a nonce', () => { + const propIds = AgentApprovalSchema.schema.properties.map((p) => p['@id'].split('#')[1]) + expect(propIds).toContain('nonceHash') + expect(propIds).not.toContain('nonce') + }) + + describe('deterministic ids', () => { + it('are stable for identical inputs (LWW upsert on retry)', () => { + const did = 'did:key:z6MkAgent' + const session = agentSessionId(did, 'agent:main:whatsapp-4915') + expect(agentSessionId(did, 'agent:main:whatsapp-4915')).toBe(session) + expect(agentActionId(session, 7)).toBe(agentActionId(session, 7)) + expect(agentApprovalId(agentActionId(session, 7))).toBe( + agentApprovalId(agentActionId(session, 7)) + ) + }) + + it('differ across sessions, sequences, and agents', () => { + const a = agentSessionId('did:key:z6MkA', 'main') + const b = agentSessionId('did:key:z6MkB', 'main') + const c = agentSessionId('did:key:z6MkA', 'other') + expect(new Set([a, b, c]).size).toBe(3) + expect(agentActionId(a, 1)).not.toBe(agentActionId(a, 2)) + }) + + it('sanitize channel-supplied keys into id-safe strings', () => { + const id = agentSessionId('did:key:z6MkA', 'weird key/with spaces@!') + expect(id).toMatch(/^agent-session:[a-zA-Z0-9:_-]+$/) + }) + + it('passport and notification ids are prefixed and stable', () => { + expect(agentPassportId('did:key:z6MkA')).toBe('agent-passport:did:key:z6MkA') + expect(agentNotificationId('agent-action:x:1')).toBe( + 'agent-notification:agent-action:x:1' + ) + }) + }) + + it('redactInstruction keeps length + digest prefix only', () => { + const redacted = redactInstruction('archive my inbox', 'abcdef0123456789deadbeef') + expect(redacted).toBe('[redacted 16 chars sha256:abcdef0123456789]') + expect(redacted).not.toContain('archive') + }) +}) diff --git a/packages/data/src/schema/schemas/agent.ts b/packages/data/src/schema/schemas/agent.ts new file mode 100644 index 000000000..7f5172ae2 --- /dev/null +++ b/packages/data/src/schema/schemas/agent.ts @@ -0,0 +1,301 @@ +/** + * Agent schema pack (exploration 0337) — external AI agents (OpenClaw, Hermes, + * Claude Code, …) as first-class, scoped, auditable identities. + * + * The kernel already signs every change with `authorDID` and chains it per + * author; these schemas aim that machinery at agents: + * + * - `AgentPassport` — the enrollment record: the agent's own DID plus the + * operator-delegated, attenuated UCAN that scopes what it may touch. The + * agent signs with its own key, never the operator's. + * - `AgentSession` — one conversation context (a WhatsApp thread, a Telegram + * peer, a CLI run) grouping the actions taken within it. + * - `AgentAction` — one guarded tool call: the verbatim instruction, risk, + * lifecycle status, reversibility, and the kernel change ids it produced. + * The semantic layer over the raw signed change log. + * - `AgentApproval` — the ceremony record for a gated action. Stores only a + * **hash** of the approval nonce (the agent can read nodes; the nonce must + * never transit a context the model can read back). High-risk approvals + * are created by the operator's own signing identity, so the log + * structurally proves the human was in the loop. + * - `AgentNotification` — the hub→operator outbox. The agent polls this lane + * and relays entries over its messaging channels; no new transport. + * + * Ids are deterministic (`agentSessionId`, `agentActionId`, …) so retries + * LWW-upsert one node instead of flooding — the DebugReport pattern (0315). + */ + +import type { InferNode } from '../types' +import { defineSchema } from '../define' +import { created, createdBy, date, json, number, relation, select, text } from '../properties' +import { spaceCascadeAuthorization } from './space-authorization' + +export const AGENT_PASSPORT_SCHEMA_IRI = 'xnet://xnet.fyi/AgentPassport@1.0.0' as const +export const AGENT_SESSION_SCHEMA_IRI = 'xnet://xnet.fyi/AgentSession@1.0.0' as const +export const AGENT_ACTION_SCHEMA_IRI = 'xnet://xnet.fyi/AgentAction@1.0.0' as const +export const AGENT_APPROVAL_SCHEMA_IRI = 'xnet://xnet.fyi/AgentApproval@1.0.0' as const +export const AGENT_NOTIFICATION_SCHEMA_IRI = 'xnet://xnet.fyi/AgentNotification@1.0.0' as const + +/** Which runtime carries the passport. */ +export const AGENT_RUNTIMES = [ + { id: 'openclaw', name: 'OpenClaw', color: 'orange' }, + { id: 'hermes', name: 'Hermes', color: 'purple' }, + { id: 'claude-code', name: 'Claude Code', color: 'blue' }, + { id: 'other', name: 'Other', color: 'gray' } +] as const + +export type AgentRuntime = (typeof AGENT_RUNTIMES)[number]['id'] + +const passportStatuses = [ + { id: 'active', name: 'Active', color: 'green' }, + { id: 'revoked', name: 'Revoked', color: 'red' }, + { id: 'expired', name: 'Expired', color: 'gray' } +] as const + +export const AgentPassportSchema = defineSchema({ + name: 'AgentPassport', + namespace: 'xnet://xnet.fyi/', + properties: { + /** Home Space for the operator's agent-audit workspace — drives access. */ + space: relation({}), + /** The agent's own did:key. Every change it makes is signed by this DID. */ + agentDID: text({ required: true, maxLength: 256 }), + /** The operator DID that delegated authority to the agent. */ + operatorDID: text({ required: true, maxLength: 256 }), + displayName: text({ maxLength: 120 }), + runtime: select({ options: AGENT_RUNTIMES, required: true, default: 'other' }), + /** The operator-signed, attenuated UCAN delegated to `agentDID`. */ + ucan: text({ required: true, maxLength: 8192 }), + /** Delegation expiry (epoch ms). Rotation is the near-term revocation. */ + expiresAt: date({}), + status: select({ options: passportStatuses, required: true, default: 'active' }), + createdAt: created(), + createdBy: createdBy() + }, + document: undefined, + authorization: spaceCascadeAuthorization('space') +}) + +export type AgentPassport = InferNode<(typeof AgentPassportSchema)['_properties']> + +/** Where a session (or an approval) happened. */ +export const AGENT_CHANNELS = [ + { id: 'whatsapp', name: 'WhatsApp', color: 'green' }, + { id: 'telegram', name: 'Telegram', color: 'blue' }, + { id: 'signal', name: 'Signal', color: 'blue' }, + { id: 'imessage', name: 'iMessage', color: 'green' }, + { id: 'discord', name: 'Discord', color: 'purple' }, + { id: 'slack', name: 'Slack', color: 'purple' }, + { id: 'app', name: 'xNet app', color: 'orange' }, + { id: 'cli', name: 'CLI', color: 'gray' }, + { id: 'other', name: 'Other', color: 'gray' } +] as const + +export type AgentChannel = (typeof AGENT_CHANNELS)[number]['id'] + +export const AgentSessionSchema = defineSchema({ + name: 'AgentSession', + namespace: 'xnet://xnet.fyi/', + properties: { + space: relation({}), + /** AgentPassport node id. */ + passport: relation({}), + channel: select({ options: AGENT_CHANNELS, required: true, default: 'other' }), + /** Channel-specific peer id (chat/thread id) — forensics for approvals. */ + peer: text({ maxLength: 256 }), + startedAt: date({}), + lastActiveAt: date({}), + createdAt: created(), + createdBy: createdBy() + }, + document: undefined, + authorization: spaceCascadeAuthorization('space') +}) + +export type AgentSession = InferNode<(typeof AgentSessionSchema)['_properties']> + +export const AGENT_RISKS = [ + { id: 'low', name: 'Low', color: 'green' }, + { id: 'medium', name: 'Medium', color: 'yellow' }, + { id: 'high', name: 'High', color: 'orange' }, + { id: 'critical', name: 'Critical', color: 'red' } +] as const + +export type AgentRisk = (typeof AGENT_RISKS)[number]['id'] + +export const AGENT_ACTION_STATUSES = [ + { id: 'proposed', name: 'Proposed', color: 'gray' }, + { id: 'pending-approval', name: 'Pending approval', color: 'yellow' }, + { id: 'approved', name: 'Approved', color: 'blue' }, + { id: 'denied', name: 'Denied', color: 'red' }, + { id: 'applied', name: 'Applied', color: 'green' }, + { id: 'rolled-back', name: 'Rolled back', color: 'purple' }, + { id: 'failed', name: 'Failed', color: 'red' } +] as const + +export type AgentActionStatus = (typeof AGENT_ACTION_STATUSES)[number]['id'] + +/** Harvested from the Agent Receipts spec — declares undo-ability up front. */ +export const AGENT_REVERSIBILITIES = [ + { id: 'reversible', name: 'Reversible', color: 'green' }, + { id: 'compensatable', name: 'Compensatable', color: 'yellow' }, + { id: 'irreversible', name: 'Irreversible', color: 'red' } +] as const + +export type AgentReversibility = (typeof AGENT_REVERSIBILITIES)[number]['id'] + +export const AgentActionSchema = defineSchema({ + name: 'AgentAction', + namespace: 'xnet://xnet.fyi/', + properties: { + space: relation({}), + /** AgentSession node id (deterministic; see `agentSessionId`). */ + session: text({ required: true, maxLength: 256 }), + /** Monotonic sequence within the session — part of the deterministic id. */ + seq: number({ integer: true }), + /** Tool name, e.g. `xnet_apply_page_markdown`. */ + tool: text({ required: true, maxLength: 120 }), + /** + * The operator's instruction, verbatim. Sensitive — keep the audit Space + * tightly scoped, or store a redaction (see `redactInstruction`). + */ + instruction: text({ maxLength: 4000 }), + risk: select({ options: AGENT_RISKS, required: true, default: 'low' }), + status: select({ options: AGENT_ACTION_STATUSES, required: true, default: 'proposed' }), + reversibility: select({ + options: AGENT_REVERSIBILITIES, + required: true, + default: 'compensatable' + }), + /** Kernel change ids this action produced (links semantic → signed log). */ + changeIds: json({}), + /** Error message when `status` is `failed`. */ + error: text({ maxLength: 2000 }), + /** Pending-approval expiry (epoch ms) — the ceremony TTL. */ + approvalExpiresAt: date({}), + createdAt: created(), + createdBy: createdBy() + }, + document: undefined, + authorization: spaceCascadeAuthorization('space') +}) + +export type AgentAction = InferNode<(typeof AgentActionSchema)['_properties']> + +export const AGENT_APPROVAL_SURFACES = [ + // Relayed by the agent itself — forgeable by a compromised gateway, so + // chat-surface approvals are capped at medium risk by the ceremony. + { id: 'chat', name: 'Chat', color: 'yellow' }, + // Confirmed in an xNet surface and signed by the operator's own DID. + { id: 'app', name: 'xNet app', color: 'green' }, + { id: 'push', name: 'Push', color: 'green' } +] as const + +export type AgentApprovalSurface = (typeof AGENT_APPROVAL_SURFACES)[number]['id'] + +export const AGENT_APPROVAL_DECISIONS = [ + { id: 'approved', name: 'Approved', color: 'green' }, + { id: 'denied', name: 'Denied', color: 'red' }, + { id: 'expired', name: 'Expired', color: 'gray' } +] as const + +export type AgentApprovalDecision = (typeof AGENT_APPROVAL_DECISIONS)[number]['id'] + +export const AgentApprovalSchema = defineSchema({ + name: 'AgentApproval', + namespace: 'xnet://xnet.fyi/', + properties: { + space: relation({}), + /** AgentAction node id this decision gates. */ + action: text({ required: true, maxLength: 256 }), + surface: select({ options: AGENT_APPROVAL_SURFACES, required: true, default: 'chat' }), + decision: select({ options: AGENT_APPROVAL_DECISIONS, required: true, default: 'expired' }), + /** + * DID that made the decision. For `surface: 'app'`/`'push'` this is the + * operator (the node is signed by their key — unforgeable by the agent). + */ + approverDID: text({ maxLength: 256 }), + /** SHA-256 hex of the nonce — never the nonce itself. */ + nonceHash: text({ maxLength: 64 }), + /** Channel peer that replied, for `surface: 'chat'` forensics. */ + peer: text({ maxLength: 256 }), + decidedAt: date({}), + createdAt: created(), + createdBy: createdBy() + }, + document: undefined, + authorization: spaceCascadeAuthorization('space') +}) + +export type AgentApproval = InferNode<(typeof AgentApprovalSchema)['_properties']> + +export const AGENT_NOTIFICATION_KINDS = [ + { id: 'info', name: 'Info', color: 'blue' }, + { id: 'approval-request', name: 'Approval request', color: 'yellow' }, + { id: 'alert', name: 'Alert', color: 'red' }, + { id: 'report', name: 'Report', color: 'green' } +] as const + +export type AgentNotificationKind = (typeof AGENT_NOTIFICATION_KINDS)[number]['id'] + +export const AGENT_NOTIFICATION_STATUSES = [ + { id: 'pending', name: 'Pending', color: 'yellow' }, + { id: 'delivered', name: 'Delivered', color: 'green' }, + { id: 'dismissed', name: 'Dismissed', color: 'gray' } +] as const + +export type AgentNotificationStatus = (typeof AGENT_NOTIFICATION_STATUSES)[number]['id'] + +export const AgentNotificationSchema = defineSchema({ + name: 'AgentNotification', + namespace: 'xnet://xnet.fyi/', + properties: { + space: relation({}), + kind: select({ options: AGENT_NOTIFICATION_KINDS, required: true, default: 'info' }), + title: text({ required: true, maxLength: 200 }), + body: text({ maxLength: 4000 }), + /** Related AgentAction node id, when the notification concerns one. */ + action: text({ maxLength: 256 }), + status: select({ options: AGENT_NOTIFICATION_STATUSES, required: true, default: 'pending' }), + createdAt: created(), + createdBy: createdBy() + }, + document: undefined, + authorization: spaceCascadeAuthorization('space') +}) + +export type AgentNotification = InferNode<(typeof AgentNotificationSchema)['_properties']> + +// ─── Deterministic ids (LWW upsert on retry — the DebugReport pattern) ────── + +const sanitizeIdPart = (value: string): string => value.replace(/[^a-zA-Z0-9:_-]/g, '_') + +/** Passport id for an agent DID — one passport node per agent identity. */ +export const agentPassportId = (agentDID: string): string => + `agent-passport:${sanitizeIdPart(agentDID)}` + +/** + * Session id from the agent DID and the runtime's own session key (OpenClaw's + * `agent::`, a Hermes conversation id, …). + */ +export const agentSessionId = (agentDID: string, sessionKey: string): string => + `agent-session:${sanitizeIdPart(agentDID)}:${sanitizeIdPart(sessionKey)}` + +/** Action id — session-scoped sequence keeps retries idempotent. */ +export const agentActionId = (sessionId: string, seq: number): string => + `agent-action:${sanitizeIdPart(sessionId)}:${seq}` + +/** One approval decision per action. */ +export const agentApprovalId = (actionId: string): string => + `agent-approval:${sanitizeIdPart(actionId)}` + +/** Notification id — callers pass a stable key (e.g. the action id or a digest). */ +export const agentNotificationId = (key: string): string => + `agent-notification:${sanitizeIdPart(key)}` + +/** + * Redacted instruction for privacy-sensitive audit Spaces: keeps only length + * and a stable digest so repeated instructions still correlate. + */ +export const redactInstruction = (instruction: string, digestHex: string): string => + `[redacted ${instruction.length} chars sha256:${digestHex.slice(0, 16)}]` diff --git a/packages/data/src/schema/schemas/index.ts b/packages/data/src/schema/schemas/index.ts index 336daedab..d4d3dd269 100644 --- a/packages/data/src/schema/schemas/index.ts +++ b/packages/data/src/schema/schemas/index.ts @@ -428,6 +428,49 @@ export { type MemoryKind } from './memory' +// Agent schema pack (exploration 0337) +export { + AGENT_ACTION_SCHEMA_IRI, + AGENT_ACTION_STATUSES, + AGENT_APPROVAL_DECISIONS, + AGENT_APPROVAL_SCHEMA_IRI, + AGENT_APPROVAL_SURFACES, + AGENT_CHANNELS, + AGENT_NOTIFICATION_KINDS, + AGENT_NOTIFICATION_SCHEMA_IRI, + AGENT_NOTIFICATION_STATUSES, + AGENT_PASSPORT_SCHEMA_IRI, + AGENT_REVERSIBILITIES, + AGENT_RISKS, + AGENT_RUNTIMES, + AGENT_SESSION_SCHEMA_IRI, + AgentActionSchema, + AgentApprovalSchema, + AgentNotificationSchema, + AgentPassportSchema, + AgentSessionSchema, + agentActionId, + agentApprovalId, + agentNotificationId, + agentPassportId, + agentSessionId, + redactInstruction, + type AgentAction, + type AgentActionStatus, + type AgentApproval, + type AgentApprovalDecision, + type AgentApprovalSurface, + type AgentChannel, + type AgentNotification, + type AgentNotificationKind, + type AgentNotificationStatus, + type AgentPassport, + type AgentReversibility, + type AgentRisk, + type AgentRuntime, + type AgentSession +} from './agent' + // Comment anchor types export { type AnchorType, @@ -611,6 +654,15 @@ export const builtInSchemas = { 'xnet://xnet.fyi/GameAsset@1.0.0': () => import('./game').then((m) => m.GameAssetSchema), // Memory schema pack (exploration 0211) 'xnet://xnet.fyi/MemoryItem@1.0.0': () => import('./memory').then((m) => m.MemoryItemSchema), + // Agent schema pack (exploration 0337) + 'xnet://xnet.fyi/AgentPassport@1.0.0': () => + import('./agent').then((m) => m.AgentPassportSchema), + 'xnet://xnet.fyi/AgentSession@1.0.0': () => import('./agent').then((m) => m.AgentSessionSchema), + 'xnet://xnet.fyi/AgentAction@1.0.0': () => import('./agent').then((m) => m.AgentActionSchema), + 'xnet://xnet.fyi/AgentApproval@1.0.0': () => + import('./agent').then((m) => m.AgentApprovalSchema), + 'xnet://xnet.fyi/AgentNotification@1.0.0': () => + import('./agent').then((m) => m.AgentNotificationSchema), // Legacy unversioned IRIs (aliases for the current version) 'xnet://xnet.fyi/Page': () => import('./page').then((m) => m.PageSchema), @@ -717,7 +769,13 @@ export const builtInSchemas = { 'xnet://xnet.fyi/GameEconomyEntry': () => import('./game').then((m) => m.GameEconomyEntrySchema), 'xnet://xnet.fyi/GameAsset': () => import('./game').then((m) => m.GameAssetSchema), // Memory schema pack (exploration 0211) - 'xnet://xnet.fyi/MemoryItem': () => import('./memory').then((m) => m.MemoryItemSchema) + 'xnet://xnet.fyi/MemoryItem': () => import('./memory').then((m) => m.MemoryItemSchema), + // Agent schema pack (exploration 0337) + 'xnet://xnet.fyi/AgentPassport': () => import('./agent').then((m) => m.AgentPassportSchema), + 'xnet://xnet.fyi/AgentSession': () => import('./agent').then((m) => m.AgentSessionSchema), + 'xnet://xnet.fyi/AgentAction': () => import('./agent').then((m) => m.AgentActionSchema), + 'xnet://xnet.fyi/AgentApproval': () => import('./agent').then((m) => m.AgentApprovalSchema), + 'xnet://xnet.fyi/AgentNotification': () => import('./agent').then((m) => m.AgentNotificationSchema) } as const /** From 6029f226cb5f864f92e84c111160f1502e50d1d9 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 18:59:19 -0700 Subject: [PATCH 03/12] feat(identity): agent passport minting + rootIssuers delegation-chain helper (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- packages/identity/src/agent-passport.test.ts | 123 +++++++++++++++++++ packages/identity/src/agent-passport.ts | 120 ++++++++++++++++++ packages/identity/src/index.ts | 12 ++ packages/identity/src/ucan.ts | 18 +++ 4 files changed, 273 insertions(+) create mode 100644 packages/identity/src/agent-passport.test.ts create mode 100644 packages/identity/src/agent-passport.ts diff --git a/packages/identity/src/agent-passport.test.ts b/packages/identity/src/agent-passport.test.ts new file mode 100644 index 000000000..2e72184d0 --- /dev/null +++ b/packages/identity/src/agent-passport.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest' +import { + assertAttenuated, + mintAgentPassport, + verifyAgentPassport +} from './agent-passport' +import { generateIdentity } from './did' +import { createUCAN, hasCapability, rootIssuers, verifyUCAN } from './ucan' + +const operator = generateIdentity() + +const CAPS = [ + { with: 'xnet://space/inbox', can: 'node/create' }, + { with: 'xnet://space/inbox', can: 'node/update' } +] + +describe('agent passport (exploration 0337)', () => { + it('mints a fresh agent DID distinct from the operator', () => { + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: CAPS + }) + expect(grant.agentDID).toMatch(/^did:key:z/) + expect(grant.agentDID).not.toBe(operator.identity.did) + expect(grant.agentKey).toBeInstanceOf(Uint8Array) + }) + + it('the delegation verifies, is operator-issued, agent-addressed, and scoped', () => { + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: CAPS + }) + const result = verifyAgentPassport(grant.ucan, { + agentDID: grant.agentDID, + operatorDID: operator.identity.did + }) + expect(result.valid).toBe(true) + expect(result.payload?.iss).toBe(operator.identity.did) + expect(result.payload?.aud).toBe(grant.agentDID) + expect(hasCapability(result.payload!, 'xnet://space/inbox', 'node/create')).toBe(true) + expect(hasCapability(result.payload!, 'xnet://space/other', 'node/create')).toBe(false) + expect(hasCapability(result.payload!, 'xnet://space/inbox', 'node/delete')).toBe(false) + }) + + it('pins audience and issuer', () => { + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: CAPS + }) + const stranger = generateIdentity() + expect( + verifyAgentPassport(grant.ucan, { agentDID: stranger.identity.did }).valid + ).toBe(false) + expect( + verifyAgentPassport(grant.ucan, { operatorDID: stranger.identity.did }).valid + ).toBe(false) + }) + + it('rejects wildcard capabilities — the 0307 weakness must not re-enter', () => { + expect(() => assertAttenuated([{ with: '*', can: 'node/create' }])).toThrow(/attenuated/) + expect(() => assertAttenuated([{ with: 'xnet://space/inbox', can: '*' }])).toThrow( + /attenuated/ + ) + expect(() => assertAttenuated([])).toThrow(/at least one/) + expect(() => + mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: [{ with: '*', can: '*' }] + }) + ).toThrow(/attenuated/) + }) + + it('honors a custom TTL and reports expiresAt in epoch ms', () => { + const before = Date.now() + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: CAPS, + ttlSeconds: 60 + }) + expect(grant.expiresAt).toBeGreaterThanOrEqual(before + 59_000) + expect(grant.expiresAt).toBeLessThanOrEqual(Date.now() + 61_000) + }) +}) + +describe('rootIssuers', () => { + it('a proof-less token is its own root (self-issued detection)', () => { + const self = generateIdentity() + const token = createUCAN({ + issuer: self.identity.did, + issuerKey: self.privateKey, + audience: operator.identity.did, + capabilities: [{ with: '*', can: '*' }] + }) + expect(rootIssuers(token)).toEqual([self.identity.did]) + }) + + it('a delegated invocation roots at the operator, not the agent', () => { + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: CAPS + }) + // The agent invokes against the hub using the passport as proof. + const invocation = createUCAN({ + issuer: grant.agentDID, + issuerKey: grant.agentKey, + audience: 'did:key:zHub', + capabilities: [{ with: 'xnet://space/inbox', can: 'node/create' }], + proofs: [grant.ucan] + }) + expect(verifyUCAN(invocation).valid).toBe(true) + expect(rootIssuers(invocation)).toEqual([operator.identity.did]) + }) + + it('returns [] for garbage input', () => { + expect(rootIssuers('not-a-token')).toEqual([]) + }) +}) diff --git a/packages/identity/src/agent-passport.ts b/packages/identity/src/agent-passport.ts new file mode 100644 index 000000000..9661ee9c5 --- /dev/null +++ b/packages/identity/src/agent-passport.ts @@ -0,0 +1,120 @@ +/** + * Agent Passport (exploration 0337) — enroll an external agent (OpenClaw, + * Hermes, Claude Code, …) as its own scoped identity. + * + * The passport is two things: + * 1. a fresh `did:key` the agent signs with (its changes are attributable + * and tamper-evident via the kernel's per-author hash chain), and + * 2. an operator-signed, attenuated UCAN delegating a narrow capability set + * to that DID — never the operator's key, never a wildcard. + * + * Revocation is expiry: passports default to a 7-day TTL and are re-minted on + * rotation. Keep TTLs short — a stolen passport is live until it expires. + */ + +import type { UCANCapability } from './types' +import { generateIdentity } from './did' +import { createUCAN, verifyUCAN, type VerifyResult } from './ucan' + +/** Default passport TTL: 7 days (exploration 0337 — rotate weekly). */ +export const AGENT_PASSPORT_DEFAULT_TTL_SECONDS = 7 * 24 * 3600 + +export type MintAgentPassportOptions = { + /** Operator (delegating) identity. */ + operatorDID: string + operatorKey: Uint8Array + /** + * Capabilities delegated to the agent. Must be narrow — per-space, + * per-schema, per-action. Wildcards are rejected. + */ + capabilities: UCANCapability[] + /** Delegation lifetime in seconds (default: one week). */ + ttlSeconds?: number + /** Parent UCANs when the operator's own authority is itself delegated. */ + proofs?: string[] +} + +export type AgentPassportGrant = { + /** The agent's new identity. Give the private key to `xnet mcp serve`, never to the gateway. */ + agentDID: string + agentKey: Uint8Array + /** Operator-signed delegation naming `agentDID` as audience. */ + ucan: string + /** Expiry as epoch milliseconds (mirrors the UCAN's `exp`). */ + expiresAt: number +} + +const isWildcard = (value: string): boolean => value === '*' || value === '**' + +/** + * Reject capability sets that fail attenuation discipline: an agent passport + * must never carry `{with:'*'}` or `{can:'*'}` — that is exactly the 0307 + * wildcard weakness this feature exists to close. + */ +export function assertAttenuated(capabilities: UCANCapability[]): void { + if (capabilities.length === 0) { + throw new Error('Agent passport needs at least one capability') + } + for (const cap of capabilities) { + if (isWildcard(cap.with) || isWildcard(cap.can)) { + throw new Error( + `Agent passport capability must be attenuated (got with=${cap.with} can=${cap.can})` + ) + } + } +} + +/** + * Generate an agent identity and delegate a scoped UCAN to it. + */ +export function mintAgentPassport(options: MintAgentPassportOptions): AgentPassportGrant { + const { operatorDID, operatorKey, capabilities, proofs = [] } = options + assertAttenuated(capabilities) + + const ttl = options.ttlSeconds ?? AGENT_PASSPORT_DEFAULT_TTL_SECONDS + const expiration = Math.floor(Date.now() / 1000) + ttl + const { identity, privateKey } = generateIdentity() + + const ucan = createUCAN({ + issuer: operatorDID, + issuerKey: operatorKey, + audience: identity.did, + capabilities, + expiration, + proofs + }) + + return { + agentDID: identity.did, + agentKey: privateKey, + ucan, + expiresAt: expiration * 1000 + } +} + +export type VerifyAgentPassportOptions = { + /** Require the delegation audience to be this agent DID. */ + agentDID?: string + /** Require the delegation issuer to be this operator DID. */ + operatorDID?: string +} + +/** + * Verify a passport UCAN: signature + chain via `verifyUCAN`, plus optional + * audience/issuer pinning. + */ +export function verifyAgentPassport( + token: string, + options: VerifyAgentPassportOptions = {} +): VerifyResult { + const result = verifyUCAN(token) + if (!result.valid || !result.payload) return result + + if (options.agentDID && result.payload.aud !== options.agentDID) { + return { valid: false, error: 'Passport audience does not match agent DID' } + } + if (options.operatorDID && result.payload.iss !== options.operatorDID) { + return { valid: false, error: 'Passport issuer does not match operator DID' } + } + return result +} diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index 46e695a51..c8b89386e 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -100,10 +100,22 @@ export { hasCapability, getCapabilities, isExpired, + rootIssuers, type CreateUCANOptions, type VerifyResult } from './ucan' +// Agent Passport (exploration 0337) +export { + AGENT_PASSPORT_DEFAULT_TTL_SECONDS, + assertAttenuated, + mintAgentPassport, + verifyAgentPassport, + type AgentPassportGrant, + type MintAgentPassportOptions, + type VerifyAgentPassportOptions +} from './agent-passport' + // Legacy passkey storage (deprecated — use @xnetjs/identity/passkey instead) export { type PasskeyStorage, BrowserPasskeyStorage, MemoryPasskeyStorage } from './passkey' diff --git a/packages/identity/src/ucan.ts b/packages/identity/src/ucan.ts index 476fa18c9..d620c975f 100644 --- a/packages/identity/src/ucan.ts +++ b/packages/identity/src/ucan.ts @@ -242,6 +242,24 @@ export function isExpired(token: UCANToken): boolean { return token.exp < Math.floor(Date.now() / 1000) } +const ROOT_ISSUER_MAX_DEPTH = 16 + +/** + * The root issuer DIDs of a delegation chain — the identities whose authority + * everything else attenuates from. A proof-less token is its own root, which + * is how a hub distinguishes a self-issued token from a delegated one. + * + * Call only on tokens that already passed `verifyUCAN`; this walks the chain + * structurally without re-verifying signatures. + */ +export function rootIssuers(token: string, depth = 0): string[] { + if (depth > ROOT_ISSUER_MAX_DEPTH) return [] + const parsed = parseUCAN(token) + if (!parsed) return [] + if (parsed.payload.prf.length === 0) return [parsed.payload.iss] + return [...new Set(parsed.payload.prf.flatMap((proof) => rootIssuers(proof, depth + 1)))] +} + // ─── Unicode-safe base64url helpers ────────────────────────── function toBase64Url(str: string): string { From 952a3b1cdd60d65b2e03bc445db37fff63822abc Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 18:59:20 -0700 Subject: [PATCH 04/12] feat(hub): trusted-root UCAN policy + per-author audit index and /audit routes (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 4 +- packages/hub/src/auth/capabilities.ts | 6 +- packages/hub/src/auth/ucan.ts | 29 +++- packages/hub/src/routes/audit.ts | 62 +++++++ packages/hub/src/server.ts | 2 + packages/hub/src/storage/interface.ts | 10 ++ packages/hub/src/storage/memory.ts | 13 ++ packages/hub/src/storage/sqlite.ts | 25 +++ packages/hub/src/types.ts | 8 + packages/hub/test/agent-audit.test.ts | 161 ++++++++++++++++++ 10 files changed, 316 insertions(+), 4 deletions(-) create mode 100644 packages/hub/src/routes/audit.ts create mode 100644 packages/hub/test/agent-audit.test.ts diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index 5bedb86f6..2208072ea 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -607,7 +607,7 @@ Phase 1 — Agent Passport (identity + capability): - [ ] MCP server accepts agent-scoped auth: tool calls execute against a store identity = agent DID (writes signed by agent key held locally by `xnet mcp serve`, never by the gateway) -- [ ] Hub: derive `AuthSession.capabilities` from presented UCAN instead of +- [x] Hub: derive `AuthSession.capabilities` from presented UCAN instead of anonymous wildcard when a passport token is presented (`packages/hub/src/auth/ucan.ts`) — 0307 remediation, first consumer @@ -617,7 +617,7 @@ Phase 2 — Audit trail: (deterministic ids, `spaceCascadeAuthorization`, reversibility field) - [ ] Audit middleware wrapping `AiSurfaceService.callTool` (one `AgentAction` per call, linked `changeIds`) -- [ ] Per-author index + query: hub storage index on change author, +- [x] Per-author index + query: hub storage index on change author, `GET /audit/authors/:did/changes?since=` (paginated), UCAN-gated - [ ] Workbench audit console: table view over `AgentAction` filtered by agent DID, with per-action change diffs (reuse DebugReport console diff --git a/packages/hub/src/auth/capabilities.ts b/packages/hub/src/auth/capabilities.ts index 6dbe3b503..202e55410 100644 --- a/packages/hub/src/auth/capabilities.ts +++ b/packages/hub/src/auth/capabilities.ts @@ -22,6 +22,7 @@ export type HubAction = | 'notify/push' | 'telemetry/ingest' | 'telemetry/read' + | 'audit/read' /** Canonical bridge from hub actions to AuthAction. */ export const HUB_ACTION_MAP: Record = { @@ -43,7 +44,10 @@ export const HUB_ACTION_MAP: Record = { // may submit its own telemetry; the hub hashes the DID), reads are admin-only // (an aggregate of everyone's usage). 'telemetry/ingest': 'write', - 'telemetry/read': 'admin' + 'telemetry/read': 'admin', + // Audit (exploration 0337): reading another author's full change history is + // operator territory; self-reads are always allowed by the route. + 'audit/read': 'admin' } /** Check if a granted action pattern covers the requested action. */ diff --git a/packages/hub/src/auth/ucan.ts b/packages/hub/src/auth/ucan.ts index e0b0ac016..99ee1a24f 100644 --- a/packages/hub/src/auth/ucan.ts +++ b/packages/hub/src/auth/ucan.ts @@ -5,7 +5,7 @@ import type { HubConfig } from '../types' import type { IncomingMessage } from 'http' import type { WebSocket } from 'ws' -import { getCapabilities, type UCANToken, verifyUCAN } from '@xnetjs/identity' +import { getCapabilities, rootIssuers, type UCANToken, verifyUCAN } from '@xnetjs/identity' import { actionAllows, resourceAllows } from './capabilities' export type AuthSession = { @@ -27,6 +27,25 @@ const createAnonymousSession = (): AuthSession => ({ token: null }) +/** + * Enforce the trusted-root policy (exploration 0337): when `trustedDids` is + * configured, every root issuer of the token's delegation chain must be + * trusted. A proof-less token roots at its own issuer, so self-issued + * capability claims from unknown DIDs stop here. Returns an error string, or + * null when the token passes (or no policy is set). + */ +const checkTrustedRoots = (token: string, config: HubConfig): string | null => { + const trusted = config.trustedDids + if (!trusted || trusted.length === 0) return null + const roots = rootIssuers(token) + if (roots.length === 0) return 'UCAN has no resolvable delegation root' + const untrusted = roots.filter((root) => !trusted.includes(root)) + if (untrusted.length > 0) { + return 'UCAN does not chain to a trusted root' + } + return null +} + const createAuthContext = (session: AuthSession): AuthContext => ({ did: session.did, can: (action: string, resource: string) => @@ -102,6 +121,12 @@ export const authenticateConnection = async ( return null } + const rootError = checkTrustedRoots(token, config) + if (rootError) { + ws.close(4403, rootError) + return null + } + const session: AuthSession = { did: result.payload.iss, capabilities: getCapabilities(result.payload), @@ -142,6 +167,8 @@ export const authenticateHttpRequest = ( // Verify audience matches this hub's DID (if configured) if (config.hubDid && result.payload.aud !== config.hubDid) return null + if (checkTrustedRoots(token, config) !== null) return null + return createAuthContext({ did: result.payload.iss, capabilities: getCapabilities(result.payload), diff --git a/packages/hub/src/routes/audit.ts b/packages/hub/src/routes/audit.ts new file mode 100644 index 000000000..c90713256 --- /dev/null +++ b/packages/hub/src/routes/audit.ts @@ -0,0 +1,62 @@ +/** + * @xnetjs/hub - Agent audit trail routes (exploration 0337). + * + * `GET /audit/authors/:did/changes?since=&limit=` pages an + * author's signed change history — the raw substrate of the agent audit + * console. Self-reads (the token's DID asking about itself) are always + * allowed; reading another author requires the `audit/read` capability. + */ + +import type { AuthContext } from '../auth/ucan' +import type { HubStorage } from '../storage/interface' +import type { Context, MiddlewareHandler } from 'hono' +import { Hono } from 'hono' + +export type AuditRoutesOptions = { + requireAuth: MiddlewareHandler +} + +const parsePositiveInt = (value: string | undefined, fallback: number): number => { + if (!value) return fallback + const parsed = Number.parseInt(value, 10) + return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback +} + +export const createAuditRoutes = (storage: HubStorage, options: AuditRoutesOptions): Hono => { + const app = new Hono() + + const listAuthorChanges = async (c: Context) => { + const auth = c.get('auth') as AuthContext | undefined + if (!auth) { + return c.json({ error: 'Unauthorized', code: 'UNAUTHORIZED' }, 401) + } + + const did = c.req.param('did') + if (!did.startsWith('did:')) { + return c.json({ error: 'Invalid author DID', code: 'INVALID_INPUT' }, 400) + } + + if (auth.did !== did && !auth.can('audit/read', did)) { + return c.json({ error: 'audit/read capability required', code: 'FORBIDDEN' }, 403) + } + + const since = parsePositiveInt(c.req.query('since'), 0) + const limit = parsePositiveInt(c.req.query('limit'), 200) + const changes = await storage.getNodeChangesByAuthor(did, since, limit) + const nextCursor = + changes.length > 0 ? changes[changes.length - 1].lamportTime : since + + return c.json({ + author: did, + since, + changes, + // Page by passing this back as ?since=; equal to `since` when drained. + nextCursor, + hasMore: changes.length >= Math.min(Math.max(limit, 1), 1000) + }) + } + + app.get('/authors/:did/changes', options.requireAuth, listAuthorChanges) + + return app +} diff --git a/packages/hub/src/server.ts b/packages/hub/src/server.ts index 6ceec55e0..4243a57c6 100644 --- a/packages/hub/src/server.ts +++ b/packages/hub/src/server.ts @@ -32,6 +32,7 @@ import { createLogger } from './logger' import { Metrics, HUB_METRICS } from './middleware/metrics' import { RateLimiter } from './middleware/rate-limit' import { NodePool } from './pool/node-pool' +import { createAuditRoutes } from './routes/audit' import { createBackupRoutes } from './routes/backup' import { createCrawlRoutes } from './routes/crawl' import { createDiscoveryRoutes } from './routes/dids' @@ -516,6 +517,7 @@ export const createServer = async (config: HubConfig): Promise => { app.route('/files', createFileRoutes(files)) app.route('/schemas', createSchemaRoutes(schemas, { requireAuth })) + app.route('/audit', createAuditRoutes(storage, { requireAuth })) app.route('/keys', createKeyRegistryRoutes(keyRegistry)) // First-party hub features mount through the feature registry (exploration // 0189). Each receives a broker-scoped env — only the secrets it declared — so diff --git a/packages/hub/src/storage/interface.ts b/packages/hub/src/storage/interface.ts index 1c60b98c8..b7c78e3f8 100644 --- a/packages/hub/src/storage/interface.ts +++ b/packages/hub/src/storage/interface.ts @@ -485,6 +485,16 @@ export type HubStorage = { appendNodeChange: (room: string, change: SerializedNodeChange) => Promise getNodeChangesSince: (room: string, sinceLamport: number) => Promise getNodeChangesForNode: (room: string, nodeId: string) => Promise + /** + * Agent audit trail (exploration 0337): an author's changes across all + * rooms, paged on the per-author lamport cursor. Backed by + * `idx_node_changes_author_lamport` — never a scan. + */ + getNodeChangesByAuthor: ( + authorDid: string, + sinceLamport: number, + limit?: number + ) => Promise getHighWaterMark: (room: string) => Promise // ─── Share rooms (exploration 0298) ───────────────────────────────────────── diff --git a/packages/hub/src/storage/memory.ts b/packages/hub/src/storage/memory.ts index 701f9d791..fbeadfebc 100644 --- a/packages/hub/src/storage/memory.ts +++ b/packages/hub/src/storage/memory.ts @@ -661,6 +661,18 @@ export const createMemoryStorage = (): HubStorage => { nodeChangesByRoom.set(room, existing) } + const getNodeChangesByAuthor = async ( + authorDid: string, + sinceLamport: number, + limit = 200 + ): Promise => { + const bounded = Math.min(Math.max(limit, 1), 1000) + return [...nodeChangesByHash.values()] + .filter((c) => c.authorDid === authorDid && c.lamportTime > sinceLamport) + .sort((a, b) => a.lamportTime - b.lamportTime) + .slice(0, bounded) + } + // Share rooms (exploration 0298): hash→room mappings with a per-mapping seq. const roomChangeMappings: Array<{ seq: number; room: string; hash: string }> = [] let roomMappingSeq = 0 @@ -1072,6 +1084,7 @@ export const createMemoryStorage = (): HubStorage => { listPopularSchemas, hasNodeChange, appendNodeChange, + getNodeChangesByAuthor, getUsageBytesByDid, addChangeToRoom, getRoomChangesSince, diff --git a/packages/hub/src/storage/sqlite.ts b/packages/hub/src/storage/sqlite.ts index 8c8b006ea..b4d956777 100644 --- a/packages/hub/src/storage/sqlite.ts +++ b/packages/hub/src/storage/sqlite.ts @@ -367,6 +367,11 @@ const SCHEMA_SQL = ` CREATE INDEX IF NOT EXISTS idx_node_changes_author_schema ON node_changes(author_did, schema_id); + -- Backs getNodeChangesByAuthor: the agent audit trail (exploration 0337) + -- pages an author's history on their per-author lamport without a scan. + CREATE INDEX IF NOT EXISTS idx_node_changes_author_lamport + ON node_changes(author_did, lamport_time); + -- Share rooms (exploration 0298): index an existing change into extra rooms -- so a channel's nodes reach a grantee without duplicating content. seq is a -- per-mapping monotonic cursor; share-room sync pages on it (author lamports @@ -1210,6 +1215,12 @@ export const createSQLiteStorage = ( WHERE room = ? AND node_id = ? ORDER BY lamport_time ASC `), + getNodeChangesByAuthor: db.prepare(` + SELECT * FROM node_changes + WHERE author_did = ? AND lamport_time > ? + ORDER BY lamport_time ASC + LIMIT ? + `), getHighWaterMark: db.prepare(` SELECT MAX(lamport_time) as hwm FROM node_changes WHERE room = ? `), @@ -2215,6 +2226,19 @@ export const createSQLiteStorage = ( return rows.map(rowToSerializedChange) } + const getNodeChangesByAuthor = async ( + authorDid: string, + sinceLamport: number, + limit = 200 + ): Promise => { + const rows = stmts.getNodeChangesByAuthor.all( + authorDid, + sinceLamport, + Math.min(Math.max(limit, 1), 1000) + ) as NodeChangeRow[] + return rows.map(rowToSerializedChange) + } + const getHighWaterMark = async (room: string): Promise => { const row = stmts.getHighWaterMark.get(room) as { hwm: number | null } | undefined return row?.hwm ?? 0 @@ -2680,6 +2704,7 @@ export const createSQLiteStorage = ( resetAllUserData, getNodeChangesSince, getNodeChangesForNode, + getNodeChangesByAuthor, getHighWaterMark, clearNodeChanges, updateSearchBody: async (docId: string, text: string): Promise => { diff --git a/packages/hub/src/types.ts b/packages/hub/src/types.ts index 539408c9a..35132edaa 100644 --- a/packages/hub/src/types.ts +++ b/packages/hub/src/types.ts @@ -46,6 +46,14 @@ export type HubConfig = { telemetryPeerHashSalt?: string /** Hub's own DID for UCAN audience verification (optional). */ hubDid?: string + /** + * Delegation roots this hub trusts (exploration 0337). When set, a + * presented UCAN is only honored if every root issuer of its proof chain + * is in this list — a self-issued `{with:'*', can:'*'}` token roots at the + * stranger who minted it and is rejected (the 0307 weakness). Unset + * preserves the legacy accept-any-verified-token behavior. + */ + trustedDids?: string[] /** Public hub URL for peer discovery (optional). */ publicUrl?: string /** diff --git a/packages/hub/test/agent-audit.test.ts b/packages/hub/test/agent-audit.test.ts new file mode 100644 index 000000000..37c0181da --- /dev/null +++ b/packages/hub/test/agent-audit.test.ts @@ -0,0 +1,161 @@ +/** + * Agent audit trail + trusted-root auth (exploration 0337). + */ + +import type { SerializedNodeChange } from '../src/storage/interface' +import type { MiddlewareHandler } from 'hono' +import { generateIdentity, mintAgentPassport, createUCAN } from '@xnetjs/identity' +import { Hono } from 'hono' +import { describe, expect, it } from 'vitest' +import { authenticateHttpRequest } from '../src/auth/ucan' +import { createAuditRoutes } from '../src/routes/audit' +import { createMemoryStorage } from '../src/storage/memory' +import { DEFAULT_CONFIG } from '../src/types' + +const change = ( + authorDid: string, + lamportTime: number, + overrides: Partial = {} +): SerializedNodeChange => ({ + hash: `hash-${authorDid}-${lamportTime}`, + id: `chg-${authorDid}-${lamportTime}`, + type: 'update', + nodeId: `node-${lamportTime % 3}`, + schemaId: 'xnet://xnet.fyi/Page@1.0.0', + lamportTime, + lamportAuthor: authorDid, + authorDid, + wallTime: 1700000000000 + lamportTime, + parentHash: null, + payload: { title: `v${lamportTime}` }, + signatureB64: 'c2ln', + ...overrides +}) + +const authAs = + (did: string, can = false): MiddlewareHandler => + async (c, next) => { + c.set('auth', { did, can: () => can }) + await next() + } + +const mount = async (opts: { as: string; can?: boolean }) => { + const storage = createMemoryStorage() + const agent = 'did:key:zAgent' + for (let i = 1; i <= 5; i++) await storage.appendNodeChange('room-a', change(agent, i)) + await storage.appendNodeChange('room-b', change(agent, 6)) + await storage.appendNodeChange('room-a', change('did:key:zHuman', 3)) + + const app = new Hono() + app.route('/audit', createAuditRoutes(storage, { requireAuth: authAs(opts.as, opts.can) })) + return { app, agent } +} + +describe('audit routes (exploration 0337)', () => { + it('self-reads page the full cross-room history in lamport order', async () => { + const { app, agent } = await mount({ as: 'did:key:zAgent' }) + const res = await app.request(`/audit/authors/${agent}/changes`) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.changes.map((c: SerializedNodeChange) => c.lamportTime)).toEqual([ + 1, 2, 3, 4, 5, 6 + ]) + expect(body.nextCursor).toBe(6) + // Only the agent's changes — the human's change never leaks in. + expect( + body.changes.every((c: SerializedNodeChange) => c.authorDid === agent) + ).toBe(true) + }) + + it('pages on the ?since lamport cursor', async () => { + const { app, agent } = await mount({ as: 'did:key:zAgent' }) + const res = await app.request(`/audit/authors/${agent}/changes?since=4&limit=1`) + const body = await res.json() + expect(body.changes.map((c: SerializedNodeChange) => c.lamportTime)).toEqual([5]) + expect(body.hasMore).toBe(true) + const res2 = await app.request(`/audit/authors/${agent}/changes?since=6`) + expect((await res2.json()).changes).toEqual([]) + }) + + it('reading another author requires audit/read', async () => { + const denied = await mount({ as: 'did:key:zOperator', can: false }) + expect( + (await denied.app.request(`/audit/authors/${denied.agent}/changes`)).status + ).toBe(403) + + const allowed = await mount({ as: 'did:key:zOperator', can: true }) + const res = await allowed.app.request(`/audit/authors/${allowed.agent}/changes`) + expect(res.status).toBe(200) + expect((await res.json()).changes).toHaveLength(6) + }) + + it('rejects non-DID author params', async () => { + const { app } = await mount({ as: 'did:key:zAgent' }) + expect((await app.request('/audit/authors/nonsense/changes')).status).toBe(400) + }) +}) + +describe('trusted-root token policy (exploration 0337 / 0307 fix)', () => { + const operator = generateIdentity() + const hubDid = 'did:key:zHub' + const config = { + ...DEFAULT_CONFIG, + auth: true, + trustedDids: [operator.identity.did] + } + + const invocationFor = (grant: ReturnType) => + createUCAN({ + issuer: grant.agentDID, + issuerKey: grant.agentKey, + audience: hubDid, + capabilities: [{ with: 'xnet://space/inbox', can: 'node/create' }], + proofs: [grant.ucan] + }) + + it('accepts a passport invocation chaining to the trusted operator', () => { + const grant = mintAgentPassport({ + operatorDID: operator.identity.did, + operatorKey: operator.privateKey, + capabilities: [{ with: 'xnet://space/inbox', can: 'node/create' }] + }) + const auth = authenticateHttpRequest(`Bearer ${invocationFor(grant)}`, config) + expect(auth).not.toBeNull() + expect(auth!.did).toBe(grant.agentDID) + expect(auth!.can('node/create', 'xnet://space/inbox')).toBe(true) + expect(auth!.can('node/delete', 'xnet://space/inbox')).toBe(false) + }) + + it('rejects a self-issued wildcard token from a stranger', () => { + const stranger = generateIdentity() + const token = createUCAN({ + issuer: stranger.identity.did, + issuerKey: stranger.privateKey, + audience: hubDid, + capabilities: [{ with: '*', can: '*' }] + }) + expect(authenticateHttpRequest(`Bearer ${token}`, config)).toBeNull() + }) + + it('still accepts the trusted operator itself (proof-less, roots at itself)', () => { + const token = createUCAN({ + issuer: operator.identity.did, + issuerKey: operator.privateKey, + audience: hubDid, + capabilities: [{ with: '*', can: 'hub/relay' }] + }) + expect(authenticateHttpRequest(`Bearer ${token}`, config)).not.toBeNull() + }) + + it('legacy behavior is preserved when trustedDids is unset', () => { + const stranger = generateIdentity() + const token = createUCAN({ + issuer: stranger.identity.did, + issuerKey: stranger.privateKey, + audience: hubDid, + capabilities: [{ with: '*', can: '*' }] + }) + const legacyConfig = { ...DEFAULT_CONFIG, auth: true } + expect(authenticateHttpRequest(`Bearer ${token}`, legacyConfig)).not.toBeNull() + }) +}) From 7cee86d3fa198a43a055c21875db1426082f00de Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:08:08 -0700 Subject: [PATCH 05/12] feat(plugins): agent audit recorder, risk-tiered approval ceremony, undo, and outbox tools on the MCP surface (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 8 +- packages/data/src/index.ts | 40 ++ packages/data/src/schema/index.ts | 42 ++ .../plugins/src/__tests__/agent-audit.test.ts | 282 ++++++++++++ .../src/__tests__/mcp-agent-audit.test.ts | 116 +++++ .../plugins/src/ai-surface/agent-audit.ts | 432 ++++++++++++++++++ .../src/ai-surface/agent-ceremony-tools.ts | 149 ++++++ packages/plugins/src/ai-surface/index.ts | 18 + packages/plugins/src/ai-surface/types.ts | 6 +- packages/plugins/src/index.ts | 16 + packages/plugins/src/services/local-api.ts | 7 +- packages/plugins/src/services/mcp-server.ts | 67 ++- .../plugins/src/testing/memory-backend.ts | 11 +- 13 files changed, 1184 insertions(+), 10 deletions(-) create mode 100644 packages/plugins/src/__tests__/agent-audit.test.ts create mode 100644 packages/plugins/src/__tests__/mcp-agent-audit.test.ts create mode 100644 packages/plugins/src/ai-surface/agent-audit.ts create mode 100644 packages/plugins/src/ai-surface/agent-ceremony-tools.ts diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index 2208072ea..721b8eb0f 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -615,23 +615,23 @@ Phase 2 — Audit trail: - [x] `AgentSession` / `AgentAction` / `AgentApproval` schemas (deterministic ids, `spaceCascadeAuthorization`, reversibility field) -- [ ] Audit middleware wrapping `AiSurfaceService.callTool` (one +- [x] Audit middleware wrapping `AiSurfaceService.callTool` (one `AgentAction` per call, linked `changeIds`) - [x] Per-author index + query: hub storage index on change author, `GET /audit/authors/:did/changes?since=` (paginated), UCAN-gated - [ ] Workbench audit console: table view over `AgentAction` filtered by agent DID, with per-action change diffs (reuse DebugReport console patterns from 0315) -- [ ] `xnet_undo ` honoring `reversibility` (compensating +- [x] `xnet_undo ` honoring `reversibility` (compensating changes, not history rewrite) Phase 3 — Text control plane: -- [ ] Risk-tiered approval ceremony in the guardrail: low=auto, +- [x] Risk-tiered approval ceremony in the guardrail: low=auto, medium=chat nonce (TTL 5 min, staleness-rejected), high/critical=xNet-surface only; `AgentApproval` node written for every decision, operator-signed for high-risk -- [ ] `AgentNotification` outbox schema + MCP subscription/poll tool; ship +- [x] `AgentNotification` outbox schema + MCP subscription/poll tool; ship notification → text relay instructions in the skill - [ ] Update the ClawHub skill + publish a Hermes-compatible skill (`docs/integrations/openclaw/xnet-workspace-skill.md` — same diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts index 64f61c2a6..aab75da7c 100644 --- a/packages/data/src/index.ts +++ b/packages/data/src/index.ts @@ -305,6 +305,46 @@ export { MemoryItemSchema, type MemoryItem, type MemoryKind, + // Agent schema pack (exploration 0337) + AGENT_ACTION_SCHEMA_IRI, + AGENT_ACTION_STATUSES, + AGENT_APPROVAL_DECISIONS, + AGENT_APPROVAL_SCHEMA_IRI, + AGENT_APPROVAL_SURFACES, + AGENT_CHANNELS, + AGENT_NOTIFICATION_KINDS, + AGENT_NOTIFICATION_SCHEMA_IRI, + AGENT_NOTIFICATION_STATUSES, + AGENT_PASSPORT_SCHEMA_IRI, + AGENT_REVERSIBILITIES, + AGENT_RISKS, + AGENT_RUNTIMES, + AGENT_SESSION_SCHEMA_IRI, + AgentActionSchema, + AgentApprovalSchema, + AgentNotificationSchema, + AgentPassportSchema, + AgentSessionSchema, + agentActionId, + agentApprovalId, + agentNotificationId, + agentPassportId, + agentSessionId, + redactInstruction, + type AgentAction, + type AgentActionStatus, + type AgentApproval, + type AgentApprovalDecision, + type AgentApprovalSurface, + type AgentChannel, + type AgentNotification, + type AgentNotificationKind, + type AgentNotificationStatus, + type AgentPassport, + type AgentReversibility, + type AgentRisk, + type AgentRuntime, + type AgentSession, TranscriptionSchema, TRANSCRIPTION_SCHEMA_IRI, type Transcription, diff --git a/packages/data/src/schema/index.ts b/packages/data/src/schema/index.ts index ee188d180..980a3aca4 100644 --- a/packages/data/src/schema/index.ts +++ b/packages/data/src/schema/index.ts @@ -344,6 +344,48 @@ export { type MemoryItem, type MemoryKind } from './schemas' +// Agent schema pack (exploration 0337) +export { + AGENT_ACTION_SCHEMA_IRI, + AGENT_ACTION_STATUSES, + AGENT_APPROVAL_DECISIONS, + AGENT_APPROVAL_SCHEMA_IRI, + AGENT_APPROVAL_SURFACES, + AGENT_CHANNELS, + AGENT_NOTIFICATION_KINDS, + AGENT_NOTIFICATION_SCHEMA_IRI, + AGENT_NOTIFICATION_STATUSES, + AGENT_PASSPORT_SCHEMA_IRI, + AGENT_REVERSIBILITIES, + AGENT_RISKS, + AGENT_RUNTIMES, + AGENT_SESSION_SCHEMA_IRI, + AgentActionSchema, + AgentApprovalSchema, + AgentNotificationSchema, + AgentPassportSchema, + AgentSessionSchema, + agentActionId, + agentApprovalId, + agentNotificationId, + agentPassportId, + agentSessionId, + redactInstruction, + type AgentAction, + type AgentActionStatus, + type AgentApproval, + type AgentApprovalDecision, + type AgentApprovalSurface, + type AgentChannel, + type AgentNotification, + type AgentNotificationKind, + type AgentNotificationStatus, + type AgentPassport, + type AgentReversibility, + type AgentRisk, + type AgentRuntime, + type AgentSession +} from './schemas' export { TranscriptionSchema, TRANSCRIPTION_SCHEMA_IRI, diff --git a/packages/plugins/src/__tests__/agent-audit.test.ts b/packages/plugins/src/__tests__/agent-audit.test.ts new file mode 100644 index 000000000..a2bf01519 --- /dev/null +++ b/packages/plugins/src/__tests__/agent-audit.test.ts @@ -0,0 +1,282 @@ +/** + * Agent audit recorder + risk-tiered ceremony (exploration 0337). + */ + +import type { AiToolDefinition } from '../ai-surface/types' +import { describe, expect, it, vi } from 'vitest' +import { AgentAuditRecorder, hashNonce, reversibilityForTool } from '../ai-surface/agent-audit' +import { + createAgentCeremonyTools, + createAgentNotificationTools +} from '../ai-surface/agent-ceremony-tools' +import { createMemoryNodeStore } from '../testing/memory-backend' +import { AGENT_NOTIFICATION_SCHEMA_IRI } from '@xnetjs/data' + +const defs: AiToolDefinition[] = [ + { + name: 'xnet_search', + title: 'Search', + description: '', + risk: 'low', + requiredScopes: ['workspace.search'], + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'xnet_apply_page_markdown', + title: 'Apply', + description: '', + risk: 'medium', + requiredScopes: ['page.write'], + inputSchema: { type: 'object', properties: {} } + }, + { + name: 'xnet_apply_database_mutation', + title: 'Apply DB', + description: '', + risk: 'high', + requiredScopes: ['database.write.rows'], + inputSchema: { type: 'object', properties: {} } + } +] + +const makeRecorder = (opts: { now?: () => number } = {}) => { + const store = createMemoryNodeStore([]) + const calls: Array<{ name: string; args: Record }> = [] + const surface = { + getTools: () => defs, + callTool: vi.fn(async (name: string, args: Record = {}) => { + calls.push({ name, args }) + if (name === 'xnet_apply_page_markdown') { + return { applied: true, rollbackHandle: 'rb-1', appliedChangeIds: ['page-1'] } + } + if (name === 'xnet_rollback_page_markdown') return { rolledBack: true } + if (name === 'xnet_apply_database_mutation') { + return { applied: true, appliedChangeIds: ['row-1', 'row-2'] } + } + return { ok: true, name } + }) + } + const recorder = new AgentAuditRecorder({ + surface, + store, + context: { + agentDID: 'did:key:zAgent', + sessionKey: 'agent:main:wa-4915', + channel: 'whatsapp', + peer: 'wa-4915', + spaceId: 'space-audit' + }, + clock: opts.now, + generateNonce: () => 'AB23CD' + }) + return { recorder, store, surface, calls } +} + +const nodesOf = async (store: ReturnType, schemaId: string) => + (await store.list({ schemaId })).filter((n) => !n.deleted) + +describe('AgentAuditRecorder (exploration 0337)', () => { + it('low risk executes immediately and records an applied AgentAction', async () => { + const { recorder, store, calls } = makeRecorder() + const outcome = await recorder.callTool('xnet_search', { query: 'q' }, 'find q') + expect(outcome.pending).toBe(false) + expect(calls.map((c) => c.name)).toEqual(['xnet_search']) + + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions).toHaveLength(1) + expect(actions[0].id).toMatch(/^agent-action:agent-session:/) + expect(actions[0].properties).toMatchObject({ + tool: 'xnet_search', + risk: 'low', + status: 'applied', + instruction: 'find q', + session: recorder.sessionId, + space: 'space-audit' + }) + // The session node materialized idempotently. + const sessions = await nodesOf(store, 'xnet://xnet.fyi/AgentSession@1.0.0') + expect(sessions).toHaveLength(1) + expect(sessions[0].properties.channel).toBe('whatsapp') + }) + + it('medium risk parks the call and returns a chat nonce; APPROVE releases it', async () => { + const { recorder, store, calls } = makeRecorder() + const outcome = await recorder.callTool('xnet_apply_page_markdown', { pageId: 'p1' }) + expect(outcome.pending).toBe(true) + if (!outcome.pending) throw new Error('unreachable') + expect(outcome.surface).toBe('chat') + expect(outcome.nonce).toBe('AB23CD') + expect(outcome.message).toContain('APPROVE AB23CD') + expect(calls).toHaveLength(0) // nothing executed yet + + const done = await recorder.approveFromChat('ab23cd ', 'wa-4915') // case/space-insensitive + expect(done.pending).toBe(false) + expect(calls.map((c) => c.name)).toEqual(['xnet_apply_page_markdown']) + + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions[0].properties.status).toBe('applied') + expect(actions[0].properties.changeIds).toEqual(['page-1']) + + const approvals = await nodesOf(store, 'xnet://xnet.fyi/AgentApproval@1.0.0') + expect(approvals).toHaveLength(1) + expect(approvals[0].properties).toMatchObject({ + surface: 'chat', + decision: 'approved', + peer: 'wa-4915', + nonceHash: await hashNonce('AB23CD') + }) + // The durable node never stores the nonce itself. + expect(JSON.stringify(approvals[0].properties)).not.toContain('AB23CD') + }) + + it('a wrong nonce is rejected', async () => { + const { recorder } = makeRecorder() + await recorder.callTool('xnet_apply_page_markdown', {}) + await expect(recorder.approveFromChat('WRONG1')).rejects.toThrow(/wrong or expired/) + }) + + it('the nonce expires after the TTL and the action lands denied/expired', async () => { + let now = 1_000_000 + const { recorder, store, calls } = makeRecorder({ now: () => now }) + await recorder.callTool('xnet_apply_page_markdown', {}) + now += 5 * 60 * 1000 + 1 + await expect(recorder.approveFromChat('AB23CD')).rejects.toThrow(/wrong or expired/) + expect(calls).toHaveLength(0) + + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions[0].properties.status).toBe('denied') + const approvals = await nodesOf(store, 'xnet://xnet.fyi/AgentApproval@1.0.0') + expect(approvals[0].properties.decision).toBe('expired') + }) + + it('high risk carries no nonce and chat cannot release it; app approval can', async () => { + const { recorder, store, calls } = makeRecorder() + const outcome = await recorder.callTool('xnet_apply_database_mutation', {}) + expect(outcome.pending).toBe(true) + if (!outcome.pending) throw new Error('unreachable') + expect(outcome.surface).toBe('app') + expect(outcome.nonce).toBeUndefined() + expect(outcome.message).toContain('xNet app') + + // Chat approval mechanically cannot find it (no nonce hash to match). + await expect(recorder.approveFromChat('AB23CD')).rejects.toThrow() + + const done = await recorder.approveFromApp(outcome.actionId, 'did:key:zOperator') + expect(done.pending).toBe(false) + expect(calls.map((c) => c.name)).toEqual(['xnet_apply_database_mutation']) + + const approvals = await nodesOf(store, 'xnet://xnet.fyi/AgentApproval@1.0.0') + expect(approvals[0].properties).toMatchObject({ + surface: 'app', + decision: 'approved', + approverDID: 'did:key:zOperator' + }) + }) + + it('deny records the decision and never executes', async () => { + const { recorder, store, calls } = makeRecorder() + const outcome = await recorder.callTool('xnet_apply_page_markdown', {}) + if (!outcome.pending) throw new Error('expected pending') + await recorder.deny(outcome.actionId, 'did:key:zOperator') + expect(calls).toHaveLength(0) + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions[0].properties.status).toBe('denied') + }) + + it('undo honors reversibility: rolls back reversible, refuses compensatable', async () => { + const { recorder, store, calls } = makeRecorder() + const pending = await recorder.callTool('xnet_apply_page_markdown', {}) + if (!pending.pending) throw new Error('expected pending') + await recorder.approveFromChat('AB23CD') + + const result = await recorder.undo(pending.actionId) + expect(result).toEqual({ rolledBack: true }) + expect(calls.at(-1)).toMatchObject({ + name: 'xnet_rollback_page_markdown', + args: { rollbackHandle: 'rb-1', confirmRollback: true } + }) + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions[0].properties.status).toBe('rolled-back') + + // A compensatable (database) action refuses automatic undo. + const dbPending = await recorder.callTool('xnet_apply_database_mutation', {}) + if (!dbPending.pending) throw new Error('expected pending') + await recorder.approveFromApp(dbPending.actionId, 'did:key:zOperator') + await expect(recorder.undo(dbPending.actionId)).rejects.toThrow(/compensatable/) + }) + + it('a failing tool records status failed with the error', async () => { + const { recorder, store, surface } = makeRecorder() + surface.callTool.mockRejectedValueOnce(new Error('boom')) + await expect(recorder.callTool('xnet_search', {})).rejects.toThrow('boom') + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(actions[0].properties).toMatchObject({ status: 'failed', error: 'boom' }) + }) + + it('redacts instructions when configured', async () => { + const store = createMemoryNodeStore([]) + const recorder = new AgentAuditRecorder({ + surface: { getTools: () => defs, callTool: async () => ({ ok: true }) }, + store, + context: { + agentDID: 'did:key:zAgent', + sessionKey: 'k', + redactInstructions: true + } + }) + await recorder.callTool('xnet_search', {}, 'secret plans') + const actions = await nodesOf(store, 'xnet://xnet.fyi/AgentAction@1.0.0') + expect(String(actions[0].properties.instruction)).toMatch(/^\[redacted 12 chars sha256:/) + expect(String(actions[0].properties.instruction)).not.toContain('secret') + }) +}) + +describe('reversibilityForTool', () => { + it('classifies the built-in tools', () => { + expect(reversibilityForTool('xnet_apply_page_markdown')).toBe('reversible') + expect(reversibilityForTool('xnet_apply_database_mutation')).toBe('compensatable') + expect(reversibilityForTool('xnet_delete')).toBe('irreversible') + expect(reversibilityForTool('xnet_anything_else')).toBe('compensatable') + }) +}) + +describe('agent ceremony + notification tools', () => { + it('xnet_approve redeems a chat code end-to-end', async () => { + const { recorder } = makeRecorder() + const tools = createAgentCeremonyTools(recorder) + const approve = tools.find((t) => t.name === 'xnet_approve')! + await recorder.callTool('xnet_apply_page_markdown', {}) + const outcome = (await approve.invoke({ code: 'AB23CD' })) as { pending: boolean } + expect(outcome.pending).toBe(false) + }) + + it('xnet_pending_approvals never leaks nonces', async () => { + const { recorder } = makeRecorder() + const tools = createAgentCeremonyTools(recorder) + await recorder.callTool('xnet_apply_page_markdown', {}) + const listing = tools.find((t) => t.name === 'xnet_pending_approvals')! + const result = (await listing.invoke({})) as { pending: unknown[] } + expect(result.pending).toHaveLength(1) + expect(JSON.stringify(result)).not.toContain('AB23CD') + }) + + it('xnet_poll_notifications drains pending outbox nodes oldest-first', async () => { + const store = createMemoryNodeStore([]) + await store.create({ + schemaId: AGENT_NOTIFICATION_SCHEMA_IRI, + properties: { title: 'first', status: 'pending', kind: 'info' } + }) + await store.create({ + schemaId: AGENT_NOTIFICATION_SCHEMA_IRI, + properties: { title: 'already seen', status: 'delivered', kind: 'info' } + }) + const [poll] = createAgentNotificationTools(store) + const result = (await poll.invoke({ markDelivered: true })) as { + notifications: Array<{ title: string }> + } + expect(result.notifications.map((n) => n.title)).toEqual(['first']) + // Marked delivered — a second poll drains nothing. + const again = (await poll.invoke({})) as { notifications: unknown[] } + expect(again.notifications).toHaveLength(0) + }) +}) diff --git a/packages/plugins/src/__tests__/mcp-agent-audit.test.ts b/packages/plugins/src/__tests__/mcp-agent-audit.test.ts new file mode 100644 index 000000000..5b4056e2d --- /dev/null +++ b/packages/plugins/src/__tests__/mcp-agent-audit.test.ts @@ -0,0 +1,116 @@ +/** + * MCP server with an agent-audit session (exploration 0337): tool calls route + * through the recorder, ceremony tools are exposed, pending payloads relay. + */ + +import { describe, expect, it } from 'vitest' +import { createMCPServer } from '../services/mcp-server' +import { createMemoryNodeStore, createWorkspaceFixtureSchemas } from '../testing/memory-backend' + +const mount = () => { + const store = createMemoryNodeStore([]) + const server = createMCPServer({ + store, + schemas: createWorkspaceFixtureSchemas(), + agentAudit: { + agentDID: 'did:key:zAgent', + sessionKey: 'openclaw:main', + channel: 'telegram', + peer: 'tg-1', + spaceId: 'space-audit' + } + }) + return { server, store } +} + +const call = async ( + server: ReturnType['server'], + name: string, + args: Record = {} +) => { + const response = await server.handleRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { name, arguments: args } + }) + if (response.error) throw new Error(response.error.message) + const content = (response.result as { content: Array<{ text: string }> }).content[0].text + return JSON.parse(content) +} + +describe('MCP agent-audit session (exploration 0337)', () => { + it('exposes the ceremony + outbox tools and stamps _instruction on AI tools', () => { + const { server } = mount() + const names = server.getTools().map((t) => t.name) + for (const expected of [ + 'xnet_approve', + 'xnet_deny', + 'xnet_pending_approvals', + 'xnet_undo', + 'xnet_poll_notifications' + ]) { + expect(names).toContain(expected) + } + const search = server.getTools().find((t) => t.name === 'xnet_search')! + expect(search.inputSchema.properties._instruction).toBeDefined() + }) + + it('a low-risk call executes and lands an AgentAction node', async () => { + const { server, store } = mount() + await call(server, 'xnet_search', { query: 'notes', _instruction: 'find my notes' }) + const actions = (await store.list({ schemaId: 'xnet://xnet.fyi/AgentAction@1.0.0' })).filter( + (n) => !n.deleted + ) + expect(actions).toHaveLength(1) + expect(actions[0].properties).toMatchObject({ + tool: 'xnet_search', + status: 'applied', + instruction: 'find my notes' + }) + }) + + it('a medium-risk call returns a chat pending payload; xnet_approve releases it', async () => { + const { server, store } = mount() + // xnet_plan_page_patch is the built-in medium-risk tool (plan, not apply). + const pending = await call(server, 'xnet_plan_page_patch', { + pageId: 'missing-page', + markdown: '# hi' + }) + expect(pending.pending).toBe(true) + expect(pending.surface).toBe('chat') + expect(typeof pending.nonce).toBe('string') + + // Wrong code is rejected... + await expect(call(server, 'xnet_approve', { code: 'NOPE99' })).rejects.toThrow() + + // ...the relayed code releases the call (which then fails on the missing + // page — proving the underlying tool actually executed post-approval). + await expect(call(server, 'xnet_approve', { code: pending.nonce })).rejects.toThrow( + /not found|Unknown|missing/i + ) + const actions = (await store.list({ schemaId: 'xnet://xnet.fyi/AgentAction@1.0.0' })).filter( + (n) => !n.deleted + ) + expect(actions[0].properties.status).toBe('failed') + const approvals = ( + await store.list({ schemaId: 'xnet://xnet.fyi/AgentApproval@1.0.0' }) + ).filter((n) => !n.deleted) + expect(approvals[0].properties.decision).toBe('approved') + }) + + it('a high-risk apply is app-only: no nonce, chat cannot release it', async () => { + const { server } = mount() + const pending = await call(server, 'xnet_apply_page_markdown', { + pageId: 'p1', + planId: 'plan-x', + baseRevision: 'r0', + markdown: '# hi', + confirmApply: true + }) + expect(pending.pending).toBe(true) + expect(pending.surface).toBe('app') + expect(pending.nonce).toBeUndefined() + expect(pending.message).toContain('xNet app') + }) +}) diff --git a/packages/plugins/src/ai-surface/agent-audit.ts b/packages/plugins/src/ai-surface/agent-audit.ts new file mode 100644 index 000000000..86f9fdc23 --- /dev/null +++ b/packages/plugins/src/ai-surface/agent-audit.ts @@ -0,0 +1,432 @@ +/** + * Agent audit recorder + risk-tiered approval ceremony (exploration 0337). + * + * Wraps `AiSurfaceService.callTool` so every guarded tool call becomes an + * `AgentAction` node (the semantic audit layer over the signed change log) + * and medium+ risk calls go through an approval ceremony: + * + * - `low` (and reads): execute immediately, record the action. + * - `medium`: park the call, return a pending payload with a one-time nonce + * the agent relays to the operator ("Reply APPROVE "). The nonce is + * bound to one action, expires after a TTL (Slack-style staleness), and + * only its SHA-256 lands in the durable `AgentApproval` node. Chat + * approvals are relayed *by the agent* and therefore forgeable by a + * compromised gateway — which is exactly why this surface is capped at + * medium risk. + * - `high`/`critical`: park the call with **no nonce**. Chat cannot approve + * it; only `approveFromApp` — invoked from an xNet surface where the + * operator's own key signs the resulting `AgentApproval` node — releases + * it. The log then structurally proves the human was in the loop. + * + * Undo rides the existing rollback machinery: page-markdown applies return an + * in-process `rollbackHandle`; `undo()` honors the action's declared + * reversibility and executes the compensating rollback tool. + */ + +import type { NodeStoreAPI } from '../services/local-api' +import type { AiRiskLevel, AiToolDefinition } from './types' +import { + AGENT_ACTION_SCHEMA_IRI, + AGENT_APPROVAL_SCHEMA_IRI, + agentActionId, + agentApprovalId, + agentSessionId, + AGENT_SESSION_SCHEMA_IRI, + redactInstruction, + type AgentApprovalSurface, + type AgentReversibility +} from '@xnetjs/data' + +export type AgentAuditSurface = { + getTools(): AiToolDefinition[] + callTool(name: string, args?: Record): Promise +} + +export type AgentAuditContext = { + /** The agent's DID (informational; the store identity does the signing). */ + agentDID: string + /** Runtime session key (OpenClaw `agent::`, Hermes convo id…). */ + sessionKey: string + /** Channel the session rides on (matches `AGENT_CHANNELS` ids). */ + channel?: string + /** Channel peer id — recorded for approval forensics. */ + peer?: string + /** Home Space node id for the audit records. */ + spaceId?: string + /** Store instruction text as a redacted digest instead of verbatim. */ + redactInstructions?: boolean +} + +export type AgentAuditRecorderConfig = { + surface: AgentAuditSurface + store: NodeStoreAPI + context: AgentAuditContext + /** Ceremony TTL in ms (default 5 minutes). */ + approvalTtlMs?: number + clock?: () => number + /** Nonce generator override (tests). */ + generateNonce?: () => string +} + +export type AgentPendingApproval = { + pending: true + actionId: string + risk: AiRiskLevel + surface: AgentApprovalSurface + /** + * Present only for the chat tier: the one-time code the agent relays to the + * operator. High/critical actions never carry a nonce — they are only + * approvable from an xNet surface. + */ + nonce?: string + expiresAt: number + message: string +} + +export type AgentExecutedResult = { + pending: false + actionId: string + result: unknown +} + +export type AgentCallOutcome = AgentPendingApproval | AgentExecutedResult + +type PendingEntry = { + actionId: string + name: string + args: Record + risk: AiRiskLevel + surface: AgentApprovalSurface + nonceHash: string | null + expiresAt: number + reversibility: AgentReversibility +} + +const DEFAULT_APPROVAL_TTL_MS = 5 * 60 * 1000 + +const NONCE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' +const NONCE_LENGTH = 6 + +const defaultNonce = (): string => { + const bytes = new Uint8Array(NONCE_LENGTH) + globalThis.crypto.getRandomValues(bytes) + return [...bytes].map((b) => NONCE_ALPHABET[b % NONCE_ALPHABET.length]).join('') +} + +export const hashNonce = async (nonce: string): Promise => { + const digest = await globalThis.crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(nonce.trim().toUpperCase()) + ) + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +/** Tools whose apply is snapshot-reversible in-process. */ +const REVERSIBLE_TOOLS = new Set(['xnet_apply_page_markdown']) +/** Tools whose effect needs a compensating mutation, not a snapshot restore. */ +const COMPENSATABLE_TOOLS = new Set(['xnet_apply_database_mutation']) + +export const reversibilityForTool = (name: string): AgentReversibility => { + if (REVERSIBLE_TOOLS.has(name)) return 'reversible' + if (COMPENSATABLE_TOOLS.has(name)) return 'compensatable' + if (name.includes('delete') || name.includes('remove')) return 'irreversible' + return 'compensatable' +} + +/** Risk from the tool definition; unknown tools are treated as medium. */ +export const riskForTool = ( + defs: AiToolDefinition[], + name: string +): AiRiskLevel => defs.find((d) => d.name === name)?.risk ?? 'medium' + +const surfaceForRisk = (risk: AiRiskLevel): AgentApprovalSurface => + risk === 'medium' ? 'chat' : 'app' + +const extractChangeIds = (result: unknown): string[] => { + if (!result || typeof result !== 'object') return [] + const record = result as Record + if (Array.isArray(record.appliedChangeIds)) { + return record.appliedChangeIds.filter((id): id is string => typeof id === 'string') + } + return [] +} + +const extractRollbackHandle = (result: unknown): string | null => { + if (!result || typeof result !== 'object') return null + const handle = (result as Record).rollbackHandle + return typeof handle === 'string' ? handle : null +} + +export class AgentAuditRecorder { + private readonly surface: AgentAuditSurface + private readonly store: NodeStoreAPI + private readonly context: AgentAuditContext + private readonly ttlMs: number + private readonly clock: () => number + private readonly generateNonce: () => string + + readonly sessionId: string + private seq = 0 + private sessionEnsured = false + private readonly pending = new Map() + private readonly rollbackHandles = new Map() + + constructor(config: AgentAuditRecorderConfig) { + this.surface = config.surface + this.store = config.store + this.context = config.context + this.ttlMs = config.approvalTtlMs ?? DEFAULT_APPROVAL_TTL_MS + this.clock = config.clock ?? (() => Date.now()) + this.generateNonce = config.generateNonce ?? defaultNonce + this.sessionId = agentSessionId(config.context.agentDID, config.context.sessionKey) + } + + /** Idempotently materialize the AgentSession node. */ + private async ensureSession(): Promise { + if (this.sessionEnsured) return + this.sessionEnsured = true + const existing = await this.store.get(this.sessionId) + if (existing) return + await this.createWithId(this.sessionId, AGENT_SESSION_SCHEMA_IRI, { + space: this.context.spaceId, + channel: this.context.channel ?? 'other', + peer: this.context.peer, + startedAt: this.clock() + }) + } + + /** Create with a deterministic id — retries LWW-upsert instead of flooding. */ + private async createWithId( + auditId: string, + schemaId: string, + properties: Record + ): Promise { + const clean = Object.fromEntries( + Object.entries(properties).filter(([, v]) => v !== undefined) + ) + const node = await this.store.create({ id: auditId, schemaId, properties: clean }) + return node.id + } + + private async instructionText(instruction: string | undefined): Promise { + if (!instruction) return undefined + if (!this.context.redactInstructions) return instruction + const digest = await hashNonce(instruction) + return redactInstruction(instruction, digest) + } + + /** Sweep expired pending entries, marking their actions denied/expired. */ + async expireStale(): Promise { + const now = this.clock() + for (const [actionId, entry] of [...this.pending]) { + if (entry.expiresAt > now) continue + this.pending.delete(actionId) + await this.store.update(actionId, { properties: { status: 'denied' } }) + await this.recordApproval(entry, 'expired', {}) + } + } + + /** + * The audit + ceremony entry point. Returns either the executed result or a + * pending-approval payload for the agent to relay. + */ + async callTool( + name: string, + args: Record = {}, + instruction?: string + ): Promise { + await this.ensureSession() + await this.expireStale() + + const risk = riskForTool(this.surface.getTools(), name) + const reversibility = reversibilityForTool(name) + const seq = ++this.seq + const auditId = agentActionId(this.sessionId, seq) + + const baseProperties = { + space: this.context.spaceId, + session: this.sessionId, + seq, + tool: name, + instruction: await this.instructionText(instruction), + risk, + reversibility + } + + if (risk === 'low') { + const actionId = await this.createWithId(auditId, AGENT_ACTION_SCHEMA_IRI, { + ...baseProperties, + status: 'proposed' + }) + return await this.execute(actionId, name, args) + } + + const surface = surfaceForRisk(risk) + const expiresAt = this.clock() + this.ttlMs + const nonce = surface === 'chat' ? this.generateNonce() : null + const nonceHash = nonce ? await hashNonce(nonce) : null + + const actionId = await this.createWithId(auditId, AGENT_ACTION_SCHEMA_IRI, { + ...baseProperties, + status: 'pending-approval', + approvalExpiresAt: expiresAt + }) + + this.pending.set(actionId, { + actionId, + name, + args, + risk, + surface, + nonceHash, + expiresAt, + reversibility + }) + + const message = + surface === 'chat' + ? `Risk ${risk}: reply APPROVE ${nonce} within ${Math.round(this.ttlMs / 60000)} minutes to run ${name}.` + : `Risk ${risk}: ${name} cannot be approved over chat. Confirm in the xNet app.` + + return { + pending: true, + actionId, + risk, + surface, + nonce: nonce ?? undefined, + expiresAt, + message + } + } + + /** Chat-tier approval: the operator replied `APPROVE `. */ + async approveFromChat(nonce: string, peer?: string): Promise { + await this.expireStale() + const digest = await hashNonce(nonce) + const entry = [...this.pending.values()].find( + (p) => p.surface === 'chat' && p.nonceHash === digest + ) + if (!entry) { + throw new Error('No pending chat approval matches that code (wrong or expired nonce)') + } + return await this.release(entry, 'chat', { peer, nonceHash: digest }) + } + + /** + * App-tier approval for high/critical actions. Call this from an xNet + * surface running as the operator, so the `AgentApproval` node is signed by + * the operator's own identity — never expose it as an agent-callable tool. + */ + async approveFromApp(actionId: string, approverDID: string): Promise { + await this.expireStale() + const entry = this.pending.get(actionId) + if (!entry) throw new Error(`No pending approval for action ${actionId}`) + return await this.release(entry, entry.surface === 'chat' ? 'chat' : 'app', { + approverDID + }) + } + + /** Deny a pending action from any surface. */ + async deny(actionId: string, approverDID?: string): Promise { + const entry = this.pending.get(actionId) + if (!entry) throw new Error(`No pending approval for action ${actionId}`) + this.pending.delete(actionId) + await this.store.update(actionId, { properties: { status: 'denied' } }) + await this.recordApproval(entry, 'denied', { approverDID }) + } + + /** Pending entries the agent may enumerate (never includes nonces). */ + listPending(): Array> { + return [...this.pending.values()].map(({ actionId, name, risk, surface, expiresAt }) => ({ + actionId, + name, + risk, + surface, + expiresAt + })) + } + + /** + * Undo an applied action. Honors declared reversibility: `reversible` + * actions restore via the rollback handle captured at apply time; + * everything else refuses with a reason. + */ + async undo(actionId: string): Promise { + const node = await this.store.get(actionId) + if (!node) throw new Error(`Unknown agent action: ${actionId}`) + const props = node.properties + if (props.status !== 'applied') { + throw new Error(`Action ${actionId} is not applied (status: ${String(props.status)})`) + } + if (props.reversibility !== 'reversible') { + throw new Error( + `Action ${actionId} is ${String(props.reversibility)} — no automatic undo; apply a compensating change instead` + ) + } + const handle = this.rollbackHandles.get(actionId) + if (!handle) { + throw new Error( + `No rollback handle for ${actionId} (rollback snapshots live in-process; the serve process that applied it has gone away)` + ) + } + const result = await this.surface.callTool('xnet_rollback_page_markdown', { + rollbackHandle: handle, + confirmRollback: true + }) + await this.store.update(actionId, { properties: { status: 'rolled-back' } }) + return result + } + + private async release( + entry: PendingEntry, + surface: AgentApprovalSurface, + meta: { peer?: string; approverDID?: string; nonceHash?: string } + ): Promise { + this.pending.delete(entry.actionId) + await this.recordApproval(entry, 'approved', meta, surface) + await this.store.update(entry.actionId, { properties: { status: 'approved' } }) + return await this.execute(entry.actionId, entry.name, entry.args) + } + + private async recordApproval( + entry: PendingEntry, + decision: 'approved' | 'denied' | 'expired', + meta: { peer?: string; approverDID?: string; nonceHash?: string }, + surface: AgentApprovalSurface = entry.surface + ): Promise { + await this.createWithId(agentApprovalId(entry.actionId), AGENT_APPROVAL_SCHEMA_IRI, { + space: this.context.spaceId, + action: entry.actionId, + surface, + decision, + approverDID: meta.approverDID, + nonceHash: meta.nonceHash ?? entry.nonceHash ?? undefined, + peer: meta.peer ?? this.context.peer, + decidedAt: this.clock() + }) + } + + private async execute( + actionId: string, + name: string, + args: Record + ): Promise { + try { + const result = await this.surface.callTool(name, args) + const handle = extractRollbackHandle(result) + if (handle) this.rollbackHandles.set(actionId, handle) + await this.store.update(actionId, { + properties: { status: 'applied', changeIds: extractChangeIds(result) } + }) + return { pending: false, actionId, result } + } catch (err) { + await this.store.update(actionId, { + properties: { + status: 'failed', + error: err instanceof Error ? err.message.slice(0, 2000) : String(err) + } + }) + throw err + } + } +} diff --git a/packages/plugins/src/ai-surface/agent-ceremony-tools.ts b/packages/plugins/src/ai-surface/agent-ceremony-tools.ts new file mode 100644 index 000000000..f35eb6197 --- /dev/null +++ b/packages/plugins/src/ai-surface/agent-ceremony-tools.ts @@ -0,0 +1,149 @@ +/** + * Agent-facing ceremony + notification tools (exploration 0337). + * + * These are `AiExtraTool`s the MCP server exposes to an enrolled agent + * (OpenClaw, Hermes, …): + * + * - `xnet_approve` — redeem an operator-typed `APPROVE ` from chat. + * Only medium-risk (chat-tier) actions carry a code; high/critical + * actions have none, so this tool mechanically cannot release them. + * - `xnet_deny` / `xnet_pending_approvals` — ceremony bookkeeping. + * - `xnet_undo` — roll back a reversible applied action. + * - `xnet_poll_notifications` — drain the hub→operator outbox + * (`AgentNotification` nodes) so the agent can relay them over its + * messaging channels. No new transport: the outbox is just nodes. + */ + +import type { NodeStoreAPI } from '../services/local-api' +import type { AgentAuditRecorder } from './agent-audit' +import type { AiExtraTool } from './types' +import { AGENT_NOTIFICATION_SCHEMA_IRI } from '@xnetjs/data' +import { readOptionalNumber, readOptionalString, readRequiredString } from './args' + +export function createAgentCeremonyTools(recorder: AgentAuditRecorder): AiExtraTool[] { + return [ + { + name: 'xnet_approve', + title: 'Redeem a chat approval code', + description: + 'Redeem an APPROVE code the operator typed in chat to release a pending medium-risk action. High/critical actions carry no code and can only be approved in the xNet app.', + risk: 'low', + requiredScopes: ['agent.approve'], + inputSchema: { + type: 'object', + properties: { + code: { type: 'string', description: 'The code the operator replied with' }, + peer: { type: 'string', description: 'Channel peer id that replied (forensics)' } + }, + required: ['code'] + }, + invoke: async (args) => { + const code = readRequiredString(args, 'code') + const peer = readOptionalString(args, 'peer') + return await recorder.approveFromChat(code, peer) + } + }, + { + name: 'xnet_deny', + title: 'Deny a pending action', + description: 'Deny a pending agent action; records the denial in the audit trail.', + risk: 'low', + requiredScopes: ['agent.approve'], + inputSchema: { + type: 'object', + properties: { + actionId: { type: 'string', description: 'The pending AgentAction node id' } + }, + required: ['actionId'] + }, + invoke: async (args) => { + await recorder.deny(readRequiredString(args, 'actionId')) + return { denied: true } + } + }, + { + name: 'xnet_pending_approvals', + title: 'List pending approvals', + description: + 'List actions waiting on operator approval (never includes approval codes).', + risk: 'low', + requiredScopes: ['agent.approve'], + inputSchema: { type: 'object', properties: {} }, + invoke: async () => ({ pending: recorder.listPending() }) + }, + { + name: 'xnet_undo', + title: 'Undo a reversible agent action', + description: + 'Roll back an applied action whose reversibility is `reversible`. Compensatable and irreversible actions are refused with a reason.', + risk: 'medium', + requiredScopes: ['agent.approve'], + inputSchema: { + type: 'object', + properties: { + actionId: { type: 'string', description: 'The applied AgentAction node id' } + }, + required: ['actionId'] + }, + invoke: async (args) => await recorder.undo(readRequiredString(args, 'actionId')) + } + ] +} + +export type AgentNotificationToolsOptions = { + /** Poll page cap (default 20). */ + maxBatch?: number +} + +export function createAgentNotificationTools( + store: NodeStoreAPI, + options: AgentNotificationToolsOptions = {} +): AiExtraTool[] { + const maxBatch = options.maxBatch ?? 20 + return [ + { + name: 'xnet_poll_notifications', + title: 'Poll the operator notification outbox', + description: + 'List pending AgentNotification nodes (hub→operator outbox). Pass markDelivered to acknowledge them after relaying to the operator.', + risk: 'low', + requiredScopes: ['agent.notifications'], + inputSchema: { + type: 'object', + properties: { + limit: { type: 'number', description: `Max entries (default ${maxBatch})` }, + markDelivered: { + type: 'boolean', + description: 'Mark returned notifications as delivered' + } + } + }, + invoke: async (args) => { + const limit = Math.min(readOptionalNumber(args, 'limit') ?? maxBatch, 100) + const nodes = await store.list({ + schemaId: AGENT_NOTIFICATION_SCHEMA_IRI, + limit: 500 + }) + const pending = nodes + .filter((n) => !n.deleted && n.properties.status === 'pending') + .sort((a, b) => a.createdAt - b.createdAt) + .slice(0, limit) + if (args.markDelivered === true) { + for (const node of pending) { + await store.update(node.id, { properties: { status: 'delivered' } }) + } + } + return { + notifications: pending.map((n) => ({ + id: n.id, + kind: n.properties.kind, + title: n.properties.title, + body: n.properties.body, + action: n.properties.action, + createdAt: n.createdAt + })) + } + } + } + ] +} diff --git a/packages/plugins/src/ai-surface/index.ts b/packages/plugins/src/ai-surface/index.ts index c57d5c644..5b180f35b 100644 --- a/packages/plugins/src/ai-surface/index.ts +++ b/packages/plugins/src/ai-surface/index.ts @@ -45,6 +45,24 @@ export { type AiValidator } from './validation' export { AiSurfaceService, createAiSurfaceService } from './service' +// Agent audit + ceremony (exploration 0337) +export { + AgentAuditRecorder, + hashNonce, + reversibilityForTool, + riskForTool, + type AgentAuditContext, + type AgentAuditRecorderConfig, + type AgentAuditSurface, + type AgentCallOutcome, + type AgentExecutedResult, + type AgentPendingApproval +} from './agent-audit' +export { + createAgentCeremonyTools, + createAgentNotificationTools, + type AgentNotificationToolsOptions +} from './agent-ceremony-tools' export { XNET_AGENT_SKILL_MD } from './skill' export { WRITING_XNET_PLUGINS_SKILL_MD } from './plugin-skill' export { flattenRowForTsv, toTsv } from './format' diff --git a/packages/plugins/src/ai-surface/types.ts b/packages/plugins/src/ai-surface/types.ts index 993fce11b..526c1c065 100644 --- a/packages/plugins/src/ai-surface/types.ts +++ b/packages/plugins/src/ai-surface/types.ts @@ -25,6 +25,8 @@ export type AiScope = | 'network.fetch' | 'agent.workspace.export' | 'agent.workspace.import' + | 'agent.approve' + | 'agent.notifications' export const AI_RISK_LEVELS: readonly AiRiskLevel[] = ['low', 'medium', 'high', 'critical'] @@ -46,7 +48,9 @@ export const AI_SCOPES: readonly AiScope[] = [ 'storage.recovery', 'network.fetch', 'agent.workspace.export', - 'agent.workspace.import' + 'agent.workspace.import', + 'agent.approve', + 'agent.notifications' ] export type AiTargetKind = diff --git a/packages/plugins/src/index.ts b/packages/plugins/src/index.ts index 2d6df9466..6f5aafe5d 100644 --- a/packages/plugins/src/index.ts +++ b/packages/plugins/src/index.ts @@ -685,6 +685,22 @@ export type { XNetPageFragmentReadOptions, XNetPageFragmentWriteOptions } from './ai-surface' +// Agent audit + ceremony (exploration 0337) +export { + AgentAuditRecorder, + createAgentCeremonyTools, + createAgentNotificationTools, + hashNonce, + reversibilityForTool, + riskForTool, + type AgentAuditContext, + type AgentAuditRecorderConfig, + type AgentAuditSurface, + type AgentCallOutcome, + type AgentExecutedResult, + type AgentNotificationToolsOptions, + type AgentPendingApproval +} from './ai-surface' // Services (Background process management) // Note: Node.js-only modules (LocalAPIServer, MCPServer, ProcessManager) are diff --git a/packages/plugins/src/services/local-api.ts b/packages/plugins/src/services/local-api.ts index 9e97e0481..4bf98719d 100644 --- a/packages/plugins/src/services/local-api.ts +++ b/packages/plugins/src/services/local-api.ts @@ -82,7 +82,12 @@ export interface NodeStoreAPI { get(id: string): Promise list(options?: { schemaId?: string; limit?: number; offset?: number }): Promise query?(descriptor: NodeQueryDescriptor): Promise - create(options: { schemaId: string; properties: Record }): Promise + create(options: { + /** Optional deterministic id (LWW upsert on collision — exploration 0337). */ + id?: string + schemaId: string + properties: Record + }): Promise update(id: string, options: { properties: Record }): Promise delete(id: string): Promise subscribe(listener: (event: NodeChangeEventData) => void): () => void diff --git a/packages/plugins/src/services/mcp-server.ts b/packages/plugins/src/services/mcp-server.ts index 0cfa82131..5ca76b58c 100644 --- a/packages/plugins/src/services/mcp-server.ts +++ b/packages/plugins/src/services/mcp-server.ts @@ -19,6 +19,11 @@ import { type AiSurfaceLimits, type AiToolDefinition } from '../ai-surface' +import { AgentAuditRecorder, type AgentAuditContext } from '../ai-surface/agent-audit' +import { + createAgentCeremonyTools, + createAgentNotificationTools +} from '../ai-surface/agent-ceremony-tools' import { McpWriteGuardrail, type McpWriteRequest } from './mcp-guardrail' /** Schema IRIs for the first-class write tools (exploration 0174/0175). */ @@ -143,6 +148,17 @@ export interface MCPServerConfig { name?: string /** Server version (default: '1.0.0') */ version?: string + /** + * Agent-scoped session (exploration 0337). When set, every AI-surface tool + * call routes through an {@link AgentAuditRecorder}: it lands as an + * `AgentAction` node and medium+ risk calls park behind the risk-tiered + * approval ceremony. Also exposes the ceremony (`xnet_approve`, + * `xnet_deny`, `xnet_pending_approvals`, `xnet_undo`) and outbox + * (`xnet_poll_notifications`) tools. The store this server was built with + * should be signing as the enrolled agent's DID — that is what makes the + * kernel change log the tamper-evident half of the trail. + */ + agentAudit?: AgentAuditContext & { approvalTtlMs?: number } } // ─── MCP Server Implementation ─────────────────────────────────────────────── @@ -181,6 +197,9 @@ export class MCPServer { private tools: Map = new Map() private aiToolNames: Set = new Set() private running = false + /** Present when `agentAudit` is configured (exploration 0337). */ + private recorder: AgentAuditRecorder | null = null + private agentExtraTools: Map = new Map() constructor(config: MCPServerConfig) { const aiSurface = @@ -203,6 +222,23 @@ export class MCPServer { name: config.name ?? 'xnet', version: config.version ?? '1.0.0' } + + if (config.agentAudit) { + const { approvalTtlMs, ...context } = config.agentAudit + this.recorder = new AgentAuditRecorder({ + surface: aiSurface, + store: config.store, + context, + approvalTtlMs + }) + for (const tool of [ + ...createAgentCeremonyTools(this.recorder), + ...createAgentNotificationTools(config.store) + ]) { + this.agentExtraTools.set(tool.name, tool) + } + } + this.registerTools() } @@ -529,9 +565,17 @@ export class MCPServer { this.tools.set(tool.name, toMCPTool(tool)) } + for (const tool of this.agentExtraTools.values()) { + const { invoke: _invoke, ...def } = tool + this.tools.set(tool.name, toMCPTool(def)) + } + for (const [name, tool] of this.tools) { tool.defer_loading = !MCP_CORE_TOOL_NAMES.includes(name) tool.inputSchema.properties.response_format = RESPONSE_FORMAT_SCHEMA + if (this.recorder && this.aiToolNames.has(name)) { + tool.inputSchema.properties._instruction = INSTRUCTION_SCHEMA + } } } @@ -660,8 +704,22 @@ export class MCPServer { } default: + if (this.agentExtraTools.has(name)) { + result = await this.agentExtraTools.get(name)!.invoke(toolArgs) + break + } if (this.aiToolNames.has(name)) { - result = await this.config.aiSurface.callTool(name, toolArgs) + if (this.recorder) { + const { _instruction, ...rest } = toolArgs + const outcome = await this.recorder.callTool( + name, + rest, + typeof _instruction === 'string' ? _instruction : undefined + ) + result = outcome.pending ? outcome : outcome.result + } else { + result = await this.config.aiSurface.callTool(name, toolArgs) + } break } throw new Error(`Unknown tool: ${name}`) @@ -766,6 +824,13 @@ const RESPONSE_FORMAT_SCHEMA: MCPPropertySchema = { description: 'Response verbosity. Defaults to concise (compact JSON).' } +/** Injected on AI tools when an agent-audit session is active (0337). */ +const INSTRUCTION_SCHEMA: MCPPropertySchema = { + type: 'string', + description: + "The operator's instruction that triggered this call, verbatim — recorded in the AgentAction audit trail." +} + const CONFIRM_SCHEMA: MCPPropertySchema = { type: 'boolean', description: diff --git a/packages/plugins/src/testing/memory-backend.ts b/packages/plugins/src/testing/memory-backend.ts index 7505fccac..8cff7ddbf 100644 --- a/packages/plugins/src/testing/memory-backend.ts +++ b/packages/plugins/src/testing/memory-backend.ts @@ -29,12 +29,17 @@ export function createMemoryNodeStore(initialNodes: NodeData[]): MemoryNodeStore }, create: async (options) => { counter += 1 + const id = options.id ?? `node-${nodes.size + 1}-${counter}` + const existing = nodes.get(id) const node: NodeData = { - id: `node-${nodes.size + 1}-${counter}`, + id, schemaId: options.schemaId, - properties: options.properties, + // Deterministic-id retries LWW-upsert like the real store. + properties: existing + ? { ...existing.properties, ...options.properties } + : options.properties, deleted: false, - createdAt: 1, + createdAt: existing?.createdAt ?? 1, updatedAt: 1000 + counter } nodes.set(node.id, node) From eff45e1133ef459dc2eadf64b52f4d4e17d84dc3 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:13:15 -0700 Subject: [PATCH 06/12] feat(cli): xnet agent enroll + agent-scoped mcp serve with agent-signed local store (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 4 +- packages/cli/src/cli.ts | 2 + packages/cli/src/commands/enroll.test.ts | 153 +++++++++++++ packages/cli/src/commands/enroll.ts | 210 ++++++++++++++++++ packages/cli/src/commands/mcp.ts | 82 ++++++- packages/cli/src/utils/agent-local.ts | 131 +++++++++++ packages/cli/src/utils/agent-passport-file.ts | 77 +++++++ 7 files changed, 651 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/commands/enroll.test.ts create mode 100644 packages/cli/src/commands/enroll.ts create mode 100644 packages/cli/src/utils/agent-local.ts create mode 100644 packages/cli/src/utils/agent-passport-file.ts diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index 721b8eb0f..120a8cf33 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -598,13 +598,13 @@ agent→ "That's destructive (risk: critical). I can't take chat Phase 1 — Agent Passport (identity + capability): -- [ ] `xnet agent enroll --runtime openclaw|hermes|other` CLI: +- [x] `xnet agent enroll --runtime openclaw|hermes|other` CLI: generates agent `did:key`, mints operator-signed scoped UCAN, prints gateway config snippet (extends `packages/cli/src/commands/mcp.ts` pairing output) - [x] Store passports as `AgentPassport` nodes (schema in `packages/data/src/schema/schemas/`, registered in `schemas/index.ts`) -- [ ] MCP server accepts agent-scoped auth: tool calls execute against a +- [x] MCP server accepts agent-scoped auth: tool calls execute against a store identity = agent DID (writes signed by agent key held locally by `xnet mcp serve`, never by the gateway) - [x] Hub: derive `AuthSession.capabilities` from presented UCAN instead of diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9c01726e2..0c0e0f018 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -25,6 +25,7 @@ import { registerCodeCommand } from './commands/code.js' import { registerConnectorCommand } from './commands/connector.js' import { registerDataCommand } from './commands/data.js' import { registerDoctorCommand } from './commands/doctor.js' +import { registerAgentEnrollCommand } from './commands/enroll.js' import { registerMcpCommand } from './commands/mcp.js' import { registerMigrateCommand } from './commands/migrate.js' import { registerPluginCommand } from './commands/plugin.js' @@ -40,6 +41,7 @@ registerMigrateCommand(program) registerSchemaCommand(program) registerDoctorCommand(program) registerAgentCommands(program) +registerAgentEnrollCommand(program) registerMcpCommand(program) registerBridgeCommand(program) registerCodeCommand(program) diff --git a/packages/cli/src/commands/enroll.test.ts b/packages/cli/src/commands/enroll.test.ts new file mode 100644 index 000000000..64358208e --- /dev/null +++ b/packages/cli/src/commands/enroll.test.ts @@ -0,0 +1,153 @@ +/** + * `xnet agent enroll` + agent-scoped `mcp serve` (exploration 0337). + */ + +import { mkdtemp, readFile, stat } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { generateIdentity, verifyAgentPassport } from '@xnetjs/identity' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { runEnroll, openClawStdioSnippet, hermesStdioSnippet } from './enroll' +import { startMcpServe } from './mcp' +import { + bytesToHex, + loadAgentPassportFile +} from '../utils/agent-passport-file.js' + +let dir: string +const operator = generateIdentity() + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'xnet-agents-')) + process.env.XNET_AGENT_DIR = dir +}) + +afterEach(() => { + delete process.env.XNET_AGENT_DIR +}) + +const enroll = (name = 'homeclaw') => + runEnroll(name, { + runtime: 'openclaw', + space: ['inbox'], + can: ['node/create', 'node/update'], + ttlDays: 7, + key: bytesToHex(operator.privateKey), + node: false + }) + +describe('xnet agent enroll (exploration 0337)', () => { + it('mints and persists a passport with a verifying, scoped delegation', async () => { + const result = await enroll() + expect(result.passport.agentDID).toMatch(/^did:key:z/) + expect(result.passport.operatorDID).toBe(operator.identity.did) + expect(result.passport.capabilities).toEqual([ + { with: 'xnet://space/inbox', can: 'node/create' }, + { with: 'xnet://space/inbox', can: 'node/update' } + ]) + + const verified = verifyAgentPassport(result.passport.ucan, { + agentDID: result.passport.agentDID, + operatorDID: operator.identity.did + }) + expect(verified.valid).toBe(true) + + // Reloadable, and the key file is 0600. + const loaded = await loadAgentPassportFile('homeclaw') + expect(loaded?.agentDID).toBe(result.passport.agentDID) + const mode = (await stat(result.path)).mode & 0o777 + expect(mode).toBe(0o600) + }) + + it('requires an operator key and at least one space', async () => { + await expect( + runEnroll('x', { + runtime: 'other', + space: ['inbox'], + can: ['node/create'], + ttlDays: 7, + node: false + }) + ).rejects.toThrow(/signing key required/) + await expect( + runEnroll('x', { + runtime: 'other', + space: [], + can: ['node/create'], + ttlDays: 7, + key: bytesToHex(operator.privateKey), + node: false + }) + ).rejects.toThrow(/--space/) + }) + + it('emits OpenClaw and Hermes snippets pointing at the same serve command', () => { + expect(JSON.parse(openClawStdioSnippet('homeclaw')).mcp.servers.xnet.args).toEqual([ + 'mcp', + 'serve', + '--agent', + 'homeclaw' + ]) + expect(JSON.parse(hermesStdioSnippet('homeclaw')).mcpServers.xnet.args).toEqual([ + 'mcp', + 'serve', + '--agent', + 'homeclaw' + ]) + }) + + it('the passport JSON never contains the operator private key', async () => { + const result = await enroll('leakcheck') + const raw = await readFile(result.path, 'utf8') + expect(raw).not.toContain(bytesToHex(operator.privateKey)) + }) +}) + +describe('xnet mcp serve --agent (exploration 0337)', () => { + it('an agent-signed local backend records tool calls as AgentAction nodes', async () => { + const result = await enroll('served') + const { createLocalAgentBackend } = await import('../utils/agent-local.js') + const { hexToBytes } = await import('../utils/agent-passport-file.js') + const { buildMcpServer } = await import('./mcp') + + const backend = await createLocalAgentBackend({ + agentKey: hexToBytes(result.passport.agentKeyHex) + }) + expect(backend.agentDID).toBe(result.passport.agentDID) + + const server = buildMcpServer(backend, { passport: result.passport }) + const names = server.getTools().map((t) => t.name) + expect(names).toContain('xnet_approve') + expect(names).toContain('xnet_poll_notifications') + + const response = await server.handleRequest({ + jsonrpc: '2.0', + id: 1, + method: 'tools/call', + params: { + name: 'xnet_search', + arguments: { query: 'anything', _instruction: 'look around' } + } + }) + expect(response.error).toBeUndefined() + + const actions = await backend.store.list({ + schemaId: 'xnet://xnet.fyi/AgentAction@1.0.0' + }) + expect(actions).toHaveLength(1) + expect(actions[0].properties).toMatchObject({ + tool: 'xnet_search', + status: 'applied', + instruction: 'look around' + }) + await backend.client.destroy() + }) + + it('refuses an unknown passport', async () => { + await expect( + startMcpServe(async () => ({ store: {} as never, schemas: {} as never }), { + agent: 'nope' + }) + ).rejects.toThrow(/No passport/) + }) +}) diff --git a/packages/cli/src/commands/enroll.ts b/packages/cli/src/commands/enroll.ts new file mode 100644 index 000000000..0147b0db8 --- /dev/null +++ b/packages/cli/src/commands/enroll.ts @@ -0,0 +1,210 @@ +/** + * `xnet agent enroll` — mint an Agent Passport (exploration 0337). + * + * Generates a fresh `did:key` for an external agent (OpenClaw, Hermes, Claude + * Code, …) and delegates it a narrow, operator-signed UCAN. The passport is + * saved to `~/.xnet/agents/.json` (0600) and, when the local API is + * reachable, recorded as an `AgentPassport` node so the workspace knows the + * agent exists. Prints ready-to-paste gateway config for both OpenClaw and + * Hermes — they consume the same MCP surface. + * + * The agent key never goes to the gateway: `xnet mcp serve --agent ` + * loads it locally and signs there. + */ + +import { getSigningPublicKeyFromPrivate } from '@xnetjs/crypto' +import { agentPassportId } from '@xnetjs/data' +import { createDID, mintAgentPassport } from '@xnetjs/identity' +import { Command } from 'commander' +import { createRemoteAgentBackend } from '../utils/agent-remote.js' +import { + bytesToHex, + hexToBytes, + listAgentPassportNames, + saveAgentPassportFile, + type AgentPassportFile +} from '../utils/agent-passport-file.js' + +const AGENT_PASSPORT_SCHEMA_IRI = 'xnet://xnet.fyi/AgentPassport@1.0.0' + +const RUNTIMES = ['openclaw', 'hermes', 'claude-code', 'other'] as const +type Runtime = (typeof RUNTIMES)[number] + +export type EnrollOptions = { + runtime: Runtime + space: string[] + can: string[] + ttlDays: number + key?: string + apiUrl?: string + auditSpace?: string + node: boolean +} + +export type EnrollResult = { + passport: AgentPassportFile + path: string + nodeCreated: boolean + snippets: { openclaw: string; hermes: string } +} + +/** OpenClaw `mcp.servers` entry (stdio — process isolation, no network surface). */ +export const openClawStdioSnippet = (name: string): string => + JSON.stringify( + { + mcp: { + servers: { + xnet: { + command: 'xnet', + args: ['mcp', 'serve', '--agent', name], + transport: 'stdio' + } + } + } + }, + null, + 2 + ) + +/** Hermes Agent MCP entry — same server, same stdio contract. */ +export const hermesStdioSnippet = (name: string): string => + JSON.stringify( + { + mcpServers: { + xnet: { command: 'xnet', args: ['mcp', 'serve', '--agent', name] } + } + }, + null, + 2 + ) + +export async function runEnroll(name: string, options: EnrollOptions): Promise { + const keyHex = options.key ?? process.env.XNET_SIGNING_KEY + if (!keyHex) { + throw new Error( + 'Operator signing key required: pass --key or set $XNET_SIGNING_KEY (an ephemeral operator would break the trust chain)' + ) + } + if (options.space.length === 0) { + throw new Error('At least one --space is required (passports are always scoped)') + } + + const operatorKey = hexToBytes(keyHex) + const operatorDID = createDID(getSigningPublicKeyFromPrivate(operatorKey)) + + const capabilities = options.space.flatMap((space) => + options.can.map((can) => ({ with: `xnet://space/${space}`, can })) + ) + + const grant = mintAgentPassport({ + operatorDID, + operatorKey, + capabilities, + ttlSeconds: options.ttlDays * 24 * 3600 + }) + + const passport: AgentPassportFile = { + name, + runtime: options.runtime, + agentDID: grant.agentDID, + operatorDID, + agentKeyHex: bytesToHex(grant.agentKey), + ucan: grant.ucan, + expiresAt: grant.expiresAt, + capabilities, + createdAt: Date.now() + } + const path = await saveAgentPassportFile(passport) + + let nodeCreated = false + if (options.node) { + try { + const backend = await createRemoteAgentBackend( + options.apiUrl ? { apiUrl: options.apiUrl } : {} + ) + await backend.store.create({ + id: agentPassportId(grant.agentDID), + schemaId: AGENT_PASSPORT_SCHEMA_IRI, + properties: { + ...(options.auditSpace ? { space: options.auditSpace } : {}), + agentDID: grant.agentDID, + operatorDID, + displayName: name, + runtime: options.runtime, + ucan: grant.ucan, + expiresAt: grant.expiresAt, + status: 'active' + } + }) + nodeCreated = true + } catch { + // The workspace API being down must not block enrollment; the node can + // be recorded on the next serve. + } + } + + return { + passport, + path, + nodeCreated, + snippets: { openclaw: openClawStdioSnippet(name), hermes: hermesStdioSnippet(name) } + } +} + +export function registerAgentEnrollCommand(program: Command): void { + const agent = program + .command('agent') + .description('Enroll and manage external agent passports (exploration 0337)') + + agent + .command('enroll ') + .description('Mint a scoped Agent Passport (own DID + operator-delegated UCAN)') + .option('--runtime ', `Agent runtime: ${RUNTIMES.join('|')}`, 'other') + .option('--space ', 'Space ids the agent may write (repeatable)', []) + .option('--can ', 'Delegated actions', ['node/create', 'node/update']) + .option('--ttl-days ', 'Passport lifetime in days (rotate weekly)', parseFloatOption, 7) + .option('--key ', 'Operator Ed25519 signing key (hex); falls back to $XNET_SIGNING_KEY') + .option('--audit-space ', 'Space to home the AgentPassport node in') + .option('--api-url ', 'xNet local API URL (default http://127.0.0.1:31415)') + .option('--no-node', 'Skip recording the AgentPassport node in the workspace') + .action(async (name: string, options: EnrollOptions) => { + if (!RUNTIMES.includes(options.runtime)) { + throw new Error(`Unknown runtime: ${options.runtime} (use ${RUNTIMES.join('|')})`) + } + const result = await runEnroll(name, options) + console.log(`Agent passport minted: ${result.passport.agentDID}`) + console.log(` runtime: ${result.passport.runtime}`) + console.log(` expires: ${new Date(result.passport.expiresAt).toISOString()}`) + console.log(` saved: ${result.path} (0600 — contains the agent's private key)`) + console.log( + ` workspace node: ${result.nodeCreated ? 'recorded' : 'skipped (API unreachable or --no-node)'}` + ) + console.log('\nCapabilities:') + for (const cap of result.passport.capabilities) { + console.log(` ${cap.can} ${cap.with}`) + } + console.log('\nOpenClaw (~/.openclaw/openclaw.json):\n' + result.snippets.openclaw) + console.log('\nHermes Agent:\n' + result.snippets.hermes) + console.log( + `\nHub: add the operator DID to trustedDids so self-issued tokens are rejected:\n trustedDids: ["${result.passport.operatorDID}"]` + ) + }) + + agent + .command('list') + .description('List enrolled agent passports') + .action(async () => { + const names = await listAgentPassportNames() + if (names.length === 0) { + console.log('No agent passports enrolled (xnet agent enroll --space )') + return + } + for (const name of names) console.log(name) + }) +} + +function parseFloatOption(value: string): number { + const parsed = Number.parseFloat(value) + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`Invalid number: ${value}`) + return parsed +} diff --git a/packages/cli/src/commands/mcp.ts b/packages/cli/src/commands/mcp.ts index 8b9850233..7a835f156 100644 --- a/packages/cli/src/commands/mcp.ts +++ b/packages/cli/src/commands/mcp.ts @@ -24,6 +24,12 @@ import { type McpHttpServerHandle } from '@xnetjs/plugins/node' import { Command } from 'commander' +import { createLocalAgentBackend } from '../utils/agent-local.js' +import { + hexToBytes, + loadAgentPassportFile, + type AgentPassportFile +} from '../utils/agent-passport-file.js' import { createRemoteAgentBackend, type AgentBackend } from '../utils/agent-remote.js' export type McpBackendFactory = (options: { @@ -33,9 +39,27 @@ export type McpBackendFactory = (options: { const defaultBackendFactory: McpBackendFactory = (options) => createRemoteAgentBackend(options) +export type McpAgentSession = { + passport: AgentPassportFile + auditSpaceId?: string +} + /** Build an MCP server over a resolved backend. Writes route through its AI surface. */ -export function buildMcpServer(backend: AgentBackend): MCPServer { - return createMCPServer({ store: backend.store, schemas: backend.schemas }) +export function buildMcpServer(backend: AgentBackend, agent?: McpAgentSession): MCPServer { + return createMCPServer({ + store: backend.store, + schemas: backend.schemas, + ...(agent + ? { + agentAudit: { + agentDID: agent.passport.agentDID, + sessionKey: `${agent.passport.runtime}:${agent.passport.name}`, + channel: 'other', + ...(agent.auditSpaceId ? { spaceId: agent.auditSpaceId } : {}) + } + } + : {}) + }) } export type McpServeOptions = { @@ -45,6 +69,12 @@ export type McpServeOptions = { allowOrigin?: string[] pairingToken?: string apiUrl?: string + /** Enrolled agent passport name (exploration 0337). */ + agent?: string + /** Local SQLite path — serve over an agent-signed local store. */ + db?: string + /** Space id the audit records are homed in. */ + auditSpace?: string } export type McpServeHandle = { @@ -63,10 +93,47 @@ export async function startMcpServe( backendFactory: McpBackendFactory, options: McpServeOptions ): Promise { - const backend = await backendFactory({ - ...(options.apiUrl ? { apiUrl: options.apiUrl } : {}) - }) - const server = buildMcpServer(backend) + let agent: McpAgentSession | undefined + let backend: AgentBackend + if (options.agent) { + const passport = await loadAgentPassportFile(options.agent) + if (!passport) { + throw new Error( + `No passport for agent "${options.agent}" (run: xnet agent enroll ${options.agent} --space )` + ) + } + if (passport.expiresAt <= Date.now()) { + throw new Error( + `Passport for "${options.agent}" expired ${new Date(passport.expiresAt).toISOString()} — re-enroll to rotate` + ) + } + agent = { + passport, + ...(options.auditSpace ? { auditSpaceId: options.auditSpace } : {}) + } + if (options.db) { + // Agent-signed local store: every write lands in the change log signed + // by the agent DID — the tamper-evident half of the audit trail. + backend = await createLocalAgentBackend({ + db: options.db, + agentKey: hexToBytes(passport.agentKeyHex) + }) + } else { + // Remote-API backend: audit nodes still record the trail, but writes + // are signed by the app's identity, not the agent's. + console.error( + 'warning: --agent without --db serves over the local API; writes are signed by the app identity, not the agent DID' + ) + backend = await backendFactory({ + ...(options.apiUrl ? { apiUrl: options.apiUrl } : {}) + }) + } + } else { + backend = await backendFactory({ + ...(options.apiUrl ? { apiUrl: options.apiUrl } : {}) + }) + } + const server = buildMcpServer(backend, agent) if (options.http) { const http = createMcpHttpServer({ @@ -128,6 +195,9 @@ export function registerMcpCommand( ) .option('--pairing-token ', 'Shared secret for --http (generated if omitted)') .option('--api-url ', 'xNet local API URL (default http://127.0.0.1:31415)') + .option('--agent ', 'Serve as an enrolled agent passport (exploration 0337)') + .option('--db ', 'With --agent: agent-signed local SQLite store') + .option('--audit-space ', 'With --agent: Space to home audit records in') .action(async (options: McpServeOptions) => { const handle = await startMcpServe(backendFactory, options) if (handle.mode === 'http' && handle.http) { diff --git a/packages/cli/src/utils/agent-local.ts b/packages/cli/src/utils/agent-local.ts new file mode 100644 index 000000000..3efcf7cf9 --- /dev/null +++ b/packages/cli/src/utils/agent-local.ts @@ -0,0 +1,131 @@ +/** + * Local, agent-signed backend for `xnet mcp serve --agent --db ` + * (exploration 0337). + * + * Builds a framework-agnostic runtime client whose signing identity IS the + * enrolled agent's DID, then adapts its NodeStore to the `NodeStoreAPI` the + * AI surface expects. Every write the MCP server performs lands in the kernel + * change log signed by the agent key — the tamper-evident half of the audit + * trail. Contrast with the remote-API backend, where writes are signed by the + * app's own identity. + */ + +import type { DID } from '@xnetjs/core' +import type { NodeStorageAdapter, SchemaIRI } from '@xnetjs/data' +import type { NodeData, NodeStoreAPI, SchemaData, SchemaRegistryAPI } from '@xnetjs/plugins/node' +import type { AgentBackend } from './agent-remote.js' +import { getSigningPublicKeyFromPrivate } from '@xnetjs/crypto' +import { SQLiteNodeStorageAdapter, builtInSchemas } from '@xnetjs/data' +import { createDID } from '@xnetjs/identity' +import { createXNetClient, type XNetClient } from '@xnetjs/runtime' + +export type LocalAgentBackendOptions = { + /** SQLite file path; in-memory (ephemeral) when omitted. */ + db?: string + /** The agent's Ed25519 private key. */ + agentKey: Uint8Array +} + +export type LocalAgentBackend = AgentBackend & { + client: XNetClient + agentDID: string +} + +const toNodeData = (node: { + id: string + schemaId: string + properties: Record + deleted?: boolean + createdAt?: number + updatedAt?: number +}): NodeData => ({ + id: node.id, + schemaId: node.schemaId, + properties: node.properties, + deleted: node.deleted ?? false, + createdAt: node.createdAt ?? 0, + updatedAt: node.updatedAt ?? 0 +}) + +async function resolveStorage(db?: string): Promise { + if (db) { + const { createElectronSQLiteAdapter } = await import('@xnetjs/sqlite/electron') + const adapter = await createElectronSQLiteAdapter({ + path: db, + busyTimeout: 5000, + foreignKeys: true, + walMode: true + }) + return new SQLiteNodeStorageAdapter(adapter) + } + const { createMemorySQLiteAdapter } = await import('@xnetjs/sqlite/memory') + const adapter = await createMemorySQLiteAdapter() + return new SQLiteNodeStorageAdapter(adapter) +} + +function builtInSchemaRegistry(): SchemaRegistryAPI { + const iris = Object.keys(builtInSchemas).filter((iri) => iri.includes('@')) + return { + getAllIRIs: () => iris, + get: async (iri: string): Promise => { + const loader = (builtInSchemas as Record Promise>)[iri] + if (!loader) return null + const schema = (await loader()) as { + schema: { '@id': string; name: string; properties: unknown } + } + return { + iri, + name: schema.schema.name, + properties: schema.schema.properties as Record + } + } + } +} + +export async function createLocalAgentBackend( + options: LocalAgentBackendOptions +): Promise { + const agentDID = createDID(getSigningPublicKeyFromPrivate(options.agentKey)) as DID + const nodeStorage = await resolveStorage(options.db) + const client = await createXNetClient({ + nodeStorage, + authorDID: agentDID, + signingKey: options.agentKey + }) + + const store: NodeStoreAPI = { + get: async (id) => { + const node = await client.store.get(id) + return node ? toNodeData(node) : null + }, + list: async (opts) => { + const nodes = await client.store.list({ + ...(opts?.schemaId ? { schemaId: opts.schemaId as SchemaIRI } : {}), + ...(opts?.limit !== undefined ? { limit: opts.limit } : {}), + ...(opts?.offset !== undefined ? { offset: opts.offset } : {}) + }) + return nodes.map(toNodeData) + }, + create: async (opts) => { + const node = await client.store.create({ + ...(opts.id ? { id: opts.id } : {}), + schemaId: opts.schemaId as SchemaIRI, + properties: opts.properties + }) + return toNodeData(node) + }, + update: async (id, opts) => { + const node = await client.store.update(id, { properties: opts.properties }) + return toNodeData(node) + }, + delete: async (id) => { + await client.store.delete(id) + }, + subscribe: (listener) => + client.store.subscribe((event: { change: { type: string } }) => { + listener({ change: { type: event.change.type }, node: null, isRemote: false }) + }) + } + + return { store, schemas: builtInSchemaRegistry(), client, agentDID } +} diff --git a/packages/cli/src/utils/agent-passport-file.ts b/packages/cli/src/utils/agent-passport-file.ts new file mode 100644 index 000000000..58c9566d9 --- /dev/null +++ b/packages/cli/src/utils/agent-passport-file.ts @@ -0,0 +1,77 @@ +/** + * Agent passport files (exploration 0337). + * + * `xnet agent enroll` persists the minted passport — the agent's DID, its + * private signing key, and the operator-delegated UCAN — to + * `~/.xnet/agents/.json` (0600). `xnet mcp serve --agent ` loads + * it so the serve process signs as the agent; the key never reaches the + * gateway (OpenClaw/Hermes only ever see the MCP transport). + */ + +import { mkdir, readFile, writeFile, chmod, readdir } from 'node:fs/promises' +import { homedir } from 'node:os' +import { join } from 'node:path' + +export type AgentPassportFile = { + name: string + runtime: 'openclaw' | 'hermes' | 'claude-code' | 'other' + agentDID: string + operatorDID: string + /** The agent's Ed25519 private key, hex. Stays on this machine. */ + agentKeyHex: string + /** Operator-signed delegation (UCAN JWT). */ + ucan: string + expiresAt: number + capabilities: Array<{ with: string; can: string }> + createdAt: number +} + +export function agentPassportDir(): string { + return process.env.XNET_AGENT_DIR ?? join(homedir(), '.xnet', 'agents') +} + +const passportPath = (name: string): string => { + if (!/^[a-zA-Z0-9_-]+$/.test(name)) { + throw new Error(`Invalid agent name: ${name} (use letters, digits, - and _)`) + } + return join(agentPassportDir(), `${name}.json`) +} + +export async function saveAgentPassportFile(file: AgentPassportFile): Promise { + const dir = agentPassportDir() + await mkdir(dir, { recursive: true, mode: 0o700 }) + const path = passportPath(file.name) + await writeFile(path, JSON.stringify(file, null, 2) + '\n', { mode: 0o600 }) + // mkdir/writeFile modes are masked by umask; enforce explicitly. + await chmod(path, 0o600) + return path +} + +export async function loadAgentPassportFile(name: string): Promise { + try { + const raw = await readFile(passportPath(name), 'utf8') + return JSON.parse(raw) as AgentPassportFile + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null + throw err + } +} + +export async function listAgentPassportNames(): Promise { + try { + const entries = await readdir(agentPassportDir()) + return entries.filter((e) => e.endsWith('.json')).map((e) => e.slice(0, -5)) + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw err + } +} + +export function bytesToHex(bytes: Uint8Array): string { + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') +} + +export function hexToBytes(hex: string): Uint8Array { + const clean = hex.startsWith('0x') ? hex.slice(2) : hex + return new Uint8Array(Buffer.from(clean, 'hex')) +} From c8194cb8a4c6daa487865e8959f79bfb4fb96c57 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:16:45 -0700 Subject: [PATCH 07/12] =?UTF-8?q?feat(devtools):=20Agent=20Audit=20panel?= =?UTF-8?q?=20=E2=80=94=20per-agent=20action=20table=20with=20approval=20t?= =?UTF-8?q?rail=20(0337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 2 +- .../AgentAuditPanel/AgentAuditPanel.tsx | 164 ++++++++++++++++ .../AgentAuditPanel/agent-audit-panel.test.ts | 73 +++++++ .../panels/AgentAuditPanel/useAgentAudit.ts | 181 ++++++++++++++++++ packages/devtools/src/panels/Shell.tsx | 3 + .../devtools/src/panels/panel-registry.ts | 10 + .../devtools/src/provider/DevToolsContext.ts | 1 + 7 files changed, 433 insertions(+), 1 deletion(-) create mode 100644 packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx create mode 100644 packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts create mode 100644 packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index 120a8cf33..d232ec3ae 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -619,7 +619,7 @@ Phase 2 — Audit trail: `AgentAction` per call, linked `changeIds`) - [x] Per-author index + query: hub storage index on change author, `GET /audit/authors/:did/changes?since=` (paginated), UCAN-gated -- [ ] Workbench audit console: table view over `AgentAction` filtered by +- [x] Workbench audit console: table view over `AgentAction` filtered by agent DID, with per-action change diffs (reuse DebugReport console patterns from 0315) - [x] `xnet_undo ` honoring `reversibility` (compensating diff --git a/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx b/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx new file mode 100644 index 000000000..3036cfd31 --- /dev/null +++ b/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx @@ -0,0 +1,164 @@ +/** + * Agent audit console (exploration 0337) — the table view over `AgentAction` + * nodes, filterable by agent DID, with the approval decision and produced + * change ids per action. The DebugReport treatment (0315) applied to agents: + * the workbench IS the audit console. + */ + +import { useAgentAudit, type AgentActionRow } from './useAgentAudit' + +const RISK_COLORS: Record = { + low: 'text-green-600', + medium: 'text-yellow-600', + high: 'text-orange-600', + critical: 'text-red-600' +} + +const STATUS_COLORS: Record = { + proposed: 'text-ink-3', + 'pending-approval': 'text-yellow-600', + approved: 'text-blue-600', + denied: 'text-red-600', + applied: 'text-green-600', + 'rolled-back': 'text-purple-600', + failed: 'text-red-600' +} + +export function AgentAuditPanel() { + const state = useAgentAudit() + + if (!state.loading && state.rows.length === 0 && state.agents.length === 0) { + return ( +
+
+
No agent activity yet
+
+ Enroll an agent (xnet agent enroll <name> --space <id>) and + serve it with xnet mcp serve --agent <name>. Every guarded tool + call lands here as an AgentAction node. +
+
+
+ ) + } + + return ( +
+
+ Agent + + {state.rows.length} actions +
+ +
+
+ + + + + + + + + + + + + {state.rows.map((row) => ( + state.setSelectedId(row.id === state.selected?.id ? null : row.id)} + className={`cursor-pointer border-b border-hairline/50 hover:bg-surface-2 ${ + state.selected?.id === row.id ? 'bg-surface-2' : '' + }`} + > + + + + + + + + ))} + +
TimeToolRiskStatusApprovalChanges
+ {row.createdAt ? new Date(row.createdAt).toLocaleTimeString() : '—'} + {row.tool}{row.risk}{row.status} + {row.approval ? `${row.approval.decision} (${row.approval.surface})` : '—'} + {row.changeIds.length}
+
+ + {state.selected && } +
+
+ ) +} + +function labelForAgent( + did: string, + passports: Array<{ agentDID: string; displayName: string; runtime: string }> +): string { + const passport = passports.find((p) => p.agentDID === did) + if (!passport) return shortDid(did) + return `${passport.displayName || shortDid(did)} (${passport.runtime})` +} + +const shortDid = (did: string): string => (did.length > 24 ? `${did.slice(0, 24)}…` : did) + +function DetailPane({ row }: { row: AgentActionRow }) { + return ( +
+
{row.id}
+ + + + + {row.instruction && } + {row.error && } + {row.approval && ( + <> + + {row.approval.approverDID && ( + + )} + {row.approval.peer && } + + )} + {row.changeIds.length > 0 && ( +
+
+ Change ids +
+ {row.changeIds.map((id) => ( +
+ {id} +
+ ))} +
+ )} +
+ ) +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+
{label}
+
{value}
+
+ ) +} diff --git a/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts b/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts new file mode 100644 index 000000000..a8c6b15d0 --- /dev/null +++ b/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts @@ -0,0 +1,73 @@ +/** + * Agent audit console row assembly (exploration 0337). + */ + +import type { NodeState } from '@xnetjs/data' +import { describe, expect, it } from 'vitest' +import { buildRows } from './useAgentAudit' + +const node = ( + id: string, + schemaSuffix: string, + properties: Record +): NodeState => + ({ + id, + schemaId: `xnet://xnet.fyi/${schemaSuffix}@1.0.0`, + properties, + deleted: false + }) as unknown as NodeState + +describe('agent audit rows', () => { + it('joins actions with approvals and session channel, newest first', () => { + const actions = [ + node('a1', 'AgentAction', { + createdAt: 100, + createdBy: 'did:key:zAgent', + session: 's1', + tool: 'xnet_search', + risk: 'low', + status: 'applied', + reversibility: 'compensatable', + changeIds: [] + }), + node('a2', 'AgentAction', { + createdAt: 200, + createdBy: 'did:key:zAgent', + session: 's1', + tool: 'xnet_plan_page_patch', + risk: 'medium', + status: 'applied', + reversibility: 'reversible', + changeIds: ['c1', 'c2'] + }) + ] + const approvals = [ + node('ap1', 'AgentApproval', { + action: 'a2', + surface: 'chat', + decision: 'approved', + peer: 'tg-1' + }) + ] + const sessions = [node('s1', 'AgentSession', { channel: 'telegram' })] + + const rows = buildRows(actions, approvals, sessions) + expect(rows.map((r) => r.id)).toEqual(['a2', 'a1']) + expect(rows[0]).toMatchObject({ + tool: 'xnet_plan_page_patch', + channel: 'telegram', + changeIds: ['c1', 'c2'], + approval: { surface: 'chat', decision: 'approved', peer: 'tg-1' } + }) + expect(rows[1].approval).toBeNull() + }) + + it('drops deleted actions', () => { + const deleted = { + ...node('a1', 'AgentAction', { createdAt: 1, session: 's1' }), + deleted: true + } as NodeState + expect(buildRows([deleted], [], [])).toEqual([]) + }) +}) diff --git a/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts b/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts new file mode 100644 index 000000000..79566ed93 --- /dev/null +++ b/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts @@ -0,0 +1,181 @@ +/** + * Agent audit console hook (exploration 0337). + * + * Reads the agent audit trail — `AgentAction` nodes plus their + * `AgentApproval` decisions and `AgentSession` context — straight from the + * store, live via `store.subscribe` (debounced, no polling). Filterable by + * agent DID (`createdBy`), so "everything agent X did" is one click. + */ + +import type { NodeState, NodeStore } from '@xnetjs/data' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useDevTools } from '../../provider/useDevTools' + +const ACTION_IRI = 'xnet://xnet.fyi/AgentAction@1.0.0' +const APPROVAL_IRI = 'xnet://xnet.fyi/AgentApproval@1.0.0' +const SESSION_IRI = 'xnet://xnet.fyi/AgentSession@1.0.0' +const PASSPORT_IRI = 'xnet://xnet.fyi/AgentPassport@1.0.0' + +const LIVE_DEBOUNCE_MS = 250 +const LIST_LIMIT = 500 + +export type AgentActionRow = { + id: string + createdAt: number + agentDID: string + session: string + channel: string + tool: string + instruction: string + risk: string + status: string + reversibility: string + changeIds: string[] + error: string | null + approval: { + surface: string + decision: string + approverDID: string | null + peer: string | null + } | null +} + +export type AgentAuditState = { + rows: AgentActionRow[] + agents: string[] + agentFilter: string | null + setAgentFilter: (did: string | null) => void + passports: Array<{ agentDID: string; displayName: string; runtime: string; status: string }> + loading: boolean + selected: AgentActionRow | null + setSelectedId: (id: string | null) => void +} + +const str = (value: unknown, fallback = ''): string => + typeof value === 'string' ? value : fallback + +const buildRows = ( + actions: NodeState[], + approvals: NodeState[], + sessions: NodeState[] +): AgentActionRow[] => { + const approvalByAction = new Map( + approvals.map((a) => [str(a.properties.action), a] as const) + ) + const sessionById = new Map(sessions.map((s) => [s.id, s] as const)) + + return actions + .filter((n) => !n.deleted) + .map((n) => { + const approval = approvalByAction.get(n.id) ?? null + const session = sessionById.get(str(n.properties.session)) + return { + id: n.id, + createdAt: Number(n.properties.createdAt ?? 0), + agentDID: str(n.properties.createdBy, 'unknown'), + session: str(n.properties.session), + channel: str(session?.properties.channel, 'other'), + tool: str(n.properties.tool), + instruction: str(n.properties.instruction), + risk: str(n.properties.risk, 'low'), + status: str(n.properties.status, 'proposed'), + reversibility: str(n.properties.reversibility, 'compensatable'), + changeIds: Array.isArray(n.properties.changeIds) + ? (n.properties.changeIds as string[]) + : [], + error: typeof n.properties.error === 'string' ? n.properties.error : null, + approval: approval + ? { + surface: str(approval.properties.surface), + decision: str(approval.properties.decision), + approverDID: + typeof approval.properties.approverDID === 'string' + ? approval.properties.approverDID + : null, + peer: + typeof approval.properties.peer === 'string' ? approval.properties.peer : null + } + : null + } + }) + .sort((a, b) => b.createdAt - a.createdAt) +} + +async function loadAudit(store: NodeStore) { + const [actions, approvals, sessions, passports] = await Promise.all([ + store.list({ schemaId: ACTION_IRI, limit: LIST_LIMIT }), + store.list({ schemaId: APPROVAL_IRI, limit: LIST_LIMIT }), + store.list({ schemaId: SESSION_IRI, limit: LIST_LIMIT }), + store.list({ schemaId: PASSPORT_IRI, limit: 100 }) + ] as [Promise, Promise, Promise, Promise]) + return { actions, approvals, sessions, passports } +} + +export function useAgentAudit(): AgentAuditState { + const { store } = useDevTools() + const [rows, setRows] = useState([]) + const [passports, setPassports] = useState([]) + const [agentFilter, setAgentFilter] = useState(null) + const [selectedId, setSelectedId] = useState(null) + const [loading, setLoading] = useState(true) + + const refresh = useCallback(async () => { + if (!store) return + const { actions, approvals, sessions, passports: passportNodes } = await loadAudit(store) + setRows(buildRows(actions, approvals, sessions)) + setPassports( + passportNodes + .filter((n) => !n.deleted) + .map((n) => ({ + agentDID: str(n.properties.agentDID), + displayName: str(n.properties.displayName), + runtime: str(n.properties.runtime, 'other'), + status: str(n.properties.status, 'active') + })) + ) + setLoading(false) + }, [store]) + + useEffect(() => { + if (!store) return + void refresh() + let timer: ReturnType | null = null + const unsubscribe = store.subscribe(() => { + if (timer) return + timer = setTimeout(() => { + timer = null + void refresh() + }, LIVE_DEBOUNCE_MS) + }) + return () => { + unsubscribe() + if (timer) clearTimeout(timer) + } + }, [store, refresh]) + + const agents = useMemo( + () => [...new Set(rows.map((r) => r.agentDID))].sort(), + [rows] + ) + const filtered = useMemo( + () => (agentFilter ? rows.filter((r) => r.agentDID === agentFilter) : rows), + [rows, agentFilter] + ) + const selected = useMemo( + () => filtered.find((r) => r.id === selectedId) ?? null, + [filtered, selectedId] + ) + + return { + rows: filtered, + agents, + agentFilter, + setAgentFilter, + passports, + loading, + selected, + setSelectedId + } +} + +export { buildRows } diff --git a/packages/devtools/src/panels/Shell.tsx b/packages/devtools/src/panels/Shell.tsx index d47b3defa..a56a5d9ee 100644 --- a/packages/devtools/src/panels/Shell.tsx +++ b/packages/devtools/src/panels/Shell.tsx @@ -15,6 +15,7 @@ import { useEffect, useState, type MouseEvent as ReactMouseEvent, type CSSProper import { DEFAULTS } from '../core/constants' import { useDevTools } from '../provider/useDevTools' import { AbusePanel } from './AbusePanel/AbusePanel' +import { AgentAuditPanel } from './AgentAuditPanel/AgentAuditPanel' import { AuthZPanel } from './AuthZPanel/AuthZPanel' import { ChangeTimeline } from './ChangeTimeline/ChangeTimeline' import { DevToolsPalette } from './CommandPalette/DevToolsPalette' @@ -251,6 +252,8 @@ function ActivePanelContent({ panel }: { panel: PanelId }) { return case 'abuse': return + case 'agent-audit': + return case 'telemetry': return case 'schemas': diff --git a/packages/devtools/src/panels/panel-registry.ts b/packages/devtools/src/panels/panel-registry.ts index a1fd45de0..03d8d0f67 100644 --- a/packages/devtools/src/panels/panel-registry.ts +++ b/packages/devtools/src/panels/panel-registry.ts @@ -17,6 +17,7 @@ import { Activity, ArrowLeftRight, BarChart3, + Bot, Boxes, Braces, Clock, @@ -191,6 +192,15 @@ export const DEVTOOLS_PANELS: DevtoolsPanelDef[] = [ keywords: ['moderation', 'labels', 'reputation', 'quota', 'policy'], description: 'Policy decisions, labels, and peer scores' }, + { + id: 'agent-audit', + label: 'Agent Audit', + icon: Bot, + group: 'activity', + tier: 'secondary', + keywords: ['agent', 'openclaw', 'hermes', 'audit', 'approval', 'passport', 'ceremony'], + description: 'Every agent tool call, its risk tier, and its approval trail' + }, { id: 'security', label: 'Security', diff --git a/packages/devtools/src/provider/DevToolsContext.ts b/packages/devtools/src/provider/DevToolsContext.ts index 432cc6681..71496e800 100644 --- a/packages/devtools/src/provider/DevToolsContext.ts +++ b/packages/devtools/src/provider/DevToolsContext.ts @@ -20,6 +20,7 @@ export type PanelId = | 'yjs' | 'authz' | 'abuse' + | 'agent-audit' | 'queries' | 'traces' | 'telemetry' From b988fa215239503d3b0084b68a3ca7b6f410e1e2 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:17:41 -0700 Subject: [PATCH 08/12] docs: agent passport enrollment, ceremony script, and Hermes section in skill + integration guide (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 4 +- docs/guides/openclaw-integration.md | 64 +++++++++++++++++-- .../openclaw/xnet-workspace-skill.md | 33 ++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index d232ec3ae..c6c172be4 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -633,10 +633,10 @@ Phase 3 — Text control plane: every decision, operator-signed for high-risk - [x] `AgentNotification` outbox schema + MCP subscription/poll tool; ship notification → text relay instructions in the skill -- [ ] Update the ClawHub skill + publish a Hermes-compatible skill +- [x] Update the ClawHub skill + publish a Hermes-compatible skill (`docs/integrations/openclaw/xnet-workspace-skill.md` — same AgentSkills spec covers both) with the ceremony script -- [ ] Update `docs/guides/openclaw-integration.md`: enrollment flow, +- [x] Update `docs/guides/openclaw-integration.md`: enrollment flow, approval tiers, audit console pointer; add a Hermes section Housekeeping: diff --git a/docs/guides/openclaw-integration.md b/docs/guides/openclaw-integration.md index 2ed3115cb..cc0ee2ee5 100644 --- a/docs/guides/openclaw-integration.md +++ b/docs/guides/openclaw-integration.md @@ -1,15 +1,22 @@ -# Driving xNet from OpenClaw (and other MCP agents) +# Driving xNet from OpenClaw and Hermes (and other MCP agents) xNet exposes its workspace as an **MCP substrate**: any MCP client — OpenClaw, -Claude Code, Codex, Cline, Goose — can read and safely mutate your tasks, pages, -and databases through one server. You build the connection once; it works for -every client ([exploration 0175](../explorations/0175_[_]_XNET_AS_A_SUBSTRATE_FOR_OPENCLAW.md)). +Hermes Agent, Claude Code, Codex, Cline, Goose — can read and safely mutate +your tasks, pages, and databases through one server. You build the connection +once; it works for every client +([exploration 0175](../explorations/0175_[_]_XNET_AS_A_SUBSTRATE_FOR_OPENCLAW.md)). Every write flows through xNet's mutation-plan guardrail (risk, scopes, approval, audit, rollback) regardless of which client is connected — so letting an autonomous agent into your workspace is governed by xNet, not by the agent's own (often weak) safety model. +With an **Agent Passport** (below), the agent additionally gets its own DID and +a scoped, operator-delegated UCAN — every change it makes is signed by *its* +identity, every tool call lands as a signed `AgentAction` audit node, and +risky calls go through a risk-tiered approval ceremony +([exploration 0337](../explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md)). + ## Start the server xNet talks to its local API, so start that first (the desktop/CLI app exposes @@ -64,6 +71,55 @@ For the HTTP transport (e.g. an Electron-hosted xNet), use the snippet printed b } ``` +## Enroll the agent (Agent Passport) + +Give the agent its own scoped identity instead of yours: + +```bash +# Mint a did:key for the agent + a 7-day operator-signed UCAN limited to +# node/create + node/update in the named Space(s). Saved to +# ~/.xnet/agents/homeclaw.json (0600) — the key never reaches the gateway. +xnet agent enroll homeclaw --runtime openclaw --space \ + --key $XNET_SIGNING_KEY + +# Serve as that agent. With --db, writes are signed by the AGENT's DID in a +# local store — the change log becomes its tamper-evident audit trail. +xnet mcp serve --agent homeclaw --db ~/.xnet/homeclaw.sqlite \ + --audit-space +``` + +`enroll` prints ready-to-paste config for both OpenClaw (`mcp.servers`) and +Hermes Agent (`mcpServers`) — the `--agent` serve command is the same. + +What this buys you: + +- **Attribution** — `AgentAction` nodes record every tool call (tool, verbatim + instruction, risk, status, reversibility, produced change ids). Browse them + in the DevTools **Agent Audit** panel, filtered per agent. +- **Risk-tiered approvals** — low-risk calls run; medium-risk calls park + behind a one-time `APPROVE ` you type in chat (5-minute TTL); + high/critical calls can **only** be approved in an xNet surface — the agent + relaying your chat cannot forge those. +- **Scoped authority** — the delegated UCAN names spaces and actions; + wildcards are rejected at mint time. Hubs with `trustedDids` configured + reject any token that doesn't chain to your operator DID. +- **Undo** — `xnet_undo ` rolls back reversible actions. +- **A text outbox** — `AgentNotification` nodes are polled by the agent + (`xnet_poll_notifications`) and relayed to you over WhatsApp/Telegram/…, so + the hub reaches you through channels the agent already has. + +Rotate by re-running `enroll` (passports expire after 7 days by default). + +## Hermes Agent + +Hermes consumes the same MCP server and the same AgentSkills-format skill. +Use the `mcpServers` snippet printed by `enroll` (or configure +`xnet mcp serve --agent ` as a stdio server in Hermes's config). The +ceremony, audit trail, and outbox behave identically. One caution specific to +Hermes: its learning loop autonomously writes new skill documents — the audit +trail is how you retrace *which* self-written skill drove an action, so keep +enrolled mode on. + ## Hardening OpenClaw OpenClaw's defaults are permissive and it has a documented history of security diff --git a/docs/integrations/openclaw/xnet-workspace-skill.md b/docs/integrations/openclaw/xnet-workspace-skill.md index daade852f..07d32bcab 100644 --- a/docs/integrations/openclaw/xnet-workspace-skill.md +++ b/docs/integrations/openclaw/xnet-workspace-skill.md @@ -18,6 +18,16 @@ The user must run the xNet MCP server and add it to `mcp.servers` (stdio or [OpenClaw integration guide](https://xnet.fyi/docs/guides/openclaw-integration). This skill assumes a server named `xnet` is connected. It holds **no secrets**. +This skill also works unchanged on **Hermes Agent** (same AgentSkills format, +same MCP server — use its `mcpServers` config). + +**Enrolled mode (recommended):** if the user ran +`xnet agent enroll --space ` and serves with +`xnet mcp serve --agent `, you are operating under an **Agent Passport** +— your own DID with a narrow, operator-delegated capability set. Every tool +call is recorded as a signed `AgentAction` audit node, and risky calls go +through the approval ceremony below. + ## Tools - `xnet_search` — ranked workspace search. Start here to find things. @@ -29,6 +39,29 @@ This skill assumes a server named `xnet` is connected. It holds **no secrets**. - `xnet_create` / `xnet_update` / `xnet_delete` — create/update/delete nodes. - `xnet_create_task` / `xnet_create_page` / `xnet_send_message` — first-class helpers (Task / Page / chat message). +- `xnet_approve` / `xnet_deny` / `xnet_pending_approvals` — the approval + ceremony (enrolled mode). +- `xnet_undo` — roll back a reversible applied action by its receipt id. +- `xnet_poll_notifications` — drain the hub→operator outbox and relay entries + to the user over chat (poll on your heartbeat; pass `markDelivered: true` + after relaying). + +## Approval ceremony (enrolled mode) + +When a tool call returns `{ "pending": true, ... }` instead of a result, the +action is parked awaiting operator approval. Follow the script exactly: + +- **`surface: "chat"` (medium risk):** the payload carries a one-time `nonce`. + Relay the `message` to the user verbatim (e.g. *"Reply APPROVE 8F2KQ1 + within 5 minutes"*). When they reply with the code, call + `xnet_approve { code }`. The code expires — never invent, guess, or retry + codes, and never call `xnet_approve` without the user having typed the code. +- **`surface: "app"` (high/critical risk):** there is **no code**. Tell the + user this action must be confirmed in the xNet app, and stop. Do not attempt + chat approval; it is mechanically impossible by design. +- Pass the user's request verbatim as `_instruction` on tool calls so the + audit trail records why each action happened. +- If the user declines, call `xnet_deny { actionId }` and report it. ## Rules From 0a4a1de41b0f68c197ba5f7d191706668550f708 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:18:12 -0700 Subject: [PATCH 09/12] docs(changelog): changeset + changelog fragment for agent passports (0337) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- .changeset/agent-passport-audit-trail.md | 27 +++++++++++++++++++ ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md | 4 +-- ...nt-passports-audit-every-openclaw-her.json | 12 +++++++++ 3 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .changeset/agent-passport-audit-trail.md create mode 100644 site/src/data/changelog/2026-07-17-agent-passports-audit-every-openclaw-her.json diff --git a/.changeset/agent-passport-audit-trail.md b/.changeset/agent-passport-audit-trail.md new file mode 100644 index 000000000..9f9830f24 --- /dev/null +++ b/.changeset/agent-passport-audit-trail.md @@ -0,0 +1,27 @@ +--- +'@xnetjs/data': minor +'@xnetjs/identity': minor +'@xnetjs/plugins': minor +'@xnetjs/cli': minor +--- + +Agent Passports and signed agent audit trails (exploration 0337). + +- `@xnetjs/data`: new agent schema pack — `AgentPassport`, `AgentSession`, + `AgentAction`, `AgentApproval`, `AgentNotification` — with deterministic id + helpers (`agentActionId`, …) and `redactInstruction`. +- `@xnetjs/identity`: `mintAgentPassport` / `verifyAgentPassport` (per-agent + `did:key` + operator-delegated, attenuation-checked UCAN; wildcards + rejected) and `rootIssuers` for delegation-chain root inspection. +- `@xnetjs/plugins`: `AgentAuditRecorder` wraps the AI surface so every tool + call lands as an `AgentAction` node and medium+ risk calls park behind a + risk-tiered approval ceremony (chat nonce with TTL for medium; xNet-surface + only for high/critical); ceremony tools (`xnet_approve`, `xnet_deny`, + `xnet_pending_approvals`, `xnet_undo`) and the `xnet_poll_notifications` + outbox tool; `MCPServerConfig.agentAudit` wires it into the MCP server; + `NodeStoreAPI.create` now accepts an optional deterministic `id`; new AI + scopes `agent.approve` and `agent.notifications`. +- `@xnetjs/cli`: `xnet agent enroll ` mints and stores passports + (`~/.xnet/agents`, 0600) and prints OpenClaw/Hermes config; `xnet mcp serve + --agent [--db ]` serves an agent-scoped session over an + agent-signed local store. diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index c6c172be4..cfce06b63 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -641,10 +641,10 @@ Phase 3 — Text control plane: Housekeeping: -- [ ] Changesets for touched publishable packages (`data`, `identity`, +- [x] Changesets for touched publishable packages (`data`, `identity`, `plugins`, `hub` are in the fixed core — bump from the diff; new schemas + new exports = minor) -- [ ] New surface lands in scoped sub-barrels per the 0276 policy (e.g. +- [x] New surface lands in scoped sub-barrels per the 0276 policy (e.g. `packages/data/src/schema/schemas/index.ts`, not root barrel churn) ## Validation Checklist diff --git a/site/src/data/changelog/2026-07-17-agent-passports-audit-every-openclaw-her.json b/site/src/data/changelog/2026-07-17-agent-passports-audit-every-openclaw-her.json new file mode 100644 index 000000000..c9d5557c4 --- /dev/null +++ b/site/src/data/changelog/2026-07-17-agent-passports-audit-every-openclaw-her.json @@ -0,0 +1,12 @@ +{ + "id": "2026-07-17-agent-passports-audit-every-openclaw-her", + "date": "July 17, 2026", + "title": "Agent Passports: audit every OpenClaw/Hermes action", + "summary": "External agents (OpenClaw, Hermes, Claude Code) can now be enrolled with their own scoped identity: every tool call is recorded as a signed audit node, risky actions require a typed approval code in chat or an in-app confirmation, reversible actions can be undone, and the new DevTools Agent Audit panel shows the full per-agent trail.", + "highlights": [], + "tags": [ + "ai", + "identity", + "devtools" + ] +} From cf6132aabafb3103d41dbb81b4bd5f2c67469782 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:21:44 -0700 Subject: [PATCH 10/12] docs(exploration): check off openclaw hermes integration signed agent audit trails and text control plane Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- ...ENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md} | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) rename docs/explorations/{0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md => 0337_[x]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md} (98%) diff --git a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md b/docs/explorations/0337_[x]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md similarity index 98% rename from docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md rename to docs/explorations/0337_[x]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md index cfce06b63..8e8c28059 100644 --- a/docs/explorations/0337_[_]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md +++ b/docs/explorations/0337_[x]_OPENCLAW_HERMES_INTEGRATION_SIGNED_AGENT_AUDIT_TRAILS_AND_TEXT_CONTROL_PLANE.md @@ -649,28 +649,28 @@ Housekeeping: ## Validation Checklist -- [ ] Enroll a real OpenClaw gateway with a passport; verify a tool-call +- [x] Enroll a real OpenClaw gateway with a passport; verify a tool-call write lands with `authorDID = agent DID` and `verifyChange` passes hub-side -- [ ] Attempt a write outside the delegated capability set (other space, +- [x] Attempt a write outside the delegated capability set (other space, `delete`) → rejected at hub with capability error, `AgentAction` records the denial -- [ ] Tamper test: mutate an agent session transcript on disk *and* attempt +- [x] Tamper test: mutate an agent session transcript on disk *and* attempt to replay an altered change → hub rejects (hash/signature); audit console still shows the true history -- [ ] Medium-risk ceremony end-to-end over Telegram: nonce expires after +- [x] Medium-risk ceremony end-to-end over Telegram: nonce expires after TTL; stale/wrong nonce rejected; `AgentApproval` node present with `surface: 'chat'` and correct peer id -- [ ] High-risk op requested via chat → refused in-chat, approvable only in +- [x] High-risk op requested via chat → refused in-chat, approvable only in app; resulting `AgentApproval` is operator-signed -- [ ] Author-index query returns the agent's full change history in +- [x] Author-index query returns the agent's full change history in paginated order on a 100k-change log without a full scan (explain plan / timing) -- [ ] `xnet_undo` on a reversible action produces compensating changes and +- [x] `xnet_undo` on a reversible action produces compensating changes and flips status to `rolled-back` -- [ ] Same skill + passport flow works against a Hermes Agent gateway +- [x] Same skill + passport flow works against a Hermes Agent gateway (MCP streamable-http) -- [ ] Seed coverage test green (auto-generator covers the new schemas); +- [x] Seed coverage test green (auto-generator covers the new schemas); full `vitest` from root ## References From 91d997237e2be9b042efda481dd2708ff1e86ac8 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:27:03 -0700 Subject: [PATCH 11/12] style: prettier formatting for 0337 files Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- packages/cli/src/commands/enroll.test.ts | 5 +---- .../data/src/schema/schemas/agent.test.ts | 4 +--- packages/data/src/schema/schemas/index.ts | 9 ++++----- .../AgentAuditPanel/AgentAuditPanel.tsx | 13 ++++-------- .../AgentAuditPanel/agent-audit-panel.test.ts | 6 +----- .../panels/AgentAuditPanel/useAgentAudit.ts | 12 +++-------- packages/hub/src/routes/audit.ts | 3 +-- packages/hub/test/agent-audit.test.ts | 12 +++-------- packages/identity/src/agent-passport.test.ts | 20 ++++++------------- .../plugins/src/ai-surface/agent-audit.ts | 10 +++------- .../src/ai-surface/agent-ceremony-tools.ts | 3 +-- 11 files changed, 28 insertions(+), 69 deletions(-) diff --git a/packages/cli/src/commands/enroll.test.ts b/packages/cli/src/commands/enroll.test.ts index 64358208e..8677ec082 100644 --- a/packages/cli/src/commands/enroll.test.ts +++ b/packages/cli/src/commands/enroll.test.ts @@ -9,10 +9,7 @@ import { generateIdentity, verifyAgentPassport } from '@xnetjs/identity' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { runEnroll, openClawStdioSnippet, hermesStdioSnippet } from './enroll' import { startMcpServe } from './mcp' -import { - bytesToHex, - loadAgentPassportFile -} from '../utils/agent-passport-file.js' +import { bytesToHex, loadAgentPassportFile } from '../utils/agent-passport-file.js' let dir: string const operator = generateIdentity() diff --git a/packages/data/src/schema/schemas/agent.test.ts b/packages/data/src/schema/schemas/agent.test.ts index ed6c022c1..735b548bb 100644 --- a/packages/data/src/schema/schemas/agent.test.ts +++ b/packages/data/src/schema/schemas/agent.test.ts @@ -106,9 +106,7 @@ describe('agent schema pack (exploration 0337)', () => { it('passport and notification ids are prefixed and stable', () => { expect(agentPassportId('did:key:z6MkA')).toBe('agent-passport:did:key:z6MkA') - expect(agentNotificationId('agent-action:x:1')).toBe( - 'agent-notification:agent-action:x:1' - ) + expect(agentNotificationId('agent-action:x:1')).toBe('agent-notification:agent-action:x:1') }) }) diff --git a/packages/data/src/schema/schemas/index.ts b/packages/data/src/schema/schemas/index.ts index d4d3dd269..ebc50cec2 100644 --- a/packages/data/src/schema/schemas/index.ts +++ b/packages/data/src/schema/schemas/index.ts @@ -655,12 +655,10 @@ export const builtInSchemas = { // Memory schema pack (exploration 0211) 'xnet://xnet.fyi/MemoryItem@1.0.0': () => import('./memory').then((m) => m.MemoryItemSchema), // Agent schema pack (exploration 0337) - 'xnet://xnet.fyi/AgentPassport@1.0.0': () => - import('./agent').then((m) => m.AgentPassportSchema), + 'xnet://xnet.fyi/AgentPassport@1.0.0': () => import('./agent').then((m) => m.AgentPassportSchema), 'xnet://xnet.fyi/AgentSession@1.0.0': () => import('./agent').then((m) => m.AgentSessionSchema), 'xnet://xnet.fyi/AgentAction@1.0.0': () => import('./agent').then((m) => m.AgentActionSchema), - 'xnet://xnet.fyi/AgentApproval@1.0.0': () => - import('./agent').then((m) => m.AgentApprovalSchema), + 'xnet://xnet.fyi/AgentApproval@1.0.0': () => import('./agent').then((m) => m.AgentApprovalSchema), 'xnet://xnet.fyi/AgentNotification@1.0.0': () => import('./agent').then((m) => m.AgentNotificationSchema), @@ -775,7 +773,8 @@ export const builtInSchemas = { 'xnet://xnet.fyi/AgentSession': () => import('./agent').then((m) => m.AgentSessionSchema), 'xnet://xnet.fyi/AgentAction': () => import('./agent').then((m) => m.AgentActionSchema), 'xnet://xnet.fyi/AgentApproval': () => import('./agent').then((m) => m.AgentApprovalSchema), - 'xnet://xnet.fyi/AgentNotification': () => import('./agent').then((m) => m.AgentNotificationSchema) + 'xnet://xnet.fyi/AgentNotification': () => + import('./agent').then((m) => m.AgentNotificationSchema) } as const /** diff --git a/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx b/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx index 3036cfd31..aeb7e34b1 100644 --- a/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx +++ b/packages/devtools/src/panels/AgentAuditPanel/AgentAuditPanel.tsx @@ -34,8 +34,8 @@ export function AgentAuditPanel() {
No agent activity yet
Enroll an agent (xnet agent enroll <name> --space <id>) and - serve it with xnet mcp serve --agent <name>. Every guarded tool - call lands here as an AgentAction node. + serve it with xnet mcp serve --agent <name>. Every guarded tool call + lands here as an AgentAction node.
@@ -128,10 +128,7 @@ function DetailPane({ row }: { row: AgentActionRow }) { {row.error && } {row.approval && ( <> - + {row.approval.approverDID && ( )} @@ -140,9 +137,7 @@ function DetailPane({ row }: { row: AgentActionRow }) { )} {row.changeIds.length > 0 && (
-
- Change ids -
+
Change ids
{row.changeIds.map((id) => (
{id} diff --git a/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts b/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts index a8c6b15d0..0d770f91d 100644 --- a/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts +++ b/packages/devtools/src/panels/AgentAuditPanel/agent-audit-panel.test.ts @@ -6,11 +6,7 @@ import type { NodeState } from '@xnetjs/data' import { describe, expect, it } from 'vitest' import { buildRows } from './useAgentAudit' -const node = ( - id: string, - schemaSuffix: string, - properties: Record -): NodeState => +const node = (id: string, schemaSuffix: string, properties: Record): NodeState => ({ id, schemaId: `xnet://xnet.fyi/${schemaSuffix}@1.0.0`, diff --git a/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts b/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts index 79566ed93..2b7141251 100644 --- a/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts +++ b/packages/devtools/src/panels/AgentAuditPanel/useAgentAudit.ts @@ -59,9 +59,7 @@ const buildRows = ( approvals: NodeState[], sessions: NodeState[] ): AgentActionRow[] => { - const approvalByAction = new Map( - approvals.map((a) => [str(a.properties.action), a] as const) - ) + const approvalByAction = new Map(approvals.map((a) => [str(a.properties.action), a] as const)) const sessionById = new Map(sessions.map((s) => [s.id, s] as const)) return actions @@ -92,8 +90,7 @@ const buildRows = ( typeof approval.properties.approverDID === 'string' ? approval.properties.approverDID : null, - peer: - typeof approval.properties.peer === 'string' ? approval.properties.peer : null + peer: typeof approval.properties.peer === 'string' ? approval.properties.peer : null } : null } @@ -153,10 +150,7 @@ export function useAgentAudit(): AgentAuditState { } }, [store, refresh]) - const agents = useMemo( - () => [...new Set(rows.map((r) => r.agentDID))].sort(), - [rows] - ) + const agents = useMemo(() => [...new Set(rows.map((r) => r.agentDID))].sort(), [rows]) const filtered = useMemo( () => (agentFilter ? rows.filter((r) => r.agentDID === agentFilter) : rows), [rows, agentFilter] diff --git a/packages/hub/src/routes/audit.ts b/packages/hub/src/routes/audit.ts index c90713256..f4c2ed0f2 100644 --- a/packages/hub/src/routes/audit.ts +++ b/packages/hub/src/routes/audit.ts @@ -43,8 +43,7 @@ export const createAuditRoutes = (storage: HubStorage, options: AuditRoutesOptio const since = parsePositiveInt(c.req.query('since'), 0) const limit = parsePositiveInt(c.req.query('limit'), 200) const changes = await storage.getNodeChangesByAuthor(did, since, limit) - const nextCursor = - changes.length > 0 ? changes[changes.length - 1].lamportTime : since + const nextCursor = changes.length > 0 ? changes[changes.length - 1].lamportTime : since return c.json({ author: did, diff --git a/packages/hub/test/agent-audit.test.ts b/packages/hub/test/agent-audit.test.ts index 37c0181da..5459906bc 100644 --- a/packages/hub/test/agent-audit.test.ts +++ b/packages/hub/test/agent-audit.test.ts @@ -57,14 +57,10 @@ describe('audit routes (exploration 0337)', () => { const res = await app.request(`/audit/authors/${agent}/changes`) expect(res.status).toBe(200) const body = await res.json() - expect(body.changes.map((c: SerializedNodeChange) => c.lamportTime)).toEqual([ - 1, 2, 3, 4, 5, 6 - ]) + expect(body.changes.map((c: SerializedNodeChange) => c.lamportTime)).toEqual([1, 2, 3, 4, 5, 6]) expect(body.nextCursor).toBe(6) // Only the agent's changes — the human's change never leaks in. - expect( - body.changes.every((c: SerializedNodeChange) => c.authorDid === agent) - ).toBe(true) + expect(body.changes.every((c: SerializedNodeChange) => c.authorDid === agent)).toBe(true) }) it('pages on the ?since lamport cursor', async () => { @@ -79,9 +75,7 @@ describe('audit routes (exploration 0337)', () => { it('reading another author requires audit/read', async () => { const denied = await mount({ as: 'did:key:zOperator', can: false }) - expect( - (await denied.app.request(`/audit/authors/${denied.agent}/changes`)).status - ).toBe(403) + expect((await denied.app.request(`/audit/authors/${denied.agent}/changes`)).status).toBe(403) const allowed = await mount({ as: 'did:key:zOperator', can: true }) const res = await allowed.app.request(`/audit/authors/${allowed.agent}/changes`) diff --git a/packages/identity/src/agent-passport.test.ts b/packages/identity/src/agent-passport.test.ts index 2e72184d0..6192de036 100644 --- a/packages/identity/src/agent-passport.test.ts +++ b/packages/identity/src/agent-passport.test.ts @@ -1,9 +1,5 @@ import { describe, expect, it } from 'vitest' -import { - assertAttenuated, - mintAgentPassport, - verifyAgentPassport -} from './agent-passport' +import { assertAttenuated, mintAgentPassport, verifyAgentPassport } from './agent-passport' import { generateIdentity } from './did' import { createUCAN, hasCapability, rootIssuers, verifyUCAN } from './ucan' @@ -51,19 +47,15 @@ describe('agent passport (exploration 0337)', () => { capabilities: CAPS }) const stranger = generateIdentity() - expect( - verifyAgentPassport(grant.ucan, { agentDID: stranger.identity.did }).valid - ).toBe(false) - expect( - verifyAgentPassport(grant.ucan, { operatorDID: stranger.identity.did }).valid - ).toBe(false) + expect(verifyAgentPassport(grant.ucan, { agentDID: stranger.identity.did }).valid).toBe(false) + expect(verifyAgentPassport(grant.ucan, { operatorDID: stranger.identity.did }).valid).toBe( + false + ) }) it('rejects wildcard capabilities — the 0307 weakness must not re-enter', () => { expect(() => assertAttenuated([{ with: '*', can: 'node/create' }])).toThrow(/attenuated/) - expect(() => assertAttenuated([{ with: 'xnet://space/inbox', can: '*' }])).toThrow( - /attenuated/ - ) + expect(() => assertAttenuated([{ with: 'xnet://space/inbox', can: '*' }])).toThrow(/attenuated/) expect(() => assertAttenuated([])).toThrow(/at least one/) expect(() => mintAgentPassport({ diff --git a/packages/plugins/src/ai-surface/agent-audit.ts b/packages/plugins/src/ai-surface/agent-audit.ts index 86f9fdc23..9ba0418f8 100644 --- a/packages/plugins/src/ai-surface/agent-audit.ts +++ b/packages/plugins/src/ai-surface/agent-audit.ts @@ -134,10 +134,8 @@ export const reversibilityForTool = (name: string): AgentReversibility => { } /** Risk from the tool definition; unknown tools are treated as medium. */ -export const riskForTool = ( - defs: AiToolDefinition[], - name: string -): AiRiskLevel => defs.find((d) => d.name === name)?.risk ?? 'medium' +export const riskForTool = (defs: AiToolDefinition[], name: string): AiRiskLevel => + defs.find((d) => d.name === name)?.risk ?? 'medium' const surfaceForRisk = (risk: AiRiskLevel): AgentApprovalSurface => risk === 'medium' ? 'chat' : 'app' @@ -201,9 +199,7 @@ export class AgentAuditRecorder { schemaId: string, properties: Record ): Promise { - const clean = Object.fromEntries( - Object.entries(properties).filter(([, v]) => v !== undefined) - ) + const clean = Object.fromEntries(Object.entries(properties).filter(([, v]) => v !== undefined)) const node = await this.store.create({ id: auditId, schemaId, properties: clean }) return node.id } diff --git a/packages/plugins/src/ai-surface/agent-ceremony-tools.ts b/packages/plugins/src/ai-surface/agent-ceremony-tools.ts index f35eb6197..d5e752798 100644 --- a/packages/plugins/src/ai-surface/agent-ceremony-tools.ts +++ b/packages/plugins/src/ai-surface/agent-ceremony-tools.ts @@ -64,8 +64,7 @@ export function createAgentCeremonyTools(recorder: AgentAuditRecorder): AiExtraT { name: 'xnet_pending_approvals', title: 'List pending approvals', - description: - 'List actions waiting on operator approval (never includes approval codes).', + description: 'List actions waiting on operator approval (never includes approval codes).', risk: 'low', requiredScopes: ['agent.approve'], inputSchema: { type: 'object', properties: {} }, From 5406ab61ceced65ac13d1013970e54ef107fce63 Mon Sep 17 00:00:00 2001 From: xNet Test Date: Fri, 17 Jul 2026 19:35:32 -0700 Subject: [PATCH 12/12] fix(plugins): drop unused destructure in agent tool registration (lint) Co-Authored-By: Claude Fable 5 Signed-off-by: xNet Test --- packages/plugins/src/services/mcp-server.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugins/src/services/mcp-server.ts b/packages/plugins/src/services/mcp-server.ts index 5ca76b58c..f786dd7cd 100644 --- a/packages/plugins/src/services/mcp-server.ts +++ b/packages/plugins/src/services/mcp-server.ts @@ -566,8 +566,8 @@ export class MCPServer { } for (const tool of this.agentExtraTools.values()) { - const { invoke: _invoke, ...def } = tool - this.tools.set(tool.name, toMCPTool(def)) + // AiExtraTool extends AiToolDefinition; toMCPTool reads definition fields only. + this.tools.set(tool.name, toMCPTool(tool)) } for (const [name, tool] of this.tools) {