@mnemom/mnemom 0.17.32 → 0.17.34-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 (88) hide show
  1. package/dist/commands/agent-fuel.d.ts +9 -0
  2. package/dist/commands/agent-fuel.js +36 -2
  3. package/dist/commands/agent-run.d.ts +50 -0
  4. package/dist/commands/agent-run.js +221 -0
  5. package/dist/commands/agent-setup.d.ts +8 -3
  6. package/dist/commands/agent-setup.js +32 -9
  7. package/dist/commands/agent-share-fixture.d.ts +3 -1
  8. package/dist/commands/agent-share-fixture.js +11 -7
  9. package/dist/commands/agent-share-render.d.ts +23 -0
  10. package/dist/commands/agent-share-render.js +72 -0
  11. package/dist/commands/agent-share.d.ts +15 -2
  12. package/dist/commands/agent-share.js +30 -9
  13. package/dist/commands/agent.d.ts +34 -4
  14. package/dist/commands/agent.js +116 -20
  15. package/dist/commands/protection.js +1 -1
  16. package/dist/commands/scan.d.ts +97 -0
  17. package/dist/commands/scan.js +238 -0
  18. package/dist/index.js +55 -0
  19. package/dist/lib/api.d.ts +13 -0
  20. package/dist/lib/api.js +25 -1
  21. package/dist/lib/auth.d.ts +6 -0
  22. package/dist/lib/auth.js +161 -14
  23. package/dist/lib/experimental.d.ts +1 -1
  24. package/dist/lib/experimental.js +13 -0
  25. package/dist/lib/oauth.d.ts +7 -0
  26. package/dist/lib/oauth.js +13 -0
  27. package/dist/lib/runner/bundle.d.ts +129 -0
  28. package/dist/lib/runner/bundle.js +319 -0
  29. package/dist/lib/runner/contract-header.d.ts +46 -0
  30. package/dist/lib/runner/contract-header.js +85 -0
  31. package/dist/lib/runner/executor-client.d.ts +71 -0
  32. package/dist/lib/runner/executor-client.js +97 -0
  33. package/dist/lib/runner/job.d.ts +70 -0
  34. package/dist/lib/runner/job.js +463 -0
  35. package/dist/lib/runner/loop.d.ts +45 -0
  36. package/dist/lib/runner/loop.js +104 -0
  37. package/dist/lib/runner/oidc.d.ts +38 -0
  38. package/dist/lib/runner/oidc.js +89 -0
  39. package/dist/lib/runner/options.d.ts +41 -0
  40. package/dist/lib/runner/options.js +47 -0
  41. package/dist/lib/runner/outcome.d.ts +47 -0
  42. package/dist/lib/runner/outcome.js +89 -0
  43. package/dist/lib/runner/phases.d.ts +49 -0
  44. package/dist/lib/runner/phases.js +99 -0
  45. package/dist/lib/runner/proxy.d.ts +45 -0
  46. package/dist/lib/runner/proxy.js +153 -0
  47. package/dist/lib/runner/settings.d.ts +31 -0
  48. package/dist/lib/runner/settings.js +44 -0
  49. package/dist/lib/scan/caps.d.ts +35 -0
  50. package/dist/lib/scan/caps.js +67 -0
  51. package/dist/lib/scan/credential.d.ts +37 -0
  52. package/dist/lib/scan/credential.js +104 -0
  53. package/dist/lib/scan/failure.d.ts +10 -0
  54. package/dist/lib/scan/failure.js +43 -0
  55. package/dist/lib/scan/pr-source.d.ts +57 -0
  56. package/dist/lib/scan/pr-source.js +130 -0
  57. package/dist/lib/scan/record.d.ts +49 -0
  58. package/dist/lib/scan/record.js +85 -0
  59. package/dist/lib/scan/report-email.d.ts +92 -0
  60. package/dist/lib/scan/report-email.js +136 -0
  61. package/dist/lib/scan/report.d.ts +13 -0
  62. package/dist/lib/scan/report.js +30 -0
  63. package/dist/lib/scan/scan.d.ts +64 -0
  64. package/dist/lib/scan/scan.js +182 -0
  65. package/dist/lib/scan/share.d.ts +38 -0
  66. package/dist/lib/scan/share.js +47 -0
  67. package/dist/lib/scan/spend/ai-tool-detect.d.ts +6 -0
  68. package/dist/lib/scan/spend/ai-tool-detect.js +18 -0
  69. package/dist/lib/scan/spend/anthropic-usage.d.ts +57 -0
  70. package/dist/lib/scan/spend/anthropic-usage.js +244 -0
  71. package/dist/lib/scan/spend/cost-corpus.v1.d.ts +56 -0
  72. package/dist/lib/scan/spend/cost-corpus.v1.js +64 -0
  73. package/dist/lib/scan/spend/prices.d.ts +33 -0
  74. package/dist/lib/scan/spend/prices.js +57 -0
  75. package/dist/lib/scan/spend/spend-estimate.d.ts +30 -0
  76. package/dist/lib/scan/spend/spend-estimate.js +70 -0
  77. package/dist/lib/scan/spend/spend-tiers.d.ts +25 -0
  78. package/dist/lib/scan/spend/spend-tiers.js +42 -0
  79. package/dist/lib/scan/spend/token-rate.d.ts +30 -0
  80. package/dist/lib/scan/spend/token-rate.js +54 -0
  81. package/dist/lib/session-key.d.ts +41 -0
  82. package/dist/lib/session-key.js +124 -0
  83. package/dist/lib/statusline-feed.js +10 -11
  84. package/dist/lib/statusline.d.ts +6 -0
  85. package/dist/lib/statusline.js +19 -9
  86. package/dist/rc-key-401.mjs +75 -0
  87. package/dist/rc-proxy.mjs +290 -30
  88. package/package.json +2 -1
@@ -43,9 +43,15 @@ export interface RenderedLinePreview {
43
43
  coverage?: string;
44
44
  kind: string;
45
45
  }
46
- /** The aip `renderLines` contract, injected (not yet published). */
46
+ /**
47
+ * The aip `renderLines` contract, injected. Resolves to the lines the card may show; REJECTS
48
+ * (with the renderer's reasons) when the card is not verifiable or breaks claim-lint, in which
49
+ * case no link is printed. `period` asks for the period-card surface (reverts always shown).
50
+ */
47
51
  export interface LineRenderer {
48
- renderLines(card: unknown): RenderedLinePreview[];
52
+ renderLines(card: unknown, opts?: {
53
+ period?: boolean;
54
+ }): RenderedLinePreview[] | Promise<RenderedLinePreview[]>;
49
55
  }
50
56
  export interface StatementSource {
51
57
  listLines(): Promise<CandidateLine[]>;
@@ -56,6 +62,11 @@ export interface CardIssuer {
56
62
  }
57
63
  export interface AgentShareDeps {
58
64
  source: StatementSource;
65
+ /**
66
+ * Optional claim-lint screen (aip `lintRendering`) run on each candidate before it is
67
+ * offered; returns a reason when the line must not be shared. Word lists live in aip only.
68
+ */
69
+ screen?: (line: SharedCardLine) => Promise<string | null> | string | null;
59
70
  issuer: CardIssuer;
60
71
  renderer: LineRenderer;
61
72
  /** Interactive picker: returns the chosen line ids. */
@@ -66,6 +77,8 @@ export interface AgentShareDeps {
66
77
  err: (line: string) => void;
67
78
  }
68
79
  export interface AgentShareFlags {
80
+ /** Period card: every offered revert line is included, and a card without one is refused. */
81
+ period?: boolean;
69
82
  lines?: string;
70
83
  list?: boolean;
71
84
  json?: boolean;
@@ -17,6 +17,7 @@
17
17
  import { createInterface } from "node:readline/promises";
18
18
  import { getWebsiteUrl } from "../lib/config.js";
19
19
  import { fmt } from "../lib/format.js";
20
+ import { aipLineRenderer, aipLineScreen } from "./agent-share-render.js";
20
21
  export const AGENT_SHARE_ENV = "MNEMOM_AGENT_SHARE";
21
22
  export function agentShareEnabled(env = process.env) {
22
23
  return env[AGENT_SHARE_ENV] === "1";
@@ -132,10 +133,16 @@ export async function agentShareCommand(flags, deps, env = process.env) {
132
133
  const refused = [];
133
134
  for (const c of candidates) {
134
135
  const r = refuseReason(c);
135
- if (r)
136
+ if (r) {
136
137
  refused.push(r);
138
+ continue;
139
+ }
140
+ const shared = toSharedLine(c);
141
+ const lint = deps.screen ? await deps.screen(shared) : null;
142
+ if (lint)
143
+ refused.push({ id: shared.id, reason: "estimate", detail: lint });
137
144
  else
138
- offered.push(toSharedLine(c));
145
+ offered.push(shared);
139
146
  }
140
147
  if (flags.list) {
141
148
  if (flags.json) {
@@ -175,6 +182,15 @@ export async function agentShareCommand(flags, deps, env = process.env) {
175
182
  deps.err(fmt.error("No lines picked."));
176
183
  return 1;
177
184
  }
185
+ if (flags.period) {
186
+ for (const l of offered)
187
+ if (l.kind === "revert" && !ids.includes(l.id))
188
+ ids.push(l.id);
189
+ if (!offered.some((l) => l.kind === "revert")) {
190
+ deps.err(fmt.error("A period card always shows reverts, and none are available to share."));
191
+ return 1;
192
+ }
193
+ }
178
194
  const chosen = [];
179
195
  for (const id of ids) {
180
196
  const r = refused.find((x) => x.id === id);
@@ -202,13 +218,22 @@ export async function agentShareCommand(flags, deps, env = process.env) {
202
218
  deps.err(fmt.error(`Refusing to share: ${problem}.`));
203
219
  return 1;
204
220
  }
221
+ // The renderer is the gate: nothing is linked unless aip renders the card (verified, lint clean).
222
+ let rendered;
223
+ try {
224
+ rendered = await deps.renderer.renderLines(card, { period: flags.period === true });
225
+ }
226
+ catch (error) {
227
+ deps.err(fmt.error(`Refusing to share: ${error.message}.`));
228
+ return 1;
229
+ }
205
230
  const link = buildShareLink(card, deps.websiteBase());
206
231
  if (flags.json) {
207
232
  deps.out(JSON.stringify({ link, lines: chosen }, null, 2));
208
233
  return 0;
209
234
  }
210
235
  deps.out("Your card:");
211
- for (const r of deps.renderer.renderLines(card)) {
236
+ for (const r of rendered) {
212
237
  deps.out(` ${r.text}${r.coverage ? ` (${r.coverage})` : ""}`);
213
238
  }
214
239
  deps.out("");
@@ -224,12 +249,8 @@ const notConnected = () => {
224
249
  export const liveAgentShareDeps = {
225
250
  source: { listLines: async () => notConnected() },
226
251
  issuer: { issue: async () => notConnected() },
227
- renderer: {
228
- renderLines: (card) => (card.lines ?? []).map((l) => ({
229
- text: `${l.label}: ${l.value}`,
230
- kind: l.kind,
231
- })),
232
- },
252
+ renderer: aipLineRenderer(undefined),
253
+ screen: aipLineScreen,
233
254
  pick: async (lines) => {
234
255
  const rl = createInterface({ input: process.stdin, output: process.stderr });
235
256
  try {
@@ -25,6 +25,8 @@
25
25
  * line, or the proxy's stdin under RC). It is never logged, printed, or passed as
26
26
  * an argv value.
27
27
  */
28
+ import { type ChildProcess } from "node:child_process";
29
+ import { type SessionKeyDeps, type SessionKeyMintArgs, type SessionKeyRenewal } from "../lib/session-key.js";
28
30
  import { type MnemomKeyDeps } from "../lib/subscription-login.js";
29
31
  import { type SessionRecord } from "../lib/agent-ledger.js";
30
32
  import { type ExperimentalId } from "../lib/experimental.js";
@@ -417,21 +419,49 @@ export interface BaseUrlProxyOptions {
417
419
  */
418
420
  export declare function startBaseUrlProxy(door: string, done: (baseUrl: string | null, recordStatePath?: string) => void, opts?: BaseUrlProxyOptions): void;
419
421
  /**
420
- * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
421
- * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
422
- * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
423
- * when this machine stores one or the launch pins. Never logged.
422
+ * The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
423
+ * credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
424
+ * forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
425
+ * launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
426
+ * credential ({@link newRcProxyCredential}). Never logged.
424
427
  */
425
428
  export declare function rcProxyStdin(ctx: {
426
429
  anthropicKey?: string;
427
430
  mnemomKey?: string;
431
+ proxyCredential: string;
428
432
  }): string;
433
+ /**
434
+ * MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
435
+ * owner's keys and its loopback port is reachable by every local process, so it serves only a
436
+ * caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
437
+ * hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
438
+ */
439
+ export declare function newRcProxyCredential(): string;
440
+ /**
441
+ * The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
442
+ * session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
443
+ * and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
444
+ */
445
+ export declare function rcProxyUrl(port: string, proxyCredential: string): string;
429
446
  /**
430
447
  * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
431
448
  * `mnm-<uuid>` (any case), else launcher.toml `agent_id` (only when no `--agent` was given —
432
449
  * selectLaunchAgent). A truncated/garbled `mnm-…` throws instead of becoming a name.
433
450
  */
434
451
  export declare function resolveLaunchPin(options: Pick<AgentOptions, "agent" | "agentId">): string | undefined;
452
+ /** The longest line the proxy sends the launcher (`remint <key id>`) is far shorter than this. */
453
+ export declare const RC_CONTROL_LINE_MAX = 1024;
454
+ /**
455
+ * Renew the Remote Control proxy's session key over its control pipe (fd 3; rc-proxy.mjs SESSION
456
+ * KEY RENEWAL): a `key <id> <expires> <key>` line before each expiry, a `remint <id>` line from the
457
+ * proxy when the gateway rejects its key, answered with a new key or `key-failed`. Renewal events
458
+ * go to the proxy log (the terminal belongs to Claude Code by then), as key ids and expiry times.
459
+ */
460
+ export declare function startRcSessionKeyRenewal(proxy: Pick<ChildProcess, "stdio">, proxyLog: string, initial: {
461
+ keyId: string;
462
+ expiresAt: string;
463
+ mintArgs: SessionKeyMintArgs;
464
+ }, makeDeps?: (say: (line: string) => void) => SessionKeyDeps): SessionKeyRenewal | undefined;
435
465
  /**
436
466
  * Locates the `handoff` MCP server (src/mcp/handoff-server.mjs in dev, dist/mcp/handoff-server.mjs
437
467
  * once built — a raw, uncompiled `.mjs` file that `copy-assets.mjs` copies verbatim, exactly like
@@ -26,8 +26,8 @@
26
26
  * an argv value.
27
27
  */
28
28
  import { spawn, spawnSync } from "node:child_process";
29
- import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, renameSync, } from "node:fs";
30
- import { randomUUID } from "node:crypto";
29
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, renameSync, } from "node:fs";
30
+ import { randomBytes, randomUUID } from "node:crypto";
31
31
  import { tmpdir, homedir, hostname } from "node:os";
32
32
  import { basename, join, dirname } from "node:path";
33
33
  import { fileURLToPath } from "node:url";
@@ -39,7 +39,7 @@ import { ALLOW_AUTOCOMPACT_OFF_ENV, dropAutocompactOffEnv, hasOwnSettingsFlag, m
39
39
  import { getApiUrl, getGatewayUrl, MNEMOM_DIR } from "../lib/config.js";
40
40
  import { createApiKey } from "../lib/api.js";
41
41
  import { recordOrgFor } from "../lib/record-org.js";
42
- import { defaultSessionKeyDeps, mintSessionGatewayKey, revokeSessionGatewayKey, } from "../lib/session-key.js";
42
+ import { defaultSessionKeyDeps, mintSessionGatewayKey, revokeSessionGatewayKey, startSessionKeyRenewal, } from "../lib/session-key.js";
43
43
  import { MNEMOM_KEY_STORE_FIELD, resolveAgentAuth, sayRetiredConsoleNotice, readMnemomKey, resolveSubscriptionMnemomKey, subscriptionChildEnv, subscriptionShape, } from "../lib/subscription-login.js";
44
44
  import { LEDGER_FILE, appendSessionRecord, findResumeCandidate, readSessionRecords, resolveRetentionDays, resumedContextProfile, toLedgerContract, } from "../lib/agent-ledger.js";
45
45
  import { isLoggedIn } from "../lib/auth.js";
@@ -612,6 +612,8 @@ export async function agentCommand(scenarioArg, options = {}) {
612
612
  // org is the key owner's personal org, where the agent does not exist, so the API answers 404
613
613
  // and the launch would fail closed (door1-path run 40400479127). Same read as Record's.
614
614
  let sessionKeyId;
615
+ // What a Remote Control launch needs to re-mint before the 24h expiry (startSessionKeyRenewal).
616
+ let sessionKeyRenewal;
615
617
  // One read of the served agent's org serves the mint and Record; each says why in its own
616
618
  // words when the read fails and the account org is used instead.
617
619
  let servedOrg;
@@ -632,13 +634,15 @@ export async function agentCommand(scenarioArg, options = {}) {
632
634
  say(fmt.warn(`mnemom agent: could not read ${servedAgentId}'s org (${o.fallback}); minting the session key under ${account.orgId}.`));
633
635
  mintOrgId = o.orgId;
634
636
  }
635
- const minted = await mintSessionGatewayKey(defaultSessionKeyDeps(say), {
637
+ const mintArgs = {
636
638
  agentId: servedAgentId,
637
639
  orgId: mintOrgId,
638
640
  sessionId: cc.id ?? undefined,
639
- });
641
+ };
642
+ const minted = await mintSessionGatewayKey(defaultSessionKeyDeps(say), mintArgs);
640
643
  mnemomKey = minted.key;
641
644
  sessionKeyId = minted.keyId;
645
+ sessionKeyRenewal = { expiresAt: minted.expiresAt, mintArgs };
642
646
  }
643
647
  // ── 1M context window by default ── the model this launch runs on gets `[1m]` when it has no
644
648
  // window suffix and supports 1M (resolveWindowModel); an explicit choice — the
@@ -828,6 +832,7 @@ export async function agentCommand(scenarioArg, options = {}) {
828
832
  yolo,
829
833
  ccSessionId,
830
834
  sessionKeyId,
835
+ sessionKeyRenewal,
831
836
  });
832
837
  }
833
838
  // ───────────────────────────────────────────────────────────────────────────
@@ -1620,13 +1625,31 @@ export function startBaseUrlProxy(door, done, opts = {}) {
1620
1625
  // Remote Control launch (terminal RC + headless server) — behind the bundled proxy
1621
1626
  // ───────────────────────────────────────────────────────────────────────────
1622
1627
  /**
1623
- * The three secret lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai). The
1624
- * Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy forwards it as
1625
- * x-mnemom-api-key either way; a subscription launch always has one, an api-key launch has one
1626
- * when this machine stores one or the launch pins. Never logged.
1628
+ * The four lines the Remote Control proxy reads on stdin (anthropic, mnemom, openai, proxy
1629
+ * credential). The Mnemom key rides line 2 on BOTH auth modes when the launch has one — the proxy
1630
+ * forwards it as x-mnemom-api-key either way; a subscription launch always has one, an api-key
1631
+ * launch has one when this machine stores one or the launch pins. Line 4 is this session's proxy
1632
+ * credential ({@link newRcProxyCredential}). Never logged.
1627
1633
  */
1628
1634
  export function rcProxyStdin(ctx) {
1629
- return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n`;
1635
+ return `${ctx.anthropicKey ?? ""}\n${ctx.mnemomKey ?? ""}\n\n${ctx.proxyCredential}\n`;
1636
+ }
1637
+ /**
1638
+ * MNE-9233: a fresh per-session credential for the Remote Control proxy. The proxy injects the
1639
+ * owner's keys and its loopback port is reachable by every local process, so it serves only a
1640
+ * caller that presents this value as `Proxy-Authorization: Basic` (rc-proxy.mjs). 32 random bytes,
1641
+ * hex: URL-safe in the HTTPS_PROXY userinfo without escaping.
1642
+ */
1643
+ export function newRcProxyCredential() {
1644
+ return randomBytes(32).toString("hex");
1645
+ }
1646
+ /**
1647
+ * The HTTPS_PROXY value for the Remote Control child: the proxy's loopback port, with this
1648
+ * session's credential as the userinfo. Claude Code sends it as `Proxy-Authorization` on CONNECT
1649
+ * and on absolute-form requests. The user name must match rc-proxy.mjs's PROXY_USER.
1650
+ */
1651
+ export function rcProxyUrl(port, proxyCredential) {
1652
+ return `http://mnemom:${proxyCredential}@127.0.0.1:${port}`;
1630
1653
  }
1631
1654
  /**
1632
1655
  * The agent id a launch pins, or undefined for a name launch: `--agent` when it is a full
@@ -1652,6 +1675,8 @@ function launchRemoteControl(ctx) {
1652
1675
  }
1653
1676
  }
1654
1677
  const proxyScript = resolveProxyScript();
1678
+ // MNE-9233: only the child launched below may use this session's proxy (stdin line 4 + HTTPS_PROXY).
1679
+ const proxyCredential = newRcProxyCredential();
1655
1680
  const rcDir = mkdtempSync(join(rcStateRoot(), "mnemom-code-rc."));
1656
1681
  writeFileSync(join(rcDir, "owner.pid"), `${process.pid}\n`, { mode: 0o600 }); // rc-state.mjs sweep
1657
1682
  const proxyLog = process.env.MNEMOM_AGENT_RC_PROXY_LOG || join(rcDir, "proxy.log");
@@ -1668,13 +1693,28 @@ function launchRemoteControl(ctx) {
1668
1693
  MNEMOM_AGENT_RC_PARENT_PID: String(process.pid),
1669
1694
  MNEMOM_AGENT_RC_AUTH: ctx.anthropicKey ? "api-key" : "subscription",
1670
1695
  };
1696
+ // The minted key expires after 24h and an RC seat outlives that: renew it over a control pipe
1697
+ // (fd 3) — the new key never touches argv, env or disk. Only its id and expiry ride in the env.
1698
+ const renewing = Boolean(ctx.sessionKeyId && ctx.sessionKeyRenewal && ctx.mnemomKey);
1699
+ if (renewing) {
1700
+ proxyEnv.MNEMOM_AGENT_RC_CONTROL_FD = "3";
1701
+ proxyEnv.MNEMOM_AGENT_RC_MNEMOM_KEY_ID = ctx.sessionKeyId;
1702
+ proxyEnv.MNEMOM_AGENT_RC_MNEMOM_KEY_EXPIRES_AT = ctx.sessionKeyRenewal?.expiresAt;
1703
+ }
1671
1704
  // MNE-8245 defense-in-depth: the proxy gets the session's (minted) Mnemom key on stdin only
1672
1705
  // and reads none of the owner's own Mnemom credential vars, so they never ride into its env.
1673
1706
  stripOwnerCredentialEnv(proxyEnv);
1674
1707
  const proxy = spawn("node", [proxyScript], {
1675
1708
  env: proxyEnv,
1676
- stdio: ["pipe", "pipe", "inherit"],
1709
+ stdio: ["pipe", "pipe", "inherit", renewing ? "pipe" : "ignore"],
1677
1710
  });
1711
+ const renewal = renewing
1712
+ ? startRcSessionKeyRenewal(proxy, proxyLog, {
1713
+ keyId: ctx.sessionKeyId,
1714
+ expiresAt: ctx.sessionKeyRenewal?.expiresAt,
1715
+ mintArgs: ctx.sessionKeyRenewal?.mintArgs,
1716
+ })
1717
+ : undefined;
1678
1718
  let stopped = false;
1679
1719
  const stopProxyAndTempDir = () => {
1680
1720
  if (!proxy.killed) {
@@ -1701,9 +1741,12 @@ function launchRemoteControl(ctx) {
1701
1741
  if (stopped)
1702
1742
  return;
1703
1743
  stopped = true;
1744
+ renewal?.stop();
1704
1745
  stopProxyAndTempDir();
1705
- if (ctx.sessionKeyId) {
1706
- await revokeSessionGatewayKey(defaultSessionKeyDeps(say), ctx.sessionKeyId);
1746
+ // After a renewal the proxy holds a newer key than the one minted at launch: revoke that one.
1747
+ const keyId = renewal?.currentKeyId() ?? ctx.sessionKeyId;
1748
+ if (keyId) {
1749
+ await revokeSessionGatewayKey(defaultSessionKeyDeps(say), keyId);
1707
1750
  }
1708
1751
  };
1709
1752
  // Last-resort SYNC fallback only — Node's "exit" handlers cannot await, so this branch never
@@ -1716,11 +1759,12 @@ function launchRemoteControl(ctx) {
1716
1759
  if (stopped)
1717
1760
  return;
1718
1761
  stopped = true;
1762
+ renewal?.stop();
1719
1763
  stopProxyAndTempDir();
1720
1764
  });
1721
- // Secrets on stdin — three lines (anthropic, mnemom, openai). An api-key launch sends the
1722
- // Anthropic key, plus this machine's Mnemom key when it has one (the key principal; always
1723
- // with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1765
+ // Secrets on stdin — four lines (anthropic, mnemom, openai, proxy credential). An api-key launch
1766
+ // sends the Anthropic key, plus this machine's Mnemom key when it has one (the key principal;
1767
+ // always with a pin); a subscription launch sends the Mnemom key only (the identity anchor — the
1724
1768
  // user's OAuth bearer is what pays). openai is always empty on this Anthropic-only door.
1725
1769
  // A proxy that exits before reading them turns this write into an EPIPE; that is
1726
1770
  // not a failure of its own — the exited-before-READY check below refuses the
@@ -1731,7 +1775,7 @@ function launchRemoteControl(ctx) {
1731
1775
  say(`mnemom agent: could not hand the secrets to the Remote Control proxy: ${err.message}`);
1732
1776
  void onExit().finally(() => process.exit(1));
1733
1777
  });
1734
- proxy.stdin?.end(rcProxyStdin(ctx));
1778
+ proxy.stdin?.end(rcProxyStdin({ ...ctx, proxyCredential }));
1735
1779
  let stdoutBuf = "";
1736
1780
  const start = Date.now();
1737
1781
  const tryStart = () => {
@@ -1748,7 +1792,7 @@ function launchRemoteControl(ctx) {
1748
1792
  void onExit().finally(() => process.exit(1));
1749
1793
  return;
1750
1794
  }
1751
- spawnRcCli({ ...ctx, port, ca, onExit });
1795
+ spawnRcCli({ ...ctx, port, ca, proxyCredential, onExit });
1752
1796
  return;
1753
1797
  }
1754
1798
  if (proxy.exitCode !== null) {
@@ -1773,6 +1817,58 @@ function launchRemoteControl(ctx) {
1773
1817
  });
1774
1818
  tryStart();
1775
1819
  }
1820
+ /** The longest line the proxy sends the launcher (`remint <key id>`) is far shorter than this. */
1821
+ export const RC_CONTROL_LINE_MAX = 1024;
1822
+ /**
1823
+ * Renew the Remote Control proxy's session key over its control pipe (fd 3; rc-proxy.mjs SESSION
1824
+ * KEY RENEWAL): a `key <id> <expires> <key>` line before each expiry, a `remint <id>` line from the
1825
+ * proxy when the gateway rejects its key, answered with a new key or `key-failed`. Renewal events
1826
+ * go to the proxy log (the terminal belongs to Claude Code by then), as key ids and expiry times.
1827
+ */
1828
+ export function startRcSessionKeyRenewal(proxy, proxyLog, initial, makeDeps = defaultSessionKeyDeps) {
1829
+ const control = proxy.stdio?.[3];
1830
+ if (!control)
1831
+ return undefined;
1832
+ const logLine = (line) => {
1833
+ try {
1834
+ appendFileSync(proxyLog, `${new Date().toISOString()} launcher: ${line}\n`);
1835
+ }
1836
+ catch {
1837
+ /* best effort: the log dir may be gone at exit */
1838
+ }
1839
+ };
1840
+ control.on("error", () => {
1841
+ /* the proxy exited: the next delivery fails and is logged */
1842
+ });
1843
+ const renewal = startSessionKeyRenewal({
1844
+ deps: makeDeps(logLine),
1845
+ mintArgs: initial.mintArgs,
1846
+ initial: { keyId: initial.keyId, expiresAt: initial.expiresAt },
1847
+ deliver: (minted) => {
1848
+ if (control.destroyed || !control.writable)
1849
+ throw new Error("the proxy's control pipe is closed");
1850
+ control.write(`key ${minted.keyId} ${minted.expiresAt} ${minted.key}\n`);
1851
+ },
1852
+ refuse: (reason) => {
1853
+ if (!control.destroyed && control.writable)
1854
+ control.write(`key-failed ${reason}\n`);
1855
+ },
1856
+ });
1857
+ let buf = "";
1858
+ control.on("data", (chunk) => {
1859
+ buf += chunk.toString("utf8");
1860
+ let i;
1861
+ while ((i = buf.indexOf("\n")) >= 0) {
1862
+ const [cmd, keyId] = buf.slice(0, i).split(" ");
1863
+ buf = buf.slice(i + 1);
1864
+ if (cmd === "remint")
1865
+ void renewal.renew("rejected", keyId);
1866
+ }
1867
+ if (buf.length > RC_CONTROL_LINE_MAX)
1868
+ buf = ""; // a runaway line, never a real request
1869
+ });
1870
+ return renewal;
1871
+ }
1776
1872
  function spawnRcCli(ctx) {
1777
1873
  // Session name for the claude.ai session list: "<agent>-<scenario>", unless
1778
1874
  // the caller passed their own name after `--` (`--name x`, `--name=x`, `-n x`; lifted out here).
@@ -1805,8 +1901,8 @@ function spawnRcCli(ctx) {
1805
1901
  // env. The RC gate forwards the Mnemom identity via the proxy's own auth plane, never from
1806
1902
  // this child's env, so there is no legitimate use for these vars here either.
1807
1903
  stripOwnerCredentialEnv(childEnv);
1808
- childEnv.HTTPS_PROXY = `http://127.0.0.1:${ctx.port}`;
1809
- childEnv.https_proxy = `http://127.0.0.1:${ctx.port}`;
1904
+ childEnv.HTTPS_PROXY = rcProxyUrl(ctx.port, ctx.proxyCredential);
1905
+ childEnv.https_proxy = childEnv.HTTPS_PROXY;
1810
1906
  childEnv.NODE_EXTRA_CA_CERTS = ctx.ca;
1811
1907
  childEnv[SESSION_NAME_ENV] = rcName;
1812
1908
  // Autocompact guard (lib/autocompact-guard.ts). The interactive shape gets the --settings layer
@@ -15,7 +15,7 @@ const OP_SEVERITIES = ["low", "medium", "high", "critical"];
15
15
  // Per ADR-037 Decision 4: deny public LLM endpoints + public DNS providers,
16
16
  // and the any-host CIDRs, at write time.
17
17
  // T8-4 (2026-05-19) extended the corpus per
18
- // `safe-house-hardening/audit/deny-list-completeness.md`: added emerging
18
+ // `safe-house-hardening/audits/deny-list-completeness.md`: added emerging
19
19
  // LLM providers (Replicate, Together, Anyscale, Fireworks, xAI,
20
20
  // Perplexity, DeepSeek) and three reserved IP ranges (link-local v4/v6
21
21
  // + multicast v4).
@@ -0,0 +1,97 @@
1
+ /**
2
+ * `mnemom scan`: a read-only local scan of GitHub PR metadata. Runs entirely on this machine; nothing is
3
+ * uploaded in this build. The share-level prompt gates what a later upload step may send, and the chosen
4
+ * payload is printed so the user sees exactly what would leave. Pure logic is exported so each reject
5
+ * arm is unit-testable without Commander (same pattern as commands/usage.ts).
6
+ */
7
+ import { type GitHubCredential } from "../lib/scan/credential.js";
8
+ import { type ScanDeps } from "../lib/scan/scan.js";
9
+ import { type AnthropicUsageDeps } from "../lib/scan/spend/anthropic-usage.js";
10
+ import { type RecordDeps, type RecordOutcome } from "../lib/scan/record.js";
11
+ import { type ReportEmailDeps } from "../lib/scan/report-email.js";
12
+ import { type ReportRecipients } from "../lib/scan/share.js";
13
+ export interface ScanCommandOptions {
14
+ repos: string[];
15
+ period?: {
16
+ start: string;
17
+ end: string;
18
+ };
19
+ share?: string;
20
+ /** Report recipient. Required unless the runner is the CTO (asked when omitted). */
21
+ ctoEmail?: string;
22
+ /** Tier 2: the CTO's own monthly figure, as typed. Needs MNEMOM_ORG_SCAN_MODE=true. */
23
+ monthlySpend?: string;
24
+ /** Tier 2 without a figure on the command line: ask for one on the terminal. */
25
+ noAnthropicUsage?: boolean;
26
+ /** Tier 1: ask for the Anthropic admin key on the terminal (masked). Otherwise ANTHROPIC_ADMIN_KEY is used if set. */
27
+ anthropicAdminKey?: boolean;
28
+ /** Tier 1 result, when the caller already has it (test seam; set from the Anthropic reads otherwise). */
29
+ anthropicUsageUsd?: number;
30
+ anthropicOver200kShare?: number | null;
31
+ /** Test seam: the fetch behind the two Anthropic reads. */
32
+ anthropicDeps?: AnthropicUsageDeps;
33
+ /**
34
+ * The org the report is emailed for; inferred from the signed-in user's single non-personal org when
35
+ * omitted (see resolveScanOrg). The Record totals preview (MNEMOM_ORG_SCAN_MODE=true) reads ONLY an
36
+ * org passed explicitly here, never an inferred one: a guess could read the wrong org.
37
+ */
38
+ org?: string;
39
+ /** Test seam: the Record read. */
40
+ recordDeps?: RecordDeps;
41
+ /** Test seam: the report-email POST. */
42
+ emailDeps?: ReportEmailDeps;
43
+ /** Test seam: the signed-in user's orgs, used to infer --org when it is omitted. */
44
+ listOrgs?: () => Promise<Array<{
45
+ org_id: string;
46
+ name?: string;
47
+ is_personal?: boolean;
48
+ }>>;
49
+ /** Test seams. */
50
+ isTty?: boolean;
51
+ env?: NodeJS.ProcessEnv;
52
+ runnerEmail?: string;
53
+ credential?: GitHubCredential;
54
+ deps?: Partial<ScanDeps>;
55
+ now?: () => number;
56
+ }
57
+ /** Parses repeated/comma-separated `--repo owner/name`. Returns null on any invalid slug. */
58
+ export declare function parseRepoFlags(raw: string[] | undefined): string[] | null;
59
+ export declare const isEmail: (v: unknown) => v is string;
60
+ /** The runner's email (mnemom login, else asked) and the CTO's (flag, else "are you the CTO?"). */
61
+ export declare function resolveRecipients(o: {
62
+ runnerEmail?: string;
63
+ ctoEmail?: string;
64
+ }): Promise<ReportRecipients>;
65
+ /** The CTO's figure in USD, or null for none. Throws fixed text only; the input is never echoed. */
66
+ export declare function resolveSelfReportedSpend(o: ScanCommandOptions): Promise<number | null>;
67
+ export declare const NO_ADMIN_KEY_TERMINAL_MSG = "Set ANTHROPIC_ADMIN_KEY (there is no terminal to ask on).";
68
+ export declare const TIER1_FALLBACK_NOTE = "Tier 1 skipped: falling back to the next tier.";
69
+ /**
70
+ * Tier 1: read measured usage with the CTO's Anthropic admin key, locally. The key comes from
71
+ * ANTHROPIC_ADMIN_KEY or a masked prompt (--anthropic-admin-key; never a value on argv), lives only in this
72
+ * call, and goes only to api.anthropic.com. Returns null for "no key" and for every failure, after printing a
73
+ * fixed sentence: the key, the thrown value and the response never reach the terminal. The caller then falls
74
+ * back to tier 2, then tier 3.
75
+ */
76
+ export declare function resolveMeasuredUsage(o: ScanCommandOptions, period: {
77
+ start: string;
78
+ end: string;
79
+ }): Promise<{
80
+ usd: number;
81
+ over200kShare: number | null;
82
+ } | null>;
83
+ /**
84
+ * --org is optional only because it can be inferred: the report is emailed on every scan, so the scan
85
+ * needs an org. Inference uses the signed-in user's own memberships (GET /v1/orgs, personal org
86
+ * excluded): exactly one org is used; none, several, or a failed read stops the scan loudly BEFORE any
87
+ * scan work, naming the fix. It never guesses between orgs and never skips the email.
88
+ */
89
+ export declare function resolveScanOrg(o: ScanCommandOptions): Promise<string>;
90
+ export declare const INVALID_ORG_MSG = "--org must be an org id like org-1a2b3c4d.";
91
+ /**
92
+ * The Record read for the preview: null when the flag gate is off (no read, no output, as before).
93
+ * With the gate on and no --org it is a note, not a guess. 404 becomes a note; 401/403 throw
94
+ * RecordAuthError (fail closed); every other failure becomes a fixed note. Nothing is stored.
95
+ */
96
+ export declare function resolveRecordOutcome(o: ScanCommandOptions): Promise<RecordOutcome | "no_org" | null>;
97
+ export declare function scanCommand(o: ScanCommandOptions): Promise<void>;