@evomap/evolver 1.85.3 → 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 (43) hide show
  1. package/package.json +4 -1
  2. package/scripts/check-changelog.js +166 -0
  3. package/src/evolve/guards.js +1 -1
  4. package/src/evolve/pipeline/collect.js +1 -1
  5. package/src/evolve/pipeline/dispatch.js +1 -1
  6. package/src/evolve/pipeline/enrich.js +1 -1
  7. package/src/evolve/pipeline/hub.js +1 -1
  8. package/src/evolve/pipeline/select.js +1 -1
  9. package/src/evolve/pipeline/signals.js +1 -1
  10. package/src/evolve/utils.js +1 -1
  11. package/src/evolve.js +1 -1
  12. package/src/gep/a2aProtocol.js +1 -1
  13. package/src/gep/candidateEval.js +1 -1
  14. package/src/gep/candidates.js +1 -1
  15. package/src/gep/contentHash.js +1 -1
  16. package/src/gep/crypto.js +1 -1
  17. package/src/gep/curriculum.js +1 -1
  18. package/src/gep/deviceId.js +1 -1
  19. package/src/gep/envFingerprint.js +1 -1
  20. package/src/gep/epigenetics.js +1 -1
  21. package/src/gep/explore.js +1 -1
  22. package/src/gep/hash.js +1 -1
  23. package/src/gep/hubFetch.js +1 -1
  24. package/src/gep/hubReview.js +1 -1
  25. package/src/gep/hubSearch.js +1 -1
  26. package/src/gep/hubVerify.js +1 -1
  27. package/src/gep/learningSignals.js +1 -1
  28. package/src/gep/memoryGraph.js +1 -1
  29. package/src/gep/memoryGraphAdapter.js +1 -1
  30. package/src/gep/mutation.js +1 -1
  31. package/src/gep/narrativeMemory.js +1 -1
  32. package/src/gep/openPRRegistry.js +1 -1
  33. package/src/gep/paths.js +124 -31
  34. package/src/gep/personality.js +1 -1
  35. package/src/gep/policyCheck.js +1 -1
  36. package/src/gep/prompt.js +1 -1
  37. package/src/gep/recallVerifier.js +1 -1
  38. package/src/gep/reflection.js +1 -1
  39. package/src/gep/selector.js +1 -1
  40. package/src/gep/skillDistiller.js +1 -1
  41. package/src/gep/solidify.js +1 -1
  42. package/src/gep/strategy.js +1 -1
  43. package/src/gep/workspaceKeychain.js +1 -0
package/src/gep/paths.js CHANGED
@@ -299,32 +299,51 @@ function getEvomapPath(...segments) {
299
299
  // claim a different workspace. workspace-id replaces that self-report
300
300
  // with a secret that only the legitimate workspace's evolver knows
301
301
  // (Bugbot PR #108 round-3 Agentic Security Review MEDIUM).
302
- function getWorkspaceId() {
303
- if (process.env.EVOLVER_WORKSPACE_ID) return String(process.env.EVOLVER_WORKSPACE_ID);
304
- const dir = path.join(getWorkspaceRoot(), '.evolver');
305
- const file = path.join(dir, 'workspace-id');
306
-
307
- // Refuse to follow symlinks at either the directory or file level.
308
- // A malicious repo can pre-place `.evolver` or `.evolver/workspace-id`
309
- // as a symlink to an attacker-chosen path outside the workspace, and
310
- // mkdirSync({recursive:true}) / writeFileSync would silently follow
311
- // it — clobbering the linked file with the secret payload (Bugbot PR
312
- // #109 round-2 HIGH, Agentic Security Review).
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.
310
+
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);
313
316
  try {
314
317
  const dirStat = fs.lstatSync(dir, { throwIfNoEntry: false });
315
318
  if (dirStat && dirStat.isSymbolicLink()) return null;
316
319
  const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
317
- if (fileStat) {
318
- if (fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
319
- try {
320
- const raw = fs.readFileSync(file, 'utf8').trim();
321
- if (raw && /^[a-f0-9]{32,}$/i.test(raw)) return raw;
322
- } catch { /* unreadable — fall through to recreate */ }
323
- }
324
- } 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);
325
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;
326
345
  fs.mkdirSync(dir, { recursive: true });
327
- const id = require('crypto').randomBytes(16).toString('hex');
346
+ const payload = id || require('crypto').randomBytes(16).toString('hex');
328
347
  // Atomic create-and-fail-if-exists so we never overwrite an
329
348
  // attacker-pre-placed file (TOCTOU between lstat and writeFileSync
330
349
  // could otherwise race a symlink in). O_NOFOLLOW also refuses to
@@ -337,32 +356,106 @@ function getWorkspaceId() {
337
356
  try {
338
357
  fd = fs.openSync(file, flags, 0o600);
339
358
  } catch (e) {
340
- // EEXIST means another process beat us to it — re-read with the
341
- // same symlink guards as above.
342
359
  if (e && e.code === 'EEXIST') {
343
- const fileStat = fs.lstatSync(file, { throwIfNoEntry: false });
344
- if (!fileStat || fileStat.isSymbolicLink() || !fileStat.isFile()) return null;
345
- try {
346
- const raw = fs.readFileSync(file, 'utf8').trim();
347
- if (raw && /^[a-f0-9]{32,}$/i.test(raw)) return raw;
348
- } catch { /* unreadable */ }
349
- return null;
360
+ // Another process beat us re-read with the same symlink guards.
361
+ return _readWorkspaceIdFromFs(file);
350
362
  }
351
363
  // ELOOP / EMLINK from O_NOFOLLOW hitting a symlink — refuse.
352
364
  return null;
353
365
  }
354
366
  try {
355
- fs.writeSync(fd, id + '\n', 0, 'utf8');
367
+ fs.writeSync(fd, payload + '\n', 0, 'utf8');
356
368
  } finally {
357
369
  fs.closeSync(fd);
358
370
  }
359
371
  try { fs.chmodSync(file, 0o600); } catch { /* best-effort */ }
360
- return id;
372
+ return payload;
361
373
  } catch {
362
374
  return null;
363
375
  }
364
376
  }
365
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
+
366
459
  module.exports = {
367
460
  getRepoRoot,
368
461
  getEvolverInstallRoot,