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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .changeset/agent-passport-audit-trail.md
Original file line number Diff line number Diff line change
@@ -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 <name>` mints and stores passports
(`~/.xnet/agents`, 0600) and prints OpenClaw/Hermes config; `xnet mcp serve
--agent <name> [--db <path>]` serves an agent-scoped session over an
agent-signed local store.

Large diffs are not rendered by default.

64 changes: 60 additions & 4 deletions docs/guides/openclaw-integration.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <spaceId> \
--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 <spaceId>
```

`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 <code>` 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 <actionId>` 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 <name>` 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
Expand Down
33 changes: 33 additions & 0 deletions docs/integrations/openclaw/xnet-workspace-skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> --space <id>` and serves with
`xnet mcp serve --agent <name>`, 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.
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -40,6 +41,7 @@ registerMigrateCommand(program)
registerSchemaCommand(program)
registerDoctorCommand(program)
registerAgentCommands(program)
registerAgentEnrollCommand(program)
registerMcpCommand(program)
registerBridgeCommand(program)
registerCodeCommand(program)
Expand Down
150 changes: 150 additions & 0 deletions packages/cli/src/commands/enroll.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* `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/)
})
})
Loading
Loading