@mnemom/mnemom 0.7.2 → 0.9.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.
@@ -0,0 +1,542 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as os from "node:os";
4
+ import { spawnSync } from "node:child_process";
5
+ import yaml from "js-yaml";
6
+ import { PROTECTION_CARD_MAX_BYTES, getProtectionCard, putProtectionCard, resolveAgentId, } from "../lib/api.js";
7
+ import { requireAuth } from "../lib/auth.js";
8
+ import { fmt } from "../lib/format.js";
9
+ import { askYesNo, isInteractive } from "../lib/prompt.js";
10
+ const PROTECTION_MODES = ["off", "observe", "nudge", "enforce"];
11
+ const SURFACE_KEYS = ["incoming", "outgoing", "tool_calls", "tool_responses"];
12
+ // Per ADR-037 Decision 4: deny public LLM endpoints + public DNS providers,
13
+ // and the any-host CIDRs, at write time.
14
+ const DENY_DOMAINS = new Set([
15
+ "api.openai.com",
16
+ "api.anthropic.com",
17
+ "generativelanguage.googleapis.com",
18
+ "api.cohere.ai",
19
+ "api.mistral.ai",
20
+ "api.groq.com",
21
+ "cloud.google.com",
22
+ "dns.google",
23
+ "cloudflare-dns.com",
24
+ "one.one.one.one",
25
+ "dns.quad9.net",
26
+ ]);
27
+ const DENY_IP_PREFIXES = [
28
+ "0.0.0.0/0",
29
+ "::/0",
30
+ "8.8.8.0/24",
31
+ "8.8.4.0/24",
32
+ "1.1.1.0/24",
33
+ "1.0.0.0/24",
34
+ "9.9.9.0/24",
35
+ ];
36
+ const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+(:\d{1,5})?$/i;
37
+ const AGENT_ID_RE = /^mnm-[a-z0-9-]{4,}$/i;
38
+ const CIDR_RE = /^([0-9]{1,3}(\.[0-9]{1,3}){3})\/([0-9]|[12][0-9]|3[0-2])$|^([0-9a-f:]+)\/(\d{1,3})$/i;
39
+ function isObject(v) {
40
+ return typeof v === "object" && v !== null && !Array.isArray(v);
41
+ }
42
+ function validateDomain(d) {
43
+ const lower = d.toLowerCase();
44
+ if (DENY_DOMAINS.has(lower.split(":")[0])) {
45
+ return "domain is on the static deny-list (public LLM/DNS endpoint)";
46
+ }
47
+ if (!DOMAIN_RE.test(lower))
48
+ return "not a valid DNS name (or host:port)";
49
+ return null;
50
+ }
51
+ function validateAgentId(a) {
52
+ if (!AGENT_ID_RE.test(a))
53
+ return "must match Mnemom agent ID format (mnm-*)";
54
+ return null;
55
+ }
56
+ function validateCidr(c) {
57
+ if (DENY_IP_PREFIXES.includes(c)) {
58
+ return "CIDR is on the static deny-list (public DNS / 0.0.0.0/0 / ::/0)";
59
+ }
60
+ if (!CIDR_RE.test(c))
61
+ return "not a valid CIDR notation";
62
+ return null;
63
+ }
64
+ function validateTrustedBucket(name, bucket, checks, perEntry) {
65
+ if (bucket === undefined)
66
+ return;
67
+ if (!Array.isArray(bucket)) {
68
+ checks.push({
69
+ name: `trusted_sources.${name}`,
70
+ passed: false,
71
+ message: `Must be an array of strings (got ${typeof bucket}). Per ADR-037 trusted_sources is an object of typed buckets — not an array of objects.`,
72
+ });
73
+ return;
74
+ }
75
+ let bad = 0;
76
+ bucket.forEach((entry, i) => {
77
+ if (typeof entry !== "string") {
78
+ checks.push({
79
+ name: `trusted_sources.${name}[${i}]`,
80
+ passed: false,
81
+ message: "Must be a string",
82
+ });
83
+ bad++;
84
+ return;
85
+ }
86
+ const err = perEntry(entry);
87
+ if (err) {
88
+ checks.push({
89
+ name: `trusted_sources.${name}[${i}]`,
90
+ passed: false,
91
+ message: `${entry}: ${err}`,
92
+ });
93
+ bad++;
94
+ }
95
+ });
96
+ if (bad === 0) {
97
+ checks.push({
98
+ name: `trusted_sources.${name}`,
99
+ passed: true,
100
+ message: `${bucket.length} entr${bucket.length === 1 ? "y" : "ies"}`,
101
+ });
102
+ }
103
+ }
104
+ /**
105
+ * Validate a protection card against ADR-037 canonical form.
106
+ *
107
+ * Required: card_version, agent_id, mode (off|observe|nudge|enforce).
108
+ * Optional: thresholds (warn ≤ quarantine ≤ block, all in [0,1]),
109
+ * screen_surfaces (object of bools with the four named keys),
110
+ * trusted_sources (object of typed buckets, per-bucket deny-lists).
111
+ */
112
+ export function validateProtectionCard(card) {
113
+ const checks = [];
114
+ // ── card_version (required) ──
115
+ if (typeof card.card_version !== "string" || card.card_version.length === 0) {
116
+ checks.push({
117
+ name: "card_version",
118
+ passed: false,
119
+ message: 'Required (string, e.g. "protection/2026-04-26"). See ADR-037.',
120
+ });
121
+ }
122
+ else {
123
+ checks.push({ name: "card_version", passed: true, message: card.card_version });
124
+ }
125
+ // ── agent_id (required) ──
126
+ if (typeof card.agent_id !== "string" || card.agent_id.length === 0) {
127
+ checks.push({
128
+ name: "agent_id",
129
+ passed: false,
130
+ message: "Required (string).",
131
+ });
132
+ }
133
+ else {
134
+ checks.push({ name: "agent_id", passed: true, message: card.agent_id });
135
+ }
136
+ // ── mode (required, ADR-037 canonical enum) ──
137
+ const mode = card.mode;
138
+ if (typeof mode !== "string") {
139
+ checks.push({
140
+ name: "mode",
141
+ passed: false,
142
+ message: `Required. Must be one of: ${PROTECTION_MODES.join(" | ")}. Per ADR-037 the legacy "block"/"warn" values are no longer accepted.`,
143
+ });
144
+ }
145
+ else if (!PROTECTION_MODES.includes(mode)) {
146
+ checks.push({
147
+ name: "mode",
148
+ passed: false,
149
+ message: `Invalid: "${mode}". Must be one of: ${PROTECTION_MODES.join(" | ")}. (Per ADR-037 the legacy "block"/"warn" values are no longer accepted; use "enforce"/"nudge".)`,
150
+ });
151
+ }
152
+ else {
153
+ checks.push({ name: "mode", passed: true, message: mode });
154
+ }
155
+ // ── thresholds (optional) ──
156
+ if (card.thresholds !== undefined) {
157
+ if (!isObject(card.thresholds)) {
158
+ checks.push({ name: "thresholds", passed: false, message: "Must be an object if present" });
159
+ }
160
+ else {
161
+ const t = card.thresholds;
162
+ const nums = {};
163
+ let bad = false;
164
+ for (const key of ["warn", "quarantine", "block"]) {
165
+ const v = t[key];
166
+ if (v === undefined) {
167
+ checks.push({
168
+ name: `thresholds.${key}`,
169
+ passed: false,
170
+ message: "Required when thresholds is present",
171
+ });
172
+ bad = true;
173
+ continue;
174
+ }
175
+ if (typeof v !== "number" || v < 0 || v > 1 || Number.isNaN(v)) {
176
+ checks.push({
177
+ name: `thresholds.${key}`,
178
+ passed: false,
179
+ message: "Must be a number in [0, 1]",
180
+ });
181
+ bad = true;
182
+ continue;
183
+ }
184
+ nums[key] = v;
185
+ }
186
+ if (nums.warn !== undefined && nums.quarantine !== undefined && nums.warn > nums.quarantine) {
187
+ checks.push({
188
+ name: "thresholds.warn",
189
+ passed: false,
190
+ message: `Must be ≤ thresholds.quarantine (warn=${nums.warn} > quarantine=${nums.quarantine})`,
191
+ });
192
+ bad = true;
193
+ }
194
+ if (nums.quarantine !== undefined && nums.block !== undefined && nums.quarantine > nums.block) {
195
+ checks.push({
196
+ name: "thresholds.quarantine",
197
+ passed: false,
198
+ message: `Must be ≤ thresholds.block (quarantine=${nums.quarantine} > block=${nums.block})`,
199
+ });
200
+ bad = true;
201
+ }
202
+ if (!bad) {
203
+ checks.push({
204
+ name: "thresholds",
205
+ passed: true,
206
+ message: `warn=${nums.warn}, quarantine=${nums.quarantine}, block=${nums.block}`,
207
+ });
208
+ }
209
+ }
210
+ }
211
+ // ── screen_surfaces (optional, object of bools) ──
212
+ if (card.screen_surfaces !== undefined) {
213
+ if (Array.isArray(card.screen_surfaces)) {
214
+ checks.push({
215
+ name: "screen_surfaces",
216
+ passed: false,
217
+ message: 'Must be an object of booleans, not an array. Per ADR-037 use { incoming: true, outgoing: true, tool_calls: true, tool_responses: true }.',
218
+ });
219
+ }
220
+ else if (!isObject(card.screen_surfaces)) {
221
+ checks.push({ name: "screen_surfaces", passed: false, message: "Must be an object if present" });
222
+ }
223
+ else {
224
+ const s = card.screen_surfaces;
225
+ let bad = false;
226
+ for (const key of SURFACE_KEYS) {
227
+ const v = s[key];
228
+ if (v !== undefined && typeof v !== "boolean") {
229
+ checks.push({
230
+ name: `screen_surfaces.${key}`,
231
+ passed: false,
232
+ message: "Must be a boolean",
233
+ });
234
+ bad = true;
235
+ }
236
+ }
237
+ for (const key of Object.keys(s)) {
238
+ if (!SURFACE_KEYS.includes(key)) {
239
+ checks.push({
240
+ name: `screen_surfaces.${key}`,
241
+ passed: false,
242
+ message: `Unknown surface (allowed: ${SURFACE_KEYS.join(", ")})`,
243
+ });
244
+ bad = true;
245
+ }
246
+ }
247
+ if (!bad) {
248
+ const enabled = SURFACE_KEYS.filter(k => s[k] === true).length;
249
+ checks.push({
250
+ name: "screen_surfaces",
251
+ passed: true,
252
+ message: `${enabled}/${SURFACE_KEYS.length} surfaces enabled`,
253
+ });
254
+ }
255
+ }
256
+ }
257
+ // ── trusted_sources (optional, typed buckets) ──
258
+ if (card.trusted_sources !== undefined) {
259
+ if (Array.isArray(card.trusted_sources)) {
260
+ checks.push({
261
+ name: "trusted_sources",
262
+ passed: false,
263
+ message: 'Must be an object of typed buckets, not an array. Per ADR-037 use { domains: [...], agent_ids: [...], ip_ranges: [...] } — the legacy [{pattern, ...}] shape is no longer accepted.',
264
+ });
265
+ }
266
+ else if (!isObject(card.trusted_sources)) {
267
+ checks.push({ name: "trusted_sources", passed: false, message: "Must be an object if present" });
268
+ }
269
+ else {
270
+ const ts = card.trusted_sources;
271
+ validateTrustedBucket("domains", ts.domains, checks, validateDomain);
272
+ validateTrustedBucket("agent_ids", ts.agent_ids, checks, validateAgentId);
273
+ validateTrustedBucket("ip_ranges", ts.ip_ranges, checks, validateCidr);
274
+ for (const key of Object.keys(ts)) {
275
+ if (!["domains", "agent_ids", "ip_ranges"].includes(key)) {
276
+ checks.push({
277
+ name: `trusted_sources.${key}`,
278
+ passed: false,
279
+ message: "Unknown bucket (allowed: domains, agent_ids, ip_ranges)",
280
+ });
281
+ }
282
+ }
283
+ }
284
+ }
285
+ return checks;
286
+ }
287
+ // ============================================================================
288
+ // File parsing
289
+ // ============================================================================
290
+ function parseProtectionFile(filePath) {
291
+ const raw = fs.readFileSync(filePath, "utf-8");
292
+ const ext = path.extname(filePath).toLowerCase();
293
+ const format = (ext === ".yaml" || ext === ".yml") ? "yaml" : "json";
294
+ if (format === "yaml") {
295
+ const parsed = yaml.load(raw);
296
+ if (!parsed || typeof parsed !== "object") {
297
+ throw new Error("YAML did not produce a valid object");
298
+ }
299
+ return { parsed, raw, format };
300
+ }
301
+ return { parsed: JSON.parse(raw), raw, format };
302
+ }
303
+ // ============================================================================
304
+ // Subcommands
305
+ // ============================================================================
306
+ export async function protectionShowCommand(agentName) {
307
+ const agentId = await resolveAgentId(agentName);
308
+ console.log("\nFetching protection card...\n");
309
+ try {
310
+ const { body, contentType } = await getProtectionCard(agentId);
311
+ if (!body) {
312
+ console.log(fmt.warn("No protection card found"));
313
+ console.log("\nPublish one with:\n");
314
+ console.log(" mnemom protection publish <file.yaml>\n");
315
+ return;
316
+ }
317
+ if (contentType.includes("yaml") || contentType.includes("text/yaml")) {
318
+ console.log(fmt.header("Protection Card"));
319
+ console.log();
320
+ console.log(body);
321
+ }
322
+ else {
323
+ const parsed = JSON.parse(body);
324
+ console.log(fmt.header("Protection Card"));
325
+ console.log();
326
+ console.log(yaml.dump(parsed, { lineWidth: 120, noRefs: true }));
327
+ }
328
+ }
329
+ catch (error) {
330
+ const message = error instanceof Error ? error.message : String(error);
331
+ console.log("\n" + fmt.error(`Failed to fetch protection card: ${message}`) + "\n");
332
+ process.exit(1);
333
+ }
334
+ }
335
+ export async function protectionPublishCommand(file, agentName, options = {}) {
336
+ const agentId = await resolveAgentId(agentName);
337
+ const filePath = path.resolve(file);
338
+ if (!fs.existsSync(filePath)) {
339
+ console.log("\n" + fmt.error(`File not found: ${filePath}`) + "\n");
340
+ process.exit(1);
341
+ }
342
+ let parsed;
343
+ try {
344
+ parsed = parseProtectionFile(filePath);
345
+ }
346
+ catch (e) {
347
+ const msg = e instanceof Error ? e.message : String(e);
348
+ console.log("\n" + fmt.error(`Could not parse file: ${msg}`) + "\n");
349
+ process.exit(1);
350
+ }
351
+ // Validate locally
352
+ const checks = validateProtectionCard(parsed.parsed);
353
+ const allPassed = checks.every((c) => c.passed);
354
+ console.log(fmt.header("Protection Card Validation"));
355
+ console.log(fmt.label(" Format:", ` ${parsed.format.toUpperCase()}`));
356
+ console.log();
357
+ for (const check of checks) {
358
+ if (check.passed) {
359
+ console.log(fmt.success(`${check.name}: ${check.message}`));
360
+ }
361
+ else {
362
+ console.log(fmt.error(`${check.name}: ${check.message}`));
363
+ }
364
+ }
365
+ console.log();
366
+ if (!allPassed) {
367
+ console.log(fmt.error("Validation failed. Fix the errors above before publishing.") + "\n");
368
+ process.exit(1);
369
+ }
370
+ await requireAuth();
371
+ if (isInteractive()) {
372
+ const confirm = await askYesNo(`Publish this protection card for agent ${agentId}?`, false);
373
+ if (!confirm) {
374
+ console.log("\nPublish cancelled.\n");
375
+ return;
376
+ }
377
+ }
378
+ try {
379
+ console.log("\nPublishing protection card...");
380
+ const contentType = parsed.format === "yaml" ? "text/yaml" : "application/json";
381
+ const body = parsed.format === "yaml" ? parsed.raw : JSON.stringify(parsed.parsed);
382
+ const bodyBytes = Buffer.byteLength(body, "utf-8");
383
+ if (bodyBytes > PROTECTION_CARD_MAX_BYTES) {
384
+ console.log("\n" +
385
+ fmt.error(`Protection card is ${bodyBytes} bytes; limit is ${PROTECTION_CARD_MAX_BYTES} bytes (64 KB). The API will return 413.`) +
386
+ "\n");
387
+ process.exit(1);
388
+ }
389
+ const result = await putProtectionCard(agentId, body, contentType, {
390
+ idempotencyKey: options.idempotencyKey,
391
+ });
392
+ console.log(fmt.success("Protection card published!"));
393
+ if (typeof result.card_id === "string" && result.card_id.length > 0) {
394
+ console.log(fmt.label(" Card ID:", ` ${result.card_id}`));
395
+ }
396
+ console.log();
397
+ }
398
+ catch (error) {
399
+ const message = error instanceof Error ? error.message : String(error);
400
+ console.log("\n" + fmt.error(`Failed to publish protection card: ${message}`) + "\n");
401
+ process.exit(1);
402
+ }
403
+ }
404
+ export async function protectionValidateCommand(file) {
405
+ const filePath = path.resolve(file);
406
+ if (!fs.existsSync(filePath)) {
407
+ console.log("\n" + fmt.error(`File not found: ${filePath}`) + "\n");
408
+ process.exit(1);
409
+ }
410
+ let parsed;
411
+ try {
412
+ parsed = parseProtectionFile(filePath);
413
+ }
414
+ catch (e) {
415
+ const msg = e instanceof Error ? e.message : String(e);
416
+ console.log("\n" + fmt.error(`Could not parse file: ${msg}`) + "\n");
417
+ process.exit(1);
418
+ }
419
+ const checks = validateProtectionCard(parsed.parsed);
420
+ const allPassed = checks.every((c) => c.passed);
421
+ const passCount = checks.filter((c) => c.passed).length;
422
+ const failCount = checks.filter((c) => !c.passed).length;
423
+ console.log(fmt.header("Protection Card Validation Report"));
424
+ console.log();
425
+ console.log(fmt.label(" File:", ` ${filePath}`));
426
+ console.log(fmt.label(" Format:", ` ${parsed.format.toUpperCase()}`));
427
+ console.log();
428
+ for (const check of checks) {
429
+ if (check.passed) {
430
+ console.log(fmt.success(`${check.name}: ${check.message}`));
431
+ }
432
+ else {
433
+ console.log(fmt.error(`${check.name}: ${check.message}`));
434
+ }
435
+ }
436
+ console.log();
437
+ if (allPassed) {
438
+ console.log(fmt.success(`All ${passCount} checks passed`) + "\n");
439
+ }
440
+ else {
441
+ console.log(fmt.error(`${failCount} check(s) failed, ${passCount} passed`) + "\n");
442
+ process.exit(1);
443
+ }
444
+ }
445
+ export async function protectionEditCommand(agentName, options = {}) {
446
+ const agentId = await resolveAgentId(agentName);
447
+ await requireAuth();
448
+ console.log("\nFetching current protection card...\n");
449
+ const { body: original } = await getProtectionCard(agentId);
450
+ if (!original) {
451
+ console.log(fmt.warn("No protection card found. Creating a template..."));
452
+ }
453
+ const cardYaml = original || yaml.dump({
454
+ card_version: "protection/2026-04-26",
455
+ agent_id: agentId,
456
+ mode: "observe",
457
+ thresholds: { warn: 0.3, quarantine: 0.6, block: 0.9 },
458
+ screen_surfaces: {
459
+ incoming: true,
460
+ outgoing: true,
461
+ tool_calls: true,
462
+ tool_responses: true,
463
+ },
464
+ trusted_sources: { domains: [], agent_ids: [], ip_ranges: [] },
465
+ }, { lineWidth: 120, noRefs: true });
466
+ const tmpDir = os.tmpdir();
467
+ const tmpFile = path.join(tmpDir, `mnemom-protection-${agentId}.yaml`);
468
+ fs.writeFileSync(tmpFile, cardYaml);
469
+ const editor = process.env.EDITOR || process.env.VISUAL || "vi";
470
+ console.log(`Opening ${editor}...`);
471
+ const result = spawnSync(editor, [tmpFile], { stdio: "inherit" });
472
+ if (result.status !== 0) {
473
+ console.log("\n" + fmt.error("Editor exited with an error") + "\n");
474
+ try {
475
+ fs.unlinkSync(tmpFile);
476
+ }
477
+ catch { /* ignore */ }
478
+ process.exit(1);
479
+ }
480
+ const edited = fs.readFileSync(tmpFile, "utf-8");
481
+ try {
482
+ fs.unlinkSync(tmpFile);
483
+ }
484
+ catch { /* ignore */ }
485
+ if (edited === cardYaml) {
486
+ console.log("\nNo changes made.\n");
487
+ return;
488
+ }
489
+ let parsed;
490
+ try {
491
+ parsed = yaml.load(edited);
492
+ if (!parsed || typeof parsed !== "object")
493
+ throw new Error("Invalid YAML");
494
+ }
495
+ catch (e) {
496
+ const msg = e instanceof Error ? e.message : String(e);
497
+ console.log("\n" + fmt.error(`Invalid YAML: ${msg}`) + "\n");
498
+ process.exit(1);
499
+ }
500
+ const checks = validateProtectionCard(parsed);
501
+ const allPassed = checks.every((c) => c.passed);
502
+ if (!allPassed) {
503
+ console.log(fmt.header("Validation Errors"));
504
+ console.log();
505
+ for (const check of checks.filter((c) => !c.passed)) {
506
+ console.log(fmt.error(`${check.name}: ${check.message}`));
507
+ }
508
+ console.log();
509
+ console.log(fmt.error("Validation failed. Card not published.") + "\n");
510
+ process.exit(1);
511
+ }
512
+ if (isInteractive()) {
513
+ const confirm = await askYesNo("Publish updated protection card?", true);
514
+ if (!confirm) {
515
+ console.log("\nPublish cancelled.\n");
516
+ return;
517
+ }
518
+ }
519
+ try {
520
+ console.log("\nPublishing protection card...");
521
+ const editedBytes = Buffer.byteLength(edited, "utf-8");
522
+ if (editedBytes > PROTECTION_CARD_MAX_BYTES) {
523
+ console.log("\n" +
524
+ fmt.error(`Protection card is ${editedBytes} bytes; limit is ${PROTECTION_CARD_MAX_BYTES} bytes (64 KB). The API will return 413.`) +
525
+ "\n");
526
+ process.exit(1);
527
+ }
528
+ const putResult = await putProtectionCard(agentId, edited, "text/yaml", {
529
+ idempotencyKey: options.idempotencyKey,
530
+ });
531
+ console.log(fmt.success("Protection card published!"));
532
+ if (typeof putResult.card_id === "string" && putResult.card_id.length > 0) {
533
+ console.log(fmt.label(" Card ID:", ` ${putResult.card_id}`));
534
+ }
535
+ console.log();
536
+ }
537
+ catch (error) {
538
+ const message = error instanceof Error ? error.message : String(error);
539
+ console.log("\n" + fmt.error(`Failed to publish protection card: ${message}`) + "\n");
540
+ process.exit(1);
541
+ }
542
+ }