@evomap/evolver 1.85.2 → 1.86.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.
Files changed (51) hide show
  1. package/package.json +4 -1
  2. package/scripts/check-changelog.js +166 -0
  3. package/src/adapters/scripts/evolver-session-end.js +0 -1
  4. package/src/evolve/guards.js +1 -1
  5. package/src/evolve/pipeline/collect.js +1 -1
  6. package/src/evolve/pipeline/dispatch.js +1 -1
  7. package/src/evolve/pipeline/enrich.js +1 -1
  8. package/src/evolve/pipeline/hub.js +1 -1
  9. package/src/evolve/pipeline/select.js +1 -1
  10. package/src/evolve/pipeline/signals.js +1 -1
  11. package/src/evolve/utils.js +1 -1
  12. package/src/evolve.js +1 -1
  13. package/src/gep/a2aProtocol.js +1 -1
  14. package/src/gep/candidateEval.js +1 -1
  15. package/src/gep/candidates.js +1 -1
  16. package/src/gep/claimNudge.js +10 -10
  17. package/src/gep/contentHash.js +1 -1
  18. package/src/gep/crypto.js +1 -1
  19. package/src/gep/curriculum.js +1 -1
  20. package/src/gep/deviceId.js +1 -1
  21. package/src/gep/envFingerprint.js +1 -1
  22. package/src/gep/epigenetics.js +1 -1
  23. package/src/gep/explore.js +1 -1
  24. package/src/gep/featureFlags.js +11 -8
  25. package/src/gep/hash.js +1 -1
  26. package/src/gep/hubFetch.js +1 -1
  27. package/src/gep/hubReview.js +1 -1
  28. package/src/gep/hubSearch.js +1 -1
  29. package/src/gep/hubVerify.js +1 -1
  30. package/src/gep/learningSignals.js +1 -1
  31. package/src/gep/localStateAwareness.js +8 -9
  32. package/src/gep/memoryGraph.js +1 -1
  33. package/src/gep/memoryGraphAdapter.js +1 -1
  34. package/src/gep/mutation.js +1 -1
  35. package/src/gep/narrativeMemory.js +1 -1
  36. package/src/gep/openPRRegistry.js +1 -1
  37. package/src/gep/paths.js +204 -48
  38. package/src/gep/personality.js +1 -1
  39. package/src/gep/policyCheck.js +1 -1
  40. package/src/gep/prompt.js +1 -1
  41. package/src/gep/recallVerifier.js +1 -1
  42. package/src/gep/reflection.js +1 -1
  43. package/src/gep/selector.js +1 -1
  44. package/src/gep/skillDistiller.js +1 -1
  45. package/src/gep/solidify.js +1 -1
  46. package/src/gep/strategy.js +1 -1
  47. package/src/gep/validator/stakeBootstrap.js +12 -11
  48. package/src/gep/workspaceKeychain.js +1 -0
  49. package/src/proxy/index.js +4 -4
  50. package/src/proxy/lifecycle/manager.js +44 -1
  51. package/src/webui/observer/interactions.js +4 -3
package/src/gep/paths.js CHANGED
@@ -1,5 +1,6 @@
1
1
  const path = require('path');
2
2
  const fs = require('fs');
3
+ const os = require('os');
3
4
 
4
5
  let _cachedRepoRoot = null;
5
6
 
@@ -42,34 +43,73 @@ function getRepoRoot() {
42
43
  const legacyFlag = process.env.EVOLVER_USE_PARENT_GIT;
43
44
  const legacyOptOut = typeof legacyFlag === 'string' && legacyFlag.toLowerCase() === 'false';
44
45
 
45
- // Walk upward from process.cwd() the project the user is standing in.
46
- if (!noParent && !legacyOptOut) {
47
- let cwd = process.cwd();
48
- while (cwd !== path.dirname(cwd)) {
49
- if (fs.existsSync(path.join(cwd, '.git'))) {
50
- if (!process.env.EVOLVER_QUIET_PARENT_GIT) {
51
- console.log('[evolver] Using host git repository at:', cwd);
52
- }
53
- _cachedRepoRoot = cwd;
54
- return _cachedRepoRoot;
55
- }
56
- cwd = path.dirname(cwd);
57
- }
46
+ // Both upward walks below must stop at the parent of the nearest
47
+ // `node_modules` ancestor never escape into whatever `.git` happens
48
+ // to live above it (issue #541). On macOS with Homebrew, the global
49
+ // install lives at `/opt/homebrew/lib/node_modules/@evomap/evolver`
50
+ // and `/opt/homebrew` is itself a git repo; an unbounded walk
51
+ // therefore resolves repoRoot to `/opt/homebrew`, sending
52
+ // workspaceRoot / memoryDir / evolutionDir to a directory that
53
+ // doesn't belong to the user and silently producing evolution
54
+ // proposals for the wrong codebase.
55
+ //
56
+ // Boundary semantics: returns the parent of the nearest `node_modules`
57
+ // ancestor (inclusive — a `.git` at that parent IS still picked up),
58
+ // or null if `dir` is not inside any `node_modules` (dev clone /
59
+ // user project root). Callers stop AFTER checking the boundary path
60
+ // itself.
61
+ //
62
+ // For a local install (`<project>/node_modules/@evomap/evolver`), the
63
+ // parent of node_modules IS the user's project, so the boundary
64
+ // includes `<project>` and `<project>/.git` is still picked up
65
+ // correctly. For a dev clone, the boundary is null and the walk is
66
+ // unbounded as before.
67
+ function _nodeModulesBoundary(dir) {
68
+ const segments = dir.split(path.sep);
69
+ const nmIdx = segments.lastIndexOf('node_modules');
70
+ if (nmIdx <= 0) return null;
71
+ return segments.slice(0, nmIdx).join(path.sep) || path.sep;
58
72
  }
59
73
 
60
- // Walk upward from ownDir's parent (local install inside node_modules).
61
- if (!noParent && !legacyOptOut) {
62
- let dir = path.dirname(ownDir);
74
+ function _walkForGit(start) {
75
+ const stopAt = _nodeModulesBoundary(start);
76
+ let dir = start;
63
77
  while (dir !== path.dirname(dir)) {
64
78
  if (fs.existsSync(path.join(dir, '.git'))) {
65
79
  if (!process.env.EVOLVER_QUIET_PARENT_GIT) {
66
80
  console.log('[evolver] Using host git repository at:', dir);
67
81
  }
68
- _cachedRepoRoot = dir;
69
- return _cachedRepoRoot;
82
+ return dir;
70
83
  }
84
+ if (stopAt !== null && dir === stopAt) break;
71
85
  dir = path.dirname(dir);
72
86
  }
87
+ return null;
88
+ }
89
+
90
+ // Walk upward from process.cwd() — the project the user is standing in.
91
+ // Bounded the same way as the ownDir walk: a user who `cd`s into the
92
+ // global install (e.g. `cd /opt/homebrew/lib/node_modules/@evomap/evolver`
93
+ // to debug) would otherwise hit `/opt/homebrew/.git` here BEFORE the
94
+ // ownDir walk runs, defeating its boundary. The boundary still
95
+ // includes the parent of node_modules, so a user `cd`'d into
96
+ // `<their-project>/node_modules/lodash` still has `<their-project>/.git`
97
+ // picked correctly.
98
+ if (!noParent && !legacyOptOut) {
99
+ const hit = _walkForGit(process.cwd());
100
+ if (hit) {
101
+ _cachedRepoRoot = hit;
102
+ return _cachedRepoRoot;
103
+ }
104
+ }
105
+
106
+ // Walk upward from ownDir's parent (local install inside node_modules).
107
+ if (!noParent && !legacyOptOut) {
108
+ const hit = _walkForGit(path.dirname(ownDir));
109
+ if (hit) {
110
+ _cachedRepoRoot = hit;
111
+ return _cachedRepoRoot;
112
+ }
73
113
  }
74
114
 
75
115
  // Fallback: evolver's own directory (dev mode or isolated install).
@@ -224,6 +264,27 @@ function getEvolverInstallRoot() {
224
264
  return path.resolve(__dirname, '..', '..');
225
265
  }
226
266
 
267
+ // Resolve the per-user `~/.evomap` directory, with `EVOLVER_HOME` env var
268
+ // override. Lazy (function call, not a module-level `const`) so tests can
269
+ // flip `EVOLVER_HOME` per case without monkey-patching `os.homedir`.
270
+ //
271
+ // Existing call sites used to duplicate `path.join(os.homedir(), '.evomap')`
272
+ // across ~9 modules; about two thirds silently ignored `EVOLVER_HOME` (it
273
+ // worked for stake bootstrap and claim nudge but not for node-id, device-id,
274
+ // feature flags, etc.). #114 consolidates onto this helper so the override
275
+ // is uniform and tests don't need to monkey-patch the global homedir
276
+ // function (which doesn't compose with `node --test` parallel execution).
277
+ function getEvomapDir() {
278
+ return process.env.EVOLVER_HOME || path.join(os.homedir(), '.evomap');
279
+ }
280
+
281
+ // Join sub-segments under `~/.evomap`. Just a convenience wrapper so call
282
+ // sites don't have to `path.join(getEvomapDir(), 'mailbox', 'state.json')`
283
+ // in two pieces.
284
+ function getEvomapPath(...segments) {
285
+ return path.join(getEvomapDir(), ...segments);
286
+ }
287
+
227
288
  // Per-workspace random secret used to attest that a memory_graph.jsonl
228
289
  // entry was written by the same workspace that's now reading it. Stored
229
290
  // at <workspace>/.evolver/workspace-id with mode 0600 and lazily created
@@ -238,32 +299,51 @@ function getEvolverInstallRoot() {
238
299
  // claim a different workspace. workspace-id replaces that self-report
239
300
  // with a secret that only the legitimate workspace's evolver knows
240
301
  // (Bugbot PR #108 round-3 Agentic Security Review MEDIUM).
241
- function getWorkspaceId() {
242
- if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
243
- const dir = path.join(getWorkspaceRoot(), '.evolver');
244
- const file = path.join(dir, 'workspace-id');
302
+ //
303
+ // Issue #111 Phase 1: optionally backs the secret with the OS keychain
304
+ // (`@napi-rs/keyring` optional dep) to close the same-uid readability
305
+ // gap. Mode is controlled by `EVOLVER_WORKSPACE_KEYCHAIN` (auto/force/
306
+ // off, default `auto`). FS file is RETAINED on successful keychain
307
+ // migration so bun-compiled binaries (which can't `require()` the
308
+ // addon yet — Phase 2) still see the same id when handing off to a
309
+ // node-CLI session in the same workspace.
245
310
 
246
- // Refuse to follow symlinks at either the directory or file level.
247
- // A malicious repo can pre-place `.evolver` or `.evolver/workspace-id`
248
- // as a symlink to an attacker-chosen path outside the workspace, and
249
- // mkdirSync({recursive:true}) / writeFileSync would silently follow
250
- // it clobbering the linked file with the secret payload (Bugbot PR
251
- // #109 round-2 HIGH, Agentic Security Review).
311
+ // Read the FS-backed workspace-id at <workspace>/.evolver/workspace-id.
312
+ // Returns the id on a clean read, null on any error or missing file.
313
+ // Symlink rejection matches the pre-keychain hardening from PR #109.
314
+ function _readWorkspaceIdFromFs(file) {
315
+ const dir = path.dirname(file);
252
316
  try {
253
317
  const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
254
318
  if (dirStat && dirStat.isSymbolicLink()) return null;
255
319
  const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
256
- if (fileStat) {
257
- if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
258
- try {
259
- const raw = fs.readFileSync(file, 'utf8').trim();
260
- if (raw && /^[a-f0-9]{32,}$/i.test(raw)) return raw;
261
- } catch { /* unreadable — fall through to recreate */ }
262
- }
263
- } catch { /* fall through to create */ }
320
+ if (!fileStat) return null;
321
+ if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
322
+ const raw = fs.readFileSync(file, 'utf8').trim();
323
+ if (raw && /^[a-f0-9]{32,}$/i.test(raw)) return raw;
324
+ return null;
325
+ } catch {
326
+ return null;
327
+ }
328
+ }
329
+
330
+ // Atomically create <workspace>/.evolver/workspace-id with the given id
331
+ // (or generate one if `id` is null). Returns the id that ended up on
332
+ // disk, or null on any unrecoverable error. EEXIST races re-read.
333
+ function _writeWorkspaceIdToFs(file, id) {
334
+ const dir = path.dirname(file);
264
335
  try {
336
+ // Refuse to write if `.evolver` is a symlink. mkdirSync({recursive:true})
337
+ // happily traverses an existing symlinked directory and the subsequent
338
+ // open() lands the secret file in the attacker-controlled target —
339
+ // O_NOFOLLOW only guards the FINAL path component, not intermediate
340
+ // directories. The pre-refactor monolithic getWorkspaceId() returned
341
+ // null on a symlinked dir before reaching the write; preserve that
342
+ // here (Bugbot PR #121 round-1 HIGH; original guard PR #109 round-2 HIGH).
343
+ const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
344
+ if (dirStat && dirStat.isSymbolicLink()) return null;
265
345
  fs.mkdirSync(dir, { recursive: true });
266
- const id = require('crypto').randomBytes(16).toString('hex');
346
+ const payload = id || require('crypto').randomBytes(16).toString('hex');
267
347
  // Atomic create-and-fail-if-exists so we never overwrite an
268
348
  // attacker-pre-placed file (TOCTOU between lstat and writeFileSync
269
349
  // could otherwise race a symlink in). O_NOFOLLOW also refuses to
@@ -276,32 +356,106 @@ function getWorkspaceId() {
276
356
  try {
277
357
  fd = fs.openSync(file, flags, 0o600);
278
358
  } catch (e) {
279
- // EEXIST means another process beat us to it — re-read with the
280
- // same symlink guards as above.
281
359
  if (e && e.code === 'EEXIST') {
282
- const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
283
- if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
284
- try {
285
- const raw = fs.readFileSync(file, 'utf8').trim();
286
- if (raw && /^[a-f0-9]{32,}$/i.test(raw)) return raw;
287
- } catch { /* unreadable */ }
288
- return null;
360
+ // Another process beat us re-read with the same symlink guards.
361
+ return _readWorkspaceIdFromFs(file);
289
362
  }
290
363
  // ELOOP / EMLINK from O_NOFOLLOW hitting a symlink — refuse.
291
364
  return null;
292
365
  }
293
366
  try {
294
- fs.writeSync(fd, id + '\n', 0, 'utf8');
367
+ fs.writeSync(fd, payload + '\n', 0, 'utf8');
295
368
  } finally {
296
369
  fs.closeSync(fd);
297
370
  }
298
371
  try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
299
- return id;
372
+ return payload;
300
373
  } catch {
301
374
  return null;
302
375
  }
303
376
  }
304
377
 
378
+ function getWorkspaceId() {
379
+ if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
380
+ const workspaceRoot = getWorkspaceRoot();
381
+ const dir = path.join(workspaceRoot, '.evolver');
382
+ const file = path.join(dir, 'workspace-id');
383
+
384
+ let mode = 'off';
385
+ let keychain = null;
386
+ try {
387
+ keychain = require('./workspaceKeychain');
388
+ mode = keychain.getMode();
389
+ } catch {
390
+ // workspaceKeychain.js missing — degrade silently to FS-only.
391
+ mode = 'off';
392
+ }
393
+
394
+ if (mode !== 'off' && keychain) {
395
+ const addonAvailable = keychain.loadAddon() !== null;
396
+ if (mode === 'force' && !addonAvailable) {
397
+ throw new Error(
398
+ 'EVOLVER_WORKSPACE_KEYCHAIN=force but @napi-rs/keyring is not installed. ' +
399
+ 'Install it (`npm i @napi-rs/keyring`) or set EVOLVER_WORKSPACE_KEYCHAIN=auto/off.'
400
+ );
401
+ }
402
+ if (addonAvailable) {
403
+ const hit = keychain.readFromKeychain(workspaceRoot);
404
+ if (hit.available && hit.id) return hit.id;
405
+
406
+ // `force` must NEVER fall back to FS read/write — that would
407
+ // silently re-introduce same-uid plaintext exposure of the
408
+ // workspace secret, which is exactly what `force` exists to
409
+ // prevent (Bugbot PR #121 round-2 MEDIUM Agentic Security).
410
+ // Generate a fresh id and write it ONLY to the keychain; if
411
+ // that write fails, throw rather than mirror to FS.
412
+ if (mode === 'force') {
413
+ if (hit.available) {
414
+ // Keychain reachable but empty — mint and write keychain-only.
415
+ const newId = require('crypto').randomBytes(16).toString('hex');
416
+ if (!keychain.writeToKeychain(workspaceRoot, newId)) {
417
+ throw new Error(
418
+ 'EVOLVER_WORKSPACE_KEYCHAIN=force: keychain write failed; ' +
419
+ 'refusing to fall back to filesystem secret.'
420
+ );
421
+ }
422
+ return newId;
423
+ }
424
+ // Addon loaded but read claims unavailable (e.g. locked
425
+ // keyring on Linux, no D-Bus session). Refuse rather than
426
+ // silently degrade.
427
+ throw new Error(
428
+ 'EVOLVER_WORKSPACE_KEYCHAIN=force: keychain reports unavailable ' +
429
+ '(locked keyring / no session?); refusing to fall back to filesystem.'
430
+ );
431
+ }
432
+
433
+ // mode === 'auto', keychain miss — try to migrate an existing
434
+ // FS secret in.
435
+ const fsId = _readWorkspaceIdFromFs(file);
436
+ if (fsId) {
437
+ keychain.writeToKeychain(workspaceRoot, fsId); // best-effort
438
+ return fsId;
439
+ }
440
+
441
+ // No secret anywhere — generate, write FS atomically, then
442
+ // mirror to keychain. FS write is the source of truth for the
443
+ // value (race-resistant via O_EXCL); keychain is the upgrade.
444
+ const newId = _writeWorkspaceIdToFs(file, null);
445
+ if (!newId) return null;
446
+ keychain.writeToKeychain(workspaceRoot, newId); // best-effort
447
+ return newId;
448
+ }
449
+ // mode === 'auto' && addon unavailable → fall through to FS.
450
+ }
451
+
452
+ // FS-only path (mode === 'off' or auto-fallback). Identical to the
453
+ // pre-#111 implementation in observable behavior.
454
+ const existing = _readWorkspaceIdFromFs(file);
455
+ if (existing) return existing;
456
+ return _writeWorkspaceIdToFs(file, null);
457
+ }
458
+
305
459
  module.exports = {
306
460
  getRepoRoot,
307
461
  getEvolverInstallRoot,
@@ -319,4 +473,6 @@ module.exports = {
319
473
  getNarrativePath,
320
474
  getEvolutionPrinciplesPath,
321
475
  getReflectionLogPath,
476
+ getEvomapDir,
477
+ getEvomapPath,
322
478
  };