@xultrax-web/agent-memory-mcp 0.11.7 → 0.13.0

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.
Files changed (2) hide show
  1. package/dist/index.js +158 -48
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -27,7 +27,7 @@ import { CallToolRequestSchema, CreateMessageResultSchema, GetPromptRequestSchem
27
27
  import Fuse from "fuse.js";
28
28
  import matter from "gray-matter";
29
29
  import { spawnSync } from "node:child_process";
30
- import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
30
+ import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, sign as cryptoSign, timingSafeEqual, verify as cryptoVerify, } from "node:crypto";
31
31
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync, } from "node:fs";
32
32
  import { homedir } from "node:os";
33
33
  import { join, resolve } from "node:path";
@@ -615,27 +615,27 @@ export function toolDeleteMemory(args) {
615
615
  const fp = memoryFilePath(name);
616
616
  if (!existsSync(fp))
617
617
  return `Memory "${name}" not found.`;
618
- // v0.11.3 · receipt-gated path. If a receipt is supplied, validate it
619
- // against the current rule set + required caveats. If validation fails,
620
- // refuse the delete with a clear reason. If no receipt is supplied,
621
- // proceed (back-compat) but log so audit can surface the gap. v0.12
622
- // will require receipts unconditionally for destructive ops.
618
+ // v0.12.0 · receipt REQUIRED for delete_memory. The v0.11.x back-compat
619
+ // path (delete without receipt) is removed. Callers MUST first call
620
+ // check_action({action_type: 'deletions'}) to obtain a fresh receipt,
621
+ // then pass it to delete_memory as the `receipt` argument.
623
622
  const receipt = parseReceiptArg(args.receipt);
624
- if (receipt) {
625
- const v = validateReceipt(receipt, {
626
- required_caveats: [{ type: "action_type", value: "deletions" }],
627
- });
628
- if (!v.valid) {
629
- logEvent("delete_denied", { name, reason: v.reason, receipt_id: receipt.id });
630
- throw new Error(`delete_memory refused · receipt invalid (${v.reason}). ` +
631
- `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
632
- `to get a fresh receipt.`);
633
- }
634
- logEvent("delete_approved_via_receipt", { name, receipt_id: receipt.id });
635
- }
636
- else {
637
- logEvent("delete_without_receipt", { name });
623
+ if (!receipt) {
624
+ logEvent("delete_refused_no_receipt", { name });
625
+ throw new Error(`delete_memory refused · receipt required (v0.12.0+). ` +
626
+ `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
627
+ `first, then pass the issued receipt as the 'receipt' argument to delete_memory.`);
628
+ }
629
+ const v = validateReceipt(receipt, {
630
+ required_caveats: [{ type: "action_type", value: "deletions" }],
631
+ });
632
+ if (!v.valid) {
633
+ logEvent("delete_denied", { name, reason: v.reason, receipt_id: receipt.id });
634
+ throw new Error(`delete_memory refused · receipt invalid (${v.reason}). ` +
635
+ `Call check_action({action: 'delete memory ${name}', action_type: 'deletions'}) ` +
636
+ `to get a fresh receipt.`);
638
637
  }
638
+ logEvent("delete_approved_via_receipt", { name, receipt_id: receipt.id });
639
639
  return withLock(() => {
640
640
  ensureTrash();
641
641
  // Trash filename: <unix-ms>-<name>.md so restore can pick the
@@ -644,12 +644,9 @@ export function toolDeleteMemory(args) {
644
644
  const trashPath = join(TRASH_DIR, `${ts}-${name}.md`);
645
645
  renameSync(fp, trashPath);
646
646
  removeIndexEntryUnlocked(name);
647
- logEvent("delete", { name, trash: `${ts}-${name}.md`, gated: !!receipt });
647
+ logEvent("delete", { name, trash: `${ts}-${name}.md`, gated: true });
648
648
  log("debug", "delete_memory", { name });
649
- const gateMsg = receipt
650
- ? ` (gated by receipt ${receipt.id})`
651
- : " (no receipt · v0.11.3 back-compat path)";
652
- return `Moved "${name}" to trash${gateMsg}. Restore with: agent-memory restore ${name}`;
649
+ return `Moved "${name}" to trash (gated by receipt ${receipt.id}). Restore with: agent-memory restore ${name}`;
653
650
  });
654
651
  }
655
652
  function toolRestoreMemory(args) {
@@ -1336,7 +1333,16 @@ function toolSaveRule(args) {
1336
1333
  // in v0.11.3.
1337
1334
  const KEYRING_DIR = join(MEMORY_DIR, ".keyring");
1338
1335
  const HMAC_KEY_FILE = join(KEYRING_DIR, "hmac-key");
1336
+ const ED25519_PRIV_FILE = join(KEYRING_DIR, "ed25519.priv");
1337
+ const ED25519_PUB_FILE = join(KEYRING_DIR, "ed25519.pub");
1339
1338
  const RECEIPT_DEFAULT_TTL_SECONDS = 60;
1339
+ /**
1340
+ * Signing-mode selector. CRP 1.0 = HMAC-SHA256 with shared symmetric secret.
1341
+ * CRP 1.1 = Ed25519 asymmetric · enables cross-server federation (validators
1342
+ * verify with the issuer's public key, no shared secret needed). Set via
1343
+ * the `CRP_SIGNING_MODE` env var. Default stays `hmac` for back-compat.
1344
+ */
1345
+ const CRP_SIGNING_MODE = process.env.CRP_SIGNING_MODE === "ed25519" ? "ed25519" : "hmac";
1340
1346
  function loadOrCreateHmacKey() {
1341
1347
  if (existsSync(HMAC_KEY_FILE)) {
1342
1348
  return readFileSync(HMAC_KEY_FILE);
@@ -1350,6 +1356,39 @@ function loadOrCreateHmacKey() {
1350
1356
  writeFileSync(HMAC_KEY_FILE, key, { mode: 0o600 });
1351
1357
  return key;
1352
1358
  }
1359
+ /**
1360
+ * Load or lazily create the Ed25519 keypair for CRP 1.1 receipts. Private
1361
+ * key (PEM, mode 0o600) signs · public key (PEM, mode 0o644) verifies and
1362
+ * can be shared with other MCP servers for federation. The public key
1363
+ * is exportable via the `agent-memory export-pubkey` CLI command.
1364
+ */
1365
+ function loadOrCreateEd25519Keys() {
1366
+ if (!existsSync(KEYRING_DIR)) {
1367
+ mkdirSync(KEYRING_DIR, { recursive: true });
1368
+ }
1369
+ if (existsSync(ED25519_PRIV_FILE) && existsSync(ED25519_PUB_FILE)) {
1370
+ return {
1371
+ privateKeyPem: readFileSync(ED25519_PRIV_FILE, "utf8"),
1372
+ publicKeyPem: readFileSync(ED25519_PUB_FILE, "utf8"),
1373
+ };
1374
+ }
1375
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
1376
+ const privPem = privateKey.export({ format: "pem", type: "pkcs8" });
1377
+ const pubPem = publicKey.export({ format: "pem", type: "spki" });
1378
+ writeFileSync(ED25519_PRIV_FILE, privPem, { mode: 0o600 });
1379
+ writeFileSync(ED25519_PUB_FILE, pubPem, { mode: 0o644 });
1380
+ return { privateKeyPem: privPem, publicKeyPem: pubPem };
1381
+ }
1382
+ /**
1383
+ * Export the Ed25519 public key (PEM) for sharing with other servers.
1384
+ * Returns null when CRP 1.1 hasn't been initialized yet · the keypair is
1385
+ * created lazily on first Ed25519 receipt issuance.
1386
+ */
1387
+ export function exportEd25519PublicKey() {
1388
+ if (!existsSync(ED25519_PUB_FILE))
1389
+ return null;
1390
+ return readFileSync(ED25519_PUB_FILE, "utf8");
1391
+ }
1353
1392
  /**
1354
1393
  * Compute the rules-version hash · first 16 hex chars of SHA-256 over
1355
1394
  * the concatenated bytes of every type=rule memory file, in sorted
@@ -1380,21 +1419,70 @@ function computeRulesVersion() {
1380
1419
  */
1381
1420
  function canonicalizeReceipt(r) {
1382
1421
  const sortedCaveats = [...r.caveats].sort((a, b) => a.type === b.type ? a.value.localeCompare(b.value) : a.type.localeCompare(b.type));
1383
- return JSON.stringify({
1422
+ // Field ordering is significant for the signed canonical form. The
1423
+ // version field is included only when present so v1.0 and v1.1 receipts
1424
+ // produce distinct signatures (preventing version-downgrade attacks).
1425
+ const base = {
1384
1426
  id: r.id,
1385
1427
  issued_at: r.issued_at,
1386
1428
  expires_at: r.expires_at,
1387
1429
  rules_version: r.rules_version,
1388
1430
  caveats: sortedCaveats,
1389
- });
1431
+ };
1432
+ if (r.version)
1433
+ base.version = r.version;
1434
+ return JSON.stringify(base);
1390
1435
  }
1391
1436
  function signReceipt(r) {
1437
+ const canonical = canonicalizeReceipt(r);
1438
+ if (r.version === "1.1") {
1439
+ const { privateKeyPem } = loadOrCreateEd25519Keys();
1440
+ const privKey = createPrivateKey(privateKeyPem);
1441
+ // Ed25519 signs the raw bytes directly (no separate digest); pass null
1442
+ // as the algorithm per Node's API contract.
1443
+ return cryptoSign(null, Buffer.from(canonical, "utf8"), privKey).toString("hex");
1444
+ }
1445
+ // Default CRP 1.0 · HMAC-SHA256
1392
1446
  const key = loadOrCreateHmacKey();
1393
- return createHmac("sha256", key).update(canonicalizeReceipt(r)).digest("hex");
1447
+ return createHmac("sha256", key).update(canonical).digest("hex");
1448
+ }
1449
+ function verifySignature(receipt) {
1450
+ const canonical = canonicalizeReceipt({
1451
+ id: receipt.id,
1452
+ issued_at: receipt.issued_at,
1453
+ expires_at: receipt.expires_at,
1454
+ rules_version: receipt.rules_version,
1455
+ caveats: receipt.caveats,
1456
+ version: receipt.version,
1457
+ });
1458
+ if (receipt.version === "1.1") {
1459
+ try {
1460
+ const { publicKeyPem } = loadOrCreateEd25519Keys();
1461
+ const pubKey = createPublicKey(publicKeyPem);
1462
+ return cryptoVerify(null, Buffer.from(canonical, "utf8"), pubKey, Buffer.from(receipt.signature, "hex"));
1463
+ }
1464
+ catch {
1465
+ return false;
1466
+ }
1467
+ }
1468
+ // CRP 1.0 · HMAC-SHA256 with constant-time compare
1469
+ const key = loadOrCreateHmacKey();
1470
+ const expected = createHmac("sha256", key).update(canonical).digest();
1471
+ const actual = Buffer.from(receipt.signature, "hex");
1472
+ if (expected.length !== actual.length)
1473
+ return false;
1474
+ return timingSafeEqual(expected, actual);
1394
1475
  }
1395
1476
  /**
1396
- * Issue a fresh Compliance Receipt with the given caveats. The receipt
1397
- * is bound to the current rule-store hash; any rule edit invalidates it.
1477
+ * Issue a fresh Compliance Receipt with the given caveats. The receipt is
1478
+ * bound to the current rule-store hash; any rule edit invalidates it.
1479
+ *
1480
+ * Signing mode selected by the `CRP_SIGNING_MODE` env var:
1481
+ * - `hmac` (default) · CRP 1.0 · HMAC-SHA256 with a 256-bit shared secret
1482
+ * - `ed25519` · CRP 1.1 · Ed25519 asymmetric · enables cross-server
1483
+ * federation since validators verify with the issuer's public key
1484
+ * without sharing any secret. Public key exportable via the
1485
+ * `agent-memory export-pubkey` CLI command.
1398
1486
  */
1399
1487
  export function issueReceipt(opts) {
1400
1488
  const now = Math.floor(Date.now() / 1000);
@@ -1405,6 +1493,7 @@ export function issueReceipt(opts) {
1405
1493
  expires_at: now + ttl,
1406
1494
  rules_version: computeRulesVersion(),
1407
1495
  caveats: opts.caveats,
1496
+ ...(CRP_SIGNING_MODE === "ed25519" ? { version: "1.1" } : {}),
1408
1497
  };
1409
1498
  return { ...base, signature: signReceipt(base) };
1410
1499
  }
@@ -1414,17 +1503,10 @@ export function issueReceipt(opts) {
1414
1503
  * {valid: false, reason: <human-readable>}.
1415
1504
  */
1416
1505
  export function validateReceipt(receipt, opts = {}) {
1417
- // 1. HMAC verification · constant-time compare to avoid timing leaks.
1418
- const expected = signReceipt({
1419
- id: receipt.id,
1420
- issued_at: receipt.issued_at,
1421
- expires_at: receipt.expires_at,
1422
- rules_version: receipt.rules_version,
1423
- caveats: receipt.caveats,
1424
- });
1425
- const expectedBuf = Buffer.from(expected, "hex");
1426
- const actualBuf = Buffer.from(receipt.signature, "hex");
1427
- if (expectedBuf.length !== actualBuf.length || !timingSafeEqual(expectedBuf, actualBuf)) {
1506
+ // 1. Signature verification · routes to HMAC (CRP 1.0) or Ed25519 (CRP 1.1)
1507
+ // based on the receipt's version field. Both paths use constant-time
1508
+ // comparisons internally to avoid timing leaks.
1509
+ if (!verifySignature(receipt)) {
1428
1510
  return { valid: false, reason: "invalid signature" };
1429
1511
  }
1430
1512
  // 2. Expiry · receipts past their expires_at are dead.
@@ -1782,7 +1864,14 @@ function recentDenials() {
1782
1864
  }));
1783
1865
  }
1784
1866
  function recentUnreceiptedDeletes() {
1785
- const records = readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_without_receipt" });
1867
+ // v0.11.x emitted "delete_without_receipt" when an unreceipted delete
1868
+ // succeeded. v0.12.0 emits "delete_refused_no_receipt" when refusing.
1869
+ // We surface BOTH event types so an audit run against pre-v0.12 logs
1870
+ // still reports historical unreceipted deletes correctly.
1871
+ const records = [
1872
+ ...readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_without_receipt" }),
1873
+ ...readEventLog({ tail: AUDIT_EVENT_TAIL, action: "delete_refused_no_receipt" }),
1874
+ ];
1786
1875
  return records.map((r) => ({
1787
1876
  ts: String(r.ts),
1788
1877
  name: String(r.name ?? ""),
@@ -2188,7 +2277,7 @@ function actionColor(action) {
2188
2277
  // -------------------------------------------------------------
2189
2278
  // Server wiring
2190
2279
  // -------------------------------------------------------------
2191
- const server = new Server({ name: "agent-memory", version: "0.11.7" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
2280
+ const server = new Server({ name: "agent-memory", version: "0.13.0" }, { capabilities: { tools: {}, resources: {}, prompts: {} } });
2192
2281
  // -------------------------------------------------------------
2193
2282
  // Resource URI scheme
2194
2283
  // -------------------------------------------------------------
@@ -2512,18 +2601,18 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
2512
2601
  {
2513
2602
  name: "delete_memory",
2514
2603
  description: "Move a memory to .trash/ (soft delete). The file is removed from the index but recoverable via restore_memory until you manually empty .trash/. " +
2515
- "v0.11.3+ accepts an optional `receipt` argument · pass a Compliance Receipt from check_action({action_type: 'deletions'}) to gate the delete against the rule store. " +
2604
+ "v0.12.0+ · receipt REQUIRED. Caller MUST first call check_action({action: 'delete memory <name>', action_type: 'deletions'}) to obtain a fresh Compliance Receipt, then pass it to this tool as `receipt`. " +
2516
2605
  "Receipts must carry the caveat {type: 'action_type', value: 'deletions'} or the delete refuses. " +
2517
- "Receipts not supplied are accepted (back-compat) but logged · v0.12 will require them.",
2606
+ "Migration from v0.11.x: previously unreceipted deletes were accepted with a warning · now they throw. Add a check_action call before each delete_memory call.",
2518
2607
  inputSchema: {
2519
2608
  type: "object",
2520
2609
  properties: {
2521
2610
  name: { type: "string", description: "The memory's name slug" },
2522
2611
  receipt: {
2523
- description: "Optional Compliance Receipt (object or JSON string) from check_action. v0.11.3 logs unreceipted deletes but doesn't block them yet.",
2612
+ description: "REQUIRED · Compliance Receipt (object or JSON string) from check_action with action_type=deletions. Without this, the delete is refused.",
2524
2613
  },
2525
2614
  },
2526
- required: ["name"],
2615
+ required: ["name", "receipt"],
2527
2616
  },
2528
2617
  },
2529
2618
  {
@@ -2872,6 +2961,7 @@ const CLI_COMMANDS = new Set([
2872
2961
  "emit-companions",
2873
2962
  "check-action",
2874
2963
  "audit",
2964
+ "export-pubkey",
2875
2965
  "ui",
2876
2966
  "import-claude-code",
2877
2967
  "help",
@@ -2986,7 +3076,19 @@ async function cliMain(command, rest) {
2986
3076
  const name = positional[0];
2987
3077
  if (!name)
2988
3078
  throw new Error("Usage: agent-memory delete <name>");
2989
- process.stdout.write(toolDeleteMemory({ name }) + "\n");
3079
+ // v0.12.0+ · delete_memory requires a Compliance Receipt. The CLI
3080
+ // is the trusted operator path (a human is running the command,
3081
+ // not an AI agent), so we auto-issue a CLI-scoped receipt rather
3082
+ // than make the operator chain `check-action` then paste JSON.
3083
+ // MCP callers (AI agents) still must go through check_action
3084
+ // explicitly — this short-circuit only fires from the CLI binary.
3085
+ const receipt = issueReceipt({
3086
+ caveats: [
3087
+ { type: "action_type", value: "deletions" },
3088
+ { type: "issued_by", value: "cli" },
3089
+ ],
3090
+ });
3091
+ process.stdout.write(toolDeleteMemory({ name, receipt: JSON.stringify(receipt) }) + "\n");
2990
3092
  return 0;
2991
3093
  }
2992
3094
  case "restore": {
@@ -3137,6 +3239,14 @@ async function cliMain(command, rest) {
3137
3239
  process.stdout.write(toolAudit({ format: flags.json ? "json" : "pretty" }) + "\n");
3138
3240
  return 0;
3139
3241
  }
3242
+ case "export-pubkey": {
3243
+ // CRP 1.1 · print the Ed25519 public key (PEM) so other MCP servers
3244
+ // can verify receipts issued by this server without sharing the
3245
+ // signing secret. Lazily initializes the keypair if missing.
3246
+ const { publicKeyPem } = loadOrCreateEd25519Keys();
3247
+ process.stdout.write(publicKeyPem);
3248
+ return 0;
3249
+ }
3140
3250
  case "ui": {
3141
3251
  // Dynamic import so Ink + React only load when the TUI runs,
3142
3252
  // keeping cold-start fast for MCP server + every other CLI command.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xultrax-web/agent-memory-mcp",
3
- "version": "0.11.7",
3
+ "version": "0.13.0",
4
4
  "mcpName": "io.github.xultrax-web/agent-memory-mcp",
5
5
  "description": "Codify how you work. Every AI tool obeys. Markdown rules + cross-tool companion files (AGENTS.md/CLAUDE.md/.cursor/rules/.gemini) + Compliance Receipts for protocol-level enforcement of destructive ops. Reference implementation of CRP 1.0. Works on every MCP client (no Sampling required).",
6
6
  "type": "module",