@intutic/mcp-governance-proxy 1.9.0 → 1.10.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 (123) hide show
  1. package/README.md +28 -0
  2. package/dist/anomaly/detectors.d.ts +129 -0
  3. package/dist/anomaly/detectors.d.ts.map +1 -0
  4. package/dist/anomaly/detectors.js +368 -0
  5. package/dist/anomaly/detectors.js.map +1 -0
  6. package/dist/anomaly/index.d.ts +52 -0
  7. package/dist/anomaly/index.d.ts.map +1 -0
  8. package/dist/anomaly/index.js +71 -0
  9. package/dist/anomaly/index.js.map +1 -0
  10. package/dist/config.d.ts +107 -1
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +129 -6
  13. package/dist/config.js.map +1 -1
  14. package/dist/daemon/healthMonitor.d.ts.map +1 -1
  15. package/dist/daemon/healthMonitor.js +45 -46
  16. package/dist/daemon/healthMonitor.js.map +1 -1
  17. package/dist/daemon/index.d.ts +15 -1
  18. package/dist/daemon/index.d.ts.map +1 -1
  19. package/dist/daemon/index.js +78 -9
  20. package/dist/daemon/index.js.map +1 -1
  21. package/dist/daemon/instrumentation.d.ts +20 -0
  22. package/dist/daemon/instrumentation.d.ts.map +1 -0
  23. package/dist/daemon/instrumentation.js +61 -0
  24. package/dist/daemon/instrumentation.js.map +1 -0
  25. package/dist/daemon/policyCache.d.ts +83 -0
  26. package/dist/daemon/policyCache.d.ts.map +1 -1
  27. package/dist/daemon/policyCache.js +280 -11
  28. package/dist/daemon/policyCache.js.map +1 -1
  29. package/dist/daemon/statusReporter.d.ts +12 -0
  30. package/dist/daemon/statusReporter.d.ts.map +1 -0
  31. package/dist/daemon/statusReporter.js +130 -0
  32. package/dist/daemon/statusReporter.js.map +1 -0
  33. package/dist/daemon/telemetryBatcher.d.ts +3 -1
  34. package/dist/daemon/telemetryBatcher.d.ts.map +1 -1
  35. package/dist/daemon/telemetryBatcher.js +56 -13
  36. package/dist/daemon/telemetryBatcher.js.map +1 -1
  37. package/dist/daemonClient.d.ts +1 -1
  38. package/dist/daemonClient.d.ts.map +1 -1
  39. package/dist/daemonClient.js +0 -2
  40. package/dist/daemonClient.js.map +1 -1
  41. package/dist/dlp.d.ts +19 -0
  42. package/dist/dlp.d.ts.map +1 -1
  43. package/dist/dlp.js +95 -18
  44. package/dist/dlp.js.map +1 -1
  45. package/dist/emitter.d.ts +40 -2
  46. package/dist/emitter.d.ts.map +1 -1
  47. package/dist/emitter.js +27 -5
  48. package/dist/emitter.js.map +1 -1
  49. package/dist/index.js +13 -2
  50. package/dist/index.js.map +1 -1
  51. package/dist/injection.d.ts +46 -0
  52. package/dist/injection.d.ts.map +1 -0
  53. package/dist/injection.js +147 -0
  54. package/dist/injection.js.map +1 -0
  55. package/dist/interceptor.d.ts +131 -1
  56. package/dist/interceptor.d.ts.map +1 -1
  57. package/dist/interceptor.js +301 -5
  58. package/dist/interceptor.js.map +1 -1
  59. package/dist/policy.d.ts +110 -2
  60. package/dist/policy.d.ts.map +1 -1
  61. package/dist/policy.js +206 -5
  62. package/dist/policy.js.map +1 -1
  63. package/dist/proxy.d.ts +205 -0
  64. package/dist/proxy.d.ts.map +1 -1
  65. package/dist/proxy.js +572 -65
  66. package/dist/proxy.js.map +1 -1
  67. package/dist/remoteBridge.d.ts +51 -0
  68. package/dist/remoteBridge.d.ts.map +1 -0
  69. package/dist/remoteBridge.js +182 -0
  70. package/dist/remoteBridge.js.map +1 -0
  71. package/dist/session.d.ts +158 -0
  72. package/dist/session.d.ts.map +1 -0
  73. package/dist/session.js +206 -0
  74. package/dist/session.js.map +1 -0
  75. package/dist/sessionScope.d.ts +51 -0
  76. package/dist/sessionScope.d.ts.map +1 -0
  77. package/dist/sessionScope.js +94 -0
  78. package/dist/sessionScope.js.map +1 -0
  79. package/dist/sessionStore.d.ts +69 -0
  80. package/dist/sessionStore.d.ts.map +1 -0
  81. package/dist/sessionStore.js +143 -0
  82. package/dist/sessionStore.js.map +1 -0
  83. package/dist/tofu.d.ts +112 -0
  84. package/dist/tofu.d.ts.map +1 -0
  85. package/dist/tofu.js +180 -0
  86. package/dist/tofu.js.map +1 -0
  87. package/dist/toolPoison.d.ts +32 -0
  88. package/dist/toolPoison.d.ts.map +1 -0
  89. package/dist/toolPoison.js +81 -0
  90. package/dist/toolPoison.js.map +1 -0
  91. package/dist/valkeyErrors.d.ts +17 -0
  92. package/dist/valkeyErrors.d.ts.map +1 -0
  93. package/dist/valkeyErrors.js +24 -0
  94. package/dist/valkeyErrors.js.map +1 -0
  95. package/dist/wasm/context.d.ts +57 -0
  96. package/dist/wasm/context.d.ts.map +1 -0
  97. package/dist/wasm/context.js +59 -0
  98. package/dist/wasm/context.js.map +1 -0
  99. package/dist/wasm/hostImports.d.ts +43 -0
  100. package/dist/wasm/hostImports.d.ts.map +1 -0
  101. package/dist/wasm/hostImports.js +158 -0
  102. package/dist/wasm/hostImports.js.map +1 -0
  103. package/dist/wasm/loader.d.ts +95 -0
  104. package/dist/wasm/loader.d.ts.map +1 -0
  105. package/dist/wasm/loader.js +198 -0
  106. package/dist/wasm/loader.js.map +1 -0
  107. package/dist/wasm/referencedFiles.d.ts +115 -0
  108. package/dist/wasm/referencedFiles.d.ts.map +1 -0
  109. package/dist/wasm/referencedFiles.js +366 -0
  110. package/dist/wasm/referencedFiles.js.map +1 -0
  111. package/dist/wasm/runner.d.ts +91 -0
  112. package/dist/wasm/runner.d.ts.map +1 -0
  113. package/dist/wasm/runner.js +308 -0
  114. package/dist/wasm/runner.js.map +1 -0
  115. package/dist/wasm/watch.d.ts +5 -0
  116. package/dist/wasm/watch.d.ts.map +1 -0
  117. package/dist/wasm/watch.js +54 -0
  118. package/dist/wasm/watch.js.map +1 -0
  119. package/dist/wasm/worker.d.ts +39 -0
  120. package/dist/wasm/worker.d.ts.map +1 -0
  121. package/dist/wasm/worker.js +245 -0
  122. package/dist/wasm/worker.js.map +1 -0
  123. package/package.json +14 -4
@@ -0,0 +1,206 @@
1
+ /**
2
+ * session.ts — SessionState: a proxy process's anomaly session state, shared
3
+ * with its sibling processes through Valkey when one is configured.
4
+ *
5
+ * ## Scope: one harness session, several processes
6
+ *
7
+ * The sync daemon wraps EACH MCP server entry with its own `McpGovernanceProxy`
8
+ * process (`mcpAutoWrite.ts`'s `wrapWithProxy`, in both `per-session` and
9
+ * `daemon` mode), so one harness session runs one proxy per wrapped server.
10
+ * This class holds the tool-call sequence and the per-detector reask counters
11
+ * the anomaly detectors read. Until Wave 5.3 (TD-437) that state was
12
+ * per-process only — a cross-server ping-pong was invisible, a reask budget
13
+ * reset per server and per restart — and the module doc claimed there was
14
+ * "no second instance to share state with", which was never true of the
15
+ * wrapping the daemon writes.
16
+ *
17
+ * Now, when `SessionState` is given a scope (`sessionScope.ts`: the workspace
18
+ * plus the parent harness process) and a store (`sessionStore.ts`: the
19
+ * Rust proxy's Valkey window shape under its own namespace), the sequence,
20
+ * the 60 s call window and the reask counters are read from and written to
21
+ * the shared window, so every sibling sees every sibling's calls. The
22
+ * in-process copy is still kept and is the fallback whenever the store is
23
+ * absent, not connected, slow or failing — never a hard dependency, and
24
+ * exactly today's behaviour in every one of those cases.
25
+ *
26
+ * Per process, deliberately: `toolsList` and `toolContractChanged` describe
27
+ * THIS process's server (TOFU pins per {workspace, server}), and `sessionId`
28
+ * is the random per-process id handed to WASM rules as `session_id`.
29
+ *
30
+ * @module
31
+ */
32
+ import * as node_crypto from 'node:crypto';
33
+ /**
34
+ * Ported from the Rust proxy's `TOOL_SEQUENCE_CAP` (referenced by
35
+ * `anchor_projection`'s doc comment in `detectors.rs`, which states the cap
36
+ * is 60 and that `ANCHOR_FREQ_SLOTS` (256) must exceed it) — same cap, same
37
+ * "oldest evicted first" rolling-window semantics.
38
+ */
39
+ export const TOOL_SEQUENCE_CAP = 60;
40
+ /** Rolling window Phase 3's `calls_last_60s` context field is computed over. */
41
+ const CALLS_WINDOW_MS = 60_000;
42
+ export class SessionState {
43
+ /** The shared-window scope, when this process has one. */
44
+ scope;
45
+ store;
46
+ /** The most recent write-behind, so tests can await it before reading back. */
47
+ lastWrite = Promise.resolve();
48
+ constructor(opts = {}) {
49
+ this.scope = opts.scope;
50
+ this.store = opts.scope ? opts.store : undefined;
51
+ }
52
+ /**
53
+ * A per-process identifier standing in for `RequestContext.session_id`
54
+ * (Phase 3, `wasm/context.ts`) — this proxy has no wire-level session id
55
+ * of its own (unlike the Rust LLM proxy, which reads one off the inbound
56
+ * request), so a random id generated once at process start is the honest
57
+ * substitute: stable for this process's lifetime, never claiming to be
58
+ * anything the harness itself asserted.
59
+ */
60
+ sessionId = node_crypto.randomUUID();
61
+ sequence = [];
62
+ /** Wall-clock time of each entry in `sequence`, same indices, same cap —
63
+ * kept only for `callsInLastMs`; nothing else in this class reads it. */
64
+ timestamps = [];
65
+ /**
66
+ * The most recent TOFU check result for this session's server (tofu.ts,
67
+ * evaluated in `proxy.ts`'s response-direction `handleServerLine` — TOFU
68
+ * only ever runs against a `tools/list` RESPONSE, never a `tools/call`
69
+ * request, so this is the honest way to surface it to Phase 3's WASM
70
+ * context, which builds from the request-direction `interceptor.decide`
71
+ * pipeline). `undefined` until at least one `tools/list` response has been
72
+ * checked; a `'skipped'` TOFU outcome (no tools declared) leaves this
73
+ * unchanged rather than resetting it, since "skipped" asserts nothing
74
+ * about whether a mismatch was seen earlier.
75
+ */
76
+ toolContractChanged;
77
+ /**
78
+ * Reask attempt counts, keyed by detector id (Phase 2) or `wasm:<ruleId>`
79
+ * (Phase 3 — "a WASM rule's reask counter is independent of an anomaly
80
+ * detector's," per the Phase 3 spec). A lifetime-per-session count, not a
81
+ * consecutive-trip count: `mod.rs`'s own doc says "trips of the same
82
+ * finding in ONE SESSION," not "consecutive trips," and a lifetime count
83
+ * is also the simpler, harder-to-game reading — an agent alternating
84
+ * between tripping and briefly correcting could otherwise reset its way
85
+ * out of ever hardening.
86
+ */
87
+ reaskAttempts = new Map();
88
+ /**
89
+ * The most recent post-curation `tools/list` response this session saw —
90
+ * Phase 1's wiring already computes this array (allowlist filtering +
91
+ * operator overrides applied); the tool-poisoning detector (Phase 2) reads
92
+ * it from here rather than re-deriving it.
93
+ */
94
+ toolsList = [];
95
+ /**
96
+ * Append a tool name to the rolling window, evicting the oldest past the
97
+ * cap — locally always, and to the shared window as a write-behind when
98
+ * there is one (the tool-call path does not wait on it).
99
+ */
100
+ recordCall(toolName) {
101
+ const now = Date.now();
102
+ this.sequence.push(toolName);
103
+ this.timestamps.push(now);
104
+ if (this.sequence.length > TOOL_SEQUENCE_CAP) {
105
+ const drop = this.sequence.length - TOOL_SEQUENCE_CAP;
106
+ this.sequence.splice(0, drop);
107
+ this.timestamps.splice(0, drop);
108
+ }
109
+ if (this.store && this.scope) {
110
+ this.lastWrite = this.store.recordCall(this.scope, toolName, now).catch(() => false);
111
+ }
112
+ }
113
+ /**
114
+ * The window `decide()` evaluates against: the shared sequence plus the
115
+ * candidate when the shared window answers, else the per-process copy.
116
+ * `callsLast60s` excludes the candidate either way, matching what
117
+ * `callsInLastMs()` returns before the call is recorded.
118
+ */
119
+ async loadWindow(toolName) {
120
+ if (this.store && this.scope) {
121
+ const snapshot = await this.store.readWindow(this.scope, Date.now(), CALLS_WINDOW_MS);
122
+ if (snapshot) {
123
+ const next = [...snapshot.sequence, toolName];
124
+ return {
125
+ prospective: next.length > TOOL_SEQUENCE_CAP ? next.slice(next.length - TOOL_SEQUENCE_CAP) : next,
126
+ callsLast60s: snapshot.callsLast60s,
127
+ shared: true,
128
+ };
129
+ }
130
+ }
131
+ return { prospective: this.prospectiveSequence(toolName), callsLast60s: this.callsInLastMs(), shared: false };
132
+ }
133
+ /**
134
+ * The reask ladder's counter, shared when the store answers. The local map
135
+ * is always bumped too, so a mid-ladder fallback keeps counting from where
136
+ * it was rather than restarting at one.
137
+ */
138
+ async incrReaskAttemptShared(key) {
139
+ const local = this.incrReaskAttempt(key);
140
+ if (this.store && this.scope) {
141
+ const shared = await this.store.incrReaskAttempt(this.scope, key);
142
+ if (shared !== undefined)
143
+ return shared;
144
+ }
145
+ return local;
146
+ }
147
+ /** Awaits the most recent write-behind; tests only. */
148
+ async flush() {
149
+ await this.lastWrite;
150
+ }
151
+ /**
152
+ * Calls recorded within the last `windowMs` — Phase 3's `calls_last_60s`
153
+ * context field. Bounded by `TOOL_SEQUENCE_CAP`'s own window like every
154
+ * other read of `sequence`/`timestamps`: a burst that outlasts 60 real
155
+ * calls undercounts, the same honest limit `TOOL_SEQUENCE_CAP` already
156
+ * imposes on every sequence-based detector.
157
+ */
158
+ callsInLastMs(windowMs = CALLS_WINDOW_MS) {
159
+ const cutoff = Date.now() - windowMs;
160
+ let count = 0;
161
+ for (const t of this.timestamps)
162
+ if (t >= cutoff)
163
+ count += 1;
164
+ return count;
165
+ }
166
+ setToolContractChanged(changed) {
167
+ this.toolContractChanged = changed;
168
+ }
169
+ /** `undefined` until at least one `tools/list` response has been TOFU-checked. */
170
+ getToolContractChanged() {
171
+ return this.toolContractChanged;
172
+ }
173
+ /** The recorded sequence, oldest first. */
174
+ getSequence() {
175
+ return this.sequence;
176
+ }
177
+ /**
178
+ * The sequence AS IF `toolName` were the next call — used to evaluate
179
+ * anomaly detectors BEFORE a decision is made, without mutating state.
180
+ * `interceptor.decide()` calls this to detect (e.g.) a fifth consecutive
181
+ * repeat on the call that WOULD BE the fifth, not the one after it;
182
+ * `recordCall` only actually persists the entry once the call is allowed
183
+ * (see `handleHarnessLine`, proxy.ts).
184
+ */
185
+ prospectiveSequence(toolName) {
186
+ const next = [...this.sequence, toolName];
187
+ return next.length > TOOL_SEQUENCE_CAP ? next.slice(next.length - TOOL_SEQUENCE_CAP) : next;
188
+ }
189
+ /** Increment and return this key's reask attempt count. */
190
+ incrReaskAttempt(key) {
191
+ const next = (this.reaskAttempts.get(key) ?? 0) + 1;
192
+ this.reaskAttempts.set(key, next);
193
+ return next;
194
+ }
195
+ /** Current reask attempt count for a key, `0` if it has never tripped. */
196
+ getReaskAttempts(key) {
197
+ return this.reaskAttempts.get(key) ?? 0;
198
+ }
199
+ setToolsList(tools) {
200
+ this.toolsList = [...tools];
201
+ }
202
+ getToolsList() {
203
+ return this.toolsList;
204
+ }
205
+ }
206
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.js","sourceRoot":"","sources":["../src/session.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ /**
2
+ * sessionScope.ts — the shared-window scope a proxy process derives for
3
+ * itself (TD-437, Wave 5.3).
4
+ *
5
+ * The sync daemon wraps EACH MCP server entry with its own proxy process, so
6
+ * one harness session runs several `McpGovernanceProxy` processes at once —
7
+ * and the proxy has no wire-level session id: it never inspects `initialize`,
8
+ * no harness sets one, and the daemon writes static config files so it cannot
9
+ * inject a per-session value. What the sibling processes DO share is their
10
+ * parent: the harness process that spawned them (`wrapWithProxy` emits
11
+ * `command: 'node'` with no shell in between, and the MCP SDK spawns with
12
+ * `shell: false`). So the scope is the workspace plus the parent pid, plus a
13
+ * best-effort token derived from the parent's start time so a recycled pid on
14
+ * a long-lived machine cannot inherit a finished session's window.
15
+ *
16
+ * `undefined` means "no shareable identity" and the proxy keeps today's
17
+ * per-process window. A shell wrapper somewhere would give each proxy a
18
+ * distinct parent and degrade to exactly that — never merge two sessions.
19
+ *
20
+ * @module
21
+ */
22
+ /** An explicit scope, for tests and for harnesses that can set one per session. */
23
+ export declare const SESSION_SCOPE_ENV = "INTUTIC_MCP_SESSION_SCOPE";
24
+ export interface SessionScopeInput {
25
+ workspaceId: string;
26
+ ppid: number;
27
+ parentStartToken?: string;
28
+ envOverride?: string;
29
+ }
30
+ /**
31
+ * Pure. Builds the scope string from its inputs, or `undefined` when there is
32
+ * no shareable identity (an orphaned process, `ppid <= 1`).
33
+ */
34
+ export declare function buildSessionScope(input: SessionScopeInput): string | undefined;
35
+ /**
36
+ * A short token identifying WHEN the parent process started, so that pid
37
+ * reuse cannot alias two sessions. Linux reads `/proc/<pid>/stat` field 22
38
+ * (starttime, in clock ticks since boot); macOS asks `ps` for `lstart`. Never
39
+ * throws and never takes longer than {@link START_TOKEN_TIMEOUT_MS}; any
40
+ * failure returns `undefined` and the scope goes without the token.
41
+ */
42
+ export declare function readParentStartToken(ppid: number, platform?: NodeJS.Platform): Promise<string | undefined>;
43
+ /**
44
+ * `/proc/<pid>/stat` is `pid (comm) state ppid …`; the comm may itself contain
45
+ * spaces and parentheses, so the fields are split AFTER the last `)`. Field 22
46
+ * (starttime) is index 19 of that remainder.
47
+ */
48
+ export declare function startTokenFromProcStat(stat: string): string | undefined;
49
+ /** The scope this process shares with its sibling proxies, or `undefined`. */
50
+ export declare function deriveSessionScope(workspaceId: string): Promise<string | undefined>;
51
+ //# sourceMappingURL=sessionScope.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionScope.d.ts","sourceRoot":"","sources":["../src/sessionScope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AASH,mFAAmF;AACnF,eAAO,MAAM,iBAAiB,8BAA8B,CAAA;AAK5D,MAAM,WAAW,iBAAiB;IAChC,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,MAAM,CAAA;IACZ,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,iBAAiB,GAAG,MAAM,GAAG,SAAS,CAS9E;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,GAAE,MAAM,CAAC,QAA2B,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAelI;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAMvE;AAMD,8EAA8E;AAC9E,wBAAsB,kBAAkB,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAKzF"}
@@ -0,0 +1,94 @@
1
+ /**
2
+ * sessionScope.ts — the shared-window scope a proxy process derives for
3
+ * itself (TD-437, Wave 5.3).
4
+ *
5
+ * The sync daemon wraps EACH MCP server entry with its own proxy process, so
6
+ * one harness session runs several `McpGovernanceProxy` processes at once —
7
+ * and the proxy has no wire-level session id: it never inspects `initialize`,
8
+ * no harness sets one, and the daemon writes static config files so it cannot
9
+ * inject a per-session value. What the sibling processes DO share is their
10
+ * parent: the harness process that spawned them (`wrapWithProxy` emits
11
+ * `command: 'node'` with no shell in between, and the MCP SDK spawns with
12
+ * `shell: false`). So the scope is the workspace plus the parent pid, plus a
13
+ * best-effort token derived from the parent's start time so a recycled pid on
14
+ * a long-lived machine cannot inherit a finished session's window.
15
+ *
16
+ * `undefined` means "no shareable identity" and the proxy keeps today's
17
+ * per-process window. A shell wrapper somewhere would give each proxy a
18
+ * distinct parent and degrade to exactly that — never merge two sessions.
19
+ *
20
+ * @module
21
+ */
22
+ import * as node_crypto from 'node:crypto';
23
+ import * as node_fs from 'node:fs/promises';
24
+ import { execFile as node_execFile } from 'node:child_process';
25
+ import { promisify } from 'node:util';
26
+ const execFile = promisify(node_execFile);
27
+ /** An explicit scope, for tests and for harnesses that can set one per session. */
28
+ export const SESSION_SCOPE_ENV = 'INTUTIC_MCP_SESSION_SCOPE';
29
+ /** How long the parent-start lookup may take before the scope goes without it. */
30
+ const START_TOKEN_TIMEOUT_MS = 500;
31
+ /**
32
+ * Pure. Builds the scope string from its inputs, or `undefined` when there is
33
+ * no shareable identity (an orphaned process, `ppid <= 1`).
34
+ */
35
+ export function buildSessionScope(input) {
36
+ const override = input.envOverride?.trim();
37
+ if (override) {
38
+ const safe = override.replace(/[^A-Za-z0-9._-]/g, '').slice(0, 64);
39
+ if (safe)
40
+ return `${input.workspaceId}:mcp:env:${safe}`;
41
+ }
42
+ if (!Number.isInteger(input.ppid) || input.ppid <= 1)
43
+ return undefined;
44
+ const token = input.parentStartToken?.trim();
45
+ return token ? `${input.workspaceId}:mcp:${input.ppid}:${token}` : `${input.workspaceId}:mcp:${input.ppid}`;
46
+ }
47
+ /**
48
+ * A short token identifying WHEN the parent process started, so that pid
49
+ * reuse cannot alias two sessions. Linux reads `/proc/<pid>/stat` field 22
50
+ * (starttime, in clock ticks since boot); macOS asks `ps` for `lstart`. Never
51
+ * throws and never takes longer than {@link START_TOKEN_TIMEOUT_MS}; any
52
+ * failure returns `undefined` and the scope goes without the token.
53
+ */
54
+ export async function readParentStartToken(ppid, platform = process.platform) {
55
+ try {
56
+ if (platform === 'linux') {
57
+ const stat = await node_fs.readFile(`/proc/${ppid}/stat`, 'utf8');
58
+ return startTokenFromProcStat(stat);
59
+ }
60
+ if (platform === 'darwin') {
61
+ const { stdout } = await execFile('ps', ['-o', 'lstart=', '-p', String(ppid)], { timeout: START_TOKEN_TIMEOUT_MS });
62
+ const lstart = stdout.trim();
63
+ return lstart ? hashToken(lstart) : undefined;
64
+ }
65
+ }
66
+ catch {
67
+ // Best effort: a missing /proc entry, a `ps` that is not there, a timeout.
68
+ }
69
+ return undefined;
70
+ }
71
+ /**
72
+ * `/proc/<pid>/stat` is `pid (comm) state ppid …`; the comm may itself contain
73
+ * spaces and parentheses, so the fields are split AFTER the last `)`. Field 22
74
+ * (starttime) is index 19 of that remainder.
75
+ */
76
+ export function startTokenFromProcStat(stat) {
77
+ const close = stat.lastIndexOf(')');
78
+ if (close < 0)
79
+ return undefined;
80
+ const fields = stat.slice(close + 1).trim().split(/\s+/);
81
+ const starttime = fields[19];
82
+ return starttime && /^\d+$/.test(starttime) ? hashToken(starttime) : undefined;
83
+ }
84
+ function hashToken(raw) {
85
+ return node_crypto.createHash('sha256').update(raw).digest('hex').slice(0, 12);
86
+ }
87
+ /** The scope this process shares with its sibling proxies, or `undefined`. */
88
+ export async function deriveSessionScope(workspaceId) {
89
+ const envOverride = process.env[SESSION_SCOPE_ENV];
90
+ const ppid = process.ppid;
91
+ const parentStartToken = envOverride ? undefined : await readParentStartToken(ppid);
92
+ return buildSessionScope({ workspaceId, ppid, parentStartToken, envOverride });
93
+ }
94
+ //# sourceMappingURL=sessionScope.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionScope.js","sourceRoot":"","sources":["../src/sessionScope.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAEH,OAAO,KAAK,WAAW,MAAM,aAAa,CAAA;AAC1C,OAAO,KAAK,OAAO,MAAM,kBAAkB,CAAA;AAC3C,OAAO,EAAE,QAAQ,IAAI,aAAa,EAAE,MAAM,oBAAoB,CAAA;AAC9D,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAErC,MAAM,QAAQ,GAAG,SAAS,CAAC,aAAa,CAAC,CAAA;AAEzC,mFAAmF;AACnF,MAAM,CAAC,MAAM,iBAAiB,GAAG,2BAA2B,CAAA;AAE5D,kFAAkF;AAClF,MAAM,sBAAsB,GAAG,GAAG,CAAA;AASlC;;;GAGG;AACH,MAAM,UAAU,iBAAiB,CAAC,KAAwB;IACxD,MAAM,QAAQ,GAAG,KAAK,CAAC,WAAW,EAAE,IAAI,EAAE,CAAA;IAC1C,IAAI,QAAQ,EAAE,CAAC;QACb,MAAM,IAAI,GAAG,QAAQ,CAAC,OAAO,CAAC,kBAAkB,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;QAClE,IAAI,IAAI;YAAE,OAAO,GAAG,KAAK,CAAC,WAAW,YAAY,IAAI,EAAE,CAAA;IACzD,CAAC;IACD,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAI,CAAC,IAAI,KAAK,CAAC,IAAI,IAAI,CAAC;QAAE,OAAO,SAAS,CAAA;IACtE,MAAM,KAAK,GAAG,KAAK,CAAC,gBAAgB,EAAE,IAAI,EAAE,CAAA;IAC5C,OAAO,KAAK,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,WAAW,QAAQ,KAAK,CAAC,IAAI,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,WAAW,QAAQ,KAAK,CAAC,IAAI,EAAE,CAAA;AAC7G,CAAC;AAED;;;;;;GAMG;AACH,MAAM,CAAC,KAAK,UAAU,oBAAoB,CAAC,IAAY,EAAE,WAA4B,OAAO,CAAC,QAAQ;IACnG,IAAI,CAAC;QACH,IAAI,QAAQ,KAAK,OAAO,EAAE,CAAC;YACzB,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,QAAQ,CAAC,SAAS,IAAI,OAAO,EAAE,MAAM,CAAC,CAAA;YACjE,OAAO,sBAAsB,CAAC,IAAI,CAAC,CAAA;QACrC,CAAC;QACD,IAAI,QAAQ,KAAK,QAAQ,EAAE,CAAC;YAC1B,MAAM,EAAE,MAAM,EAAE,GAAG,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,IAAI,EAAE,SAAS,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,CAAC,EAAE,EAAE,OAAO,EAAE,sBAAsB,EAAE,CAAC,CAAA;YACnH,MAAM,MAAM,GAAG,MAAM,CAAC,IAAI,EAAE,CAAA;YAC5B,OAAO,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;QAC/C,CAAC;IACH,CAAC;IAAC,MAAM,CAAC;QACP,2EAA2E;IAC7E,CAAC;IACD,OAAO,SAAS,CAAA;AAClB,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,sBAAsB,CAAC,IAAY;IACjD,MAAM,KAAK,GAAG,IAAI,CAAC,WAAW,CAAC,GAAG,CAAC,CAAA;IACnC,IAAI,KAAK,GAAG,CAAC;QAAE,OAAO,SAAS,CAAA;IAC/B,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,CAAA;IACxD,MAAM,SAAS,GAAG,MAAM,CAAC,EAAE,CAAC,CAAA;IAC5B,OAAO,SAAS,IAAI,OAAO,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,CAAC,CAAC,CAAC,SAAS,CAAA;AAChF,CAAC;AAED,SAAS,SAAS,CAAC,GAAW;IAC5B,OAAO,WAAW,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAA;AAChF,CAAC;AAED,8EAA8E;AAC9E,MAAM,CAAC,KAAK,UAAU,kBAAkB,CAAC,WAAmB;IAC1D,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAA;IAClD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAA;IACzB,MAAM,gBAAgB,GAAG,WAAW,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,MAAM,oBAAoB,CAAC,IAAI,CAAC,CAAA;IACnF,OAAO,iBAAiB,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,gBAAgB,EAAE,WAAW,EAAE,CAAC,CAAA;AAChF,CAAC"}
@@ -0,0 +1,69 @@
1
+ /**
2
+ * sessionStore.ts — the Valkey-backed session window the sibling proxy
3
+ * processes of one harness session share (TD-437, Wave 5.3).
4
+ *
5
+ * The Rust LLM proxy keeps a session's tool-call history in Valkey
6
+ * (`store/valkey.rs`: a capped LIST for the sequence, a ZSET for the 60 s
7
+ * call window, an INCR counter per reask key). This is the same shape under
8
+ * its own namespace — `v2:mcpsession:` rather than the Rust `v2:session:` —
9
+ * because the two lists carry different things (harness-level tool names vs.
10
+ * LLM-proposed tool calls plus `action:` tokens) and must never read each
11
+ * other.
12
+ *
13
+ * Never a hard dependency. Every operation fails open to `undefined`/`false`
14
+ * and the caller (`SessionState`) falls back to its in-process window: when
15
+ * no URL is configured, when the client is not connected (`status !==
16
+ * 'ready'`, so an absent Valkey adds no latency), when a call takes longer
17
+ * than {@link SHARED_WINDOW_TIMEOUT_MS}, or on any error. The proxy behaves
18
+ * exactly as before Wave 5.3 in every one of those cases.
19
+ *
20
+ * @module
21
+ */
22
+ /** A shared read or write that takes longer than this loses to the local window. */
23
+ export declare const SHARED_WINDOW_TIMEOUT_MS = 200;
24
+ /** Sliding lifetime of the sequence and call-window keys — the Rust `TOOL_SEQUENCE_TTL_SECS`. */
25
+ export declare const SESSION_WINDOW_TTL_SECS = 86400;
26
+ /** Lifetime of a reask allowance, set when the counter is created and never refreshed —
27
+ * the Rust `REASK_WINDOW_SECS`. A slow drip must not become immortal. */
28
+ export declare const REASK_WINDOW_SECS = 3600;
29
+ export declare const sessionKeys: {
30
+ readonly tools: (scope: string) => string;
31
+ readonly calls: (scope: string) => string;
32
+ readonly reask: (scope: string, key: string) => string;
33
+ };
34
+ export interface SharedWindowSnapshot {
35
+ /** Oldest first, at most {@link TOOL_SEQUENCE_CAP} entries. */
36
+ sequence: string[];
37
+ /** Calls recorded in the window ending now, across every sibling process. */
38
+ callsLast60s: number;
39
+ }
40
+ export interface SharedSessionStore {
41
+ /** `undefined` = unavailable; the caller uses its local window. */
42
+ readWindow(scope: string, nowMs: number, windowMs: number): Promise<SharedWindowSnapshot | undefined>;
43
+ /** `false` = not recorded remotely; the local window still has it. */
44
+ recordCall(scope: string, toolName: string, nowMs: number): Promise<boolean>;
45
+ /** The count including this trip, or `undefined` when unavailable. */
46
+ incrReaskAttempt(scope: string, key: string): Promise<number | undefined>;
47
+ close(): Promise<void>;
48
+ }
49
+ export interface ValkeySessionStoreOptions {
50
+ timeoutMs?: number;
51
+ }
52
+ export declare class ValkeySessionStore implements SharedSessionStore {
53
+ private readonly client;
54
+ private readonly timeoutMs;
55
+ private warned;
56
+ constructor(url: string, opts?: ValkeySessionStoreOptions);
57
+ readWindow(scope: string, nowMs: number, windowMs: number): Promise<SharedWindowSnapshot | undefined>;
58
+ recordCall(scope: string, toolName: string, nowMs: number): Promise<boolean>;
59
+ incrReaskAttempt(scope: string, key: string): Promise<number | undefined>;
60
+ close(): Promise<void>;
61
+ /**
62
+ * The fail-open wrapper every operation goes through: nothing is attempted
63
+ * unless the client is connected, nothing waits longer than the timeout,
64
+ * and any failure is `undefined`.
65
+ */
66
+ private guarded;
67
+ private warnOnce;
68
+ }
69
+ //# sourceMappingURL=sessionStore.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionStore.d.ts","sourceRoot":"","sources":["../src/sessionStore.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAUH,oFAAoF;AACpF,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAC3C,iGAAiG;AACjG,eAAO,MAAM,uBAAuB,QAAS,CAAA;AAC7C;0EAC0E;AAC1E,eAAO,MAAM,iBAAiB,OAAQ,CAAA;AAEtC,eAAO,MAAM,WAAW;4BACP,MAAM;4BACN,MAAM;4BACN,MAAM,OAAO,MAAM;CAC1B,CAAA;AAEV,MAAM,WAAW,oBAAoB;IACnC,+DAA+D;IAC/D,QAAQ,EAAE,MAAM,EAAE,CAAA;IAClB,6EAA6E;IAC7E,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,kBAAkB;IACjC,mEAAmE;IACnE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC,CAAA;IACrG,sEAAsE;IACtE,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAAA;IAC5E,sEAAsE;IACtE,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAAA;IACzE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAA;CACvB;AAED,MAAM,WAAW,yBAAyB;IACxC,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,qBAAa,kBAAmB,YAAW,kBAAkB;IAC3D,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAO;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAQ;IAClC,OAAO,CAAC,MAAM,CAAQ;gBAEV,GAAG,EAAE,MAAM,EAAE,IAAI,GAAE,yBAA8B;IAevD,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,oBAAoB,GAAG,SAAS,CAAC;IAcrG,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAoB5E,gBAAgB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC;IASzE,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC;IAQ5B;;;;OAIG;YACW,OAAO;IAiBrB,OAAO,CAAC,QAAQ;CASjB"}
@@ -0,0 +1,143 @@
1
+ /**
2
+ * sessionStore.ts — the Valkey-backed session window the sibling proxy
3
+ * processes of one harness session share (TD-437, Wave 5.3).
4
+ *
5
+ * The Rust LLM proxy keeps a session's tool-call history in Valkey
6
+ * (`store/valkey.rs`: a capped LIST for the sequence, a ZSET for the 60 s
7
+ * call window, an INCR counter per reask key). This is the same shape under
8
+ * its own namespace — `v2:mcpsession:` rather than the Rust `v2:session:` —
9
+ * because the two lists carry different things (harness-level tool names vs.
10
+ * LLM-proposed tool calls plus `action:` tokens) and must never read each
11
+ * other.
12
+ *
13
+ * Never a hard dependency. Every operation fails open to `undefined`/`false`
14
+ * and the caller (`SessionState`) falls back to its in-process window: when
15
+ * no URL is configured, when the client is not connected (`status !==
16
+ * 'ready'`, so an absent Valkey adds no latency), when a call takes longer
17
+ * than {@link SHARED_WINDOW_TIMEOUT_MS}, or on any error. The proxy behaves
18
+ * exactly as before Wave 5.3 in every one of those cases.
19
+ *
20
+ * @module
21
+ */
22
+ import * as node_crypto from 'node:crypto';
23
+ import { Redis } from 'ioredis';
24
+ import { createStderrLogger } from './stderrLog.js';
25
+ import { describeConnectionError } from './valkeyErrors.js';
26
+ import { TOOL_SEQUENCE_CAP } from './session.js';
27
+ const log = createStderrLogger('mcp-proxy.sessionStore');
28
+ /** A shared read or write that takes longer than this loses to the local window. */
29
+ export const SHARED_WINDOW_TIMEOUT_MS = 200;
30
+ /** Sliding lifetime of the sequence and call-window keys — the Rust `TOOL_SEQUENCE_TTL_SECS`. */
31
+ export const SESSION_WINDOW_TTL_SECS = 86_400;
32
+ /** Lifetime of a reask allowance, set when the counter is created and never refreshed —
33
+ * the Rust `REASK_WINDOW_SECS`. A slow drip must not become immortal. */
34
+ export const REASK_WINDOW_SECS = 3_600;
35
+ export const sessionKeys = {
36
+ tools: (scope) => `v2:mcpsession:${scope}:tools`,
37
+ calls: (scope) => `v2:mcpsession:${scope}:calls`,
38
+ reask: (scope, key) => `v2:mcpsession:${scope}:reask:${key}`,
39
+ };
40
+ export class ValkeySessionStore {
41
+ client;
42
+ timeoutMs;
43
+ warned = false;
44
+ constructor(url, opts = {}) {
45
+ this.timeoutMs = opts.timeoutMs ?? SHARED_WINDOW_TIMEOUT_MS;
46
+ this.client = new Redis(url, {
47
+ lazyConnect: true,
48
+ // No offline queue: a command issued before the connection is up must
49
+ // fail now, not wait for a Valkey that may never answer.
50
+ enableOfflineQueue: false,
51
+ maxRetriesPerRequest: 1,
52
+ connectTimeout: 1000,
53
+ retryStrategy: (times) => Math.min(1000 * 2 ** Math.min(times, 5), 30_000),
54
+ });
55
+ this.client.on('error', (err) => this.warnOnce('Valkey session store unreachable', err));
56
+ void this.client.connect().catch((err) => this.warnOnce('Valkey session store connect failed', err));
57
+ }
58
+ async readWindow(scope, nowMs, windowMs) {
59
+ return this.guarded(async () => {
60
+ const results = await this.client
61
+ .pipeline()
62
+ .lrange(sessionKeys.tools(scope), 0, -1)
63
+ .zcount(sessionKeys.calls(scope), nowMs - windowMs, '+inf')
64
+ .exec();
65
+ const sequence = results?.[0]?.[1];
66
+ const calls = results?.[1]?.[1];
67
+ if (!Array.isArray(sequence) || results?.[0]?.[0] || results?.[1]?.[0])
68
+ return undefined;
69
+ return { sequence: sequence.map(String), callsLast60s: Number(calls ?? 0) || 0 };
70
+ });
71
+ }
72
+ async recordCall(scope, toolName, nowMs) {
73
+ const done = await this.guarded(async () => {
74
+ const tools = sessionKeys.tools(scope);
75
+ const calls = sessionKeys.calls(scope);
76
+ await this.client
77
+ .pipeline()
78
+ .rpush(tools, toolName)
79
+ .ltrim(tools, -TOOL_SEQUENCE_CAP, -1)
80
+ .expire(tools, SESSION_WINDOW_TTL_SECS)
81
+ .zremrangebyscore(calls, '-inf', nowMs - 60_000)
82
+ // A unique member per call, so two calls in the same millisecond are
83
+ // both counted.
84
+ .zadd(calls, nowMs, `${nowMs}-${node_crypto.randomUUID()}`)
85
+ .expire(calls, SESSION_WINDOW_TTL_SECS)
86
+ .exec();
87
+ return true;
88
+ });
89
+ return done === true;
90
+ }
91
+ async incrReaskAttempt(scope, key) {
92
+ return this.guarded(async () => {
93
+ const k = sessionKeys.reask(scope, key);
94
+ const n = await this.client.incr(k);
95
+ if (n === 1)
96
+ await this.client.expire(k, REASK_WINDOW_SECS);
97
+ return n;
98
+ });
99
+ }
100
+ async close() {
101
+ try {
102
+ await this.client.quit();
103
+ }
104
+ catch {
105
+ this.client.disconnect();
106
+ }
107
+ }
108
+ /**
109
+ * The fail-open wrapper every operation goes through: nothing is attempted
110
+ * unless the client is connected, nothing waits longer than the timeout,
111
+ * and any failure is `undefined`.
112
+ */
113
+ async guarded(op) {
114
+ if (this.client.status !== 'ready')
115
+ return undefined;
116
+ let timer;
117
+ const timeout = new Promise((resolve) => {
118
+ timer = setTimeout(() => resolve(undefined), this.timeoutMs);
119
+ timer.unref();
120
+ });
121
+ try {
122
+ return await Promise.race([op(), timeout]);
123
+ }
124
+ catch (err) {
125
+ this.warnOnce('Valkey session store operation failed', err);
126
+ return undefined;
127
+ }
128
+ finally {
129
+ if (timer)
130
+ clearTimeout(timer);
131
+ }
132
+ }
133
+ warnOnce(msg, err) {
134
+ const detail = { err: describeConnectionError(err) };
135
+ if (this.warned) {
136
+ log.debug(detail, msg);
137
+ return;
138
+ }
139
+ this.warned = true;
140
+ log.warn(detail, `${msg} — falling back to the per-process session window`);
141
+ }
142
+ }
143
+ //# sourceMappingURL=sessionStore.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sessionStore.js","sourceRoot":"","sources":["../src/sessionStore.ts"],"names":[],"mappings":"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"}