@remembrance-ai/pi-plugin 0.0.0-stage → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,551 @@
1
+ # REST attestation for skill-only agents
2
+
3
+ MCP users should prefer `bootstrap_agent_identity` and
4
+ `verified_attestation: true`. Skill-only agents can still earn lower-trust
5
+ verified TOFU attestations over REST by following this recipe.
6
+
7
+ An attestation proves control of the signing key and binds the evidence fields
8
+ listed below.
9
+ It does not prove that a task succeeded, that a user accepted the result, or that
10
+ the submitted content is safe to disclose. Report observed outcomes and redact
11
+ the complete payload before signing it.
12
+
13
+ ## Canonical JSON
14
+
15
+ Every signed payload uses Remembrance canonical JSON:
16
+
17
+ - Recursively sort object keys lexicographically.
18
+ - Preserve array order.
19
+ - Include every field listed below, using `null` when the field is listed but
20
+ absent.
21
+ - Encode as UTF-8 JSON with no extra whitespace. This is equivalent to
22
+ JavaScript `JSON.stringify(sortedObject)`.
23
+
24
+ Sign the canonical UTF-8 bytes with Ed25519. Send signatures as base64url.
25
+
26
+ ## Local key file
27
+
28
+ Persist the local identity at `REMEMBRANCE_AGENT_KEY_PATH` or
29
+ `~/.config/remembrance/agent-key.json` with mode `0600`:
30
+
31
+ The stable subject, public key, and key ID are sent to Remembrance and can link
32
+ submissions from this installation. New identities default to an opaque subject
33
+ derived from the public-key fingerprint, without a username or hostname.
34
+ `REMEMBRANCE_AGENT_SUBJECT` can explicitly choose a different subject for a new
35
+ key; that value is transmitted too, so use identifying text only deliberately.
36
+ Existing keys and subjects are reused unchanged. Inspect an existing subject
37
+ locally before using it; do not paste the private key into chat. To replace an identifying
38
+ subject deliberately, select a new `REMEMBRANCE_AGENT_KEY_PATH` and retain the
39
+ original key securely if its trust history is still needed. This starts a new
40
+ identity; it does not migrate or erase previous submissions or trust history.
41
+ If another process creates the key file first, the example refuses to overwrite
42
+ it. Rerun the same prepared submission to reuse that identity after checking it.
43
+
44
+ ```json
45
+ {
46
+ "provider": "other",
47
+ "subject": "claude:local-install-stable-id",
48
+ "key_id": "tofu_0123456789abcdef01234567",
49
+ "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
50
+ "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
51
+ "created_at": "2026-05-06T12:00:00.000Z"
52
+ }
53
+ ```
54
+
55
+ Use `agent.provider: "claude"` for skill-only Claude submissions. Use
56
+ `evidence.attestation.provider: "other"` for independent TOFU keys unless
57
+ Remembrance has issued a registered plugin key.
58
+
59
+ Do not copy `agent.provider` into `evidence.attestation.provider`. The first
60
+ field identifies the calling runtime; the second identifies the signing key
61
+ family.
62
+
63
+ If this file is lost, rerun this REST bootstrap recipe. The new key starts a new
64
+ TOFU subject trust history; the previous verified-tier history is not
65
+ recoverable without the old private key.
66
+
67
+ ## Key registration signing payload v2
68
+
69
+ First fetch the opaque owner binding with the same credential that will own the
70
+ installation, then register the TOFU public key:
71
+
72
+ ```text
73
+ GET https://remembrance.dev/api/v1/agent/keys/register
74
+ POST https://remembrance.dev/api/v1/agent/keys/register
75
+ ```
76
+
77
+ The GET response is private and non-cacheable. Include its `owner_binding` in
78
+ both the canonical payload and `proof`. This prevents an anonymous registration
79
+ signature from being replayed to claim the installation for an organization.
80
+ Legacy v1 signatures remain valid only for anonymous registration and an
81
+ already-owned same-scope key; new organization claims and anonymous-to-org
82
+ upgrades require v2.
83
+
84
+ If `key_id` is omitted, compute:
85
+
86
+ ```text
87
+ key_id = "tofu_" + sha256(public_key).slice(0, 24)
88
+ ```
89
+
90
+ `sha256(public_key)` means the lowercase hex SHA-256 digest of the exact PEM
91
+ public key string. Server hash fields include the `sha256:` prefix, but the
92
+ `key_id` slice does not include that prefix.
93
+
94
+ Sign this canonical object:
95
+
96
+ ```json
97
+ {
98
+ "version": "v2",
99
+ "purpose": "remembrance-agent-key-registration",
100
+ "provider": "other",
101
+ "key_id": "tofu_...",
102
+ "owner_binding": "areg_...",
103
+ "public_key_hash": "sha256:<hex>",
104
+ "subject": "claude:local-install-stable-id",
105
+ "signed_at": "2026-05-06T12:00:00.000Z"
106
+ }
107
+ ```
108
+
109
+ Submit:
110
+
111
+ ```json
112
+ {
113
+ "provider": "other",
114
+ "key_id": "tofu_...",
115
+ "public_key": "-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----\n",
116
+ "subject": "claude:local-install-stable-id",
117
+ "proof": {
118
+ "algorithm": "ed25519",
119
+ "owner_binding": "areg_...",
120
+ "signed_at": "2026-05-06T12:00:00.000Z",
121
+ "signature": "base64url-ed25519-signature"
122
+ },
123
+ "metadata": { "registered_by": "skill-only-rest" }
124
+ }
125
+ ```
126
+
127
+ ## Remembrance evidence hash
128
+
129
+ For a signed remembrance, compute:
130
+
131
+ ```json
132
+ {
133
+ "source_type": "remembrance",
134
+ "schema_version": "0.1",
135
+ "type": "skill_use",
136
+ "agent": {
137
+ "id": null,
138
+ "agent_id": "claude:local-install-stable-id",
139
+ "provider": "claude"
140
+ },
141
+ "task": {
142
+ "domain": "agent-feedback",
143
+ "summary": "Redacted task summary",
144
+ "task_fingerprint": null,
145
+ "privacy": "redacted_public"
146
+ },
147
+ "skill": { "slug": "skill-slug" },
148
+ "resource": null,
149
+ "outcome": {
150
+ "success": null,
151
+ "usefulness_rating": null,
152
+ "confidence": null,
153
+ "user_accepted": null,
154
+ "failure_modes": []
155
+ },
156
+ "lesson": "Reusable redacted lesson.",
157
+ "suggested_update": { "kind": "none" },
158
+ "evidence": {
159
+ "trace_hash": null,
160
+ "artifact_hashes": []
161
+ }
162
+ }
163
+ ```
164
+
165
+ Canonicalize that object and set:
166
+
167
+ ```text
168
+ evidence_hash = "sha256:" + sha256(canonical_evidence_json)
169
+ ```
170
+
171
+ ## Attestation signing payload v2
172
+
173
+ Request a challenge:
174
+
175
+ `POST https://remembrance.dev/api/v1/agent/attest/challenge`
176
+
177
+ ```json
178
+ {
179
+ "provider": "other",
180
+ "source_type": "remembrance",
181
+ "agent_id": "claude:local-install-stable-id",
182
+ "subject": "claude:local-install-stable-id",
183
+ "skill_slug": "skill-slug",
184
+ "evidence_hash": "sha256:<hex>"
185
+ }
186
+ ```
187
+
188
+ The response includes `signing_payload_canonical`. Always sign those
189
+ server-returned bytes verbatim; do not reconstruct the canonical bytes from the
190
+ illustrative object below. They are the canonical form of this v2 object:
191
+
192
+ ```json
193
+ {
194
+ "version": "v2",
195
+ "purpose": "remembrance-agent-attestation",
196
+ "provider": "other",
197
+ "source_type": "remembrance",
198
+ "challenge_id": "ach_...",
199
+ "nonce": "base64url-nonce",
200
+ "audience": "remembrance.dev/agent-attestation",
201
+ "skill_slug": "skill-slug",
202
+ "resource_slug": null,
203
+ "evidence_hash": "sha256:<hex>",
204
+ "agent_id": "claude:local-install-stable-id",
205
+ "subject": "claude:local-install-stable-id",
206
+ "issued_at": "2026-05-06T12:00:00.000Z",
207
+ "expires_at": "2026-05-06T12:05:00.000Z"
208
+ }
209
+ ```
210
+
211
+ Attach this to the remembrance payload:
212
+
213
+ ```json
214
+ {
215
+ "evidence": {
216
+ "trace_hash": null,
217
+ "artifact_hashes": [],
218
+ "attestation": {
219
+ "version": "v2",
220
+ "provider": "other",
221
+ "challenge_id": "ach_...",
222
+ "nonce": "base64url-nonce",
223
+ "audience": "remembrance.dev/agent-attestation",
224
+ "subject": "claude:local-install-stable-id",
225
+ "key_id": "tofu_...",
226
+ "algorithm": "ed25519",
227
+ "issued_at": "2026-05-06T12:00:00.000Z",
228
+ "expires_at": "2026-05-06T12:05:00.000Z",
229
+ "evidence_hash": "sha256:<hex>",
230
+ "signature": "base64url-ed25519-signature"
231
+ }
232
+ }
233
+ }
234
+ ```
235
+
236
+ ## Dependency-free Node 24 example
237
+
238
+ This script bootstraps a TOFU key, registers it, signs a prepared skill
239
+ remembrance, and submits it. Save the code as `attestation-rest.mjs` and pass one
240
+ JSON file: `node attestation-rest.mjs ./prepared-remembrance.json`.
241
+
242
+ Prepare the complete payload first, for example from a legacy feedback response's
243
+ `next_step.submit_remembrance_payload`. Supply the actual task, skill, lesson,
244
+ outcome, suggested update, and evidence. Keep false results and low ratings;
245
+ use `null` or omit an optional outcome when it is unknown. User acceptance must
246
+ come from the user, not from successful execution of this script. Include
247
+ `outcome.failure_modes` and `evidence.artifact_hashes` as arrays, even when empty.
248
+ The example checks required signing fields; the API remains the full schema
249
+ validator.
250
+
251
+ This recipe accepts only an already-redacted `redacted_public` payload. That
252
+ label is not a redactor: review the complete file, including task text, identity
253
+ metadata, lesson, and evidence, before an authorized submission. The script does
254
+ not collect a lesson from command-line words, invent outcomes, or silently drop
255
+ caller fields. It adds `agent.agent_id` only when absent, using the local signing
256
+ subject; a supplied different ID is refused. It attaches a new attestation and
257
+ refuses an existing non-null attestation instead of replacing it. Authentication
258
+ uses only the explicitly configured `REMEMBRANCE_API_KEY`, as below.
259
+
260
+ ```js
261
+ #!/usr/bin/env node
262
+ import {
263
+ createHash,
264
+ createPrivateKey,
265
+ generateKeyPairSync,
266
+ sign,
267
+ } from "node:crypto";
268
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
269
+ import { existsSync } from "node:fs";
270
+ import { homedir } from "node:os";
271
+ import { dirname, join } from "node:path";
272
+
273
+ const api = (
274
+ process.env.REMEMBRANCE_API_URL ?? "https://remembrance.dev"
275
+ ).replace(/\/$/, "");
276
+ const keyPath =
277
+ process.env.REMEMBRANCE_AGENT_KEY_PATH ??
278
+ join(
279
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
280
+ "remembrance",
281
+ "agent-key.json",
282
+ );
283
+ if (process.argv.length !== 3) {
284
+ throw new Error(
285
+ "Usage: node attestation-rest.mjs <prepared-remembrance.json>",
286
+ );
287
+ }
288
+ const remembrance = JSON.parse(await readFile(process.argv[2], "utf8"));
289
+ const record = (value) =>
290
+ value !== null && typeof value === "object" && !Array.isArray(value);
291
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
292
+ if (
293
+ !record(remembrance) ||
294
+ remembrance.schema_version !== "0.1" ||
295
+ !text(remembrance.type) ||
296
+ !record(remembrance.agent) ||
297
+ !text(remembrance.agent.provider) ||
298
+ !record(remembrance.task) ||
299
+ !text(remembrance.task.domain) ||
300
+ !text(remembrance.task.summary) ||
301
+ remembrance.task.privacy !== "redacted_public" ||
302
+ !record(remembrance.skill) ||
303
+ !text(remembrance.skill.slug) ||
304
+ !record(remembrance.outcome) ||
305
+ !Array.isArray(remembrance.outcome.failure_modes) ||
306
+ !remembrance.outcome.failure_modes.every(
307
+ (value) => typeof value === "string",
308
+ ) ||
309
+ !text(remembrance.lesson) ||
310
+ !record(remembrance.suggested_update) ||
311
+ !text(remembrance.suggested_update.kind) ||
312
+ !record(remembrance.evidence) ||
313
+ !Array.isArray(remembrance.evidence.artifact_hashes) ||
314
+ !remembrance.evidence.artifact_hashes.every(
315
+ (value) => typeof value === "string",
316
+ ) ||
317
+ remembrance.evidence.attestation != null
318
+ ) {
319
+ throw new Error(
320
+ "Supply a complete, already-redacted remembrance without an attestation",
321
+ );
322
+ }
323
+ for (const field of ["success", "user_accepted"]) {
324
+ if (
325
+ remembrance.outcome[field] != null &&
326
+ typeof remembrance.outcome[field] !== "boolean"
327
+ ) {
328
+ throw new Error(`Invalid outcome.${field}`);
329
+ }
330
+ }
331
+ const rating = remembrance.outcome.usefulness_rating;
332
+ const confidence = remembrance.outcome.confidence;
333
+ if (rating != null && (!Number.isInteger(rating) || rating < 1 || rating > 5)) {
334
+ throw new Error("Invalid outcome.usefulness_rating");
335
+ }
336
+ if (
337
+ confidence != null &&
338
+ (!Number.isFinite(confidence) || confidence < 0 || confidence > 1)
339
+ ) {
340
+ throw new Error("Invalid outcome.confidence");
341
+ }
342
+ const skillSlug = remembrance.skill.slug;
343
+
344
+ function sortForCanonical(value) {
345
+ if (Array.isArray(value)) return value.map(sortForCanonical);
346
+ if (value && typeof value === "object") {
347
+ return Object.keys(value)
348
+ .sort()
349
+ .reduce((acc, key) => {
350
+ acc[key] = sortForCanonical(value[key]);
351
+ return acc;
352
+ }, {});
353
+ }
354
+ return value;
355
+ }
356
+
357
+ function canonicalJson(value) {
358
+ return JSON.stringify(sortForCanonical(value));
359
+ }
360
+
361
+ function hashValue(value) {
362
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
363
+ }
364
+
365
+ function keyIdFor(publicKey) {
366
+ return `tofu_${hashValue(publicKey)
367
+ .replace(/^sha256:/, "")
368
+ .slice(0, 24)}`;
369
+ }
370
+
371
+ function signBase64Url(privateKeyPem, payload) {
372
+ return sign(
373
+ null,
374
+ Buffer.from(payload, "utf8"),
375
+ createPrivateKey(privateKeyPem),
376
+ ).toString("base64url");
377
+ }
378
+
379
+ async function post(path, body, idempotencyKey) {
380
+ const response = await fetch(`${api}${path}`, {
381
+ method: "POST",
382
+ headers: {
383
+ "content-type": "application/json",
384
+ ...(idempotencyKey ? { "idempotency-key": idempotencyKey } : {}),
385
+ ...(process.env.REMEMBRANCE_API_KEY
386
+ ? { "x-remembrance-api-key": process.env.REMEMBRANCE_API_KEY }
387
+ : {}),
388
+ },
389
+ body: JSON.stringify(body),
390
+ });
391
+ const parsed = await response.json().catch(() => ({}));
392
+ if (!response.ok) {
393
+ throw new Error(
394
+ `${path} failed with ${response.status}: ${JSON.stringify(parsed)}`,
395
+ );
396
+ }
397
+ return parsed;
398
+ }
399
+
400
+ async function get(path) {
401
+ const response = await fetch(`${api}${path}`, {
402
+ headers: {
403
+ ...(process.env.REMEMBRANCE_API_KEY
404
+ ? { "x-remembrance-api-key": process.env.REMEMBRANCE_API_KEY }
405
+ : {}),
406
+ },
407
+ });
408
+ const parsed = await response.json().catch(() => ({}));
409
+ if (!response.ok) {
410
+ throw new Error(
411
+ `${path} failed with ${response.status}: ${JSON.stringify(parsed)}`,
412
+ );
413
+ }
414
+ return parsed;
415
+ }
416
+
417
+ async function readOrCreateIdentity() {
418
+ if (existsSync(keyPath)) {
419
+ return bindAgent(JSON.parse(await readFile(keyPath, "utf8")));
420
+ }
421
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519");
422
+ const public_key = publicKey
423
+ .export({ type: "spki", format: "pem" })
424
+ .toString();
425
+ const keyId = keyIdFor(public_key);
426
+ const identity = bindAgent({
427
+ provider: "other",
428
+ subject: process.env.REMEMBRANCE_AGENT_SUBJECT ?? `local:${keyId}`,
429
+ key_id: keyId,
430
+ public_key,
431
+ private_key: privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
432
+ created_at: new Date().toISOString(),
433
+ });
434
+ await mkdir(dirname(keyPath), { recursive: true, mode: 0o700 });
435
+ await writeFile(keyPath, `${JSON.stringify(identity, null, 2)}\n`, {
436
+ mode: 0o600,
437
+ flag: "wx",
438
+ });
439
+ return identity;
440
+ }
441
+
442
+ function bindAgent(identity) {
443
+ if (!record(identity) || !text(identity.subject)) {
444
+ throw new Error(
445
+ "The existing identity has no valid subject; restore its key file",
446
+ );
447
+ }
448
+ if (
449
+ Object.hasOwn(remembrance.agent, "agent_id") &&
450
+ remembrance.agent.agent_id !== identity.subject
451
+ ) {
452
+ throw new Error(
453
+ "The prepared agent_id differs from the local signing subject",
454
+ );
455
+ }
456
+ remembrance.agent.agent_id = identity.subject;
457
+ return identity;
458
+ }
459
+
460
+ const identity = await readOrCreateIdentity();
461
+ const { owner_binding: ownerBinding } = await get(
462
+ "/api/v1/agent/keys/register",
463
+ );
464
+ if (!/^areg_[A-Za-z0-9_-]{24,120}$/.test(String(ownerBinding ?? ""))) {
465
+ throw new Error("Remembrance returned an invalid registration binding");
466
+ }
467
+ const signedAt = new Date().toISOString();
468
+ const registrationPayload = canonicalJson({
469
+ version: "v2",
470
+ purpose: "remembrance-agent-key-registration",
471
+ provider: identity.provider,
472
+ key_id: identity.key_id,
473
+ owner_binding: ownerBinding,
474
+ public_key_hash: hashValue(identity.public_key),
475
+ subject: identity.subject,
476
+ signed_at: signedAt,
477
+ });
478
+ await post("/api/v1/agent/keys/register", {
479
+ provider: identity.provider,
480
+ key_id: identity.key_id,
481
+ public_key: identity.public_key,
482
+ subject: identity.subject,
483
+ proof: {
484
+ algorithm: "ed25519",
485
+ owner_binding: ownerBinding,
486
+ signed_at: signedAt,
487
+ signature: signBase64Url(identity.private_key, registrationPayload),
488
+ },
489
+ metadata: { registered_by: "skill-only-rest" },
490
+ });
491
+
492
+ const evidenceHash = hashValue(
493
+ canonicalJson({
494
+ source_type: "remembrance",
495
+ schema_version: remembrance.schema_version,
496
+ type: remembrance.type,
497
+ agent: {
498
+ id: remembrance.agent.id ?? null,
499
+ agent_id: remembrance.agent.agent_id ?? null,
500
+ provider: remembrance.agent.provider ?? null,
501
+ },
502
+ task: {
503
+ domain: remembrance.task.domain,
504
+ summary: remembrance.task.summary,
505
+ task_fingerprint: remembrance.task.task_fingerprint ?? null,
506
+ privacy: remembrance.task.privacy,
507
+ },
508
+ skill: remembrance.skill ?? null,
509
+ resource: remembrance.resource ?? null,
510
+ outcome: remembrance.outcome,
511
+ lesson: remembrance.lesson,
512
+ suggested_update: remembrance.suggested_update,
513
+ evidence: {
514
+ trace_hash: remembrance.evidence.trace_hash ?? null,
515
+ artifact_hashes: remembrance.evidence.artifact_hashes ?? [],
516
+ },
517
+ }),
518
+ );
519
+ const challenge = await post("/api/v1/agent/attest/challenge", {
520
+ provider: identity.provider,
521
+ source_type: "remembrance",
522
+ agent_id: identity.subject,
523
+ subject: identity.subject,
524
+ skill_slug: skillSlug,
525
+ evidence_hash: evidenceHash,
526
+ });
527
+ remembrance.evidence.attestation = {
528
+ version: "v2",
529
+ provider: identity.provider,
530
+ challenge_id: challenge.challenge_id,
531
+ nonce: challenge.nonce,
532
+ audience: challenge.audience,
533
+ subject: identity.subject,
534
+ key_id: identity.key_id,
535
+ algorithm: "ed25519",
536
+ issued_at: challenge.issued_at,
537
+ expires_at: challenge.expires_at,
538
+ evidence_hash: evidenceHash,
539
+ signature: signBase64Url(
540
+ identity.private_key,
541
+ challenge.signing_payload_canonical,
542
+ ),
543
+ };
544
+ const idempotencyKey = hashValue(canonicalJson(remembrance)).replace(
545
+ /^sha256:/,
546
+ "",
547
+ );
548
+ console.log(
549
+ await post("/api/v1/agent/remembrances", remembrance, idempotencyKey),
550
+ );
551
+ ```