@memnexus-ai/mx-agent-cli 0.1.198 → 0.1.200

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.
@@ -0,0 +1,676 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * generate-claude.mjs — materialize the runtime `.claude/` tree from the single
4
+ * committed source (a BASE config tree + an OVERLAY tree).
5
+ *
6
+ * Direction B, config single-source-of-truth (#4304), Phase 2. This is the
7
+ * GENERATOR: it takes the one hand-edited config tree and emits the tree the
8
+ * Claude Code harness actually reads. Phase 2 ships it while `.claude/` is STILL
9
+ * committed, so the generated output must reproduce the committed `.claude/`
10
+ * BYTE-FOR-BYTE — running the generator against the repo leaves
11
+ * `git diff -- .claude/` empty. That empty diff is the proof Phase 2 is a safe
12
+ * no-op. Phases 3-5 (gate flip, gitignore+untrack, self-heal removal) build on
13
+ * this once the no-op is proven in the fleet.
14
+ *
15
+ * Reproduction manifest (source → .claude/):
16
+ * settings.json ← base/settings.json (verbatim)
17
+ * hooks/ ← base/hooks/* (verbatim content;
18
+ * *.sh → 0755, else 0644)
19
+ * rules/ ← base/rules/* (verbatim)
20
+ * agents/ ← base/agents/* ∪ overlay/agents/* (union)
21
+ * skills/ ← base/skills/* ∪ overlay/skills/* (union, recursive)
22
+ * commands/ ← overlay/commands/* (files verbatim, 0644)
23
+ *
24
+ * The hooks chmod rule (every `*.sh` → 0755 regardless of the source blob's
25
+ * mode) reproduces the historical behavior of `syncClaudeConfig`, which is why
26
+ * the committed `.claude/hooks/*.sh` are all 0755 even though a couple of source
27
+ * blobs are 0644.
28
+ *
29
+ * SECURITY — NO SYMLINKS, ANYWHERE (PO directive 2026-07-20):
30
+ * The generator emits ONLY plain regular files and real directories. It NEVER
31
+ * emits a symlink. It FAILS CLOSED — a pre-scan walks the entire source tree
32
+ * (base + overlay, every subdir) BEFORE anything is written; if ANY entry is a
33
+ * symlink the generator throws and exits non-zero with nothing materialized
34
+ * (destRoot is never created). This structurally closes the former
35
+ * multi-hop-symlink-chain bypass: there is no link to chase because a link in
36
+ * the source is a hard error, and the output can hold no link at all.
37
+ *
38
+ * TWO-LAYER FAILURE MODEL (also documented at each layer):
39
+ * • The GENERATOR fails CLOSED on write — a symlink in the source, or a
40
+ * managed dest subdir that is itself a symlink, aborts with no partial tree.
41
+ * A broken/tampered config must never materialize a `.claude/`.
42
+ * • `syncClaudeConfig` (its caller) fails OPEN on availability — if the
43
+ * generator cannot run (offline, git archive fails), it leaves the existing
44
+ * committed `.claude/` in place rather than blocking the session. The
45
+ * committed `.claude/` is the Phase 2 backstop; that backstop disappears at
46
+ * Phase 4 (gitignore + untrack), after the no-op is proven in the fleet.
47
+ *
48
+ * Pure Node built-ins so it runs directly from a repo checkout with no build
49
+ * step (post-create.sh / post-start.sh call it via `node`). `--from-git <ref>`
50
+ * sources agent-config from `git archive <ref>` into a temp dir instead of the
51
+ * working tree, preserving the tamper-resistant origin/<base> semantics that
52
+ * `syncClaudeConfig` relies on. NOTE: `mx-agent start` (→ syncClaudeConfig)
53
+ * sources from origin/<base>, so local UNCOMMITTED agent-config edits are not
54
+ * previewed by a normal session start — run the generator directly against the
55
+ * working tree (no `--from-git`) to preview them.
56
+ *
57
+ * GENERALIZATION (planned, not built here): the PO intends a future standard
58
+ * per-project overlay dir (likely `.mx-agent/` at the repo root) so the CLI
59
+ * behaves identically across memnexus / spearmint / api-evaluator. This is why
60
+ * the generator takes BASE and OVERLAY as explicit parameters rather than
61
+ * hardcoding `agent-config/local` — a future caller just points OVERLAY at
62
+ * `.mx-agent/`. No multi-project behavior is implemented today; the call sites
63
+ * default OVERLAY to `<base>/local`.
64
+ */
65
+
66
+ import {
67
+ chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync,
68
+ rmSync, unlinkSync, writeFileSync,
69
+ } from 'fs';
70
+ import { homedir, tmpdir } from 'os';
71
+ import { dirname, join, relative, resolve, sep } from 'path';
72
+ import { pathToFileURL } from 'url';
73
+ import { spawnSync } from 'child_process';
74
+ import { createHash } from 'crypto';
75
+
76
+ /** Managed subdirectories of `.claude/` that the generator owns and prunes. */
77
+ export const MANAGED_SUBDIRS = ['hooks', 'agents', 'skills', 'commands', 'rules'];
78
+
79
+ /** Repo-relative path that the source `agent-config` tree lives at by default. */
80
+ export const DEFAULT_CONFIG_SOURCE = 'mx-agent-system/agent-config';
81
+
82
+ // ── small fs helpers ──────────────────────────────────────────────────────
83
+
84
+ function listDir(dir) {
85
+ try {
86
+ return readdirSync(dir, { withFileTypes: true });
87
+ } catch {
88
+ return [];
89
+ }
90
+ }
91
+
92
+ function removeIfPresent(p) {
93
+ try { lstatSync(p); } catch { return; }
94
+ unlinkSync(p);
95
+ }
96
+
97
+ function writeManagedFile(destPath, content, mode) {
98
+ mkdirSync(dirname(destPath), { recursive: true });
99
+ // Replace any pre-existing entry (file OR symlink) so a stale symlink/file
100
+ // never lingers with the wrong type. Then chmod to the exact mode — the git
101
+ // no-op depends on it (e.g. *.sh must be 0755).
102
+ removeIfPresent(destPath);
103
+ writeFileSync(destPath, content);
104
+ chmodSync(destPath, mode);
105
+ }
106
+
107
+ // ── FAIL-CLOSED symlink ban (PO directive 2026-07-20) ───────────────────────
108
+
109
+ /**
110
+ * Recursively assert that NO entry under `dir` is a symlink. Throws FAIL-CLOSED
111
+ * on the first symlink found (file OR directory link — a directory symlink is
112
+ * reported by withFileTypes as isSymbolicLink(), so we never follow it). Called
113
+ * over the whole source tree BEFORE any write, so a smuggled link aborts the run
114
+ * with nothing materialized.
115
+ *
116
+ * @param dir absolute path to scan
117
+ * @param rootRel human-readable root label for error messages (e.g. 'base')
118
+ */
119
+ function assertNoSymlinksInTree(dir, rootRel) {
120
+ for (const ent of listDir(dir)) {
121
+ const p = join(dir, ent.name);
122
+ if (ent.isSymbolicLink()) {
123
+ const rel = relative(dir, p);
124
+ throw new Error(
125
+ `generate-claude: FAIL-CLOSED — symlink found in source tree at ` +
126
+ `${rootRel}/${rel || ent.name}; the generator refuses to emit or follow ` +
127
+ `symlinks. Replace it with a plain file.`,
128
+ );
129
+ }
130
+ if (ent.isDirectory()) {
131
+ assertNoSymlinksInTree(p, `${rootRel}/${ent.name}`);
132
+ }
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Prune guard (Red Team MAJOR 4): before a managed dest subdir is created,
138
+ * read, or pruned, confirm it is not itself a symlink. If it exists and is a
139
+ * symlink, FAIL CLOSED — pruning/rewriting through a followed dir symlink could
140
+ * delete or overwrite files OUTSIDE `.claude/`. Returns the guarded path.
141
+ */
142
+ function guardManagedDir(destPath) {
143
+ let st;
144
+ try { st = lstatSync(destPath); } catch { return destPath; } // absent — nothing to guard
145
+ if (st.isSymbolicLink()) {
146
+ throw new Error(
147
+ `generate-claude: FAIL-CLOSED — managed destination ${destPath} is a symlink; ` +
148
+ `refusing to mkdir/readdir/prune through it.`,
149
+ );
150
+ }
151
+ return destPath;
152
+ }
153
+
154
+ /**
155
+ * Recursively copy a source directory of regular files into dest, forcing file
156
+ * mode 0644 (config markdown / SKILL.md — no executable bits) and preserving
157
+ * subdirectory structure. The source tree was already symlink-scanned by
158
+ * assertNoSymlinksInTree, so any symlink here is a defense-in-depth hard error.
159
+ */
160
+ function copyFileTree(srcDir, destDir) {
161
+ for (const ent of listDir(srcDir)) {
162
+ const s = join(srcDir, ent.name);
163
+ const d = join(destDir, ent.name);
164
+ if (ent.isSymbolicLink()) {
165
+ throw new Error(`generate-claude: FAIL-CLOSED — unexpected symlink in ${srcDir}: ${ent.name}`);
166
+ } else if (ent.isDirectory()) {
167
+ // CRITICAL A: guard this nested dest dir before mkdir — a pre-planted
168
+ // symlink here would be followed by mkdirSync/writeFileSync, letting a
169
+ // write escape .claude/. lstat + FAIL-CLOSED on an existing dir symlink.
170
+ guardManagedDir(d);
171
+ mkdirSync(d, { recursive: true });
172
+ copyFileTree(s, d);
173
+ } else if (ent.isFile()) {
174
+ writeManagedFile(d, readFileSync(s), 0o644);
175
+ }
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Remove any entry under destDir that is NOT in `expected` (a Set of top-level
181
+ * names). Keeps the generated tree byte-identical to the manifest across reruns
182
+ * (idempotence) and drops files removed from the source.
183
+ */
184
+ function pruneExtras(destDir, expected) {
185
+ for (const ent of listDir(destDir)) {
186
+ if (!expected.has(ent.name)) {
187
+ rmSync(join(destDir, ent.name), { recursive: true, force: true });
188
+ }
189
+ }
190
+ }
191
+
192
+ // ── last-known-good permission-layer cache (Red Team CRITICAL B) ─────────────
193
+ //
194
+ // The boot degrade path must NOT trust the working tree. If the origin fetch of
195
+ // the permission layer fails (offline, or an attacker renames the remote / blocks
196
+ // DNS after committing a weakened settings/hooks to the local checkout), the old
197
+ // behavior kept the LOCAL committed permission layer — handing the attacker their
198
+ // layer on every boot. Instead we cache the last-known-good permission-layer bytes
199
+ // (captured on the most recent successful origin-pinned generation) under
200
+ // ~/.mx-agent/ (same dir syncClaudeConfig uses for hook caching) and degrade to
201
+ // THAT cache, never the working tree.
202
+ //
203
+ // Cache layout: ~/.mx-agent/permission-cache/{settings.json, hooks/*, hash.txt}.
204
+ // hash.txt records a sha256 over the layer; restore recomputes and compares it
205
+ // before trusting the bytes. This is an INTEGRITY check (detects corruption or
206
+ // tamper-in-transit / accidental partial writes), NOT an authenticity check: the
207
+ // hash proves the cache is self-consistent, it does NOT prove the cache came from
208
+ // origin. An actor with $HOME write access can fabricate both the cache and a
209
+ // matching hash — exactly as they could poison the committed checkout — so this
210
+ // cache is an improvement only against checkout-tampering with $HOME untouched. It
211
+ // is NOT a security boundary, consistent with the git-mutation-guard's documented
212
+ // posture (a guardrail against accidental/naive tampering, bypassable by design).
213
+ // The cache is best-effort: a cache write failure never breaks generation.
214
+
215
+ function permissionCacheDir() {
216
+ return join(homedir(), '.mx-agent', 'permission-cache');
217
+ }
218
+
219
+ /** Stable sha256 over a permission layer (settings.json bytes + sorted hooks). */
220
+ function hashPermissionLayer(settingsBytes, hookEntries) {
221
+ const h = createHash('sha256');
222
+ h.update('settings\0');
223
+ h.update(settingsBytes);
224
+ const sorted = [...hookEntries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
225
+ for (const { name, bytes } of sorted) {
226
+ h.update('\0hook\0');
227
+ h.update(name);
228
+ h.update('\0');
229
+ h.update(bytes);
230
+ }
231
+ return h.digest('hex');
232
+ }
233
+
234
+ /**
235
+ * Read the permission layer (settings.json + hooks/) from a TRUSTED source dir
236
+ * (an origin-pinned snapshot). Returns null if there is no settings.json.
237
+ */
238
+ function readPermissionLayer(srcDir) {
239
+ const settingsPath = join(srcDir, 'settings.json');
240
+ if (!existsSync(settingsPath)) return null;
241
+ const settingsBytes = readFileSync(settingsPath);
242
+ const hookEntries = [];
243
+ for (const ent of listDir(join(srcDir, 'hooks'))) {
244
+ if (!ent.isFile()) continue;
245
+ hookEntries.push({
246
+ name: ent.name,
247
+ bytes: readFileSync(join(srcDir, 'hooks', ent.name)),
248
+ mode: ent.name.endsWith('.sh') ? 0o755 : 0o644,
249
+ });
250
+ }
251
+ return { settingsBytes, hookEntries };
252
+ }
253
+
254
+ /**
255
+ * Refresh the last-known-good permission cache from a TRUSTED source dir. Called
256
+ * on every successful origin-pinned generation (boot split AND per-session
257
+ * --from-git). Best-effort — swallows errors so caching never fails a generation.
258
+ *
259
+ * Least-privilege (S2): the cache dir is 0700 and every non-executable cache file
260
+ * (settings.json, hash.txt) is 0600, so a co-tenant without $HOME write access
261
+ * cannot read or replace it. `.sh` hooks keep 0755 (the generator's normal mode).
262
+ * This does not change behavior for legitimate flows; it narrows who can poison
263
+ * the cache, but it is not a security boundary (see the section header).
264
+ */
265
+ export function refreshPermissionCache(srcDir) {
266
+ try {
267
+ const layer = readPermissionLayer(srcDir);
268
+ if (!layer) return;
269
+ const cacheDir = permissionCacheDir();
270
+ const hooksDir = join(cacheDir, 'hooks');
271
+ // Clean rebuild so a hook removed upstream does not linger in the cache.
272
+ rmSync(cacheDir, { recursive: true, force: true });
273
+ mkdirSync(hooksDir, { recursive: true, mode: 0o700 });
274
+ chmodSync(cacheDir, 0o700);
275
+ writeFileSync(join(cacheDir, 'settings.json'), layer.settingsBytes);
276
+ chmodSync(join(cacheDir, 'settings.json'), 0o600);
277
+ for (const { name, bytes, mode } of layer.hookEntries) {
278
+ writeFileSync(join(hooksDir, name), bytes);
279
+ chmodSync(join(hooksDir, name), mode);
280
+ }
281
+ writeFileSync(
282
+ join(cacheDir, 'hash.txt'),
283
+ hashPermissionLayer(layer.settingsBytes, layer.hookEntries) + '\n',
284
+ );
285
+ chmodSync(join(cacheDir, 'hash.txt'), 0o600);
286
+ } catch { /* cache is best-effort; never break generation */ }
287
+ }
288
+
289
+ /**
290
+ * Restore the last-known-good permission layer from cache into destRoot
291
+ * (settings.json + hooks/). Recomputes and compares the recorded sha256 (an
292
+ * INTEGRITY gate, not an origin-authenticity gate) before trusting the bytes.
293
+ * Returns the layer hash on success, or null if no usable/integrity-checked cache
294
+ * exists — in which case the caller falls back to the UNVERIFIED committed backstop.
295
+ */
296
+ export function restorePermissionCacheToDest(destRoot) {
297
+ try {
298
+ // Enforce the apex invariant locally (Red Team API-safety): this exported
299
+ // function's first write mkdirs under destRoot; a future direct caller must
300
+ // not be able to reopen the apex-symlink hole. FAIL-CLOSED → caught → null.
301
+ guardManagedDir(destRoot);
302
+ const cacheDir = permissionCacheDir();
303
+ const cachedSettings = join(cacheDir, 'settings.json');
304
+ if (!existsSync(cachedSettings)) return null;
305
+ const settingsBytes = readFileSync(cachedSettings);
306
+ const hookEntries = [];
307
+ for (const ent of listDir(join(cacheDir, 'hooks'))) {
308
+ if (!ent.isFile()) continue;
309
+ hookEntries.push({
310
+ name: ent.name,
311
+ bytes: readFileSync(join(cacheDir, 'hooks', ent.name)),
312
+ mode: ent.name.endsWith('.sh') ? 0o755 : 0o644,
313
+ });
314
+ }
315
+ // Integrity gate: reject a corrupt/tampered cache rather than write it.
316
+ // FAIL CLOSED on an ABSENT hash too (S1): a hash-stripped or partially-written
317
+ // cache is untrustworthy, so null recorded → return null → UNVERIFIED backstop,
318
+ // never restore unverified bytes.
319
+ const recorded = existsSync(join(cacheDir, 'hash.txt'))
320
+ ? readFileSync(join(cacheDir, 'hash.txt'), 'utf-8').trim()
321
+ : null;
322
+ const actual = hashPermissionLayer(settingsBytes, hookEntries);
323
+ if (!recorded || recorded !== actual) return null;
324
+ // Write the verified bytes into dest. Guard the hooks dir against a
325
+ // pre-planted symlink (CRITICAL A parity for the degrade path).
326
+ writeManagedFile(join(destRoot, 'settings.json'), settingsBytes, 0o644);
327
+ const hooksDest = guardManagedDir(join(destRoot, 'hooks'));
328
+ mkdirSync(hooksDest, { recursive: true });
329
+ const names = new Set();
330
+ for (const { name, bytes, mode } of hookEntries) {
331
+ writeManagedFile(join(hooksDest, name), bytes, mode);
332
+ names.add(name);
333
+ }
334
+ pruneExtras(hooksDest, names);
335
+ return actual;
336
+ } catch {
337
+ return null;
338
+ }
339
+ }
340
+
341
+ // ── the generator ───────────────────────────────────────────────────────────
342
+
343
+ /**
344
+ * Generate `.claude/` from a BASE config tree and an OVERLAY tree.
345
+ *
346
+ * @param {object} opts
347
+ * @param {string} opts.baseDir absolute path to the base config tree
348
+ * (settings.json, hooks/, rules/, agents/, skills/)
349
+ * @param {string} opts.overlayDir absolute path to the per-project overlay tree
350
+ * (agents/, skills/, commands/). Today this is
351
+ * `<baseDir>/local`; a future `.mx-agent/` overlay
352
+ * just points this elsewhere. May not exist.
353
+ * @param {string} opts.destRoot absolute path to the `.claude/` dir to write
354
+ * @param {string} [opts.permissionSrcDir] alternate source for the PERMISSION
355
+ * LAYER (settings.json + hooks/) ONLY. Used by
356
+ * the container-boot split (Red Team CRITICAL 1):
357
+ * the permission layer comes from a trusted
358
+ * origin/<base> snapshot while skills/commands/
359
+ * agents/rules come from `baseDir`/`overlayDir`
360
+ * (the working tree). Defaults to `baseDir`.
361
+ * @param {boolean} [opts.skipPermissionLayer] when true, DO NOT write
362
+ * settings.json or hooks/ at all — leave whatever
363
+ * is already on disk (the committed `.claude/`
364
+ * backstop). Degraded-mode fallback for boot when
365
+ * the trusted origin snapshot is unavailable
366
+ * (fresh clone / offline). Never used per-session.
367
+ */
368
+ export function generateClaudeConfig(opts) {
369
+ const baseDir = resolve(opts.baseDir);
370
+ const overlayDir = resolve(opts.overlayDir);
371
+ const destRoot = resolve(opts.destRoot);
372
+ const skipPermissionLayer = Boolean(opts.skipPermissionLayer);
373
+ const permissionSrcDir = opts.permissionSrcDir ? resolve(opts.permissionSrcDir) : baseDir;
374
+
375
+ if (!skipPermissionLayer) {
376
+ const settingsSrc = join(permissionSrcDir, 'settings.json');
377
+ if (!existsSync(settingsSrc)) {
378
+ throw new Error(`generate-claude: permission source has no settings.json at ${settingsSrc}`);
379
+ }
380
+ }
381
+
382
+ // ── FAIL-CLOSED PRE-SCAN ────────────────────────────────────────────────
383
+ // Walk the ENTIRE source tree (base + overlay + permission source) for symlinks
384
+ // BEFORE writing anything. On a hit we throw here, so destRoot is never created
385
+ // and nothing is materialized — the smuggled-symlink test asserts
386
+ // existsSync(dest)===false. (Scanning baseDir already covers overlayDir /
387
+ // permissionSrcDir when they live inside baseDir; scan them separately only when
388
+ // they live outside baseDir.)
389
+ const insideBase = (p) => p === baseDir || p.startsWith(baseDir + sep);
390
+ const scanSources = () => {
391
+ assertNoSymlinksInTree(baseDir, 'base');
392
+ if (!insideBase(overlayDir) && existsSync(overlayDir)) {
393
+ assertNoSymlinksInTree(overlayDir, 'overlay');
394
+ }
395
+ if (!skipPermissionLayer && !insideBase(permissionSrcDir) && existsSync(permissionSrcDir)) {
396
+ assertNoSymlinksInTree(permissionSrcDir, 'permission');
397
+ }
398
+ };
399
+ scanSources();
400
+
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();
412
+
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
+ }
418
+
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);
431
+ }
432
+ pruneExtras(hooksDest, hookNames);
433
+ hookCount = hookNames.size;
434
+ }
435
+
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)) {
454
+ if (!ent.isFile()) continue;
455
+ writeManagedFile(join(agentsDest, ent.name), readFileSync(join(src, ent.name)), 0o644);
456
+ agentNames.add(ent.name);
457
+ }
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);
473
+ }
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);
495
+ }
496
+ commandNames.add(ent.name);
497
+ }
498
+ pruneExtras(commandsDest, commandNames);
499
+
500
+ return { destRoot, counts: {
501
+ hooks: hookCount, rules: ruleNames.size, agents: agentNames.size,
502
+ skills: skillNames.size, commands: commandNames.size,
503
+ } };
504
+ }
505
+
506
+ // ── --from-git: source agent-config from a git ref (tamper-resistant) ────────
507
+
508
+ /**
509
+ * Stage `<ref>:<configSource>` into a fresh temp dir via `git archive`, invoke
510
+ * `fn(baseDir)` with the extracted config tree, and clean up. Throws if the
511
+ * archive/extract fails (offline, missing ref/path). `git archive` preserves any
512
+ * committed symlink, so the generator's pre-scan still fails closed on it.
513
+ */
514
+ function withArchivedConfig(ref, repoRoot, configSource, fn) {
515
+ const staging = mkdtempSync(join(tmpdir(), 'mx-genclaude-'));
516
+ try {
517
+ const archive = spawnSync('git', ['archive', '--format=tar', ref, '--', configSource], {
518
+ cwd: repoRoot, encoding: 'buffer', maxBuffer: 256 * 1024 * 1024,
519
+ });
520
+ if (archive.status !== 0 || !archive.stdout || archive.stdout.length === 0) {
521
+ const msg = archive.stderr ? archive.stderr.toString() : `git archive ${ref} failed`;
522
+ throw new Error(`generate-claude: could not archive ${ref}:${configSource} — ${msg.trim()}`);
523
+ }
524
+ const extract = spawnSync('tar', ['-x', '-C', staging], { input: archive.stdout });
525
+ if (extract.status !== 0) {
526
+ throw new Error(`generate-claude: could not extract archive of ${ref}:${configSource}`);
527
+ }
528
+ return fn(join(staging, configSource));
529
+ } finally {
530
+ rmSync(staging, { recursive: true, force: true });
531
+ }
532
+ }
533
+
534
+ /**
535
+ * Materialize the WHOLE `.claude/` tree from `<ref>:<configSource>` (all layers,
536
+ * including the permission layer, from the reviewed remote). This is the
537
+ * per-session path used by `syncClaudeConfig` (`mx-agent start`), where an agent
538
+ * could already be running and the strongest tamper-resistance is required.
539
+ *
540
+ * @returns the generator result
541
+ */
542
+ export function generateClaudeConfigFromGit(opts) {
543
+ const { ref, repoRoot, destRoot, configSource = DEFAULT_CONFIG_SOURCE } = opts;
544
+ return withArchivedConfig(ref, repoRoot, configSource, (baseDir) => {
545
+ const result = generateClaudeConfig({ baseDir, overlayDir: join(baseDir, 'local'), destRoot });
546
+ // The whole tree (incl. permission layer) came from the reviewed remote —
547
+ // refresh the last-known-good cache the boot degrade path falls back to.
548
+ refreshPermissionCache(baseDir);
549
+ return result;
550
+ });
551
+ }
552
+
553
+ /**
554
+ * CONTAINER-BOOT SPLIT (Red Team CRITICAL 1). Materialize `.claude/` with the
555
+ * PERMISSION LAYER (settings.json + hooks/) sourced from a trusted
556
+ * `<permissionRef>` snapshot (origin/<base>), and skills/commands/agents/rules
557
+ * sourced from the WORKING TREE (`<repoRoot>/<configSource>`). This lets a
558
+ * container boot refresh behavioral config from local edits while never letting a
559
+ * tampered working-tree permission layer take effect.
560
+ *
561
+ * Fresh-clone / offline fallback: if the `<permissionRef>` snapshot cannot be
562
+ * staged (no origin, offline, or an attacker blocking the fetch), DEGRADE —
563
+ * regenerate only the non-permission layers from the working tree and restore the
564
+ * permission layer from the last-known-good cache (~/.mx-agent/, integrity-checked,
565
+ * NOT origin-authenticated — Red Team CRITICAL B) rather than trusting the working
566
+ * tree. If no cache exists, leave the committed `.claude/` settings.json + hooks/ in
567
+ * place (the Phase 2 backstop) and warn that the permission layer is UNVERIFIED.
568
+ * Never writes working-tree settings/hooks in the degrade path. Warns; never
569
+ * hard-fails boot.
570
+ *
571
+ * Note on the deny entries in the restored/committed settings.json: they raise the
572
+ * floor against naive Write/Edit TOOL use of protected paths only — they are not a
573
+ * Bash-level control and do not constrain shell commands.
574
+ *
575
+ * @returns { result, permissionPinned } — permissionPinned=false ⇒ degraded.
576
+ */
577
+ export function generateClaudeConfigForBoot(opts) {
578
+ const { permissionRef, repoRoot, destRoot, configSource = DEFAULT_CONFIG_SOURCE, warn } = opts;
579
+ const baseDir = join(repoRoot, configSource);
580
+ const overlayDir = join(baseDir, 'local');
581
+ try {
582
+ return withArchivedConfig(permissionRef, repoRoot, configSource, (permBase) => {
583
+ const result = generateClaudeConfig({ baseDir, overlayDir, destRoot, permissionSrcDir: permBase });
584
+ // Permission layer was origin-pinned — refresh the degrade-path cache.
585
+ refreshPermissionCache(permBase);
586
+ return { result, permissionPinned: true };
587
+ });
588
+ } catch (err) {
589
+ // DEGRADE — the origin fetch failed. Do NOT trust the working tree. Regenerate
590
+ // only the non-permission layers, then restore the permission layer from the
591
+ // last-known-good cache (integrity-checked, not origin-authenticated). If no
592
+ // cache exists, leave the committed backstop in place but warn UNVERIFIED.
593
+ const result = generateClaudeConfig({ baseDir, overlayDir, destRoot, skipPermissionLayer: true });
594
+ const detail = err instanceof Error ? err.message : String(err);
595
+ const restoredHash = restorePermissionCacheToDest(destRoot);
596
+ if (typeof warn === 'function') {
597
+ if (restoredHash) {
598
+ warn(`could not origin-pin permission layer from ${permissionRef} (${detail}); ` +
599
+ `restored last-known-good permission layer from cache, integrity-checked but ` +
600
+ `NOT origin-authenticated (sha256 ${restoredHash.slice(0, 12)})`);
601
+ } else {
602
+ warn(`could not origin-pin permission layer from ${permissionRef} (${detail}); ` +
603
+ `no integrity-checked cache available — leaving committed settings/hooks in place, ` +
604
+ `permission layer is UNVERIFIED`);
605
+ }
606
+ }
607
+ return { result, permissionPinned: false };
608
+ }
609
+ }
610
+
611
+ // ── CLI ──────────────────────────────────────────────────────────────────────
612
+
613
+ function parseArgs(argv) {
614
+ const out = {};
615
+ for (let i = 0; i < argv.length; i++) {
616
+ const a = argv[i];
617
+ if (a === '--repo-root') out.repoRoot = argv[++i];
618
+ else if (a === '--dest') out.dest = argv[++i];
619
+ else if (a === '--from-git') out.fromGit = argv[++i];
620
+ else if (a === '--permission-from-git') out.permissionFromGit = argv[++i];
621
+ else if (a === '--config-source') out.configSource = argv[++i];
622
+ else if (a === '--quiet') out.quiet = true;
623
+ else if (a === '--help' || a === '-h') out.help = true;
624
+ }
625
+ return out;
626
+ }
627
+
628
+ function main(argv) {
629
+ const args = parseArgs(argv);
630
+ if (args.help) {
631
+ process.stdout.write(
632
+ 'Usage: generate-claude.mjs --repo-root <path> [--dest <path>] ' +
633
+ '[--from-git <ref>] [--permission-from-git <ref>] [--config-source <rel>] [--quiet]\n',
634
+ );
635
+ return 0;
636
+ }
637
+ const repoRoot = resolve(args.repoRoot || process.cwd());
638
+ const configSource = args.configSource || DEFAULT_CONFIG_SOURCE;
639
+ const destRoot = resolve(args.dest || join(repoRoot, '.claude'));
640
+
641
+ try {
642
+ let result;
643
+ if (args.fromGit) {
644
+ // Whole tree from the reviewed remote (per-session syncClaudeConfig path).
645
+ result = generateClaudeConfigFromGit({ ref: args.fromGit, repoRoot, destRoot, configSource });
646
+ } else if (args.permissionFromGit) {
647
+ // Container-boot split: permission layer from origin/<base>, rest from the
648
+ // working tree. Degrades (warns) to committed permission layer when offline.
649
+ const warn = args.quiet ? undefined : (m) => process.stderr.write(`generate-claude: ${m}\n`);
650
+ result = generateClaudeConfigForBoot({
651
+ permissionRef: args.permissionFromGit, repoRoot, destRoot, configSource, warn,
652
+ }).result;
653
+ } else {
654
+ // Working-tree mode: base is <repoRoot>/<configSource>, overlay defaults to
655
+ // <base>/local (the future `.mx-agent/` overlay would substitute here).
656
+ const baseDir = join(repoRoot, configSource);
657
+ const overlayDir = join(baseDir, 'local');
658
+ result = generateClaudeConfig({ baseDir, overlayDir, destRoot });
659
+ }
660
+ if (!args.quiet) {
661
+ const c = result.counts;
662
+ process.stderr.write(
663
+ `generate-claude: wrote .claude/ (hooks=${c.hooks} agents=${c.agents} ` +
664
+ `skills=${c.skills} commands=${c.commands} rules=${c.rules}) → ${destRoot}\n`,
665
+ );
666
+ }
667
+ return 0;
668
+ } catch (err) {
669
+ process.stderr.write(`generate-claude: ${err instanceof Error ? err.message : String(err)}\n`);
670
+ return 1;
671
+ }
672
+ }
673
+
674
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
675
+ process.exit(main(process.argv.slice(2)));
676
+ }