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 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/ → 672 passing tests across 43 test files
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 | ✓ 672 tests | ✗ | ✗ | ✗ |
121
+ | Test coverage | ✓ 690 tests | ✗ | ✗ | ✗ |
122
122
  | Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
123
123
 
124
124
  ---
@@ -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
- const report = verifyAgainstManifest({ rootDir, manifest });
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.7",
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 content = fs.readFileSync(p, 'utf8');
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 { return null; }
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
- function readJson(file) {
28
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
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
- fs.writeFileSync(file, JSON.stringify(obj, null, 2));
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 existing = readJson(file) || {};
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 existing = readJson(file);
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
- // verify is "ok" if no missing and no modified files. Extras are non-fatal.
85
- const ok = summary.missing === 0 && summary.modified === 0;
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,