@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.
- package/dist/index.js +158 -48
- 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.
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
logEvent("
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
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:
|
|
647
|
+
logEvent("delete", { name, trash: `${ts}-${name}.md`, gated: true });
|
|
648
648
|
log("debug", "delete_memory", { name });
|
|
649
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
*
|
|
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.
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
"
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
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",
|