kodelyth-ecc 2.24.7 → 2.24.9
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/CHANGELOG.md +150 -0
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/bin/kodelyth-ecc.js +9 -3
- package/package.json +1 -1
- package/scripts/dashboard/data.js +28 -2
- package/scripts/lib/tmux-worktree-orchestrator.js +18 -0
- package/scripts/mcp/register-self.js +55 -5
- package/scripts/supply-chain/verify.js +19 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,156 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Kodelyth ECC are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.24.9 — a malformed Claude config was silently replaced on install (October 2026)
|
|
6
|
+
|
|
7
|
+
**Data-loss fix. Upgrade before running an install on a machine whose
|
|
8
|
+
`~/.claude.json` may have been hand-edited.**
|
|
9
|
+
|
|
10
|
+
MCP self-registration read the user's Claude config, merged ECC's server entry in,
|
|
11
|
+
and wrote it back:
|
|
12
|
+
|
|
13
|
+
```js
|
|
14
|
+
const existing = readJson(file) || {};
|
|
15
|
+
const next = { ...existing, mcpServers: servers };
|
|
16
|
+
writeJson(file, next);
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`readJson` returned `null` for **every** failure. So a config that merely failed to
|
|
20
|
+
parse — one trailing comma from a hand-edit, a half-written file, a bad mount —
|
|
21
|
+
was indistinguishable from a config that did not exist, and `|| {}` turned it into
|
|
22
|
+
an empty object. The merge then wrote that back.
|
|
23
|
+
|
|
24
|
+
Measured on a realistic config with two other MCP servers, a `projects` block with
|
|
25
|
+
history, and user settings: **366 bytes in, 140 bytes out**. Every other MCP
|
|
26
|
+
server, every project entry, the theme and `autoUpdates` — gone. No error, no
|
|
27
|
+
warning, no backup.
|
|
28
|
+
|
|
29
|
+
This runs automatically during install. So `npx kodelyth-ecc` on a machine whose
|
|
30
|
+
`.claude.json` had any syntax error destroyed the user's Claude configuration.
|
|
31
|
+
|
|
32
|
+
### What changed
|
|
33
|
+
|
|
34
|
+
`readConfig` now returns one of three states instead of a nullable value:
|
|
35
|
+
|
|
36
|
+
| state | meaning | action |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `absent` | missing, empty, or whitespace-only | safe to treat as `{}` and create |
|
|
39
|
+
| `present` | parsed to an object | merge normally |
|
|
40
|
+
| `unreadable` | exists but will not parse, or is not an object | **refuse** |
|
|
41
|
+
|
|
42
|
+
Refusing is the point: there is no safe way to merge into a file whose contents
|
|
43
|
+
cannot be reconstructed. The file is left byte-identical and the error names it
|
|
44
|
+
along with the parse failure. `unregisterInFile` applies the same rule on the way
|
|
45
|
+
out — removing an entry from a file we cannot read means writing a file we cannot
|
|
46
|
+
reconstruct.
|
|
47
|
+
|
|
48
|
+
Writes now go through `safeFs.replaceFilePreservingMode`: atomic via
|
|
49
|
+
temp-and-rename, so a crash or a full disk partway through cannot truncate the
|
|
50
|
+
config — the same data loss by a different route — and the file keeps its existing
|
|
51
|
+
permissions rather than having them widened.
|
|
52
|
+
|
|
53
|
+
### Two mistakes caught while fixing it
|
|
54
|
+
|
|
55
|
+
The first version of the fix called `replaceFileAtomic` without its `mode`
|
|
56
|
+
argument, which threw on **every** path including valid configs. Testing the happy
|
|
57
|
+
path, not just the bug, is what surfaced it; verifying only that the refusal
|
|
58
|
+
worked would have shipped an installer that never registered anything.
|
|
59
|
+
|
|
60
|
+
The mode-preservation test then asserted `mode === 0o600` and failed Windows CI
|
|
61
|
+
with `438 !== 384`. Windows has no POSIX permission bits, so `chmod` there is close
|
|
62
|
+
to a no-op and node reports `0666` regardless. The property worth testing was never
|
|
63
|
+
the literal value but that the write does not *change* the mode, which is true on
|
|
64
|
+
every platform.
|
|
65
|
+
|
|
66
|
+
Eight tests, where this file previously had none: the three read states,
|
|
67
|
+
preservation of unrelated config on a valid merge, byte-identical refusal across
|
|
68
|
+
four kinds of malformed input, creation from absent and empty, idempotence,
|
|
69
|
+
unregister's refusal, mode preservation, and no stray temp files.
|
|
70
|
+
|
|
71
|
+
**690 tests across 44 files.**
|
|
72
|
+
|
|
73
|
+
## v2.24.8 — adversarial pass: swarm, dashboard, supply-chain (October 2026)
|
|
74
|
+
|
|
75
|
+
Three defects found by attacking the remaining unprobed surfaces rather than
|
|
76
|
+
reading them.
|
|
77
|
+
|
|
78
|
+
### `baseRef` could reach git's argv as an option
|
|
79
|
+
|
|
80
|
+
Every spawn in the swarm orchestrator uses `spawnSync`'s array form with no shell,
|
|
81
|
+
so shell injection does not work — and that was tested, not assumed: every
|
|
82
|
+
generated `send-keys` string was executed in a real shell with a payload that
|
|
83
|
+
would touch a marker file, across sessionName, worker name, task, repoRoot and
|
|
84
|
+
baseRef. No marker was ever created.
|
|
85
|
+
|
|
86
|
+
Array form does not stop **argument** injection, though. git parses its own argv,
|
|
87
|
+
so in `git worktree add -b <branch> <path> <commit-ish>` a commit-ish beginning
|
|
88
|
+
with `-` is read as an option: `--git-dir=/elsewhere` points git at another
|
|
89
|
+
repository, `--force` changes the command's meaning. `baseRef` was the only value
|
|
90
|
+
reaching argv without being slugified or path-resolved first — sessionName goes
|
|
91
|
+
through `slugify`, branchName carries a literal prefix, worktreePath is absolute —
|
|
92
|
+
and it is user-settable through `--base-ref`. Swarm configs get shared, so this is
|
|
93
|
+
not purely self-inflicted.
|
|
94
|
+
|
|
95
|
+
Rejecting a leading `-` costs nothing: `git check-ref-format` forbids refs that
|
|
96
|
+
start with one, so `HEAD`, `main`, `v1.2.3` and `origin/main` all still work.
|
|
97
|
+
|
|
98
|
+
### An 800-character excerpt read the entire file
|
|
99
|
+
|
|
100
|
+
The dashboard's `safeReadExcerpt` called `readFileSync` on the whole file and then
|
|
101
|
+
sliced 800 characters off the front, so a fixed-size excerpt cost the full size of
|
|
102
|
+
the file. Measured on a 300MB `task.md`: **196ms and +628MB RSS** for 800
|
|
103
|
+
characters, scaling linearly — roughly twice the file, since the bytes land in a
|
|
104
|
+
Buffer and again as a UTF-16 string.
|
|
105
|
+
|
|
106
|
+
The dashboard is single threaded, so that blocks every other request, and it runs
|
|
107
|
+
three times per worker — task, handoff, status — across every session in
|
|
108
|
+
`.orchestration`. Those files are written by swarm workers, so their size is not
|
|
109
|
+
something the dashboard controls.
|
|
110
|
+
|
|
111
|
+
It now reads a bounded window: stat first, then `max * 4 + 4` bytes. Four because
|
|
112
|
+
UTF-8 encodes a character in at most four, so the window always holds at least
|
|
113
|
+
`max` characters. That detail is load bearing — the truncation decision counts
|
|
114
|
+
CHARACTERS, as it always did, so slicing a byte window would cut a multi-byte
|
|
115
|
+
character in half. 300MB now costs 0ms and +0MB. Output was diffed against the old
|
|
116
|
+
implementation across twelve cases including emoji on the boundary; all identical.
|
|
117
|
+
|
|
118
|
+
### `verify` passed with a planted file
|
|
119
|
+
|
|
120
|
+
An EXTRA file — present on disk, absent from the manifest — was advisory:
|
|
121
|
+
`ok: true`, exit code 0. Confirmed against the real CLI, where tampering and
|
|
122
|
+
deletion correctly exit 1 but planting `rules/common/zz-injected.md` exits 0.
|
|
123
|
+
|
|
124
|
+
The human output did say `⚠ extra: 1 (advisory)`. Automation did not hear it:
|
|
125
|
+
`kodelyth-ecc verify && deploy` passed with an unknown file in place — and in this
|
|
126
|
+
product an extra `.md` under `rules/`, `agents/` or `commands/` is not inert data.
|
|
127
|
+
Those directories are enumerated and loaded as instructions, so an injected rule
|
|
128
|
+
file becomes an always-on rule in every future session. That is the payload the
|
|
129
|
+
session-bundle traversal delivered in 2.24.5, reached a different way.
|
|
130
|
+
|
|
131
|
+
The default is deliberately unchanged. This is a toolkit people are meant to
|
|
132
|
+
extend, and failing verify on extras would fail it forever for anyone who added
|
|
133
|
+
their own agent — a check that always fails gets ignored. The choice is offered
|
|
134
|
+
instead:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
kodelyth-ecc verify # extras advisory, exit 0 (unchanged)
|
|
138
|
+
kodelyth-ecc verify --strict # extras fail the verdict, exit 1
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The report now carries `strict`, so a `--json` consumer need not infer which mode
|
|
142
|
+
produced the verdict.
|
|
143
|
+
|
|
144
|
+
### What held up
|
|
145
|
+
|
|
146
|
+
`scripts/evolve` was already hardened, and its comment records the same traversal
|
|
147
|
+
class found in replay along with how it was confirmed. Re-tested anyway: target
|
|
148
|
+
path traversal blocked, legitimate paths written, no prototype pollution. Swarm
|
|
149
|
+
worker names collapse through `slugify` rather than escaping the worktree root,
|
|
150
|
+
and `normalizeSeedPaths` rejects `..`, absolute paths and embedded traversal —
|
|
151
|
+
both now pinned by tests so a later change cannot quietly lose them.
|
|
152
|
+
|
|
153
|
+
**682 tests across 43 files.**
|
|
154
|
+
|
|
5
155
|
## v2.24.7 — the brand render scripts could not run (October 2026)
|
|
6
156
|
|
|
7
157
|
Developer tooling only. Nothing here ships in the npm package — `brand/` is not in
|
package/CLAUDE.md
CHANGED
|
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
|
|
|
26
26
|
bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
|
|
27
27
|
actions/ → GitHub Action (CI/CD integration for PR review)
|
|
28
28
|
docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
|
|
29
|
-
tests/ →
|
|
29
|
+
tests/ → 690 passing tests across 44 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ Most "AI agent kits" are folders of markdown files you have to remember the name
|
|
|
118
118
|
| Quality hooks | ✓ 44 | Some | ✗ | ✗ |
|
|
119
119
|
| IDE platforms | **11** (Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, Roo Code, Aider, Kimi, Gemini CLI) | 1-2 | 1 | Varies |
|
|
120
120
|
| Telemetry | **None** | Varies | None | Varies |
|
|
121
|
-
| Test coverage | ✓
|
|
121
|
+
| Test coverage | ✓ 690 tests | ✗ | ✗ | ✗ |
|
|
122
122
|
| Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
|
|
123
123
|
|
|
124
124
|
---
|
package/bin/kodelyth-ecc.js
CHANGED
|
@@ -1288,7 +1288,7 @@ if (args[0] === 'session-export' || args[0] === 'session-import' || args[0] ===
|
|
|
1288
1288
|
// Usage:
|
|
1289
1289
|
// kodelyth-ecc sbom [--root DIR] [--out FILE] [--json]
|
|
1290
1290
|
// kodelyth-ecc manifest [--root DIR] [--out FILE] [--json]
|
|
1291
|
-
// kodelyth-ecc verify [--root DIR] [--manifest FILE] [--json]
|
|
1291
|
+
// kodelyth-ecc verify [--root DIR] [--manifest FILE] [--json] [--strict]
|
|
1292
1292
|
if (args[0] === 'sbom' || args[0] === 'manifest' || args[0] === 'verify') {
|
|
1293
1293
|
(async () => {
|
|
1294
1294
|
const fs = require('fs');
|
|
@@ -1355,7 +1355,12 @@ if (args[0] === 'sbom' || args[0] === 'manifest' || args[0] === 'verify') {
|
|
|
1355
1355
|
throw new Error(`verify: manifest not found at ${manifestPath} (use --manifest FILE)`);
|
|
1356
1356
|
}
|
|
1357
1357
|
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
1358
|
-
|
|
1358
|
+
// --strict folds EXTRA files into the verdict. Off by default, because a
|
|
1359
|
+
// toolkit people extend would otherwise fail verify for everyone who added
|
|
1360
|
+
// their own agent; on for automation, where an unknown file under rules/ or
|
|
1361
|
+
// agents/ is loaded as instructions and should stop a deploy.
|
|
1362
|
+
const strict = has('--strict');
|
|
1363
|
+
const report = verifyAgainstManifest({ rootDir, manifest, strict });
|
|
1359
1364
|
|
|
1360
1365
|
if (has('--json')) {
|
|
1361
1366
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
@@ -1370,7 +1375,7 @@ if (args[0] === 'sbom' || args[0] === 'manifest' || args[0] === 'verify') {
|
|
|
1370
1375
|
console.log(` ✓ ok: ${s.ok}`);
|
|
1371
1376
|
console.log(` ✗ modified: ${s.modified}`);
|
|
1372
1377
|
console.log(` ✗ missing: ${s.missing}`);
|
|
1373
|
-
console.log(` ⚠ extra: ${s.extra} (advisory)`);
|
|
1378
|
+
console.log(` ⚠ extra: ${s.extra} ${strict ? '(fails verify: --strict)' : '(advisory — use --strict to fail on these)'}`);
|
|
1374
1379
|
|
|
1375
1380
|
if (report.details.modified.length) {
|
|
1376
1381
|
console.log(`\nModified files:`);
|
|
@@ -1724,6 +1729,7 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
1724
1729
|
npx kodelyth-ecc sbom [--out file.json] Emit a CycloneDX 1.5 SBOM for the package
|
|
1725
1730
|
npx kodelyth-ecc manifest [--out file.json] Emit a sha256 content manifest of all assets
|
|
1726
1731
|
npx kodelyth-ecc verify [--manifest file] Verify installed copy against a content manifest
|
|
1732
|
+
npx kodelyth-ecc verify --strict ...and fail on unexpected extra files too
|
|
1727
1733
|
npx kodelyth-ecc evolve analyze Generate skill / routing proposals from memory + miss signals
|
|
1728
1734
|
npx kodelyth-ecc dashboard Boot localhost-only observability dashboard (zero telemetry)
|
|
1729
1735
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.24.
|
|
3
|
+
"version": "2.24.9",
|
|
4
4
|
"description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 196 skills, 103 commands, parallel multi-agent commands, semantic intent routing, self-learning memory, and a built-in MCP server (16 tools / 6 prompts / 377 resources) that bridges to Claude Desktop, LangGraph, AutoGen, CrewAI, and OpenAI Agents SDK. Works on macOS, Linux and Windows with Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, RooCode, Aider, Kimi, and Gemini CLI.",
|
|
5
5
|
"author": "Kodelyth <github.com/sifxprime>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -335,10 +335,36 @@ function sessionDetail({ session, coordRoot = defaultCoordRoot() } = {}) {
|
|
|
335
335
|
}
|
|
336
336
|
|
|
337
337
|
function safeReadExcerpt(p, max = 800) {
|
|
338
|
+
// Reads only the window it needs. This used to readFileSync the WHOLE file and
|
|
339
|
+
// then slice 800 characters off the front, which meant a large file in a
|
|
340
|
+
// coordination directory cost its full size in time and memory to produce a
|
|
341
|
+
// fixed-size excerpt. Measured: a 300MB task.md took 693ms and +733MB RSS to
|
|
342
|
+
// return 800 characters, scaling linearly — and the dashboard is single
|
|
343
|
+
// threaded, so that blocks every other request. It is called three times per
|
|
344
|
+
// worker (task, handoff, status) across every session, so the cost multiplies.
|
|
345
|
+
//
|
|
346
|
+
// The window is max*4 bytes because UTF-8 encodes a character in at most four,
|
|
347
|
+
// so it always contains at least `max` characters when the file has them. That
|
|
348
|
+
// matters: the truncation decision below counts CHARACTERS, as it always did,
|
|
349
|
+
// so slicing a byte window would otherwise cut a multi-byte character in half
|
|
350
|
+
// and change the output for any non-ASCII file.
|
|
351
|
+
let fd;
|
|
338
352
|
try {
|
|
339
|
-
const
|
|
353
|
+
const st = fs.statSync(p);
|
|
354
|
+
if (!st.isFile()) return null;
|
|
355
|
+
const window = Math.min(st.size, max * 4 + 4);
|
|
356
|
+
const buf = Buffer.alloc(window);
|
|
357
|
+
fd = fs.openSync(p, 'r');
|
|
358
|
+
const bytesRead = fs.readSync(fd, buf, 0, window, 0);
|
|
359
|
+
const content = buf.subarray(0, bytesRead).toString('utf8');
|
|
340
360
|
return content.length > max ? content.slice(0, max) + '\n…(truncated)' : content;
|
|
341
|
-
} catch {
|
|
361
|
+
} catch {
|
|
362
|
+
return null;
|
|
363
|
+
} finally {
|
|
364
|
+
if (fd !== undefined) {
|
|
365
|
+
try { fs.closeSync(fd); } catch { /* already gone */ }
|
|
366
|
+
}
|
|
367
|
+
}
|
|
342
368
|
}
|
|
343
369
|
|
|
344
370
|
// ── IDE sessions (Claude Code / Windsurf / Antigravity) ──────────────────────
|
|
@@ -183,7 +183,25 @@ function buildOrchestrationPlan(config = {}) {
|
|
|
183
183
|
config.coordinationRoot || path.join(repoRoot, '.orchestration')
|
|
184
184
|
);
|
|
185
185
|
const coordinationDir = path.join(coordinationRoot, sessionName);
|
|
186
|
+
// baseRef is the only value in this file that reaches a command's argv without
|
|
187
|
+
// being slugified or path-resolved first. Every spawn here uses the array form
|
|
188
|
+
// with no shell, which stops shell injection — but it does NOT stop ARGUMENT
|
|
189
|
+
// injection: git parses argv itself, so a ref beginning with "-" is read as an
|
|
190
|
+
// option rather than a commit-ish. In
|
|
191
|
+
// git worktree add -b <branch> <path> <commit-ish>
|
|
192
|
+
// a baseRef of "--git-dir=/elsewhere" silently redirects git at another
|
|
193
|
+
// repository, and "--force" changes the command's meaning. It is user-settable
|
|
194
|
+
// through --base-ref, and swarm configs get shared, so it is not only
|
|
195
|
+
// self-inflicted.
|
|
196
|
+
//
|
|
197
|
+
// Rejecting a leading "-" is enough: git refs cannot begin with one anyway
|
|
198
|
+
// (git check-ref-format forbids it), so nothing legitimate is lost.
|
|
186
199
|
const baseRef = config.baseRef || 'HEAD';
|
|
200
|
+
if (typeof baseRef !== 'string' || baseRef.startsWith('-')) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`baseRef must be a ref name, not an option: ${JSON.stringify(String(baseRef))}`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
187
205
|
const defaultLauncher = config.launcherCommand || '';
|
|
188
206
|
|
|
189
207
|
if (workers.length === 0) {
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
'use strict';
|
|
8
8
|
|
|
9
9
|
const fs = require('fs');
|
|
10
|
+
const safeFs = require('../lib/safe-fs');
|
|
10
11
|
const os = require('os');
|
|
11
12
|
const path = require('path');
|
|
12
13
|
|
|
@@ -24,13 +25,48 @@ function claudeDesktopConfigPath() {
|
|
|
24
25
|
return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
28
|
+
// Reading this file has THREE outcomes, and collapsing them is destructive.
|
|
29
|
+
//
|
|
30
|
+
// readJson used to return null for every failure, and the caller did
|
|
31
|
+
// `readJson(file) || {}`. So a config that merely failed to parse — one trailing
|
|
32
|
+
// comma from a hand-edit, a half-written file, a bad mount — was treated as an
|
|
33
|
+
// empty config, and the merge wrote it back containing only ECC's own entry.
|
|
34
|
+
// Measured on a realistic config: 366 bytes in, 140 bytes out, with every other
|
|
35
|
+
// MCP server, every project entry and every setting gone. Silently, during an
|
|
36
|
+
// install the user did not think was risky.
|
|
37
|
+
//
|
|
38
|
+
// Absent is safe to treat as empty. Unreadable is not.
|
|
39
|
+
function readConfig(file) {
|
|
40
|
+
let raw;
|
|
41
|
+
try {
|
|
42
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.code === 'ENOENT') return { state: 'absent', data: {} };
|
|
45
|
+
return { state: 'unreadable', reason: err.message };
|
|
46
|
+
}
|
|
47
|
+
// An empty or whitespace-only file is effectively absent; JSON.parse would
|
|
48
|
+
// throw on it and we would otherwise refuse to ever register.
|
|
49
|
+
if (raw.trim() === '') return { state: 'absent', data: {} };
|
|
50
|
+
try {
|
|
51
|
+
const data = JSON.parse(raw);
|
|
52
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) {
|
|
53
|
+
return { state: 'unreadable', reason: 'top-level value is not an object' };
|
|
54
|
+
}
|
|
55
|
+
return { state: 'present', data };
|
|
56
|
+
} catch (err) {
|
|
57
|
+
return { state: 'unreadable', reason: err.message };
|
|
58
|
+
}
|
|
29
59
|
}
|
|
30
60
|
|
|
31
61
|
function writeJson(file, obj) {
|
|
32
62
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
33
|
-
|
|
63
|
+
// Atomic: write a temp file and rename. A crash or a full disk partway through
|
|
64
|
+
// a plain writeFileSync leaves the user with a truncated config, which is the
|
|
65
|
+
// same data loss by a different route.
|
|
66
|
+
// PreservingMode rather than Atomic: this is the user's own config file and it
|
|
67
|
+
// may carry permissions they chose. It keeps the existing mode when the file is
|
|
68
|
+
// already there and falls back to 0644 when creating it.
|
|
69
|
+
safeFs.replaceFilePreservingMode(file, JSON.stringify(obj, null, 2) + '\n');
|
|
34
70
|
}
|
|
35
71
|
|
|
36
72
|
function serverEntry() {
|
|
@@ -42,7 +78,15 @@ function serverEntry() {
|
|
|
42
78
|
}
|
|
43
79
|
|
|
44
80
|
function registerInFile(file, opts = {}) {
|
|
45
|
-
const
|
|
81
|
+
const cfg = readConfig(file);
|
|
82
|
+
if (cfg.state === 'unreadable') {
|
|
83
|
+
// Refusing is the whole point. Overwriting would destroy whatever is in there.
|
|
84
|
+
throw new Error(
|
|
85
|
+
`refusing to modify ${file}: it exists but could not be parsed (${cfg.reason}). ` +
|
|
86
|
+
`Fix or move the file, then re-run. Nothing was written.`
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const existing = cfg.data;
|
|
46
90
|
const servers = existing.mcpServers || {};
|
|
47
91
|
const already = servers[SERVER_NAME];
|
|
48
92
|
const desired = serverEntry();
|
|
@@ -56,7 +100,10 @@ function registerInFile(file, opts = {}) {
|
|
|
56
100
|
}
|
|
57
101
|
|
|
58
102
|
function unregisterInFile(file) {
|
|
59
|
-
const
|
|
103
|
+
const cfg = readConfig(file);
|
|
104
|
+
// Same rule on the way out: an unparseable config is left exactly as it is.
|
|
105
|
+
if (cfg.state === 'unreadable') return { file, action: 'skipped-unreadable', reason: cfg.reason };
|
|
106
|
+
const existing = cfg.data;
|
|
60
107
|
if (!existing || !existing.mcpServers || !existing.mcpServers[SERVER_NAME]) {
|
|
61
108
|
return { file, action: 'not-present' };
|
|
62
109
|
}
|
|
@@ -113,4 +160,7 @@ module.exports = {
|
|
|
113
160
|
registerAll,
|
|
114
161
|
unregisterAll,
|
|
115
162
|
statusAll,
|
|
163
|
+
// Exported for tests: these must be exercisable without writing to a real
|
|
164
|
+
// ~/.claude.json, and the whole point of the fix is what they refuse to do.
|
|
165
|
+
_internals: { readConfig, registerInFile, unregisterInFile },
|
|
116
166
|
};
|
|
@@ -23,7 +23,7 @@ const { hashFile, walkFiles, MANIFEST_SCHEMA } = require('./manifest.js');
|
|
|
23
23
|
|
|
24
24
|
const VERIFY_RESULT_KEYS = ['ok', 'modified', 'missing', 'extra'];
|
|
25
25
|
|
|
26
|
-
function verifyAgainstManifest({ rootDir, manifest } = {}) {
|
|
26
|
+
function verifyAgainstManifest({ rootDir, manifest, strict = false } = {}) {
|
|
27
27
|
if (!rootDir) throw new Error('verifyAgainstManifest: rootDir is required');
|
|
28
28
|
if (!manifest) throw new Error('verifyAgainstManifest: manifest is required');
|
|
29
29
|
if (manifest.schema !== MANIFEST_SCHEMA) {
|
|
@@ -81,11 +81,27 @@ function verifyAgainstManifest({ rootDir, manifest } = {}) {
|
|
|
81
81
|
extra: extraFiles.length,
|
|
82
82
|
};
|
|
83
83
|
|
|
84
|
-
//
|
|
85
|
-
|
|
84
|
+
// By default an EXTRA file is advisory: it does not fail the verdict. That is
|
|
85
|
+
// deliberate and correct for a toolkit people are meant to extend — anyone who
|
|
86
|
+
// has added their own agent or skill would otherwise see verify fail forever.
|
|
87
|
+
//
|
|
88
|
+
// It is the wrong default for automation, though. A planted file returns ok
|
|
89
|
+
// true and exit code 0, so `verify && deploy` passes with an unknown file in
|
|
90
|
+
// place — and in this product an extra .md under rules/, agents/ or commands/
|
|
91
|
+
// is not inert data, it is loaded as instructions, so an injected
|
|
92
|
+
// rules/common/zz.md becomes an always-on rule. Confirmed: a clean tree reports
|
|
93
|
+
// ok true, planting that file still reports ok true with extra 1.
|
|
94
|
+
//
|
|
95
|
+
// So the choice is offered rather than made. strict folds extras into the
|
|
96
|
+
// verdict for callers that want a gate; the default is unchanged.
|
|
97
|
+
const ok =
|
|
98
|
+
summary.missing === 0 &&
|
|
99
|
+
summary.modified === 0 &&
|
|
100
|
+
(!strict || summary.extra === 0);
|
|
86
101
|
|
|
87
102
|
return {
|
|
88
103
|
ok,
|
|
104
|
+
strict,
|
|
89
105
|
summary,
|
|
90
106
|
details: {
|
|
91
107
|
ok: okFiles,
|