kodelyth-ecc 2.24.0 → 2.24.2

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,114 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.24.2 — adversarial pass: path traversal in get_rule, unrecallable memories (October 2026)
6
+
7
+ **Security fix.** Three defects found by attacking the memory store, MCP server
8
+ and dashboard rather than reading them.
9
+
10
+ ### `get_rule` returned any markdown file on the machine
11
+
12
+ `loadRule` was the only catalog loader that built a filesystem path out of its
13
+ argument. Every other loader — agents, skills, commands, bundles — enumerates a
14
+ directory and matches by name, so a caller can only ever select something already
15
+ listed. This one did `path.join(PATHS.rules, name + '.md')`, so:
16
+
17
+ get_rule("../../README") -> 51,297 bytes of ROOT/README.md
18
+ get_rule("testing/../../../README") -> same, via nested traversal
19
+
20
+ The `.md` suffix was the only thing bounding the reach, which left every readable
21
+ markdown file on the machine in scope.
22
+
23
+ This matters because the caller is not always the user. An MCP tool argument can
24
+ originate in text the agent merely read — a dependency README, an issue comment,
25
+ a fetched page — which made this an arbitrary-markdown-read primitive reachable
26
+ by prompt injection. That is exactly the class ECC's own `prompt-injection-hunter`
27
+ agent exists to catch.
28
+
29
+ Fixed with two guards that fail differently: a name pattern that rejects anything
30
+ that is a path rather than a name before touching disk, and the existing
31
+ `resolveContained()` from `scripts/lib/safe-fs.js`, which proves the target sits
32
+ under `rules/common` once symlinks resolve. The pattern alone is not enough —
33
+ `escape-probe` is a valid rule name, and a symlink under that name reads 51KB.
34
+
35
+ `loadRule` had no test coverage at all, which is why this survived. It now has
36
+ four tests, including the symlink case.
37
+
38
+ ### One memory was mathematically unrecallable
39
+
40
+ `search()` defaulted to an absolute `minScore` of 0.5 against unnormalised BM25
41
+ scores. BM25 IDF is `log(1 + (N - df + 0.5) / (df + 0.5))`, so a term appearing in
42
+ every memory drives IDF toward zero and can never clear a fixed floor at any
43
+ corpus size. The floor is now relative to the top hit, so it scales with the
44
+ corpus instead of fighting it. An explicitly passed `minScore` is still honoured
45
+ exactly. `recall_memory` passes none, so it was using the broken default.
46
+
47
+ ### Malformed request URIs returned 500 and wrote to stderr
48
+
49
+ `decodeURIComponent` throws on `/%`, `/%zz` or a truncated `/%e0%a4`. The dashboard
50
+ did not guard it, so a malformed *client* URI became a 500 "internal server error"
51
+ plus one stderr line per request — meaning any local process could spam the
52
+ terminal the dashboard runs in. A path that cannot be decoded names no file, so
53
+ it is now a 404 like any other miss.
54
+
55
+ ### What resisted
56
+
57
+ Worth recording, since the absence of findings was tested rather than assumed.
58
+ Dashboard traversal (plain, encoded, double-encoded, backslash, absolute,
59
+ null-byte) all returned 404. Host validation rejects absent, empty and foreign
60
+ `Host` headers. Non-GET is 405. Stored XSS was tested in a real browser: a memory
61
+ captured through the real store with breakout payloads in problem, approach, tags,
62
+ language and project rendered as visible text, setting no global, creating no
63
+ element and no attribute, with the `</pre>` breakout still inside its `<pre>`.
64
+
65
+ **665 tests across 43 files.**
66
+
67
+ ## v2.24.1 — verify Windows downloads before running them (October 2026)
68
+
69
+ **Security fix.** The Windows installer added in 2.23.0 downloaded a release zip
70
+ over HTTPS and extracted it straight into the user's PATH directory **with no
71
+ integrity check at all**.
72
+
73
+ This repository ships a `supply-chain-auditor` agent whose entire job is catching
74
+ "downloads and executes a binary without verifying it". The code doing it was
75
+ ours. Both upstream projects publish a `checksums.txt` in the same release, in
76
+ standard `sha256sum` format, and nothing was reading it.
77
+
78
+ ### What it does now
79
+
80
+ Fetches `checksums.txt` from the release, finds the line for the asset it just
81
+ downloaded, hashes the file with Node's `crypto`, and compares.
82
+
83
+ - **Mismatch is fatal.** It refuses to install and reports both hashes.
84
+ - **Missing or unlisted warns and proceeds.** The fetch is HTTPS from
85
+ github.com so transport tampering is already hard, and a release that stops
86
+ publishing checksums should not make ECC look broken. The case this control
87
+ exists for is a mismatch, and that stays fatal.
88
+
89
+ Hashing uses Node's `crypto` rather than shelling out, because `certutil`,
90
+ `sha256sum` and `shasum` differ in availability and output format across the
91
+ machines this has to run on.
92
+
93
+ ### Proven, not asserted
94
+
95
+ Checked against the real artifact before a single test was written: the genuine
96
+ RTK Windows asset hashes to its published sha256, and flipping one byte produces
97
+ a mismatch.
98
+
99
+ Nine tests cover a matching checksum, a single changed byte, an unlisted asset,
100
+ a malformed entry, picking the right line out of a full file, uppercase hex, and
101
+ that a filename merely *ending* with the asset name does not satisfy the lookup
102
+ (`evil-asset.zip` must not pass as `asset.zip`).
103
+
104
+ The Windows CI job asserts the gate is exercised on every push — a security
105
+ control nobody runs is decoration. From a real `windows-latest` runner:
106
+
107
+ ```
108
+ [win] checksum verified (sha256 1623e9b45d28…)
109
+ ```
110
+
111
+ **656 tests across 43 files.**
112
+
5
113
  ## v2.24.0 — the update notice now tells you why (October 2026)
6
114
 
7
115
  The CLI has always polled npm for new versions and cached the answer for 24
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/ → 647 passing tests across 42 test files
29
+ tests/ → 665 passing tests across 43 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/README.md CHANGED
@@ -44,7 +44,7 @@ Google Antigravity, OpenCode, Cline, Roo Code, Aider, Kimi and Gemini CLI.
44
44
  [![npm downloads](https://img.shields.io/npm/dm/kodelyth-ecc.svg)](https://www.npmjs.com/package/kodelyth-ecc)
45
45
  [![GitHub Stars](https://img.shields.io/github/stars/sifxprime/kodelyth-ecc?style=social)](https://github.com/sifxprime/kodelyth-ecc/stargazers)
46
46
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
47
- ![Tests](https://img.shields.io/badge/tests-647%20passing-brightgreen)
47
+ ![Tests](https://img.shields.io/badge/tests-656%20passing-brightgreen)
48
48
  ![Claude Code](https://img.shields.io/badge/Claude%20Code-Ready-8A2BE2)
49
49
  ![Windsurf](https://img.shields.io/badge/Windsurf-Ready-06b6d4)
50
50
  ![Antigravity](https://img.shields.io/badge/Antigravity-Ready-00ADD8)
@@ -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 | ❌ | Varies |
121
- | Test coverage | ✅ 647 tests | ❌ | ❌ | ❌ |
121
+ | Test coverage | ✅ 665 tests | ❌ | ❌ | ❌ |
122
122
  | Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
123
123
 
124
124
  ---
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.24.0
1
+ 2.24.1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.24.0",
3
+ "version": "2.24.2",
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",
@@ -151,7 +151,18 @@ function serveStaticFile(res, filePath) {
151
151
  // baseDir is injectable so the containment logic can be tested against a real
152
152
  // symlink without planting one in the shipped static/ directory.
153
153
  function resolveStatic(reqPath, baseDir = STATIC_DIR) {
154
- const decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
154
+ // decodeURIComponent throws URIError on malformed percent-encoding — "/%",
155
+ // "/%zz", a truncated "/%e0%a4". That threw out to the route handler, which
156
+ // turned a client's malformed URI into a 500 "internal server error" and wrote
157
+ // a line to stderr, so any local process could both mislabel its own bad
158
+ // request as a server fault and spam the terminal the dashboard runs in.
159
+ // A path that cannot be decoded names no file, so it is simply not found.
160
+ let decoded;
161
+ try {
162
+ decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
163
+ } catch {
164
+ return null;
165
+ }
155
166
  if (decoded.includes('..')) return null;
156
167
  if (decoded === '' || decoded === '/') return path.join(baseDir, 'index.html');
157
168
  // Containment, including symlink resolution — see scripts/lib/safe-fs.js.
@@ -26,6 +26,7 @@
26
26
  const fs = require('fs');
27
27
  const os = require('os');
28
28
  const path = require('path');
29
+ const crypto = require('crypto');
29
30
  const { execFileSync, spawnSync } = require('child_process');
30
31
 
31
32
  /** Where ECC puts binaries it installs on Windows. User-writable, no admin. */
@@ -81,6 +82,29 @@ function installFromRelease({ repo, asset, binName, log = () => {} }) {
81
82
  return { installed: false, reason: `downloaded file is empty — ${url}` };
82
83
  }
83
84
 
85
+ // Verify the download before extracting, let alone running it.
86
+ //
87
+ // ECC ships a supply-chain-auditor agent whose whole job is catching
88
+ // "downloads and executes a binary with no integrity check" — and the first
89
+ // version of this file did exactly that. Both projects publish a
90
+ // checksums.txt in the same release, so there is no excuse.
91
+ const verdict = verifyChecksum({ repo, asset, file: zip, log });
92
+ if (verdict.status === 'mismatch') {
93
+ return {
94
+ installed: false,
95
+ reason: `checksum mismatch for ${asset} — expected ${verdict.expected}, got ${verdict.actual}. Refusing to install.`,
96
+ };
97
+ }
98
+ if (verdict.status === 'unavailable') {
99
+ // Warn rather than fail. The fetch is HTTPS from github.com, so transport
100
+ // tampering is already hard, and a release that simply stops publishing
101
+ // checksums should not make ECC look broken. A MISMATCH is still fatal —
102
+ // that is the case this check exists for.
103
+ log(`[win] WARNING: no checksum available for ${asset} (${verdict.reason}) — proceeding on HTTPS alone`);
104
+ } else {
105
+ log(`[win] checksum verified (sha256 ${verdict.expected.slice(0, 12)}…)`);
106
+ }
107
+
84
108
  log('[win] extracting…');
85
109
  const ex = spawnSync('tar', ['-xf', zip, '-C', dir], { stdio: 'inherit' });
86
110
  if (ex.status !== 0) {
@@ -154,4 +178,42 @@ function setxHint(dir) {
154
178
  return `setx PATH "%PATH%;${dir}"`;
155
179
  }
156
180
 
157
- module.exports = { installFromRelease, installDir, isOnUserPath, setxHint, findBinary };
181
+
182
+ /**
183
+ * Check a downloaded asset against the release's checksums.txt.
184
+ *
185
+ * Both upstreams publish the standard sha256sum format:
186
+ * <64 hex> <filename>
187
+ *
188
+ * Returns { status: 'ok' | 'mismatch' | 'unavailable' }. Hashing is done with
189
+ * Node's crypto rather than shelling out, because certutil, sha256sum and
190
+ * shasum all differ in availability and output format across the machines this
191
+ * has to run on.
192
+ */
193
+ function verifyChecksum({ repo, asset, file, log = () => {}, checksumText }) {
194
+ // checksumText is injectable so the parsing and comparison can be tested
195
+ // without reaching the network. Production callers never pass it.
196
+ let text = checksumText;
197
+ if (text === undefined) {
198
+ const url = `https://github.com/${repo}/releases/latest/download/checksums.txt`;
199
+ try {
200
+ text = execFileSync('curl', ['-fsSL', url], { encoding: 'utf8', timeout: 20000 });
201
+ } catch {
202
+ return { status: 'unavailable', reason: 'checksums.txt could not be fetched' };
203
+ }
204
+ }
205
+
206
+ const line = text.split('\n').find((l) => l.trim().endsWith(` ${asset}`) || l.trim().endsWith(` ${asset}`));
207
+ if (!line) return { status: 'unavailable', reason: `${asset} not listed in checksums.txt` };
208
+
209
+ const expected = (line.trim().split(/\s+/)[0] || '').toLowerCase();
210
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
211
+ return { status: 'unavailable', reason: 'malformed checksum entry' };
212
+ }
213
+
214
+ const actual = crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
215
+ if (actual !== expected) return { status: 'mismatch', expected, actual };
216
+ return { status: 'ok', expected };
217
+ }
218
+
219
+ module.exports = { installFromRelease, installDir, isOnUserPath, setxHint, findBinary, verifyChecksum };
@@ -8,6 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
+ const { resolveContained } = require('../lib/safe-fs');
11
12
 
12
13
  const ROOT = path.join(__dirname, '..', '..');
13
14
 
@@ -157,8 +158,28 @@ function loadCommands() {
157
158
  }
158
159
 
159
160
  // ── Rules ────────────────────────────────────────────────────────────────────
161
+ // A rule name is a flat basename: rules/common has no subdirectories, and every
162
+ // other loader here resolves a name against a directory LISTING, so a caller can
163
+ // only ever select something already enumerated. This one built a path out of the
164
+ // argument instead, which made `../../README` read ROOT/README.md — any .md file
165
+ // the process could reach, through a tool whose contract is "return a rule".
166
+ //
167
+ // That matters because the caller is not always the user. An MCP tool argument
168
+ // can originate in text the agent merely read — a dependency's README, an issue
169
+ // comment, a fetched page — so this was an arbitrary-markdown-read primitive
170
+ // reachable by prompt injection, which is exactly what ECC's own
171
+ // prompt-injection-hunter agent exists to catch.
172
+ //
173
+ // Two guards, because they fail differently. The pattern rejects anything that is
174
+ // a path rather than a name, before touching disk. resolveContained then proves
175
+ // the target really sits under rules/common once symlinks are resolved, which a
176
+ // pattern cannot know.
177
+ const RULE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
178
+
160
179
  function loadRule(name) {
161
- const file = path.join(PATHS.rules, `${name}.md`);
180
+ if (typeof name !== 'string' || !RULE_NAME.test(name)) return null;
181
+ const file = resolveContained(path.join(PATHS.rules, `${name}.md`), PATHS.rules);
182
+ if (file === null) return null;
162
183
  const raw = safeReadFile(file);
163
184
  if (raw === null) return null;
164
185
  return { name, file, relpath: path.relative(ROOT, file), body: raw };
@@ -185,8 +185,33 @@ function indexMemory(index, memory) {
185
185
  }
186
186
 
187
187
  // ── BM25 retrieval (k1=1.5, b=0.75) ──────────────────────────────────────────
188
+ /**
189
+ * Fraction of the best hit's score a result must reach to be kept, when the
190
+ * caller does not impose an absolute floor of its own.
191
+ *
192
+ * Relative, not absolute, and that is the whole point. BM25 scores are not
193
+ * normalised — they scale with corpus size and term rarity — so any fixed
194
+ * threshold is really a statement about how many memories you have. The old
195
+ * default of 0.5 made a single stored memory mathematically unrecallable: with
196
+ * N=1 and df=1 the IDF term caps the score at ~0.288, so recall() returned
197
+ * nothing no matter what you searched for.
198
+ *
199
+ * It was worse for common words. A token appearing in every memory scores
200
+ * 0.288 at N=1, 0.087 at N=5 and 0.010 at N=50 — correct BM25 behaviour, since
201
+ * a term that is everywhere carries no signal, but under a fixed floor it meant
202
+ * those words never matched at any corpus size.
203
+ *
204
+ * Three call sites had already worked around this by passing minScore: 0.1
205
+ * (doctor and two tests, one of which names the ~0.29 figure outright). The
206
+ * user-facing paths had not: the MCP recall_memory tool, the memory CLI and
207
+ * SessionStart injection all used the default and silently returned nothing.
208
+ */
209
+ const RELATIVE_SCORE_FLOOR = 0.25;
210
+
188
211
  function search(query, options = {}) {
189
- const { limit = 5, minScore = 0.5, projectFilter = null } = options;
212
+ // minScore stays honoured when passed, so existing callers keep their exact
213
+ // behaviour; only the default changes from an absolute floor to a relative one.
214
+ const { limit = 5, minScore = null, projectFilter = null } = options;
190
215
  const index = loadIndex();
191
216
  const tokens = tokenise(query);
192
217
  if (tokens.length === 0 || index.docCount === 0) return [];
@@ -208,9 +233,13 @@ function search(query, options = {}) {
208
233
  }
209
234
  }
210
235
 
211
- const ranked = Object.entries(scores)
212
- .filter(([, score]) => score >= minScore)
213
- .sort(([, a], [, b]) => b - a)
236
+ const sorted = Object.entries(scores).sort(([, a], [, b]) => b - a);
237
+ const topScore = sorted.length ? sorted[0][1] : 0;
238
+ const floor = minScore !== null && minScore !== undefined
239
+ ? minScore
240
+ : topScore * RELATIVE_SCORE_FLOOR;
241
+ const ranked = sorted
242
+ .filter(([, score]) => score >= floor)
214
243
  .slice(0, limit * 3);
215
244
 
216
245
  if (ranked.length === 0) return [];