@memnexus-ai/mx-agent-cli 0.1.198 → 0.1.199
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.
- package/dist/__tests__/claude-sync.test.js +42 -93
- package/dist/__tests__/claude-sync.test.js.map +1 -1
- package/dist/__tests__/generate-claude.test.d.ts +18 -0
- package/dist/__tests__/generate-claude.test.d.ts.map +1 -0
- package/dist/__tests__/generate-claude.test.js +484 -0
- package/dist/__tests__/generate-claude.test.js.map +1 -0
- package/dist/agent-config/rules/eval-harness.md +18 -0
- package/dist/agent-config/settings.json +5 -1
- package/dist/lib/claude.d.ts.map +1 -1
- package/dist/lib/claude.js +63 -166
- package/dist/lib/claude.js.map +1 -1
- package/package.json +2 -1
- package/scripts/generate-claude.mjs +676 -0
|
@@ -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
|
+
}
|