@memnexus-ai/mx-agent-cli 0.1.203 → 0.1.205

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.
@@ -64,8 +64,8 @@
64
64
  */
65
65
 
66
66
  import {
67
- chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
68
- rmSync, unlinkSync, writeFileSync,
67
+ chmodSync, cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
68
+ renameSync, rmSync, unlinkSync, writeFileSync,
69
69
  } from 'fs';
70
70
  import { homedir, tmpdir } from 'os';
71
71
  import { dirname, join, relative, resolve, sep } from 'path';
@@ -189,6 +189,88 @@ function pruneExtras(destDir, expected) {
189
189
  }
190
190
  }
191
191
 
192
+ // ── GENERATE-TO-STAGING + ATOMIC SWAP (Phase 4 prereq; Red Team finding 1) ────
193
+ //
194
+ // Generation writes the WHOLE tree into a staging dir (a sibling of destRoot so
195
+ // the final rename stays on one filesystem), then atomically swaps it into place.
196
+ // A crash mid-generation can only ever leave a `.claude.staging-*` sibling behind
197
+ // — never a half-written `.claude/`. On ANY staging-phase failure the caller
198
+ // removes the staging dir and leaves the existing `.claude/` byte-untouched.
199
+
200
+ /**
201
+ * Copy every existing-dest entry the current generation does NOT manage into the
202
+ * staging tree, so the swap loses nothing that lives under `.claude/` but is not
203
+ * generator-owned — chiefly `.claude/settings.local.json` (user-local, never
204
+ * generated), plus scheduled-task locks and, in the skipPermissionLayer degrade
205
+ * mode, the existing settings.json + hooks/ backstop (which staging does not
206
+ * contain in that mode). Anything the generator DID write into staging keeps its
207
+ * freshly generated version (managed wins) — which also preserves the historical
208
+ * prune behavior (a managed file removed upstream does not linger).
209
+ *
210
+ * FAILS CLOSED if the existing dest apex is a symlink, or if any entry nested in
211
+ * the existing dest is a symlink — carry-over copies plain files only, and the
212
+ * swap must never move a smuggled link into place. The real committed `.claude/`
213
+ * is symlink-free, so this is a no-op in the field.
214
+ */
215
+ function carryOverUnmanaged(destRoot, staging) {
216
+ let st;
217
+ try { st = lstatSync(destRoot); } catch { return; } // no existing dest — nothing to carry
218
+ if (st.isSymbolicLink()) {
219
+ throw new Error(
220
+ `generate-claude: FAIL-CLOSED — existing ${destRoot} is a symlink; ` +
221
+ `refusing to carry over or swap through it.`,
222
+ );
223
+ }
224
+ assertNoSymlinksInTree(destRoot, '.claude');
225
+ for (const ent of listDir(destRoot)) {
226
+ if (existsSync(join(staging, ent.name))) continue; // generator-managed — its version wins
227
+ cpSync(join(destRoot, ent.name), join(staging, ent.name), { recursive: true });
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Atomically move `staging` into place at `destRoot`:
233
+ * 1. rename existing `destRoot` → `destRoot.prev-<ts>` (if it exists)
234
+ * 2. rename `staging` → `destRoot`
235
+ * 3. remove the prev dir on success
236
+ * If step 2 fails after step 1, restore the prev dir back to `destRoot`
237
+ * (best-effort, loud). The caller removes `staging` on any throw.
238
+ */
239
+ function swapStagingIntoPlace(destRoot, staging) {
240
+ let destExists = true;
241
+ try { lstatSync(destRoot); } catch { destExists = false; }
242
+ const prev = `${destRoot}.prev-${Date.now()}-${process.pid}`;
243
+ if (destExists) renameSync(destRoot, prev);
244
+ try {
245
+ // Test-only crash injection (Change 3): simulate a failure AFTER the existing
246
+ // dest was moved to prev but BEFORE staging is swapped in, to prove the
247
+ // prev→destRoot restore path leaves the existing `.claude/` byte-identical
248
+ // with no staging/prev debris. Never set in production.
249
+ if (process.env.MX_GENCLAUDE_TEST_FAIL_MID_SWAP === '1') {
250
+ throw new Error('generate-claude: injected mid-swap failure (MX_GENCLAUDE_TEST_FAIL_MID_SWAP)');
251
+ }
252
+ renameSync(staging, destRoot);
253
+ } catch (err) {
254
+ if (destExists) {
255
+ try {
256
+ renameSync(prev, destRoot);
257
+ process.stderr.write(
258
+ `generate-claude: WARN — staging swap failed; restored the previous ` +
259
+ `.claude/ from ${prev}. No changes were applied.\n`,
260
+ );
261
+ } catch (restoreErr) {
262
+ const d = restoreErr instanceof Error ? restoreErr.message : String(restoreErr);
263
+ process.stderr.write(
264
+ `generate-claude: CRITICAL — swap failed AND could not restore the previous ` +
265
+ `.claude/ from ${prev} (${d}). Recover manually: mv ${prev} ${destRoot}\n`,
266
+ );
267
+ }
268
+ }
269
+ throw err;
270
+ }
271
+ if (destExists) rmSync(prev, { recursive: true, force: true });
272
+ }
273
+
192
274
  // ── last-known-good permission-layer cache (Red Team CRITICAL B) ─────────────
193
275
  //
194
276
  // The boot degrade path must NOT trust the working tree. If the origin fetch of
@@ -398,109 +480,136 @@ export function generateClaudeConfig(opts) {
398
480
  };
399
481
  scanSources();
400
482
 
401
- // CRITICAL (Red Team round 3): guard the dest APEX before creating it. The five
402
- // managed subdirs each get guardManagedDir, but destRoot itself was unguarded
403
- // if `.claude` is pre-planted as a symlink, mkdirSync follows it and every
404
- // subsequent write escapes the repo. lstat + FAIL-CLOSED on a symlinked apex.
405
- guardManagedDir(destRoot);
406
- mkdirSync(destRoot, { recursive: true });
407
-
408
- // MINOR (scan-to-copy race hardening): re-scan the source immediately before
409
- // the write phase. If a link was planted between the pre-scan and now (TOCTOU),
410
- // abort FAIL-CLOSED instead of silently skipping the mutated entry.
411
- scanSources();
483
+ // Build the FULL tree into a STAGING dir that is a sibling of destRoot (so the
484
+ // final rename stays on one filesystem), then atomically swap it into place. A
485
+ // crash mid-generation can only ever leave a `.claude.staging-*` sibling behind,
486
+ // never a half-written `.claude/`. All the fail-closed guards below still apply,
487
+ // now against staging (fresh mkdtemp — so a smuggled dest-side symlink cannot
488
+ // reach the write phase); the existing dest is guarded separately at swap time.
489
+ const parent = dirname(destRoot);
490
+ mkdirSync(parent, { recursive: true });
491
+ const staging = mkdtempSync(join(parent, '.claude.staging-'));
412
492
 
413
- // 1. settings.json — verbatim, 0644. From the permission source (origin/<base>
414
- // at boot; baseDir otherwise). Skipped in degraded fallback.
415
- if (!skipPermissionLayer) {
416
- writeManagedFile(join(destRoot, 'settings.json'), readFileSync(join(permissionSrcDir, 'settings.json')), 0o644);
417
- }
493
+ try {
494
+ // MINOR (scan-to-copy race hardening): re-scan the source immediately before
495
+ // the write phase. If a link was planted between the pre-scan and now (TOCTOU),
496
+ // abort FAIL-CLOSED instead of silently skipping the mutated entry.
497
+ scanSources();
498
+
499
+ // 1. settings.json — verbatim, 0644. From the permission source (origin/<base>
500
+ // at boot; baseDir otherwise). Skipped in degraded fallback.
501
+ if (!skipPermissionLayer) {
502
+ writeManagedFile(join(staging, 'settings.json'), readFileSync(join(permissionSrcDir, 'settings.json')), 0o644);
503
+ }
418
504
 
419
- // 2. hooks/ — every file verbatim; *.sh → 0755 (incl. *.test.sh), else 0644.
420
- // Permission layer: from permissionSrcDir. Skipped in degraded fallback.
421
- let hookCount = 0;
422
- if (!skipPermissionLayer) {
423
- const hooksDest = guardManagedDir(join(destRoot, 'hooks'));
424
- mkdirSync(hooksDest, { recursive: true });
425
- const hookNames = new Set();
426
- for (const ent of listDir(join(permissionSrcDir, 'hooks'))) {
427
- if (!ent.isFile()) continue;
428
- const mode = ent.name.endsWith('.sh') ? 0o755 : 0o644;
429
- writeManagedFile(join(hooksDest, ent.name), readFileSync(join(permissionSrcDir, 'hooks', ent.name)), mode);
430
- hookNames.add(ent.name);
505
+ // 2. hooks/ — every file verbatim; *.sh → 0755 (incl. *.test.sh), else 0644.
506
+ // Permission layer: from permissionSrcDir. Skipped in degraded fallback.
507
+ let hookCount = 0;
508
+ if (!skipPermissionLayer) {
509
+ const hooksDest = guardManagedDir(join(staging, 'hooks'));
510
+ mkdirSync(hooksDest, { recursive: true });
511
+ const hookNames = new Set();
512
+ for (const ent of listDir(join(permissionSrcDir, 'hooks'))) {
513
+ if (!ent.isFile()) continue;
514
+ const mode = ent.name.endsWith('.sh') ? 0o755 : 0o644;
515
+ writeManagedFile(join(hooksDest, ent.name), readFileSync(join(permissionSrcDir, 'hooks', ent.name)), mode);
516
+ hookNames.add(ent.name);
517
+ }
518
+ pruneExtras(hooksDest, hookNames);
519
+ hookCount = hookNames.size;
431
520
  }
432
- pruneExtras(hooksDest, hookNames);
433
- hookCount = hookNames.size;
434
- }
435
521
 
436
- // 3. rules/ — verbatim, 0644.
437
- const rulesDest = guardManagedDir(join(destRoot, 'rules'));
438
- mkdirSync(rulesDest, { recursive: true });
439
- const ruleNames = new Set();
440
- for (const ent of listDir(join(baseDir, 'rules'))) {
441
- if (!ent.isFile()) continue;
442
- writeManagedFile(join(rulesDest, ent.name), readFileSync(join(baseDir, 'rules', ent.name)), 0o644);
443
- ruleNames.add(ent.name);
444
- }
445
- pruneExtras(rulesDest, ruleNames);
446
-
447
- // 4. agents/ — union of base + overlay, 0644. Overlay overlays base on a
448
- // name collision (last write wins) — by design disjoint today.
449
- const agentsDest = guardManagedDir(join(destRoot, 'agents'));
450
- mkdirSync(agentsDest, { recursive: true });
451
- const agentNames = new Set();
452
- for (const src of [join(baseDir, 'agents'), join(overlayDir, 'agents')]) {
453
- for (const ent of listDir(src)) {
522
+ // 3. rules/ — verbatim, 0644.
523
+ const rulesDest = guardManagedDir(join(staging, 'rules'));
524
+ mkdirSync(rulesDest, { recursive: true });
525
+ const ruleNames = new Set();
526
+ for (const ent of listDir(join(baseDir, 'rules'))) {
454
527
  if (!ent.isFile()) continue;
455
- writeManagedFile(join(agentsDest, ent.name), readFileSync(join(src, ent.name)), 0o644);
456
- agentNames.add(ent.name);
528
+ writeManagedFile(join(rulesDest, ent.name), readFileSync(join(baseDir, 'rules', ent.name)), 0o644);
529
+ ruleNames.add(ent.name);
457
530
  }
458
- }
459
- pruneExtras(agentsDest, agentNames);
460
-
461
- // 5. skills/ union of base + overlay skill DIRECTORIES, recursive, 0644.
462
- const skillsDest = guardManagedDir(join(destRoot, 'skills'));
463
- mkdirSync(skillsDest, { recursive: true });
464
- const skillNames = new Set();
465
- for (const src of [join(baseDir, 'skills'), join(overlayDir, 'skills')]) {
466
- for (const ent of listDir(src)) {
467
- if (!ent.isDirectory()) continue;
468
- const d = join(skillsDest, ent.name);
469
- guardManagedDir(d); // CRITICAL A: guard per-skill dir before mkdir
470
- mkdirSync(d, { recursive: true });
471
- copyFileTree(join(src, ent.name), d);
472
- skillNames.add(ent.name);
531
+ pruneExtras(rulesDest, ruleNames);
532
+
533
+ // 4. agents/ — union of base + overlay, 0644. Overlay overlays base on a
534
+ // name collision (last write wins) by design disjoint today.
535
+ const agentsDest = guardManagedDir(join(staging, 'agents'));
536
+ mkdirSync(agentsDest, { recursive: true });
537
+ const agentNames = new Set();
538
+ for (const src of [join(baseDir, 'agents'), join(overlayDir, 'agents')]) {
539
+ for (const ent of listDir(src)) {
540
+ if (!ent.isFile()) continue;
541
+ writeManagedFile(join(agentsDest, ent.name), readFileSync(join(src, ent.name)), 0o644);
542
+ agentNames.add(ent.name);
543
+ }
473
544
  }
474
- }
475
- pruneExtras(skillsDest, skillNames);
476
-
477
- // 6. commands/ — from overlay/commands only. All regular files now (the former
478
- // mx-find/mx-rules/mx-save SYMLINKS were replaced by plain files), 0644.
479
- const commandsSrc = join(overlayDir, 'commands');
480
- const commandsDest = guardManagedDir(join(destRoot, 'commands'));
481
- mkdirSync(commandsDest, { recursive: true });
482
- const commandNames = new Set();
483
- for (const ent of listDir(commandsSrc)) {
484
- const s = join(commandsSrc, ent.name);
485
- const d = join(commandsDest, ent.name);
486
- if (ent.isSymbolicLink()) {
487
- // Should be unreachable after the pre-scan; kept as defense-in-depth.
488
- throw new Error(`generate-claude: FAIL-CLOSED — unexpected symlink in ${commandsSrc}: ${ent.name}`);
489
- } else if (ent.isFile()) {
490
- writeManagedFile(d, readFileSync(s), 0o644);
491
- } else if (ent.isDirectory()) {
492
- guardManagedDir(d); // CRITICAL A: guard per-command dir before mkdir
493
- mkdirSync(d, { recursive: true });
494
- copyFileTree(s, d);
545
+ pruneExtras(agentsDest, agentNames);
546
+
547
+ // 5. skills/ — union of base + overlay skill DIRECTORIES, recursive, 0644.
548
+ const skillsDest = guardManagedDir(join(staging, 'skills'));
549
+ mkdirSync(skillsDest, { recursive: true });
550
+ const skillNames = new Set();
551
+ for (const src of [join(baseDir, 'skills'), join(overlayDir, 'skills')]) {
552
+ for (const ent of listDir(src)) {
553
+ if (!ent.isDirectory()) continue;
554
+ const d = join(skillsDest, ent.name);
555
+ guardManagedDir(d); // CRITICAL A: guard per-skill dir before mkdir
556
+ mkdirSync(d, { recursive: true });
557
+ copyFileTree(join(src, ent.name), d);
558
+ skillNames.add(ent.name);
559
+ }
495
560
  }
496
- commandNames.add(ent.name);
497
- }
498
- pruneExtras(commandsDest, commandNames);
561
+ pruneExtras(skillsDest, skillNames);
562
+
563
+ // 6. commands/ — from overlay/commands only. All regular files now (the former
564
+ // mx-find/mx-rules/mx-save SYMLINKS were replaced by plain files), 0644.
565
+ const commandsSrc = join(overlayDir, 'commands');
566
+ const commandsDest = guardManagedDir(join(staging, 'commands'));
567
+ mkdirSync(commandsDest, { recursive: true });
568
+ const commandNames = new Set();
569
+ for (const ent of listDir(commandsSrc)) {
570
+ const s = join(commandsSrc, ent.name);
571
+ const d = join(commandsDest, ent.name);
572
+ if (ent.isSymbolicLink()) {
573
+ // Should be unreachable after the pre-scan; kept as defense-in-depth.
574
+ throw new Error(`generate-claude: FAIL-CLOSED — unexpected symlink in ${commandsSrc}: ${ent.name}`);
575
+ } else if (ent.isFile()) {
576
+ writeManagedFile(d, readFileSync(s), 0o644);
577
+ } else if (ent.isDirectory()) {
578
+ guardManagedDir(d); // CRITICAL A: guard per-command dir before mkdir
579
+ mkdirSync(d, { recursive: true });
580
+ copyFileTree(s, d);
581
+ }
582
+ commandNames.add(ent.name);
583
+ }
584
+ pruneExtras(commandsDest, commandNames);
499
585
 
500
- return { destRoot, counts: {
501
- hooks: hookCount, rules: ruleNames.size, agents: agentNames.size,
502
- skills: skillNames.size, commands: commandNames.size,
503
- } };
586
+ // Defense-in-depth: the generated staging tree must contain zero symlinks
587
+ // (generation only emits plain files). Fail closed BEFORE the swap if not.
588
+ assertNoSymlinksInTree(staging, '.claude.staging');
589
+
590
+ // Test-only crash injection (Change 3): simulate a failure between a fully
591
+ // generated staging tree and the swap, to prove the existing `.claude/` is
592
+ // left byte-untouched and no staging debris survives. Never set in production.
593
+ if (process.env.MX_GENCLAUDE_TEST_FAIL_BEFORE_SWAP === '1') {
594
+ throw new Error('generate-claude: injected pre-swap failure (MX_GENCLAUDE_TEST_FAIL_BEFORE_SWAP)');
595
+ }
596
+
597
+ // Carry over settings.local.json + every other unmanaged existing entry
598
+ // (guards the existing dest against symlinks — FAILS CLOSED on a pre-planted
599
+ // apex/subdir symlink), then atomically swap staging into place.
600
+ carryOverUnmanaged(destRoot, staging);
601
+ swapStagingIntoPlace(destRoot, staging);
602
+
603
+ return { destRoot, counts: {
604
+ hooks: hookCount, rules: ruleNames.size, agents: agentNames.size,
605
+ skills: skillNames.size, commands: commandNames.size,
606
+ } };
607
+ } catch (err) {
608
+ // Staging-phase failure: remove the staging dir and leave the existing
609
+ // `.claude/` completely untouched.
610
+ rmSync(staging, { recursive: true, force: true });
611
+ throw err;
612
+ }
504
613
  }
505
614
 
506
615
  // ── --from-git: source agent-config from a git ref (tamper-resistant) ────────