@geometra/mcp 1.64.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,76 +1,248 @@
1
- import { mkdirSync } from 'node:fs';
2
- import { homedir } from 'node:os';
1
+ import { accessSync, chmodSync, closeSync, constants, lstatSync, mkdirSync, openSync, } from 'node:fs';
3
2
  import path from 'node:path';
4
- import { threadId } from 'node:worker_threads';
5
3
  import { ParallelMcpOrchestrator, SqliteParallelMcpStore, } from '@razroo/parallel-mcp';
4
+ import { sanitizeRetainedCode, sanitizeRetainedError, sanitizeRetainedState, } from './state-privacy.js';
6
5
  const SESSION_NAMESPACE = 'geometra-mcp-session';
7
6
  const SESSION_TASK_KEY = 'session.live';
8
7
  const SESSION_LEASE_MS = 60_000;
9
8
  const SESSION_SWEEP_MS = 15_000;
10
9
  const SESSION_WORKER_PREFIX = `geometra-mcp:${process.pid}`;
11
- function resolveSessionStateFile() {
10
+ const SECURE_DIRECTORY_MODE = 0o700;
11
+ const SECURE_FILE_MODE = 0o600;
12
+ class SessionStateConfigurationError extends Error {
13
+ }
14
+ let lifecycleRegistry = null;
15
+ function configurationError(message) {
16
+ return new SessionStateConfigurationError(`Refusing GEOMETRA_MCP_STATE_FILE: ${message}`);
17
+ }
18
+ function lstatIfPresent(targetPath, description) {
19
+ try {
20
+ return lstatSync(targetPath);
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT')
24
+ return null;
25
+ throw configurationError(`${description} could not be inspected securely`);
26
+ }
27
+ }
28
+ function assertCurrentProcessOwns(stats, description) {
29
+ if (typeof process.geteuid === 'function' && stats.uid !== process.geteuid()) {
30
+ throw configurationError(`${description} must be owned by the current user`);
31
+ }
32
+ }
33
+ function assertSecureStateDirectory(directory) {
34
+ const stats = lstatIfPresent(directory, 'parent directory');
35
+ if (!stats)
36
+ throw configurationError('parent directory does not exist');
37
+ if (stats.isSymbolicLink() || !stats.isDirectory()) {
38
+ throw configurationError('parent must be a real directory, not a symbolic link');
39
+ }
40
+ assertCurrentProcessOwns(stats, 'parent directory');
41
+ if ((stats.mode & 0o077) !== 0) {
42
+ throw configurationError('existing parent directory must not grant group or other access (mode 0700 or stricter)');
43
+ }
44
+ try {
45
+ accessSync(directory, constants.R_OK | constants.W_OK | constants.X_OK);
46
+ }
47
+ catch {
48
+ throw configurationError('parent directory must be readable, writable, and searchable by the current user');
49
+ }
50
+ }
51
+ function assertRegularOwnedArtifact(stats, description) {
52
+ if (stats.isSymbolicLink() || !stats.isFile()) {
53
+ throw configurationError(`${description} must be a regular file, not a symbolic link or special file`);
54
+ }
55
+ assertCurrentProcessOwns(stats, description);
56
+ if (stats.nlink !== 1) {
57
+ throw configurationError(`${description} must not have multiple hard links`);
58
+ }
59
+ }
60
+ function secureExistingArtifact(artifactPath, description) {
61
+ const stats = lstatIfPresent(artifactPath, description);
62
+ if (!stats)
63
+ return false;
64
+ assertRegularOwnedArtifact(stats, description);
65
+ try {
66
+ chmodSync(artifactPath, SECURE_FILE_MODE);
67
+ }
68
+ catch {
69
+ throw configurationError(`${description} permissions could not be restricted to mode 0600`);
70
+ }
71
+ const secured = lstatIfPresent(artifactPath, description);
72
+ if (!secured)
73
+ throw configurationError(`${description} disappeared while permissions were secured`);
74
+ assertRegularOwnedArtifact(secured, description);
75
+ if ((secured.mode & 0o077) !== 0) {
76
+ throw configurationError(`${description} permissions are not private`);
77
+ }
78
+ return true;
79
+ }
80
+ function sqliteSidecarPaths(filename) {
81
+ return [
82
+ [`${filename}-wal`, 'WAL file'],
83
+ [`${filename}-shm`, 'shared-memory file'],
84
+ [`${filename}-journal`, 'rollback journal'],
85
+ ];
86
+ }
87
+ function preparePersistentStateFile(filename) {
88
+ const directory = path.dirname(filename);
89
+ if (!lstatIfPresent(directory, 'parent directory')) {
90
+ try {
91
+ mkdirSync(directory, { recursive: true, mode: SECURE_DIRECTORY_MODE });
92
+ }
93
+ catch {
94
+ throw configurationError('parent directory could not be created securely');
95
+ }
96
+ }
97
+ assertSecureStateDirectory(directory);
98
+ for (const [sidecarPath, description] of sqliteSidecarPaths(filename)) {
99
+ secureExistingArtifact(sidecarPath, description);
100
+ }
101
+ if (!secureExistingArtifact(filename, 'database file')) {
102
+ let descriptor;
103
+ try {
104
+ const noFollow = typeof constants.O_NOFOLLOW === 'number' ? constants.O_NOFOLLOW : 0;
105
+ descriptor = openSync(filename, constants.O_CREAT | constants.O_EXCL | constants.O_RDWR | noFollow, SECURE_FILE_MODE);
106
+ }
107
+ catch {
108
+ throw configurationError('database file could not be created exclusively and securely');
109
+ }
110
+ finally {
111
+ if (descriptor !== undefined)
112
+ closeSync(descriptor);
113
+ }
114
+ secureExistingArtifact(filename, 'database file');
115
+ }
116
+ }
117
+ function securePersistentArtifacts(filename) {
118
+ assertSecureStateDirectory(path.dirname(filename));
119
+ if (!secureExistingArtifact(filename, 'database file')) {
120
+ throw configurationError('database file disappeared while storage was active');
121
+ }
122
+ for (const [sidecarPath, description] of sqliteSidecarPaths(filename)) {
123
+ secureExistingArtifact(sidecarPath, description);
124
+ }
125
+ }
126
+ function resolveSessionStateStorage() {
12
127
  const raw = process.env.GEOMETRA_MCP_STATE_FILE?.trim();
13
- if (raw) {
14
- mkdirSync(path.dirname(raw), { recursive: true });
15
- return raw;
128
+ if (!raw || raw === ':memory:') {
129
+ return { filename: ':memory:', persistentPath: null };
16
130
  }
17
- const dir = path.join(homedir(), '.geometra-mcp');
18
- mkdirSync(dir, { recursive: true });
19
- return path.join(dir, `parallel-mcp-${process.pid}-${threadId}.sqlite`);
131
+ if (raw.includes('\0'))
132
+ throw configurationError('path contains an invalid null byte');
133
+ const filename = path.resolve(raw);
134
+ preparePersistentStateFile(filename);
135
+ return { filename, persistentPath: filename };
20
136
  }
21
137
  function isSessionLifecycleDisabled() {
22
138
  const raw = process.env.GEOMETRA_MCP_DISABLE_SESSION_LIFECYCLE?.trim().toLowerCase();
23
139
  return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on';
24
140
  }
25
141
  function formatSessionLifecycleInitError(error) {
26
- if (error instanceof Error) {
27
- if (error.code === 'ERR_DLOPEN_FAILED' &&
28
- error.message.includes('better_sqlite3.node')) {
29
- return `${error.message}. Rebuild the native module with \`npm rebuild better-sqlite3\` in the MCP package directory, or reinstall dependencies for the current Node.js version.`;
30
- }
142
+ if (error instanceof SessionStateConfigurationError)
31
143
  return error.message;
144
+ if (error instanceof Error &&
145
+ error.code === 'ERR_DLOPEN_FAILED' &&
146
+ error.message.includes('better_sqlite3.node')) {
147
+ return 'The SQLite native module is unavailable. Rebuild it with `npm rebuild better-sqlite3` in the MCP package directory, or reinstall dependencies for the current Node.js version.';
32
148
  }
33
- return String(error);
149
+ return 'Secure session lifecycle storage could not be initialized';
34
150
  }
35
151
  function createSessionLifecycleRegistry() {
36
152
  if (isSessionLifecycleDisabled()) {
37
- process.stderr.write('[geometra-mcp] durable session lifecycle disabled via GEOMETRA_MCP_DISABLE_SESSION_LIFECYCLE\n');
153
+ process.stderr.write('[geometra-mcp] session lifecycle disabled via GEOMETRA_MCP_DISABLE_SESSION_LIFECYCLE\n');
38
154
  return {
39
155
  available: false,
40
156
  orchestrator: null,
157
+ secureArtifacts: () => { },
41
158
  close: () => { },
42
159
  };
43
160
  }
161
+ const configuredStateFile = process.env.GEOMETRA_MCP_STATE_FILE?.trim();
162
+ const persistentStorageRequested = Boolean(configuredStateFile && configuredStateFile !== ':memory:');
163
+ let orchestrator = null;
44
164
  try {
45
- const orchestrator = new ParallelMcpOrchestrator(new SqliteParallelMcpStore({ filename: resolveSessionStateFile() }), { defaultLeaseMs: SESSION_LEASE_MS });
46
- const leaseSweep = setInterval(() => {
165
+ const storage = resolveSessionStateStorage();
166
+ orchestrator = new ParallelMcpOrchestrator(new SqliteParallelMcpStore({ filename: storage.filename }), { defaultLeaseMs: SESSION_LEASE_MS });
167
+ const secureArtifacts = storage.persistentPath
168
+ ? () => securePersistentArtifacts(storage.persistentPath)
169
+ : () => { };
170
+ secureArtifacts();
171
+ let closed = false;
172
+ let leaseSweep = null;
173
+ const close = () => {
174
+ if (closed)
175
+ return;
176
+ closed = true;
177
+ if (leaseSweep)
178
+ clearInterval(leaseSweep);
179
+ try {
180
+ secureArtifacts();
181
+ }
182
+ finally {
183
+ orchestrator?.close();
184
+ }
185
+ };
186
+ leaseSweep = setInterval(() => {
187
+ if (closed)
188
+ return;
47
189
  try {
48
- orchestrator.expireLeases();
190
+ orchestrator?.expireLeases();
191
+ secureArtifacts();
49
192
  }
50
193
  catch {
51
- /* ignore background lease sweep failures */
194
+ process.stderr.write('[geometra-mcp] session lifecycle storage failed a background security check and was closed\n');
195
+ try {
196
+ close();
197
+ }
198
+ catch {
199
+ /* already failed closed */
200
+ }
52
201
  }
53
202
  }, SESSION_SWEEP_MS);
54
203
  leaseSweep.unref();
55
204
  return {
56
- available: true,
57
- orchestrator,
58
- close: () => {
59
- clearInterval(leaseSweep);
60
- orchestrator.close();
205
+ get available() {
206
+ return !closed;
61
207
  },
208
+ orchestrator,
209
+ secureArtifacts,
210
+ close,
62
211
  };
63
212
  }
64
213
  catch (error) {
65
- process.stderr.write(`[geometra-mcp] durable session lifecycle disabled: ${formatSessionLifecycleInitError(error)}\n`);
214
+ try {
215
+ orchestrator?.close();
216
+ }
217
+ catch {
218
+ /* initialization already failed */
219
+ }
220
+ const safeError = formatSessionLifecycleInitError(error);
221
+ if (persistentStorageRequested) {
222
+ // The caught cause can contain the configured path or native-module
223
+ // filesystem details; attaching it would defeat this privacy boundary.
224
+ // eslint-disable-next-line preserve-caught-error
225
+ throw new Error(safeError);
226
+ }
227
+ process.stderr.write(`[geometra-mcp] session lifecycle unavailable: ${safeError}\n`);
66
228
  return {
67
229
  available: false,
68
230
  orchestrator: null,
231
+ secureArtifacts: () => { },
69
232
  close: () => { },
70
233
  };
71
234
  }
72
235
  }
73
- const lifecycleRegistry = createSessionLifecycleRegistry();
236
+ function getSessionLifecycleRegistry() {
237
+ if (!lifecycleRegistry)
238
+ lifecycleRegistry = createSessionLifecycleRegistry();
239
+ return lifecycleRegistry;
240
+ }
241
+ function currentSessionLifecycleRegistry() {
242
+ return lifecycleRegistry?.available && lifecycleRegistry.orchestrator
243
+ ? lifecycleRegistry
244
+ : null;
245
+ }
74
246
  function extractPageUrl(target) {
75
247
  const cached = target.cachedA11y?.meta?.pageUrl;
76
248
  if (typeof cached === 'string' && cached.length > 0)
@@ -80,31 +252,18 @@ function extractPageUrl(target) {
80
252
  return semantic.pageUrl;
81
253
  return null;
82
254
  }
83
- function toJsonValue(value) {
84
- if (value === null)
85
- return null;
86
- if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
87
- return value;
88
- }
89
- if (value instanceof Date) {
90
- return value.toISOString();
91
- }
92
- if (Array.isArray(value)) {
93
- return value.map(item => toJsonValue(item));
94
- }
95
- if (typeof value === 'object') {
96
- const object = {};
97
- for (const [key, entry] of Object.entries(value)) {
98
- if (entry === undefined)
99
- continue;
100
- object[key] = toJsonValue(entry);
101
- }
102
- return object;
255
+ function toRetainedJsonValue(value) {
256
+ return sanitizeRetainedState(value);
257
+ }
258
+ function toRetainedJsonObject(value) {
259
+ const retained = sanitizeRetainedState(value);
260
+ if (retained && typeof retained === 'object' && !Array.isArray(retained)) {
261
+ return retained;
103
262
  }
104
- return String(value);
263
+ return {};
105
264
  }
106
265
  function buildSessionContext(target, label, extra) {
107
- return {
266
+ return toRetainedJsonObject({
108
267
  sessionId: target.id,
109
268
  label,
110
269
  transportUrl: target.url,
@@ -116,8 +275,8 @@ function buildSessionContext(target, label, extra) {
116
275
  hasLayout: target.layout !== null,
117
276
  hasTree: target.tree !== null,
118
277
  connectMode: target.connectTrace?.mode ?? null,
119
- ...(extra ? { extra: toJsonValue(extra) } : {}),
120
- };
278
+ ...(extra ? { extra } : {}),
279
+ });
121
280
  }
122
281
  function liveTaskIdFor(sessionId) {
123
282
  return `${sessionId}:live`;
@@ -128,8 +287,23 @@ function liveTaskKindFor(sessionId) {
128
287
  function workerIdFor(sessionId) {
129
288
  return `${SESSION_WORKER_PREFIX}:${sessionId}`;
130
289
  }
290
+ function secureAfterWrite(registry) {
291
+ try {
292
+ registry.secureArtifacts();
293
+ }
294
+ catch {
295
+ try {
296
+ registry.close();
297
+ }
298
+ catch {
299
+ /* fail closed even if SQLite close also fails */
300
+ }
301
+ throw new Error('Session lifecycle storage failed a security check and was closed');
302
+ }
303
+ }
131
304
  export function initializeSessionLifecycle(target, options) {
132
- if (!lifecycleRegistry.available || !lifecycleRegistry.orchestrator) {
305
+ const registry = getSessionLifecycleRegistry();
306
+ if (!registry.available || !registry.orchestrator) {
133
307
  target.lifecycleFinalized = true;
134
308
  target.lifecycleTaskId = undefined;
135
309
  target.lifecycleTaskKind = undefined;
@@ -137,7 +311,7 @@ export function initializeSessionLifecycle(target, options) {
137
311
  target.lifecycleWorkerId = undefined;
138
312
  return;
139
313
  }
140
- const orchestrator = lifecycleRegistry.orchestrator;
314
+ const orchestrator = registry.orchestrator;
141
315
  const sessionId = target.id;
142
316
  const taskId = liveTaskIdFor(sessionId);
143
317
  const taskKind = liveTaskKindFor(sessionId);
@@ -145,7 +319,7 @@ export function initializeSessionLifecycle(target, options) {
145
319
  orchestrator.createRun({
146
320
  id: sessionId,
147
321
  namespace: SESSION_NAMESPACE,
148
- metadata: toJsonValue({
322
+ metadata: toRetainedJsonValue({
149
323
  transportMode: options?.transportMode ?? 'direct-ws',
150
324
  isolated: target.isolated === true,
151
325
  }),
@@ -158,7 +332,7 @@ export function initializeSessionLifecycle(target, options) {
158
332
  runId: sessionId,
159
333
  key: SESSION_TASK_KEY,
160
334
  kind: taskKind,
161
- input: toJsonValue({
335
+ input: toRetainedJsonValue({
162
336
  transportUrl: target.url,
163
337
  requestedPageUrl: options?.pageUrl ?? null,
164
338
  }),
@@ -169,13 +343,14 @@ export function initializeSessionLifecycle(target, options) {
169
343
  leaseMs: SESSION_LEASE_MS,
170
344
  });
171
345
  if (!claimed || claimed.task.id !== taskId) {
172
- throw new Error(`Failed to initialize durable session task for ${sessionId}`);
346
+ throw new Error('Failed to initialize session lifecycle task');
173
347
  }
174
348
  orchestrator.markTaskRunning({
175
349
  taskId,
176
350
  leaseId: claimed.lease.id,
177
351
  workerId,
178
352
  });
353
+ secureAfterWrite(registry);
179
354
  target.lifecycleTaskId = taskId;
180
355
  target.lifecycleTaskKind = taskKind;
181
356
  target.lifecycleLeaseId = claimed.lease.id;
@@ -183,73 +358,80 @@ export function initializeSessionLifecycle(target, options) {
183
358
  target.lifecycleFinalized = false;
184
359
  }
185
360
  export function heartbeatSessionLifecycle(target) {
186
- if (!lifecycleRegistry.orchestrator)
361
+ const registry = currentSessionLifecycleRegistry();
362
+ if (!registry?.orchestrator)
187
363
  return;
188
- const orchestrator = lifecycleRegistry.orchestrator;
189
364
  if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
190
365
  return;
191
- orchestrator.heartbeatLease({
366
+ registry.orchestrator.heartbeatLease({
192
367
  taskId: target.lifecycleTaskId,
193
368
  leaseId: target.lifecycleLeaseId,
194
369
  workerId: target.lifecycleWorkerId,
195
370
  leaseMs: SESSION_LEASE_MS,
196
371
  });
372
+ secureAfterWrite(registry);
197
373
  }
198
374
  export function recordSessionSnapshot(target, label, extra) {
199
- if (!lifecycleRegistry.orchestrator)
375
+ const registry = currentSessionLifecycleRegistry();
376
+ if (!registry?.orchestrator || !target.lifecycleTaskId)
200
377
  return;
201
- const orchestrator = lifecycleRegistry.orchestrator;
202
- if (!target.lifecycleTaskId)
203
- return;
204
- orchestrator.appendContextSnapshot({
378
+ const retainedLabel = sanitizeRetainedCode(label);
379
+ registry.orchestrator.appendContextSnapshot({
205
380
  runId: target.id,
206
381
  taskId: target.lifecycleTaskId,
207
382
  scope: 'run',
208
- label,
209
- payload: buildSessionContext(target, label, extra),
383
+ label: retainedLabel,
384
+ payload: buildSessionContext(target, retainedLabel, extra),
210
385
  });
386
+ secureAfterWrite(registry);
211
387
  }
212
388
  export function completeSessionLifecycle(target, reason, extra) {
213
- if (!lifecycleRegistry.orchestrator)
389
+ const registry = currentSessionLifecycleRegistry();
390
+ if (!registry?.orchestrator)
214
391
  return;
215
- const orchestrator = lifecycleRegistry.orchestrator;
216
392
  if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
217
393
  return;
218
- orchestrator.completeTask({
394
+ const retainedReason = sanitizeRetainedCode(reason);
395
+ registry.orchestrator.completeTask({
219
396
  taskId: target.lifecycleTaskId,
220
397
  leaseId: target.lifecycleLeaseId,
221
398
  workerId: target.lifecycleWorkerId,
222
- output: toJsonValue({
223
- reason,
399
+ output: toRetainedJsonValue({
400
+ reason: retainedReason,
224
401
  ...(extra ? { extra } : {}),
225
402
  }),
226
403
  nextContext: buildSessionContext(target, 'session.completed', {
227
- reason,
404
+ reason: retainedReason,
228
405
  ...(extra ? { ...extra } : {}),
229
406
  }),
230
407
  nextContextLabel: 'session.completed',
231
408
  });
409
+ secureAfterWrite(registry);
232
410
  target.lifecycleFinalized = true;
233
411
  }
234
412
  export function failSessionLifecycle(target, error, extra) {
235
- if (!lifecycleRegistry.orchestrator)
413
+ const registry = currentSessionLifecycleRegistry();
414
+ if (!registry?.orchestrator)
236
415
  return;
237
- const orchestrator = lifecycleRegistry.orchestrator;
238
416
  if (target.lifecycleFinalized || !target.lifecycleTaskId || !target.lifecycleLeaseId || !target.lifecycleWorkerId)
239
417
  return;
418
+ const retainedError = sanitizeRetainedError(error);
240
419
  recordSessionSnapshot(target, 'session.failed', {
241
- error,
420
+ error: retainedError,
242
421
  ...(extra ? { ...extra } : {}),
243
422
  });
244
- orchestrator.failTask({
423
+ registry.orchestrator.failTask({
245
424
  taskId: target.lifecycleTaskId,
246
425
  leaseId: target.lifecycleLeaseId,
247
426
  workerId: target.lifecycleWorkerId,
248
- error,
249
- metadata: extra ? toJsonValue(extra) : undefined,
427
+ error: retainedError,
428
+ metadata: extra ? toRetainedJsonValue(extra) : undefined,
250
429
  });
430
+ secureAfterWrite(registry);
251
431
  target.lifecycleFinalized = true;
252
432
  }
253
433
  export function shutdownSessionLifecycleRegistry() {
254
- lifecycleRegistry.close();
434
+ const registry = lifecycleRegistry;
435
+ lifecycleRegistry = null;
436
+ registry?.close();
255
437
  }