@mnemom/mnemom 0.17.38-next.0 → 0.17.38-next.2

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 (79) hide show
  1. package/dist/commands/agent-doctor.d.ts +2 -2
  2. package/dist/commands/agent-doctor.js +4 -4
  3. package/dist/commands/agent-run.d.ts +10 -2
  4. package/dist/commands/agent-run.js +12 -6
  5. package/dist/commands/agent-setup.d.ts +11 -9
  6. package/dist/commands/agent-setup.js +24 -19
  7. package/dist/commands/card.js +1 -1
  8. package/dist/commands/onboard.js +5 -4
  9. package/dist/commands/scan.d.ts +21 -7
  10. package/dist/commands/scan.js +93 -56
  11. package/dist/commands/verify-card.d.ts +25 -1
  12. package/dist/commands/verify-card.js +29 -2
  13. package/dist/commands/wrap.js +4 -3
  14. package/dist/index.js +7 -5
  15. package/dist/lib/runner/executor-client.d.ts +6 -0
  16. package/dist/lib/runner/executor-client.js +6 -0
  17. package/dist/lib/runner/job.d.ts +12 -3
  18. package/dist/lib/runner/job.js +30 -2
  19. package/dist/lib/scan/caps.d.ts +1 -35
  20. package/dist/lib/scan/caps.js +2 -67
  21. package/dist/lib/scan/cost-per-pr.d.ts +1 -93
  22. package/dist/lib/scan/cost-per-pr.js +2 -101
  23. package/dist/lib/scan/credential.d.ts +2 -26
  24. package/dist/lib/scan/credential.js +5 -58
  25. package/dist/lib/scan/failure.d.ts +1 -10
  26. package/dist/lib/scan/failure.js +2 -43
  27. package/dist/lib/scan/pr-source.d.ts +1 -57
  28. package/dist/lib/scan/pr-source.js +2 -132
  29. package/dist/lib/scan/report-email.d.ts +9 -89
  30. package/dist/lib/scan/report-email.js +76 -92
  31. package/dist/lib/scan/scan.d.ts +1 -67
  32. package/dist/lib/scan/scan.js +2 -194
  33. package/dist/lib/scan/share.d.ts +10 -6
  34. package/dist/lib/scan/share.js +28 -11
  35. package/dist/lib/scan/spend/ai-tool-detect.d.ts +1 -6
  36. package/dist/lib/scan/spend/ai-tool-detect.js +2 -18
  37. package/dist/lib/scan/spend/anthropic-usage.d.ts +1 -76
  38. package/dist/lib/scan/spend/anthropic-usage.js +2 -269
  39. package/dist/lib/scan/spend/prices.d.ts +1 -33
  40. package/dist/lib/scan/spend/prices.js +2 -57
  41. package/dist/lib/scan/spend/spend-estimate.d.ts +1 -30
  42. package/dist/lib/scan/spend/spend-estimate.js +2 -70
  43. package/dist/lib/scan/spend/spend-tiers.d.ts +1 -16
  44. package/dist/lib/scan/spend/spend-tiers.js +6 -26
  45. package/dist/lib/scan/spend/token-rate.d.ts +1 -30
  46. package/dist/lib/scan/spend/token-rate.js +2 -54
  47. package/dist/lib/starter-integrity.d.ts +20 -0
  48. package/dist/lib/starter-integrity.js +28 -0
  49. package/dist/vendor/shared/org-scan-core/caps.d.ts +35 -0
  50. package/dist/vendor/shared/org-scan-core/caps.js +68 -0
  51. package/dist/vendor/shared/org-scan-core/cost-per-pr.d.ts +93 -0
  52. package/dist/vendor/shared/org-scan-core/cost-per-pr.js +101 -0
  53. package/dist/vendor/shared/org-scan-core/credential.d.ts +43 -0
  54. package/dist/vendor/shared/org-scan-core/credential.js +74 -0
  55. package/dist/vendor/shared/org-scan-core/failure.d.ts +10 -0
  56. package/dist/vendor/shared/org-scan-core/failure.js +43 -0
  57. package/dist/vendor/shared/org-scan-core/index.d.ts +18 -0
  58. package/dist/vendor/shared/org-scan-core/index.js +19 -0
  59. package/dist/vendor/shared/org-scan-core/pr-source.d.ts +61 -0
  60. package/dist/vendor/shared/org-scan-core/pr-source.js +138 -0
  61. package/dist/vendor/shared/org-scan-core/report-build.d.ts +120 -0
  62. package/dist/vendor/shared/org-scan-core/report-build.js +119 -0
  63. package/dist/vendor/shared/org-scan-core/scan.d.ts +75 -0
  64. package/dist/vendor/shared/org-scan-core/scan.js +202 -0
  65. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.d.ts +6 -0
  66. package/dist/vendor/shared/org-scan-core/spend/ai-tool-detect.js +18 -0
  67. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.d.ts +76 -0
  68. package/dist/vendor/shared/org-scan-core/spend/anthropic-usage.js +270 -0
  69. package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.js +1 -0
  70. package/dist/vendor/shared/org-scan-core/spend/prices.d.ts +33 -0
  71. package/dist/vendor/shared/org-scan-core/spend/prices.js +58 -0
  72. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.d.ts +30 -0
  73. package/dist/vendor/shared/org-scan-core/spend/spend-estimate.js +71 -0
  74. package/dist/vendor/shared/org-scan-core/spend/spend-select.d.ts +16 -0
  75. package/dist/vendor/shared/org-scan-core/spend/spend-select.js +26 -0
  76. package/dist/vendor/shared/org-scan-core/spend/token-rate.d.ts +30 -0
  77. package/dist/vendor/shared/org-scan-core/spend/token-rate.js +55 -0
  78. package/package.json +2 -2
  79. /package/dist/{lib/scan → vendor/shared/org-scan-core}/spend/cost-corpus.v1.d.ts +0 -0
@@ -15,7 +15,7 @@ import { getAuthInfo } from "../lib/auth.js";
15
15
  import { listMyOrgs } from "../lib/api.js";
16
16
  import { getActiveOrg } from "../lib/cli-config.js";
17
17
  import { fetchRecordTotals, isOrgId, } from "../lib/scan/record.js";
18
- import { modelledSpend } from "../lib/scan/cost-per-pr.js";
18
+ import { costSpendFor, sendsSpendRange } from "../vendor/shared/org-scan-core/report-build.js";
19
19
  import { RECORD_NO_ORG_NOTE, renderRecordSection } from "../lib/scan/report.js";
20
20
  import { buildEmailBody, buildEmailReport, DRY_RUN_MSG, UNAVAILABLE_MSG, failureMessage, ReportEmailError, sendReportEmail, } from "../lib/scan/report-email.js";
21
21
  import { pickRepos, REPO_PICKER_NO_TERMINAL_HINT, } from "../lib/scan/repo-picker.js";
@@ -28,29 +28,61 @@ export function parseRepoFlags(raw) {
28
28
  .filter(Boolean);
29
29
  return out.length > 0 && out.every(isValidRepoSlug) ? out : null;
30
30
  }
31
- const EMAIL = /^[^\s@]{1,64}@[^\s@]{1,255}\.[^\s@]{2,63}$/;
32
- export const isEmail = (v) => typeof v === "string" && v.length <= 320 && EMAIL.test(v.trim());
33
- /** The runner's email (mnemom login, else asked) and the CTO's (flag, else "are you the CTO?"). */
34
- export async function resolveRecipients(o) {
35
- if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
36
- throw new Error("--cto-email must be a valid email address");
37
- let runner = o.runnerEmail ?? getAuthInfo()?.email ?? "";
38
- if (!isEmail(runner))
39
- runner = (await askInput("Your email (the report is sent to you):")).trim();
40
- if (!isEmail(runner))
41
- throw new Error("a valid runner email is required");
42
- runner = runner.trim();
43
- if (o.ctoEmail !== undefined)
44
- return { runner_email: runner, cto_email: o.ctoEmail.trim() };
45
- const isCto = await askSelect("Are you the CTO?", ["Yes, I am the CTO", "No, someone else is"]);
46
- if (isCto === null)
47
- throw new Error("--cto-email is required when you are not the CTO");
48
- if (isCto.startsWith("Yes"))
49
- return { runner_email: runner, cto_email: runner };
50
- const cto = (await askInput("CTO email (the report is sent to them too):")).trim();
51
- if (!isEmail(cto))
52
- throw new Error("--cto-email is required when you are not the CTO");
53
- return { runner_email: runner, cto_email: cto };
31
+ /**
32
+ * Same rule as the API: isPlainEmail in mnemom/mnemom-api src/org-scan/report-email/notifier.ts (no shared
33
+ * package, so this is a copy). Drift guard: the "plain-email rule edge" table in src/__tests__/scan.test.ts;
34
+ * change both together. No spaces, control chars or <>",; ; one @; every domain label non-empty; max 254. */
35
+ const plainEmail = (a) => {
36
+ if (a.length === 0 || a.length > 254)
37
+ return false;
38
+ for (let i = 0; i < a.length; i++) {
39
+ const c = a.charCodeAt(i);
40
+ if (c <= 32 || c === 127 || '<>",;'.includes(a[i]))
41
+ return false;
42
+ }
43
+ const at = a.indexOf("@");
44
+ if (at < 1 || at !== a.lastIndexOf("@"))
45
+ return false;
46
+ const labels = a.slice(at + 1).split(".");
47
+ return labels.length >= 2 && labels.every((l) => l.length > 0);
48
+ };
49
+ export const isEmail = (v) => typeof v === "string" && plainEmail(v.trim());
50
+ export const DELETE_CONTACT = "privacy@mnemom.ai";
51
+ export const EMAIL_ONE_MSG = "Enter one email address, like you@company.com.";
52
+ export const EMAIL_LIST_MSG = "Enter email addresses like you@company.com, separated by commas.";
53
+ export const MAX_EXTRA_EMAILS = 10;
54
+ /**
55
+ * The people to add to the report email (the runner and every org admin always get it). Comes from the
56
+ * repeatable --email flag (and its alias --cto-email), each value one address or a comma-separated list;
57
+ * on a terminal with none given, asks once (blank to skip). Plain-language errors; the value is never echoed.
58
+ */
59
+ export async function resolveExtraEmails(o) {
60
+ const given = [...(o.email ?? []), ...(o.ctoEmail !== undefined ? [o.ctoEmail] : [])];
61
+ let raw = given;
62
+ if (given.length === 0) {
63
+ if (!(o.isTty ?? process.stdin.isTTY))
64
+ return [];
65
+ const typed = ((await askInput("Anyone else to email the report to? Enter addresses separated by commas, or press Enter to skip:")) ?? "").trim();
66
+ if (typed === "")
67
+ return [];
68
+ raw = [typed];
69
+ }
70
+ const list = raw
71
+ .flatMap((v) => v.split(","))
72
+ .map((v) => v.trim())
73
+ .filter(Boolean);
74
+ if (list.length === 0 || !list.every(isEmail))
75
+ throw new Error(raw.length > 1 || raw.some((v) => v.includes(",")) ? EMAIL_LIST_MSG : EMAIL_ONE_MSG);
76
+ const seen = new Set();
77
+ const unique = list.filter((a) => !seen.has(a.toLowerCase()) && seen.add(a.toLowerCase()));
78
+ if (unique.length > MAX_EXTRA_EMAILS)
79
+ throw new Error(`Add at most ${MAX_EXTRA_EMAILS} extra email addresses.`);
80
+ return unique;
81
+ }
82
+ /** The runner's own address from the Mnemom login, or undefined (the server resolves it for the email either way). */
83
+ export function runnerEmailFromLogin(o) {
84
+ const e = (o.runnerEmail ?? getAuthInfo()?.email ?? "").trim();
85
+ return isEmail(e) ? e : undefined;
54
86
  }
55
87
  /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
56
88
  export async function resolveSelfReportedSpend(o) {
@@ -78,15 +110,8 @@ export async function resolveSelfReportedSpend(o) {
78
110
  * tiers off) the modelled range. Sources are never mixed.
79
111
  */
80
112
  export function resolveCostSpend(o, result, spend, measured) {
81
- if (spend === null)
82
- return modelledSpend(result.spend);
83
- if (spend.tier === 1) {
84
- const usd = measured?.usd ?? o.anthropicUsageUsd;
85
- return typeof usd === "number" ? { source: "anthropic_usage", usd } : null;
86
- }
87
- if (spend.tier === 2)
88
- return null;
89
- return spend.tier === 3 ? modelledSpend(result.spend) : null;
113
+ // Shared with the Mnemom-side run (MNE-9752): same spend source, same cost figures.
114
+ return costSpendFor(result, spend, measured?.usd ?? o.anthropicUsageUsd);
90
115
  }
91
116
  export const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
92
117
  export const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
@@ -238,16 +263,20 @@ export async function resolveRecordOutcome(o) {
238
263
  }
239
264
  export async function scanCommand(o) {
240
265
  if (o.share !== undefined && parseShareLevel(o.share) === null) {
241
- throw new Error("--share must be one of: summary, full, none");
266
+ throw new Error("--share must be one of: full, analytics, none (summary also works, same as analytics)");
242
267
  }
243
268
  const noRepoFlag = o.repos.length === 0;
269
+ const interactive = Boolean(o.isTty ?? process.stdin.isTTY);
244
270
  // No --repo: on a terminal pick from the repos this GitHub login can read; otherwise the error stays, with a hint.
245
- if (noRepoFlag && !(o.isTty ?? process.stdin.isTTY))
271
+ if (noRepoFlag && !interactive)
246
272
  throw new Error(`--repo owner/name is required (repeatable). ${REPO_PICKER_NO_TERMINAL_HINT}`);
273
+ // Consent comes first, before any data is processed (MNE-9634). The flag wins; on a terminal with no flag we
274
+ // ask (Full preselected); with no terminal and no flag the answer is Nothing (fail closed).
275
+ const level = parseShareLevel(o.share) ?? (interactive ? await promptShareLevel() : "none");
276
+ // The people added to the report email; the runner and every org admin always get it.
277
+ const extraEmails = await resolveExtraEmails(o);
247
278
  // The report is emailed on every scan, so resolve the org before any scan work; failure is loud.
248
279
  const emailOrg = await resolveScanOrg(o);
249
- if (o.ctoEmail !== undefined && !isEmail(o.ctoEmail))
250
- throw new Error("--cto-email must be a valid email address");
251
280
  const getCredential = async () => {
252
281
  const c = o.credential ?? (await resolveCredential().catch(() => null));
253
282
  if (!c)
@@ -292,19 +321,23 @@ export async function scanCommand(o) {
292
321
  else if (record !== null)
293
322
  for (const line of renderRecordSection(record))
294
323
  console.log(line);
295
- const level = parseShareLevel(o.share) ?? (await promptShareLevel());
296
324
  // Every successful scan emails the report to the person who ran it, at every share level, no opt-out.
297
325
  // Mnemom keeps nothing from a share=none send (the server sends the email and does nothing else).
298
- const emailFailure = await emailReport(o, emailOrg, result, level, spend, period, resolveCostSpend(o, result, spend, measured));
299
- if (level === "none") {
300
- console.log("Share level: nothing. No share payload is built; Mnemom keeps nothing (the report email is only sent to you).");
301
- if (emailFailure)
302
- throw new ReportEmailError(emailFailure);
303
- return;
326
+ const emailResult = await emailReport(o, emailOrg, result, level, spend, period, resolveCostSpend(o, result, spend, measured), extraEmails);
327
+ const emailFailure = emailResult === "not_sent" || emailResult === null ? null : emailResult;
328
+ // What was shared with Mnemom, and how to have it deleted (MNE-9634).
329
+ if (emailResult !== null) {
330
+ console.log("Kept on file by Mnemom: nothing, because the report was not sent.");
304
331
  }
305
- const payload = buildSharePayload(level, result, await resolveRecipients(o), spend ? (spend.shareable ? spend.line : null) : undefined);
306
- console.log(`Share level: ${level}. Upload is not wired in this build; this is what would be sent:`);
307
- console.log(JSON.stringify(payload, null, 2));
332
+ else if (level === "none") {
333
+ console.log("Kept on file by Mnemom: nothing. The report was emailed and Mnemom keeps nothing on file; its email provider keeps only a delivery log.");
334
+ }
335
+ else {
336
+ const payload = buildSharePayload(level, result, { runner_email: runnerEmailFromLogin(o), extra_emails: extraEmails }, spend ? (spend.shareable ? spend.line : null) : undefined);
337
+ console.log(`Kept on file by Mnemom: ${level === "full" ? "the totals and the counts for each repo, by name" : "the team totals only, with no repo names"}. Mnemom may keep a run record of only this much on file; the full report was emailed, not stored:`);
338
+ console.log(JSON.stringify(payload, null, 2));
339
+ }
340
+ console.log(`To have anything Mnemom holds about this scan deleted, email ${DELETE_CONTACT}.`);
308
341
  if (emailFailure)
309
342
  throw new ReportEmailError(emailFailure);
310
343
  }
@@ -312,29 +345,33 @@ export async function scanCommand(o) {
312
345
  * Emails the report. Loud on every failure: prints the reason name, never the server's words, and
313
346
  * returns it so the caller exits non-zero after the local report is shown.
314
347
  */
315
- async function emailReport(o, org, result, level, spend, period, costSpend) {
316
- const fail = (f) => {
317
- console.log(failureMessage(f));
348
+ async function emailReport(o, org, result, level, spend, period, costSpend, extraEmails) {
349
+ const fail = (f, retryAfterMs) => {
350
+ console.log(failureMessage(f, retryAfterMs));
318
351
  return f;
319
352
  };
320
353
  const report = buildEmailReport(result, {
321
354
  orgId: org,
322
355
  now: o.now ?? Date.now,
323
- sendSpendRange: spend ? spend.shareable && spend.tier === 3 : true,
356
+ sendSpendRange: sendsSpendRange(spend),
324
357
  period,
325
358
  costSpend,
326
359
  });
327
- const body = buildEmailBody(report, level, result.coverage.complete, o.ctoEmail?.trim());
360
+ const body = buildEmailBody(report, level, result.coverage.complete, extraEmails);
328
361
  const outcome = await sendReportEmail(org, body, o.emailDeps);
329
362
  if (outcome.status === "failed")
330
- return fail(outcome.reason);
363
+ return fail(outcome.reason, outcome.retryAfterMs);
331
364
  if (outcome.status === "unavailable") {
332
365
  // Org Scan is off by default: the gate's 404 is not a failure and must not break the scan.
333
366
  console.log(UNAVAILABLE_MSG);
334
- return null;
367
+ return "not_sent";
335
368
  }
336
369
  console.log(outcome.status === "dry_run"
337
370
  ? DRY_RUN_MSG
338
- : `Report emailed to you (${outcome.recipients} recipient${outcome.recipients === 1 ? "" : "s"}).`);
339
- return null;
371
+ : outcome.limited
372
+ ? "Report emailed in a limited form (your Mnemom service is older than this CLI): no per-repo counts, no org admins, and at most one added address."
373
+ : `Full report emailed to you, your org admins and anyone you added (${outcome.recipients} recipient${outcome.recipients === 1 ? "" : "s"}).`);
374
+ if (outcome.status === "sent" && outcome.adminsMissed)
375
+ console.log(`${outcome.adminsMissed} org admin${outcome.adminsMissed === 1 ? "" : "s"} could not be emailed because no email address is on file.`);
376
+ return outcome.status === "dry_run" ? "not_sent" : null;
340
377
  }
@@ -20,7 +20,8 @@
20
20
  * 3. Verify the JWS signature against the JWKS.
21
21
  * 4. Fetch the transparency-log row by log_index for the freshly-computed
22
22
  * inclusion proof, plus /v1/transparency/root for the signed root.
23
- * 5. Verify the Merkle inclusion proof against the root.
23
+ * 5. Verify the signed root's Ed25519 signature (typ AAP-TransparencyRoot/v1)
24
+ * with @mnemom/verify, then the Merkle inclusion proof against that root.
24
25
  * 6. Print a care-framed result.
25
26
  *
26
27
  * --strict mode bypasses the JWKS cache and exits non-zero on any
@@ -30,6 +31,7 @@
30
31
  * would-benefit-from vocabulary; stdout carries machine-readable
31
32
  * verification status.
32
33
  */
34
+ import type * as AipVerify from "@mnemom/verify";
33
35
  interface JwkPublic {
34
36
  kty: "OKP";
35
37
  crv: "Ed25519";
@@ -69,6 +71,14 @@ interface MerkleProof {
69
71
  position: "left" | "right";
70
72
  }[];
71
73
  }
74
+ interface SignedRoot {
75
+ typ?: string;
76
+ tree_size: number;
77
+ root_hash: string;
78
+ published_at: string;
79
+ signing_key_id: string;
80
+ signature: string;
81
+ }
72
82
  export interface VerifyCardOptions {
73
83
  agentId: string;
74
84
  at?: string;
@@ -86,6 +96,19 @@ interface JwsVerification {
86
96
  error?: string;
87
97
  }
88
98
  declare function verifyJws(jws: string, jwks: Jwks): Promise<JwsVerification>;
99
+ interface RootVerification {
100
+ ok: boolean;
101
+ kid?: string;
102
+ reasons: string[];
103
+ }
104
+ /**
105
+ * Verify the detached Ed25519 signature on GET /v1/transparency/root with the
106
+ * published offline verifier. `expectedTyp` pins the statement to
107
+ * AAP-TransparencyRoot/v1, so another signed statement cannot stand in for a
108
+ * root. A key missing from a cached JWKS reports key_not_found; --no-cache or
109
+ * --strict refetches it.
110
+ */
111
+ declare function verifySignedRoot(root: SignedRoot, jwks: Jwks, load?: () => Promise<Pick<typeof AipVerify, "verifyStatement">>): Promise<RootVerification>;
89
112
  declare function verifyInclusionProof(proof: MerkleProof, expectedRoot: string): Promise<boolean>;
90
113
  declare function internalHash(left: string, right: string): Promise<string>;
91
114
  declare function base64urlDecode(s: string): Uint8Array;
@@ -98,5 +121,6 @@ export declare const __testing: {
98
121
  base64urlDecode: typeof base64urlDecode;
99
122
  base64urlDecodeString: typeof base64urlDecodeString;
100
123
  verifyJws: typeof verifyJws;
124
+ verifySignedRoot: typeof verifySignedRoot;
101
125
  };
102
126
  export {};
@@ -20,7 +20,8 @@
20
20
  * 3. Verify the JWS signature against the JWKS.
21
21
  * 4. Fetch the transparency-log row by log_index for the freshly-computed
22
22
  * inclusion proof, plus /v1/transparency/root for the signed root.
23
- * 5. Verify the Merkle inclusion proof against the root.
23
+ * 5. Verify the signed root's Ed25519 signature (typ AAP-TransparencyRoot/v1)
24
+ * with @mnemom/verify, then the Merkle inclusion proof against that root.
24
25
  * 6. Print a care-framed result.
25
26
  *
26
27
  * --strict mode bypasses the JWKS cache and exits non-zero on any
@@ -65,7 +66,16 @@ export async function verifyCardCommand(options) {
65
66
  findings.push("merkle: transparency log is empty (no root to verify against)");
66
67
  }
67
68
  else {
68
- // Step 5: verify the Merkle inclusion proof.
69
+ // Step 5a: the root is only worth folding into if Mnemom signed it.
70
+ const rootCheck = await verifySignedRoot(root, jwks);
71
+ if (!rootCheck.ok) {
72
+ findings.push(`root: signature did not verify (${rootCheck.reasons.join(", ")})`);
73
+ exitCode = 1;
74
+ }
75
+ else {
76
+ findings.push(`root: signature ok (kid=${rootCheck.kid}, tree_size=${root.tree_size})`);
77
+ }
78
+ // Step 5b: verify the Merkle inclusion proof.
69
79
  const proofOk = await verifyInclusionProof(target.inclusion_proof, root.root_hash);
70
80
  if (!proofOk) {
71
81
  findings.push(`merkle: inclusion proof did not reconstruct root (log_index=${target.entry.log_index}, tree_size=${target.inclusion_proof.tree_size})`);
@@ -227,6 +237,22 @@ async function verifyJws(jws, jwks) {
227
237
  }
228
238
  return { ok: true, kid, payload };
229
239
  }
240
+ // ── Signed root verification ─────────────────────────────────────────
241
+ const ROOT_TYP = "AAP-TransparencyRoot/v1";
242
+ /**
243
+ * Verify the detached Ed25519 signature on GET /v1/transparency/root with the
244
+ * published offline verifier. `expectedTyp` pins the statement to
245
+ * AAP-TransparencyRoot/v1, so another signed statement cannot stand in for a
246
+ * root. A key missing from a cached JWKS reports key_not_found; --no-cache or
247
+ * --strict refetches it.
248
+ */
249
+ async function verifySignedRoot(root, jwks, load = () => import("@mnemom/verify")) {
250
+ const { verifyStatement } = await load();
251
+ const r = await verifyStatement(root, jwks, {
252
+ expectedTyp: ROOT_TYP,
253
+ });
254
+ return { ok: r.valid, kid: r.signing_key_id ?? undefined, reasons: r.reasons };
255
+ }
230
256
  // ── Merkle proof verification ────────────────────────────────────────
231
257
  async function verifyInclusionProof(proof, expectedRoot) {
232
258
  let current = proof.leaf_hash;
@@ -282,6 +308,7 @@ export const __testing = {
282
308
  base64urlDecode,
283
309
  base64urlDecodeString,
284
310
  verifyJws,
311
+ verifySignedRoot,
285
312
  };
286
313
  // Silence "MNEMOM_DIR unused" if the cache code path is taken in tests.
287
314
  void MNEMOM_DIR;
@@ -20,6 +20,7 @@ import { mergeAgentConfig } from "../lib/agent-config.js";
20
20
  import { askSelect, askInput, isInteractive } from "../lib/prompt.js";
21
21
  import { getGatewayUrl } from "../lib/config.js";
22
22
  import { fmt } from "../lib/format.js";
23
+ import { starterIntegrityMode } from "../lib/starter-integrity.js";
23
24
  import { OPENAI_WRAP_DEFAULT_MODEL, openaiBirthProbeBody } from "../lib/birth-probe.js";
24
25
  const AGENT_HEADER = "x-mnemom-agent";
25
26
  const PROVIDERS = {
@@ -98,7 +99,7 @@ export async function wrapCommand(options = {}) {
98
99
  say();
99
100
  say(`${fmt.badge("alignment", "cyan")} Seeding starter alignment card…`);
100
101
  try {
101
- await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId)), "application/json");
102
+ await putAlignmentCard(agentId, JSON.stringify(buildStarterAlignmentCard(agentId, await starterIntegrityMode(agentId))), "application/json");
102
103
  steps.push({ step: "alignment", status: "ok" });
103
104
  say(fmt.success("Alignment card set."));
104
105
  }
@@ -261,12 +262,12 @@ export async function birthThroughGateway(gatewayUrl, provider, agentName, provi
261
262
  return agentId;
262
263
  }
263
264
  // ── Starter cards ─────────────────────────────────────────────────────────────
264
- function buildStarterAlignmentCard(agentId) {
265
+ function buildStarterAlignmentCard(agentId, integrityMode) {
265
266
  return {
266
267
  card_version: "unified/2026-04-26",
267
268
  agent_id: agentId,
268
269
  autonomy_mode: "observe",
269
- integrity_mode: "off",
270
+ integrity_mode: integrityMode, // the agent's own mode; observe for a new agent (MNE-9728)
270
271
  // No capability mappings here, so unmapped tools must pass or every turn fails.
271
272
  enforcement: { allow_unmapped_tools: true },
272
273
  principal: { type: "agent", identifier: agentId, relationship: "delegated_authority" },
package/dist/index.js CHANGED
@@ -204,8 +204,8 @@ const agentCmd = program
204
204
  .option("--invite <token>", "Redeem a Mnemom Agent invitation (pinv_…): unlocks Mnemom Agent for you + credits your starter µ; launches too when a session name is given")
205
205
  .option("--setup", "Re-check the served agent and refresh its default cards before launch")
206
206
  .option("--no-setup", "Skip the default-card writes (the agent is still confirmed and claimed)")
207
- .option("--governance [features]", "Turn governance ON for the agent's cards: AIP integrity analysis + Safe House screening, observe-only (default: off for new agents; autonomy policy checks are rules only and on either way). Pick features with a comma list: integrity, safe-house. Billed at cost on top of the agent fee")
208
- .option("--no-governance", "Turn governance OFF for the agent's cards, autonomy policy checks included (by default new agents run autonomy observe, rules only; a card refresh otherwise keeps an existing agent's modes)")
207
+ .option("--governance [features]", "Choose the governance features for the agent's cards, observe-only: a comma list naming exactly the features to turn on: integrity, safe-house, all or none (default for new agents: integrity on, Safe House off; `--governance none` opts out of integrity; autonomy policy checks are rules only and on either way). Billed at cost on top of the agent fee")
208
+ .option("--no-governance", "Turn governance OFF for the agent's cards, autonomy policy checks included (by default new agents run autonomy observe, rules only, plus integrity observe; a card refresh otherwise keeps an existing agent's modes)")
209
209
  .option("--no-yolo", "Do NOT pass --dangerously-skip-permissions (yolo is on by default)")
210
210
  .option("--no-context-hint", "Do NOT seed Claude Code's context-hint feature flag before launch")
211
211
  // The Mnemom plugin lands dark: off by default and not in the published package, so its two
@@ -2383,12 +2383,13 @@ program
2383
2383
  });
2384
2384
  program
2385
2385
  .command("scan")
2386
- .description("Read-only local scan of GitHub PR metadata; the report is emailed to you every time (counts only, no repo names) and Mnemom keeps nothing at share level none")
2386
+ .description("Read-only local scan of GitHub PR metadata; the full report, with the counts for each repo, is always emailed to you, your org admins and anyone you add. --share chooses what Mnemom may keep")
2387
2387
  .option("--repo <owner/name...>", "Repo to scan (repeatable or comma-separated; omit on a terminal to pick from your repos)")
2388
2388
  .option("--start <YYYY-MM-DD>", "Period start (default: today)")
2389
2389
  .option("--end <YYYY-MM-DD>", "Period end (default: today)")
2390
- .option("--share <level>", "summary | full | none (default: full when asked; none when not interactive)")
2391
- .option("--cto-email <email>", "CTO who receives the report (required unless you are the CTO; asked when omitted)")
2390
+ .option("--share <level>", "What Mnemom may keep: full (totals and per-repo counts, default when asked) | analytics (team totals only, no repo names; summary is the old name) | none (nothing; the default when not interactive)")
2391
+ .option("--email <address...>", "Also email the report to these people (repeatable or comma-separated, up to 10, on your organization's email domain or mnemom.ai). You and every org admin always get it")
2392
+ .option("--cto-email <email>", "Same as --email (kept for older scripts)")
2392
2393
  .option("--monthly-spend <usd>", "Your own approximate monthly AI spend (self-reported; never sent or saved; needs MNEMOM_ORG_SCAN_MODE=true)")
2393
2394
  .option("--anthropic-admin-key", "Ask (masked) for your Anthropic admin key and read measured usage with it, locally; or set ANTHROPIC_ADMIN_KEY. Never stored, logged or sent anywhere but api.anthropic.com. Without this flag, workload identity federation is tried first when ANTHROPIC_FEDERATION_RULE_ID, ANTHROPIC_ORGANIZATION_ID and ANTHROPIC_SERVICE_ACCOUNT_ID are set (needs MNEMOM_ORG_SCAN_MODE=true)")
2394
2395
  .option("--no-anthropic-usage", "Decline Anthropic usage and be asked for a monthly figure (needs MNEMOM_ORG_SCAN_MODE=true)")
@@ -2405,6 +2406,7 @@ program
2405
2406
  period: { start: options.start ?? options.end ?? today, end: options.end ?? today },
2406
2407
  share: options.share,
2407
2408
  ctoEmail: options.ctoEmail,
2409
+ email: options.email,
2408
2410
  monthlySpend: options.monthlySpend,
2409
2411
  noAnthropicUsage: options.anthropicUsage === false,
2410
2412
  anthropicAdminKey: options.anthropicAdminKey === true,
@@ -34,6 +34,11 @@ export declare function errorDetails(body: unknown): Record<string, unknown>;
34
34
  export declare const JOB_READ_PATH: (sessionId: string) => string;
35
35
  /** The served bundle fetch, per claim (W-L12). */
36
36
  export declare const BUNDLE_FETCH_PATH: (sessionId: string) => string;
37
+ /**
38
+ * The job's PR token (ruling 34.34): a Record App installation token narrowed to the seal's repo,
39
+ * contents and pull_requests write, fetched with the lease key. Never cached or logged.
40
+ */
41
+ export declare const GITHUB_TOKEN_PATH: (sessionId: string) => string;
37
42
  export declare const JWKS_PATH = "/v1/.well-known/jwks.json";
38
43
  /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
39
44
  export declare const RECORD_COMMITS_PATH: (orgId: string) => string;
@@ -65,6 +70,7 @@ export interface RunnerApi {
65
70
  fail(sessionId: string, body: Record<string, unknown>): Promise<HttpResult>;
66
71
  job(sessionId: string): Promise<HttpResult>;
67
72
  bundle(sessionId: string, leaseKey: string): Promise<HttpResult>;
73
+ githubToken(sessionId: string, leaseKey: string): Promise<HttpResult>;
68
74
  jwks(): Promise<HttpResult>;
69
75
  /** One RecordCommit/v1 body, with the lease key; the api pins it to the session's lineage. */
70
76
  recordCommit(orgId: string, body: RecordCommitBody, leaseKey: string): Promise<HttpResult>;
@@ -33,6 +33,11 @@ export function errorDetails(body) {
33
33
  export const JOB_READ_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/job`;
34
34
  /** The served bundle fetch, per claim (W-L12). */
35
35
  export const BUNDLE_FETCH_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/bundle`;
36
+ /**
37
+ * The job's PR token (ruling 34.34): a Record App installation token narrowed to the seal's repo,
38
+ * contents and pull_requests write, fetched with the lease key. Never cached or logged.
39
+ */
40
+ export const GITHUB_TOKEN_PATH = (sessionId) => `/v1/executor/sessions/${sessionId}/github-token`;
36
41
  export const JWKS_PATH = "/v1/.well-known/jwks.json";
37
42
  /** Record's commit index (ruling 34.05 (a)): the lease key may write the session's own commits. */
38
43
  export const RECORD_COMMITS_PATH = (orgId) => `/v1/orgs/${encodeURIComponent(orgId)}/record/commits`;
@@ -95,6 +100,7 @@ export function runnerApi(opts) {
95
100
  fail: (id, body) => call(routePath("exec_fail", scoped(id)), "POST", ex, body),
96
101
  job: (id) => call(JOB_READ_PATH(scoped(id)), "GET", ex),
97
102
  bundle: (id, leaseKey) => call(BUNDLE_FETCH_PATH(scoped(id)), "GET", leaseKey),
103
+ githubToken: (id, leaseKey) => call(GITHUB_TOKEN_PATH(scoped(id)), "POST", leaseKey),
98
104
  jwks: () => call(JWKS_PATH, "GET", null),
99
105
  recordCommit: (orgId, body, leaseKey) => {
100
106
  if (!ORG_ID_RE.test(orgId))
@@ -42,8 +42,11 @@ export interface JobDeps {
42
42
  prepareWorkspace(repo: string, sessionId: string): Promise<Workspace>;
43
43
  /** Run one phase's process; resolves its exit code. Rejects only when it could not start. */
44
44
  runPhase(inv: PhaseInvocation, ws: Workspace, modelUrl: string, signal: AbortSignal): Promise<number>;
45
- /** Push the job branch and open its PR; resolves the PR URL. */
46
- openPr(repo: string, ws: Workspace, title: string, body: string): Promise<string>;
45
+ /**
46
+ * Push the job branch and open its PR; resolves the PR URL. `token` is the job's PR token when
47
+ * `prTokenFromLease` is set; null means the runner's own GitHub token.
48
+ */
49
+ openPr(repo: string, ws: Workspace, title: string, body: string, token: string | null): Promise<string>;
47
50
  /** Commits on the job branch that the base does not have; 0 means there is nothing to open. */
48
51
  commitsAhead(ws: Workspace): Promise<number>;
49
52
  /** The working copy's HEAD sha before a phase runs; null only for a clone with no commit. Throws otherwise. */
@@ -53,7 +56,13 @@ export interface JobDeps {
53
56
  * first, under `lineageKey` and the job's repo. A commit that cannot be read is left out.
54
57
  */
55
58
  commitBodies(ws: Workspace, since: string | null, lineageKey: string, repo: string): Promise<RecordCommitBody[]>;
56
- enableAutoMerge(prUrl: string): Promise<void>;
59
+ enableAutoMerge(prUrl: string, token: string | null): Promise<void>;
60
+ /**
61
+ * True when the credential is the --oidc exchange: the PR is pushed and opened with the job's PR
62
+ * token from the api (ruling 34.34), never the runner's own GitHub token, which in a sealed repo's
63
+ * Action is a GITHUB_TOKEN the org forbids from opening PRs. A refused token fails the job.
64
+ */
65
+ prTokenFromLease?: boolean;
57
66
  cleanup(ws: Workspace): Promise<void>;
58
67
  }
59
68
  export interface JobOptions {
@@ -379,6 +379,7 @@ export async function runJob(sessionId, opts, deps) {
379
379
  });
380
380
  }
381
381
  if (opensPr) {
382
+ let prToken = null;
382
383
  try {
383
384
  // Checked before the push, so an empty diff never reaches GitHub's 422 (amendment 59).
384
385
  if ((await deps.commitsAhead(ws)) === 0) {
@@ -386,7 +387,13 @@ export async function runJob(sessionId, opts, deps) {
386
387
  await reportFail(deps, sessionId, claim.epoch, "runner_failed", "pr_no_changes");
387
388
  return { kind: "outcome", outcome: failedOutcome(facts, "pr_no_changes") };
388
389
  }
389
- facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`, prBody(job));
390
+ if (deps.prTokenFromLease) {
391
+ const t = await fetchPrToken(deps, sessionId, claim.leaseKey, job.repo);
392
+ if (!t.ok)
393
+ throw new Error(t.detail);
394
+ prToken = t.token;
395
+ }
396
+ facts.pr_url = await deps.openPr(job.repo, ws, `mnemom: ${bundle.workflow.id} job ${sessionId.slice(0, 8)}`, prBody(job), prToken);
390
397
  }
391
398
  catch (e) {
392
399
  deps.log(`opening the PR failed: ${e.message}`);
@@ -395,7 +402,7 @@ export async function runJob(sessionId, opts, deps) {
395
402
  }
396
403
  if (facts.merge_strategy === "merge_on_green") {
397
404
  try {
398
- await deps.enableAutoMerge(facts.pr_url);
405
+ await deps.enableAutoMerge(facts.pr_url, prToken);
399
406
  }
400
407
  catch (e) {
401
408
  deps.log(`enabling auto-merge failed (the PR stays open): ${e.message}`);
@@ -469,6 +476,27 @@ export async function reportPhaseCommits(deps, report, p) {
469
476
  deps.log(`commit report failed: ${e.message}`);
470
477
  }
471
478
  }
479
+ /**
480
+ * The job's PR token (ruling 34.34), fetched once per claim with the lease key. It must name the
481
+ * job's repo; the detail never carries the token.
482
+ */
483
+ async function fetchPrToken(deps, sessionId, leaseKey, repo) {
484
+ const res = await deps.api.githubToken(sessionId, leaseKey);
485
+ if (res.status !== 200) {
486
+ return {
487
+ ok: false,
488
+ detail: `the PR token fetch answered ${res.code ?? res.networkError ?? res.status}`,
489
+ };
490
+ }
491
+ const b = (res.body ?? {});
492
+ if (typeof b.token !== "string" || b.token === "") {
493
+ return { ok: false, detail: "the PR token fetch answered 200 with no token" };
494
+ }
495
+ if (typeof b.repository !== "string" || b.repository.toLowerCase() !== repo.toLowerCase()) {
496
+ return { ok: false, detail: `the PR token names another repo than the job's ${repo}` };
497
+ }
498
+ return { ok: true, token: b.token };
499
+ }
472
500
  async function fetchBundle(deps, sessionId, claim) {
473
501
  const [jwksRes, res] = await Promise.all([
474
502
  deps.api.jwks(),
@@ -1,35 +1 @@
1
- /**
2
- * Coverage caps and sampling. Port of mnemom-api src/org-scan/scan/capped-scan.ts (PR #4208).
3
- *
4
- * Coverage decision (Shraddha, 2026-10-28): each scan covers the last 3 months OR the last 2,000 merged
5
- * PRs, whichever is MORE; above 2,000 in the window, an even deterministic sample of 2,000.
6
- */
7
- export declare const COVERAGE_MONTHS = 3;
8
- export interface ScanCaps {
9
- maxPrs: number;
10
- /** A PR with more additions+deletions than this is skipped, never read in full. */
11
- maxPrSizeLines: number;
12
- /** Upstream read calls. */
13
- maxReads: number;
14
- /** Wall-clock budget. */
15
- maxMillis: number;
16
- }
17
- export declare const DEFAULT_SCAN_CAPS: ScanCaps;
18
- export type StopReason = "pr-cap" | "read-cap" | "time-cap";
19
- /** The day `months` calendar months before `end` (YYYY-MM-DD, UTC), clamped to the month's last day. */
20
- export declare function monthsBefore(end: string, months: number): string;
21
- /** `k` items spread evenly across `items` (order kept): index floor(i*n/k). Deterministic; n <= k returns all. */
22
- export declare function evenSample<T>(items: readonly T[], k: number): T[];
23
- export declare function sanitiseCaps(c: Partial<ScanCaps>): ScanCaps;
24
- /** Running read budget. Every call is told how many reads it may spend (`room`). */
25
- export declare class Budget {
26
- private caps;
27
- private now;
28
- private startedAt;
29
- reads: number;
30
- constructor(caps: ScanCaps, now: () => number, startedAt: number);
31
- block(n?: number): StopReason | null;
32
- room(): number;
33
- overrun(): StopReason | null;
34
- charge(n: number): void;
35
- }
1
+ export * from "../../vendor/shared/org-scan-core/caps.js";