@mnemom/mnemom 0.17.37 → 0.17.38-next.1

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 (131) 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 +11 -0
  4. package/dist/commands/agent-run.js +37 -0
  5. package/dist/commands/agent-setup.d.ts +11 -9
  6. package/dist/commands/agent-setup.js +24 -19
  7. package/dist/commands/agent-share-render.d.ts +5 -5
  8. package/dist/commands/agent-share-render.js +9 -7
  9. package/dist/commands/agent-share-test-issuer.d.ts +1 -1
  10. package/dist/commands/agent-share-test-issuer.js +1 -1
  11. package/dist/commands/agent.d.ts +8 -1
  12. package/dist/commands/agent.js +14 -3
  13. package/dist/commands/card.js +1 -1
  14. package/dist/commands/onboard.js +5 -4
  15. package/dist/commands/scan.d.ts +35 -8
  16. package/dist/commands/scan.js +134 -45
  17. package/dist/commands/verify-card.d.ts +25 -1
  18. package/dist/commands/verify-card.js +29 -2
  19. package/dist/commands/wrap.js +4 -3
  20. package/dist/index.js +8 -6
  21. package/dist/lib/commit-report.d.ts +7 -13
  22. package/dist/lib/commit-report.js +12 -5
  23. package/dist/lib/conduct-receipt.d.ts +8 -3
  24. package/dist/lib/conduct-receipt.js +29 -7
  25. package/dist/lib/experimental.d.ts +1 -1
  26. package/dist/lib/experimental.js +1 -12
  27. package/dist/lib/runner/executor-client.d.ts +5 -0
  28. package/dist/lib/runner/executor-client.js +8 -0
  29. package/dist/lib/runner/job.d.ts +37 -0
  30. package/dist/lib/runner/job.js +74 -1
  31. package/dist/lib/runner/phases.d.ts +7 -2
  32. package/dist/lib/runner/phases.js +7 -2
  33. package/dist/lib/runner/proxy.d.ts +26 -2
  34. package/dist/lib/runner/proxy.js +33 -1
  35. package/dist/lib/runner/record-commit.d.ts +16 -0
  36. package/dist/lib/runner/record-commit.js +7 -0
  37. package/dist/lib/scan/report-email.d.ts +19 -2
  38. package/dist/lib/scan/report-email.js +78 -22
  39. package/dist/lib/scan/scan.d.ts +2 -0
  40. package/dist/lib/scan/scan.js +2 -1
  41. package/dist/lib/scan/share.d.ts +8 -6
  42. package/dist/lib/scan/share.js +28 -11
  43. package/dist/lib/scan/spend/anthropic-usage.d.ts +20 -1
  44. package/dist/lib/scan/spend/anthropic-usage.js +32 -7
  45. package/dist/lib/scan/spend/anthropic-wif.d.ts +42 -0
  46. package/dist/lib/scan/spend/anthropic-wif.js +153 -0
  47. package/dist/lib/starter-integrity.d.ts +20 -0
  48. package/dist/lib/starter-integrity.js +28 -0
  49. package/package.json +2 -5
  50. package/node_modules/@mnemom/aip-verifier/LICENSE +0 -190
  51. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.cts +0 -478
  52. package/node_modules/@mnemom/aip-verifier/dist/claim-lint-CVITIRcU.d.ts +0 -478
  53. package/node_modules/@mnemom/aip-verifier/dist/index.cjs +0 -971
  54. package/node_modules/@mnemom/aip-verifier/dist/index.cjs.map +0 -1
  55. package/node_modules/@mnemom/aip-verifier/dist/index.d.cts +0 -330
  56. package/node_modules/@mnemom/aip-verifier/dist/index.d.ts +0 -330
  57. package/node_modules/@mnemom/aip-verifier/dist/index.js +0 -933
  58. package/node_modules/@mnemom/aip-verifier/dist/index.js.map +0 -1
  59. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs +0 -879
  60. package/node_modules/@mnemom/aip-verifier/dist/render-lines.cjs.map +0 -1
  61. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.cts +0 -96
  62. package/node_modules/@mnemom/aip-verifier/dist/render-lines.d.ts +0 -96
  63. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js +0 -875
  64. package/node_modules/@mnemom/aip-verifier/dist/render-lines.js.map +0 -1
  65. package/node_modules/@mnemom/aip-verifier/package.json +0 -86
  66. package/node_modules/@noble/ed25519/LICENSE +0 -21
  67. package/node_modules/@noble/ed25519/README.md +0 -371
  68. package/node_modules/@noble/ed25519/index.d.ts +0 -391
  69. package/node_modules/@noble/ed25519/index.js +0 -933
  70. package/node_modules/@noble/ed25519/index.ts +0 -1134
  71. package/node_modules/@noble/ed25519/package.json +0 -54
  72. package/node_modules/@noble/hashes/LICENSE +0 -21
  73. package/node_modules/@noble/hashes/README.md +0 -478
  74. package/node_modules/@noble/hashes/_blake.d.ts +0 -20
  75. package/node_modules/@noble/hashes/_blake.js +0 -59
  76. package/node_modules/@noble/hashes/_md.d.ts +0 -95
  77. package/node_modules/@noble/hashes/_md.js +0 -209
  78. package/node_modules/@noble/hashes/_u64.d.ts +0 -36
  79. package/node_modules/@noble/hashes/_u64.js +0 -77
  80. package/node_modules/@noble/hashes/argon2.d.ts +0 -148
  81. package/node_modules/@noble/hashes/argon2.js +0 -577
  82. package/node_modules/@noble/hashes/blake1.d.ts +0 -162
  83. package/node_modules/@noble/hashes/blake1.js +0 -630
  84. package/node_modules/@noble/hashes/blake2.d.ts +0 -203
  85. package/node_modules/@noble/hashes/blake2.js +0 -573
  86. package/node_modules/@noble/hashes/blake3.d.ts +0 -68
  87. package/node_modules/@noble/hashes/blake3.js +0 -310
  88. package/node_modules/@noble/hashes/eskdf.d.ts +0 -91
  89. package/node_modules/@noble/hashes/eskdf.js +0 -225
  90. package/node_modules/@noble/hashes/hkdf.d.ts +0 -70
  91. package/node_modules/@noble/hashes/hkdf.js +0 -152
  92. package/node_modules/@noble/hashes/hmac.d.ts +0 -50
  93. package/node_modules/@noble/hashes/hmac.js +0 -93
  94. package/node_modules/@noble/hashes/index.d.ts +0 -1
  95. package/node_modules/@noble/hashes/index.js +0 -34
  96. package/node_modules/@noble/hashes/legacy.d.ts +0 -107
  97. package/node_modules/@noble/hashes/legacy.js +0 -348
  98. package/node_modules/@noble/hashes/package.json +0 -91
  99. package/node_modules/@noble/hashes/pbkdf2.d.ts +0 -66
  100. package/node_modules/@noble/hashes/pbkdf2.js +0 -176
  101. package/node_modules/@noble/hashes/scrypt.d.ts +0 -98
  102. package/node_modules/@noble/hashes/scrypt.js +0 -304
  103. package/node_modules/@noble/hashes/sha2.d.ts +0 -176
  104. package/node_modules/@noble/hashes/sha2.js +0 -446
  105. package/node_modules/@noble/hashes/sha3-addons.d.ts +0 -533
  106. package/node_modules/@noble/hashes/sha3-addons.js +0 -973
  107. package/node_modules/@noble/hashes/sha3.d.ts +0 -208
  108. package/node_modules/@noble/hashes/sha3.js +0 -475
  109. package/node_modules/@noble/hashes/src/_blake.ts +0 -67
  110. package/node_modules/@noble/hashes/src/_md.ts +0 -227
  111. package/node_modules/@noble/hashes/src/_u64.ts +0 -111
  112. package/node_modules/@noble/hashes/src/argon2.ts +0 -693
  113. package/node_modules/@noble/hashes/src/blake1.ts +0 -677
  114. package/node_modules/@noble/hashes/src/blake2.ts +0 -648
  115. package/node_modules/@noble/hashes/src/blake3.ts +0 -344
  116. package/node_modules/@noble/hashes/src/eskdf.ts +0 -265
  117. package/node_modules/@noble/hashes/src/hkdf.ts +0 -161
  118. package/node_modules/@noble/hashes/src/hmac.ts +0 -130
  119. package/node_modules/@noble/hashes/src/index.ts +0 -33
  120. package/node_modules/@noble/hashes/src/legacy.ts +0 -361
  121. package/node_modules/@noble/hashes/src/pbkdf2.ts +0 -219
  122. package/node_modules/@noble/hashes/src/scrypt.ts +0 -375
  123. package/node_modules/@noble/hashes/src/sha2.ts +0 -486
  124. package/node_modules/@noble/hashes/src/sha3-addons.ts +0 -1157
  125. package/node_modules/@noble/hashes/src/sha3.ts +0 -525
  126. package/node_modules/@noble/hashes/src/utils.ts +0 -978
  127. package/node_modules/@noble/hashes/src/webcrypto.ts +0 -283
  128. package/node_modules/@noble/hashes/utils.d.ts +0 -560
  129. package/node_modules/@noble/hashes/utils.js +0 -687
  130. package/node_modules/@noble/hashes/webcrypto.d.ts +0 -121
  131. package/node_modules/@noble/hashes/webcrypto.js +0 -228
@@ -1,12 +1,13 @@
1
1
  /**
2
- * The report email: after every successful scan the person who ran it is emailed the report, with no
3
- * opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`
4
- * (mnemom-api#4270). The server resolves the runner's address from the signed-in identity, so the
5
- * request carries no runner address; `cto_email` is sent only when the user passed --cto-email.
2
+ * The report email: after every successful scan the full report, with the counts for each repo, is
3
+ * emailed to the person who ran it, every Org Admin of the org and anyone the runner added (MNE-9634),
4
+ * with no opt-out and at every share level. One call to `POST /v1/orgs/{org_id}/scan/report-email`.
5
+ * The server resolves the runner's address from the signed-in identity and the Org Admins from the org,
6
+ * so the request carries neither; `emails` holds only the extra addresses the runner added.
6
7
  *
7
- * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals
8
- * and counts only), `share_level`, `coverage.complete` and the optional `cto_email`. No Record totals,
9
- * no repo names, no author, title, branch or body text, no credential.
8
+ * The body is exactly the route's contract and nothing else: `report` (org-scan-report/1: team totals,
9
+ * counts, and the per-repo counts `by_repo`), `share_level`, `coverage.complete` and the optional
10
+ * `emails`. No author, title, branch or body text, no credential.
10
11
  *
11
12
  * Every failure has a fixed name and fixed text. The server's body, the thrown value and the
12
13
  * credential never reach the terminal.
@@ -40,6 +41,7 @@ export function buildEmailReport(result, o) {
40
41
  // Only a scan that read every merged PR in the period gives an average that means anything.
41
42
  const exhaustive = coverage.complete && coverage.prsSkippedLarge === 0 && coverage.prsUnreadable === 0;
42
43
  const cost = buildCostPerPr(result.prSizes, o.period ?? { start: coverage.windowStart, end: coverage.windowEnd }, o.costSpend ?? null, exhaustive);
44
+ const byRepo = repoRows(result);
43
45
  return {
44
46
  schema_version: REPORT_SCHEMA_VERSION,
45
47
  org_id: o.orgId,
@@ -72,14 +74,38 @@ export function buildEmailReport(result, o) {
72
74
  },
73
75
  estimate: { name: ESTIMATE_NAME, value, unit, method_url: METHOD_URL },
74
76
  ...cost,
77
+ ...(byRepo.length > 0 ? { by_repo: byRepo } : {}),
75
78
  };
76
79
  }
77
- export function buildEmailBody(report, level, complete, ctoEmail) {
80
+ const MAX_REPOS = 500;
81
+ const REPO_NAME = /^[A-Za-z0-9._-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
82
+ /** The per-repo rows for the report: valid names only, each once (any case), busiest first, at most 500. */
83
+ function repoRows(result) {
84
+ const seen = new Set();
85
+ return [...result.byRepo]
86
+ .sort((a, b) => b.prs - a.prs || a.repo.localeCompare(b.repo))
87
+ .filter((r) => {
88
+ const k = r.repo.toLowerCase();
89
+ if (!REPO_NAME.test(r.repo) || seen.has(k))
90
+ return false;
91
+ seen.add(k);
92
+ return true;
93
+ })
94
+ .slice(0, MAX_REPOS)
95
+ .map((r) => ({
96
+ repo: r.repo,
97
+ pr_count: r.prs,
98
+ revert_count: r.reverts,
99
+ automatable_count: r.ciGreen,
100
+ examined_count: r.examined,
101
+ }));
102
+ }
103
+ export function buildEmailBody(report, level, complete, extraEmails = []) {
78
104
  return {
79
105
  report,
80
106
  share_level: level,
81
107
  coverage: { complete },
82
- ...(ctoEmail !== undefined ? { cto_email: ctoEmail } : {}),
108
+ ...(extraEmails.length > 0 ? { emails: extraEmails } : {}),
83
109
  };
84
110
  }
85
111
  const FAILURE_TEXT = {
@@ -89,7 +115,10 @@ const FAILURE_TEXT = {
89
115
  not_a_member: "your account is not a member of the org passed to --org.",
90
116
  report_rejected: "Mnemom rejected the report as malformed.",
91
117
  no_recipients: "the server accepted the report but reported zero recipients.",
92
- runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the CTO. Re-run the scan, and check that your account has a valid email address.",
118
+ runner_not_emailed: "the report was not delivered to you (the person who ran the scan); it may have gone only to the others. Re-run the scan, and check that your account has a valid email address.",
119
+ emails_rejected: "an address you added was not accepted. Add up to 10 valid email addresses.",
120
+ email_domain_not_allowed: "an address you added is not on your organization's email domain. Add addresses on your own domain or mnemom.ai.",
121
+ admins_not_found: "Mnemom could not look up your org admins, so no email was sent. Try again in a moment.",
93
122
  network_error: "Mnemom could not be reached.",
94
123
  unexpected_response: "Mnemom answered in an unexpected way.",
95
124
  };
@@ -118,11 +147,27 @@ function classifyStatus(status) {
118
147
  return "report_rejected";
119
148
  return "unexpected_response";
120
149
  }
121
- /** The body without the optional cost figures; the same object when it carries none. */
122
- function withoutCostFigures(body) {
123
- const { cost_per_merged_pr: _a, cost_by_pr_size: _b, ...rest } = body.report;
124
- return "cost_per_merged_pr" in body.report || "cost_by_pr_size" in body.report
125
- ? { ...body, report: rest }
150
+ /**
151
+ * The body an API that predates this contract understands: no cost figures, no per-repo rows, and the
152
+ * share level under its old name (`analytics` was `summary`; the meaning is the same, never upgraded).
153
+ * The same object when nothing needs changing.
154
+ */
155
+ function legacyBody(body) {
156
+ const { cost_per_merged_pr: _a, cost_by_pr_size: _b, by_repo: _c, ...rest } = body.report;
157
+ const changed = "cost_per_merged_pr" in body.report ||
158
+ "cost_by_pr_size" in body.report ||
159
+ "by_repo" in body.report ||
160
+ body.share_level === "analytics" ||
161
+ (body.emails?.length ?? 0) > 0;
162
+ const { emails, ...top } = body;
163
+ const extra = emails?.[0];
164
+ return changed
165
+ ? {
166
+ ...top,
167
+ ...(extra ? { cto_email: extra } : {}),
168
+ report: rest,
169
+ share_level: body.share_level === "analytics" ? "summary" : body.share_level,
170
+ }
126
171
  : body;
127
172
  }
128
173
  /** Never throws. The thrown value, the response body and the credential are never surfaced. */
@@ -135,15 +180,23 @@ export async function sendReportEmail(orgId, body, deps = {}) {
135
180
  catch (e) {
136
181
  if (e instanceof MnemomApiError && e.effectiveStatus === 404)
137
182
  return { status: "unavailable" };
138
- // An API that predates the cost figures rejects the unknown keys with a 400. The email must still
139
- // go out, so retry once with the figures left off; any other failure is classified as below.
183
+ // An API that predates the cost figures, the per-repo rows or the analytics name rejects them with a 400. The email must still
184
+ // go out, so retry once in the old shape; any other failure is classified as below.
140
185
  if (e instanceof MnemomApiError && e.effectiveStatus === 400) {
141
- const bare = withoutCostFigures(body);
142
- if (bare !== body)
143
- return sendReportEmail(orgId, bare, deps);
186
+ const bare = legacyBody(body);
187
+ if (bare !== body && !/email|admin/i.test(e.code ?? "")) {
188
+ const r = await sendReportEmail(orgId, bare, deps);
189
+ return r.status === "sent" ? { ...r, limited: true } : r;
190
+ }
144
191
  }
145
192
  if (e instanceof MnemomApiError) {
146
193
  // A 4xx that names the runner (the API refuses a send that would skip the runner) gets its own text.
194
+ if (e.code === "email_domain_not_allowed")
195
+ return { status: "failed", reason: "email_domain_not_allowed" };
196
+ if (/emails/i.test(e.code ?? ""))
197
+ return { status: "failed", reason: "emails_rejected" };
198
+ if (e.code === "admin_lookup_failed")
199
+ return { status: "failed", reason: "admins_not_found" };
147
200
  if (e.effectiveStatus >= 400 && e.effectiveStatus < 500 && /runner/i.test(e.code ?? ""))
148
201
  return { status: "failed", reason: "runner_not_emailed" };
149
202
  return { status: "failed", reason: classifyStatus(e.effectiveStatus) };
@@ -158,8 +211,11 @@ export async function sendReportEmail(orgId, body, deps = {}) {
158
211
  return { status: "failed", reason: "unexpected_response" };
159
212
  if (recipients === 0)
160
213
  return { status: "failed", reason: "no_recipients" };
161
- // `skipped` lists the roles ("runner", "cto") that got no copy. The runner must always get the report.
214
+ // `skipped` lists the roles ("runner", "admin", "added", "cto") that got no copy. The runner must always get the report.
162
215
  if (Array.isArray(o.skipped) && o.skipped.includes("runner"))
163
216
  return { status: "failed", reason: "runner_not_emailed" };
164
- return o.status === "dry_run" ? { status: "dry_run" } : { status: "sent", recipients };
217
+ const adminsMissed = Array.isArray(o.skipped) ? o.skipped.filter((s) => s === "admin").length : 0;
218
+ return o.status === "dry_run"
219
+ ? { status: "dry_run" }
220
+ : { status: "sent", recipients, ...(adminsMissed > 0 ? { adminsMissed } : {}) };
165
221
  }
@@ -43,6 +43,8 @@ export interface RepoTotals {
43
43
  prs: number;
44
44
  reverts: number;
45
45
  ciGreen: number;
46
+ /** PRs read and counted (not reverts, not skipped); the per-repo base of the CI-green share. */
47
+ examined: number;
46
48
  }
47
49
  export interface ScanResult {
48
50
  coverage: Coverage;
@@ -104,7 +104,7 @@ export async function runLocalScan(deps, input) {
104
104
  const bucket = (repo) => {
105
105
  let b = byRepo.get(repo);
106
106
  if (!b)
107
- byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0 }));
107
+ byRepo.set(repo, (b = { repo, prs: 0, reverts: 0, ciGreen: 0, examined: 0 }));
108
108
  return b;
109
109
  };
110
110
  const totals = { prs: 0, reverts: 0, ciGreen: 0, examined: 0 };
@@ -142,6 +142,7 @@ export async function runLocalScan(deps, input) {
142
142
  }
143
143
  totals.prs += 1;
144
144
  totals.examined += 1;
145
+ bucket(repo).examined += 1;
145
146
  bucket(repo).prs += 1;
146
147
  if (rec.ci === "success") {
147
148
  totals.ciGreen += 1;
@@ -1,20 +1,21 @@
1
1
  import type { ScanResult } from "./scan.js";
2
- export type ShareLevel = "summary" | "full" | "none";
2
+ export type ShareLevel = "full" | "analytics" | "none";
3
3
  /** The level preselected at the prompt (Enter picks it). */
4
4
  export declare const DEFAULT_SHARE_LEVEL: ShareLevel;
5
5
  export declare const SHARE_OPTIONS: ReadonlyArray<{
6
6
  level: ShareLevel;
7
7
  label: string;
8
8
  }>;
9
- /** Shown just above the share prompt, so pressing Enter is an informed choice. */
9
+ /** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
10
10
  export declare const SHARE_DISCLOSURE: string;
11
11
  /** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
12
12
  export declare function promptShareLevel(): Promise<ShareLevel>;
13
+ /** `summary` is the old name for `analytics`; the meaning is unchanged. */
13
14
  export declare function parseShareLevel(v: string | undefined): ShareLevel | null;
14
- /** Who receives the report. Sent only with a non-"none" share level. */
15
+ /** Who receives the report. The runner comes from the Mnemom login; the server resolves the Org Admins. */
15
16
  export interface ReportRecipients {
16
- runner_email: string;
17
- cto_email: string;
17
+ runner_email?: string;
18
+ extra_emails: string[];
18
19
  }
19
20
  export interface SharePayload {
20
21
  recipients: ReportRecipients;
@@ -30,6 +31,7 @@ export interface SharePayload {
30
31
  coverage_complete: boolean;
31
32
  /** Absent when no spend line may be shown. */
32
33
  spend_line?: string;
34
+ /** Present at "full" only. */
33
35
  repos?: Array<{
34
36
  repo: string;
35
37
  prs: number;
@@ -37,6 +39,6 @@ export interface SharePayload {
37
39
  ci_green: number;
38
40
  }>;
39
41
  }
40
- /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the two report recipients. */
42
+ /** Builds only what the level allows. Never includes authors, titles, branches, bodies or tokens. The only person data is the report recipients. */
41
43
  export declare function buildSharePayload(level: Exclude<ShareLevel, "none">, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload;
42
44
  export declare function buildSharePayload(level: ShareLevel, r: ScanResult, recipients: ReportRecipients, spendLine?: string | null): SharePayload | null;
@@ -1,32 +1,49 @@
1
1
  /**
2
- * Share level: what, if anything, may leave the machine. Asked before any output is built for sharing.
3
- * Full is the preselected choice (MNE-9619): pressing Enter at the prompt picks it. "none" yields no
4
- * payload at all, and is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
2
+ * "What may Mnemom keep on file?" (MNE-9634). This is a retention choice only; it never changes what is emailed. It is asked at
3
+ * the start, before any scan work. The scan itself is always complete: the full report, with the counts
4
+ * for each repo, is emailed to the runner, every Org Admin and anyone the runner adds, at every level.
5
+ *
6
+ * full Mnemom keeps the totals and the counts for each repo, by name, on file (the default).
7
+ * analytics Mnemom keeps the team totals only, with no repo names. `summary` is the old name for this
8
+ * and is accepted as an alias; it is never upgraded to full.
9
+ * none Mnemom keeps nothing on file. The report is still emailed.
10
+ *
11
+ * "none" is what a cancelled, empty-piped or non-interactive prompt resolves to (fail closed).
12
+ * Storing the shared payload on Mnemom's side is not built yet; the CLI only prints it.
5
13
  */
6
14
  import { askSelect } from "../prompt.js";
7
15
  /** The level preselected at the prompt (Enter picks it). */
8
16
  export const DEFAULT_SHARE_LEVEL = "full";
9
17
  export const SHARE_OPTIONS = [
10
- { level: "summary", label: "Summary metrics only (counts and one estimate line)" },
11
- { level: "full", label: "Full report (summary plus per-repo counts; default)" },
12
- { level: "none", label: "Nothing (keep results on this machine)" },
18
+ {
19
+ level: "full",
20
+ label: "Full report (default): Mnemom keeps the totals and the counts for each repo, by name, on file",
21
+ },
22
+ {
23
+ level: "analytics",
24
+ label: "Analytics: Mnemom keeps the team totals only on file, with no repo names",
25
+ },
26
+ { level: "none", label: "Nothing: Mnemom keeps nothing on file" },
13
27
  ];
14
- /** Shown just above the share prompt, so pressing Enter is an informed choice. */
28
+ /** Shown just above the prompt. The full report, repo names included, is emailed whatever is chosen here. */
15
29
  export const SHARE_DISCLOSURE = [
16
- "Pressing Enter shares the Full report: the whole report, including your repo names and per-repo counts.",
17
- "It is emailed to the report recipients. To share less, choose 1 (Summary: counts only, no repo names) or 3 (Nothing).",
30
+ "Whatever you choose, the full report, including repo names, is emailed to you, your org's admins and any addresses you added.",
31
+ "The next question is only about what Mnemom keeps on file. Pressing Enter chooses the Full report.",
18
32
  ].join("\n");
19
33
  /** Asks the user, with Full preselected. Anything but a recognised answer (cancel, EOF, noise) is "none": fail closed. */
20
34
  export async function promptShareLevel() {
21
35
  // eslint-disable-next-line no-console -- consent text must show on stdout directly above the prompt (askSelect also prints with console.log)
22
36
  console.log(SHARE_DISCLOSURE);
23
- const answer = await askSelect("What may leave this machine?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
37
+ const answer = await askSelect("What may Mnemom keep on file?", SHARE_OPTIONS.map((o) => o.label), SHARE_OPTIONS.findIndex((o) => o.level === DEFAULT_SHARE_LEVEL));
24
38
  if (answer === null)
25
39
  return "none";
26
40
  return SHARE_OPTIONS.find((o) => o.label === answer)?.level ?? "none";
27
41
  }
42
+ /** `summary` is the old name for `analytics`; the meaning is unchanged. */
28
43
  export function parseShareLevel(v) {
29
- return v === "summary" || v === "full" || v === "none" ? v : null;
44
+ if (v === "summary")
45
+ return "analytics";
46
+ return v === "analytics" || v === "full" || v === "none" ? v : null;
30
47
  }
31
48
  export function buildSharePayload(level, r, recipients,
32
49
  /** undefined: the legacy line; null: no spend line; string: that line. */
@@ -1,12 +1,24 @@
1
1
  export declare const ANTHROPIC_ADMIN_ORIGIN = "https://api.anthropic.com";
2
2
  export declare const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
3
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
4
+ export declare const REPORT_PATHS: readonly string[];
5
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
6
+ export declare function assertReportPath(path: string): void;
7
+ /** The exact auth header to send; same shape as mnemom-api src/org-scan/usage/credential-source.ts. */
8
+ export type AnthropicAuthHeader = {
9
+ header: "x-api-key";
10
+ value: string;
11
+ } | {
12
+ header: "authorization";
13
+ value: string;
14
+ };
3
15
  export declare const DEFAULT_MAX_PAGES = 20;
4
16
  /** One shared deadline for a request including its retries (not N x timeout). */
5
17
  export declare const DEFAULT_TIMEOUT_MS = 15000;
6
18
  export declare const DEFAULT_MAX_RETRIES = 2;
7
19
  /** Shape check only; the key is never echoed, even on a mismatch. */
8
20
  export declare const isAdminKeyShape: (k: unknown) => k is string;
9
- export type UsageFailure = "key_invalid" | "key_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
21
+ export type UsageFailure = "key_invalid" | "key_refused" | "token_refused" | "rate_limited" | "timeout" | "network" | "upstream" | "malformed" | "too_large";
10
22
  /** Fixed sentence for a failure reason. */
11
23
  export declare const usageFailureText: (r: UsageFailure) => string;
12
24
  /** Carries only a reason tag. Its message is the fixed sentence: nothing upstream can reach it. */
@@ -55,3 +67,10 @@ export declare function fetchMeasuredUsage(key: string | undefined, period: {
55
67
  start: string;
56
68
  end: string;
57
69
  }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
70
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
71
+ export declare function bearerAuth(token: string): AnthropicAuthHeader;
72
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
73
+ export declare function fetchMeasuredUsageWithAuth(auth: AnthropicAuthHeader, period: {
74
+ start: string;
75
+ end: string;
76
+ }, deps?: AnthropicUsageDeps): Promise<MeasuredUsage>;
@@ -1,6 +1,11 @@
1
1
  // MNE-9298 Tier 1: the CTO's own Anthropic Admin key, used LOCALLY, to read measured usage. Port of
2
2
  // mnemom-api src/org-scan/usage/usage-client.ts (two read-only GETs: usage_report/messages grouped by
3
- // context_window, and cost_report) with the server-only parts (egress allowlist, WIF) dropped.
3
+ // context_window, and cost_report) with the server-only parts (egress allowlist) dropped.
4
+ //
5
+ // MNE-9706: the reads take either credential, as the server's credential-source.ts does: the pasted Admin
6
+ // key (x-api-key) or a short-lived WIF access token (Authorization: Bearer, see anthropic-wif.ts). Either
7
+ // one is sent ONLY to the two report paths below; walk() refuses any other path (the fence, design T18:
8
+ // Anthropic's WIF scope for these reports is org:admin, so the fence has to live here).
4
9
  //
5
10
  // The key is held in memory for the length of the two reads. It is never stored, logged, written to disk,
6
11
  // echoed, put in argv, a URL or an error message, and is sent only to api.anthropic.com (a constant, not
@@ -11,6 +16,13 @@ export const ADMIN_KEY_ENV = "ANTHROPIC_ADMIN_KEY";
11
16
  const ANTHROPIC_VERSION = "2023-06-01";
12
17
  const USAGE_PATH = "/v1/organizations/usage_report/messages";
13
18
  const COST_PATH = "/v1/organizations/cost_report";
19
+ /** The only paths a usage credential is ever sent to (design T18: fenced to report endpoints). */
20
+ export const REPORT_PATHS = [USAGE_PATH, COST_PATH];
21
+ /** The fence, as one exported check so it is directly testable. Throws AnthropicUsageError for any other path. */
22
+ export function assertReportPath(path) {
23
+ if (!REPORT_PATHS.includes(path))
24
+ throw new AnthropicUsageError("malformed");
25
+ }
14
26
  export const DEFAULT_MAX_PAGES = 20;
15
27
  /** One shared deadline for a request including its retries (not N x timeout). */
16
28
  export const DEFAULT_TIMEOUT_MS = 15_000;
@@ -22,6 +34,7 @@ export const isAdminKeyShape = (k) => typeof k === "string" && ADMIN_KEY_RE.test
22
34
  const FAILURE_TEXT = {
23
35
  key_invalid: "The Anthropic admin key is not an Admin API key (it should start with sk-ant-admin).",
24
36
  key_refused: "Anthropic refused the admin key (401/403).",
37
+ token_refused: "Anthropic refused the workload identity token for the usage reports (401/403). The federation rule needs scope org:admin.",
25
38
  rate_limited: "Anthropic rate limit reached.",
26
39
  timeout: "Anthropic did not answer in time.",
27
40
  network: "Anthropic was unreachable.",
@@ -96,14 +109,14 @@ function retryWait(res, attempt) {
96
109
  return Number.isFinite(ra) && ra > 0 ? ra * 1000 : backoff(attempt);
97
110
  }
98
111
  class Reader {
99
- key;
112
+ auth;
100
113
  fetchImpl;
101
114
  sleep;
102
115
  maxPages;
103
116
  timeoutMs;
104
117
  maxRetries;
105
- constructor(key, deps) {
106
- this.key = key;
118
+ constructor(auth, deps) {
119
+ this.auth = auth;
107
120
  this.fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
108
121
  this.sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
109
122
  this.maxPages = deps.maxPages ?? DEFAULT_MAX_PAGES;
@@ -111,6 +124,8 @@ class Reader {
111
124
  this.maxRetries = deps.maxRetries ?? DEFAULT_MAX_RETRIES;
112
125
  }
113
126
  async walk(path, base, parse, callMaxPages) {
127
+ // The fence: the credential goes to the two report paths and nowhere else.
128
+ assertReportPath(path);
114
129
  const ceiling = typeof callMaxPages === "number" && Number.isFinite(callMaxPages) && callMaxPages >= 1
115
130
  ? Math.min(this.maxPages, Math.floor(callMaxPages))
116
131
  : this.maxPages;
@@ -154,7 +169,7 @@ class Reader {
154
169
  res = await this.fetchImpl(url, {
155
170
  method: "GET",
156
171
  headers: {
157
- "x-api-key": this.key,
172
+ [this.auth.header]: this.auth.value,
158
173
  "anthropic-version": ANTHROPIC_VERSION,
159
174
  accept: "application/json",
160
175
  },
@@ -185,7 +200,7 @@ class Reader {
185
200
  }
186
201
  await res.body?.cancel().catch(() => { });
187
202
  if (res.status === 401 || res.status === 403)
188
- throw new AnthropicUsageError("key_refused");
203
+ throw new AnthropicUsageError(this.auth.header === "x-api-key" ? "key_refused" : "token_refused");
189
204
  const retryable = res.status === 429 || res.status >= 500;
190
205
  const wait = retryable ? retryWait(res, attempt + 1) : 0;
191
206
  if (retryable &&
@@ -209,11 +224,21 @@ export async function fetchMeasuredUsage(key, period, deps = {}) {
209
224
  const k = key?.trim();
210
225
  if (!k || !isAdminKeyShape(k))
211
226
  throw new AnthropicUsageError("key_invalid");
227
+ return fetchMeasuredUsageWithAuth({ header: "x-api-key", value: k }, period, deps);
228
+ }
229
+ /** A WIF access token as the bearer for the two report reads. Printable ASCII only, else key_invalid. */
230
+ export function bearerAuth(token) {
231
+ if (!/^[\x21-\x7e]{8,4096}$/.test(token))
232
+ throw new AnthropicUsageError("key_invalid");
233
+ return { header: "authorization", value: `Bearer ${token}` };
234
+ }
235
+ /** The two reads with an already-resolved credential (pasted key or WIF bearer). Throws AnthropicUsageError only. */
236
+ export async function fetchMeasuredUsageWithAuth(auth, period, deps = {}) {
212
237
  if (!validDay(period.start) || !validDay(period.end))
213
238
  throw new AnthropicUsageError("malformed");
214
239
  const startingAt = alignStartingAt(`${period.start}T00:00:00Z`);
215
240
  const endingAt = new Date(Date.parse(`${period.end}T00:00:00Z`) + DAY_MS).toISOString();
216
- const reader = new Reader(k, deps);
241
+ const reader = new Reader(auth, deps);
217
242
  const up = new URLSearchParams({
218
243
  starting_at: startingAt,
219
244
  ending_at: endingAt,
@@ -0,0 +1,42 @@
1
+ export declare const WIF_EXCHANGE_URL = "https://api.anthropic.com/v1/oauth/token";
2
+ export declare const WIF_ENV: {
3
+ readonly rule: "ANTHROPIC_FEDERATION_RULE_ID";
4
+ readonly org: "ANTHROPIC_ORGANIZATION_ID";
5
+ readonly serviceAccount: "ANTHROPIC_SERVICE_ACCOUNT_ID";
6
+ readonly workspace: "ANTHROPIC_WORKSPACE_ID";
7
+ };
8
+ /** Same ceiling the server held a WIF token to (credential-source.ts MAX_WIF_TOKEN_LIFETIME_MS). */
9
+ export declare const MAX_WIF_TOKEN_LIFETIME_MS = 300000;
10
+ export interface WifConfig {
11
+ federationRuleId: string;
12
+ organizationId: string;
13
+ serviceAccountId: string;
14
+ workspaceId?: string;
15
+ }
16
+ export type WifFailure = "config_invalid" | "assertion_unavailable" | "assertion_refused" | "exchange_refused" | "exchange_failed";
17
+ export declare const wifFailureText: (r: WifFailure) => string;
18
+ export declare const WIF_FALLBACK_NOTE = "Falling back to the pasted Anthropic Admin key.";
19
+ /** Carries only a reason tag; its message is the fixed sentence. */
20
+ export declare class WifError extends Error {
21
+ readonly reason: WifFailure;
22
+ constructor(reason: WifFailure);
23
+ }
24
+ /**
25
+ * Reads the WIF ids from env. None of the three required ids set: null (WIF is not set up, no message).
26
+ * Some set, or any malformed: WifError("config_invalid"), so a half-done setup is said out loud.
27
+ */
28
+ export declare function readWifConfig(env: NodeJS.ProcessEnv): WifConfig | null;
29
+ export interface WifDeps {
30
+ /** Step 1 seam: returns the assertion for the org. Defaults to the mnemom-api route with the CLI's auth. */
31
+ mintAssertion?: (orgId: string) => Promise<unknown>;
32
+ /** Step 2 seam: the fetch used ONLY for the Anthropic exchange. */
33
+ fetchImpl?: typeof fetch;
34
+ now?: () => number;
35
+ }
36
+ export interface WifAccessToken {
37
+ accessToken: string;
38
+ /** Epoch ms, clamped to MAX_WIF_TOKEN_LIFETIME_MS from receipt. */
39
+ expiresAt: number;
40
+ }
41
+ /** Steps 1 and 2. Throws WifError only. */
42
+ export declare function obtainWifAccessToken(orgId: string, cfg: WifConfig, deps?: WifDeps): Promise<WifAccessToken>;
@@ -0,0 +1,153 @@
1
+ // MNE-9706 (S2.6, design T18): read the Anthropic Console through workload identity federation (WIF).
2
+ //
3
+ // Three steps, all from this machine:
4
+ // 1. Ask mnemom-api for a short-lived OIDC identity token (the "assertion") for the org, with the user's
5
+ // own CLI sign-in: POST /v1/orgs/{org_id}/scan/wif-assertion. Its sub is `mnemom:org:<org_id>`.
6
+ // 2. Exchange it DIRECTLY at Anthropic (POST https://api.anthropic.com/v1/oauth/token, RFC 7523
7
+ // jwt-bearer) for a short-lived access token. Mnemom never sees that token.
8
+ // 3. Hand the token to the usage reader, which sends it only to the two report paths (the fence).
9
+ //
10
+ // The customer's Console holds the federation issuer, service account and rule. The CLI needs only their
11
+ // non-secret ids, read from Anthropic's own variable names (ANTHROPIC_FEDERATION_RULE_ID,
12
+ // ANTHROPIC_ORGANIZATION_ID, ANTHROPIC_SERVICE_ACCOUNT_ID, optional ANTHROPIC_WORKSPACE_ID).
13
+ //
14
+ // The assertion and the access token live in memory for one scan and are never logged, stored, printed,
15
+ // put in argv, a URL or an error message. Every failure is a fixed sentence (WifFailure), so the caller can
16
+ // fall back to the pasted Admin key with a clear message.
17
+ import { MnemomApiError, postApi } from "../../api.js";
18
+ import { ANTHROPIC_ADMIN_ORIGIN } from "./anthropic-usage.js";
19
+ export const WIF_EXCHANGE_URL = `${ANTHROPIC_ADMIN_ORIGIN}/v1/oauth/token`;
20
+ export const WIF_ENV = {
21
+ rule: "ANTHROPIC_FEDERATION_RULE_ID",
22
+ org: "ANTHROPIC_ORGANIZATION_ID",
23
+ serviceAccount: "ANTHROPIC_SERVICE_ACCOUNT_ID",
24
+ workspace: "ANTHROPIC_WORKSPACE_ID",
25
+ };
26
+ /** Same ceiling the server held a WIF token to (credential-source.ts MAX_WIF_TOKEN_LIFETIME_MS). */
27
+ export const MAX_WIF_TOKEN_LIFETIME_MS = 300_000;
28
+ const EXCHANGE_TIMEOUT_MS = 15_000;
29
+ const WIF_TEXT = {
30
+ config_invalid: "The Anthropic workload identity settings are incomplete or malformed (federation rule, organization and service account ids).",
31
+ assertion_unavailable: "Mnemom could not issue a workload identity token for this org right now.",
32
+ assertion_refused: "Mnemom did not issue a workload identity token (sign in as an owner or admin of an org with Org Scan on).",
33
+ exchange_refused: "Anthropic refused the workload identity token. Check the federation rule in your Anthropic Console (its issuer, subject and scope org:admin).",
34
+ exchange_failed: "Anthropic's token exchange could not be completed.",
35
+ };
36
+ export const wifFailureText = (r) => WIF_TEXT[r];
37
+ export const WIF_FALLBACK_NOTE = "Falling back to the pasted Anthropic Admin key.";
38
+ /** Carries only a reason tag; its message is the fixed sentence. */
39
+ export class WifError extends Error {
40
+ reason;
41
+ constructor(reason) {
42
+ super(WIF_TEXT[reason]);
43
+ this.reason = reason;
44
+ this.name = "WifError";
45
+ }
46
+ }
47
+ const RULE_RE = /^fdrl_[A-Za-z0-9]{1,128}$/;
48
+ const SVAC_RE = /^svac_[A-Za-z0-9]{1,128}$/;
49
+ const WRKSPC_RE = /^wrkspc_[A-Za-z0-9]{1,128}$/;
50
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
51
+ /**
52
+ * Reads the WIF ids from env. None of the three required ids set: null (WIF is not set up, no message).
53
+ * Some set, or any malformed: WifError("config_invalid"), so a half-done setup is said out loud.
54
+ */
55
+ export function readWifConfig(env) {
56
+ const get = (k) => {
57
+ const v = env[k]?.trim();
58
+ return v ? v : undefined;
59
+ };
60
+ const rule = get(WIF_ENV.rule);
61
+ const org = get(WIF_ENV.org);
62
+ const svac = get(WIF_ENV.serviceAccount);
63
+ const ws = get(WIF_ENV.workspace);
64
+ if (!rule && !org && !svac)
65
+ return null;
66
+ if (!rule || !org || !svac)
67
+ throw new WifError("config_invalid");
68
+ if (!RULE_RE.test(rule) || !UUID_RE.test(org) || !SVAC_RE.test(svac))
69
+ throw new WifError("config_invalid");
70
+ if (ws !== undefined && !WRKSPC_RE.test(ws))
71
+ throw new WifError("config_invalid");
72
+ return {
73
+ federationRuleId: rule,
74
+ organizationId: org,
75
+ serviceAccountId: svac,
76
+ ...(ws ? { workspaceId: ws } : {}),
77
+ };
78
+ }
79
+ const JWT_RE = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
80
+ async function defaultMint(orgId) {
81
+ return postApi(`/v1/orgs/${encodeURIComponent(orgId)}/scan/wif-assertion`, {});
82
+ }
83
+ /** Steps 1 and 2. Throws WifError only. */
84
+ export async function obtainWifAccessToken(orgId, cfg, deps = {}) {
85
+ const now = deps.now ?? Date.now;
86
+ let assertion;
87
+ try {
88
+ const body = await (deps.mintAssertion ?? defaultMint)(orgId);
89
+ const a = body && typeof body === "object" ? body.assertion : undefined;
90
+ if (typeof a !== "string" || a.length > 16_384 || !JWT_RE.test(a))
91
+ throw new WifError("assertion_unavailable");
92
+ assertion = a;
93
+ }
94
+ catch (e) {
95
+ if (e instanceof WifError)
96
+ throw e;
97
+ const status = e instanceof MnemomApiError ? e.effectiveStatus : 0;
98
+ // 401/403 is "not you"; 404 is the dark route (Org Scan off for this org). Both: no token for you.
99
+ throw new WifError(status === 401 || status === 403 || status === 404
100
+ ? "assertion_refused"
101
+ : "assertion_unavailable");
102
+ }
103
+ const fetchImpl = deps.fetchImpl ?? ((...a) => fetch(...a));
104
+ let res;
105
+ try {
106
+ res = await fetchImpl(WIF_EXCHANGE_URL, {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json", accept: "application/json" },
109
+ body: JSON.stringify({
110
+ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
111
+ assertion,
112
+ federation_rule_id: cfg.federationRuleId,
113
+ organization_id: cfg.organizationId,
114
+ service_account_id: cfg.serviceAccountId,
115
+ ...(cfg.workspaceId ? { workspace_id: cfg.workspaceId } : {}),
116
+ }),
117
+ // A redirect would carry the assertion elsewhere: refuse to follow one.
118
+ redirect: "error",
119
+ signal: AbortSignal.timeout(EXCHANGE_TIMEOUT_MS),
120
+ });
121
+ }
122
+ catch {
123
+ // Never surface the thrown value: it can carry the request.
124
+ throw new WifError("exchange_failed");
125
+ }
126
+ if (!res.ok) {
127
+ await res.body?.cancel().catch(() => { });
128
+ throw new WifError(res.status === 400 || res.status === 401 || res.status === 403
129
+ ? "exchange_refused"
130
+ : "exchange_failed");
131
+ }
132
+ let json;
133
+ try {
134
+ json = await res.json();
135
+ }
136
+ catch {
137
+ throw new WifError("exchange_failed");
138
+ }
139
+ const b = json && typeof json === "object" ? json : {};
140
+ const token = b.access_token;
141
+ if (typeof token !== "string" || !/^[\x21-\x7e]{8,4096}$/.test(token))
142
+ throw new WifError("exchange_failed");
143
+ if (b.token_type !== undefined && String(b.token_type).toLowerCase() !== "bearer")
144
+ throw new WifError("exchange_failed");
145
+ const secs = typeof b.expires_in === "number" && Number.isFinite(b.expires_in) && b.expires_in > 0
146
+ ? b.expires_in
147
+ : 300;
148
+ const t = now();
149
+ return {
150
+ accessToken: token,
151
+ expiresAt: Math.min(t + secs * 1000, t + MAX_WIF_TOKEN_LIFETIME_MS),
152
+ };
153
+ }