kodelyth-ecc 2.10.0 → 2.11.0

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,64 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.11.0 — Arena run #2: three containment bugs in the dashboard (August 2026)
6
+
7
+ Pointed the arena at `scripts/dashboard` — the localhost HTTP server that serves
8
+ static files and returns your private memory store. Round 1 found **3 findings,
9
+ all 3 confirmed by executed repro**. Round 2 attacked the fixes across 7 vectors
10
+ and found **nothing new**.
11
+
12
+ All three are **low severity** and the reasoning matters: the server is
13
+ localhost-only, read-only, GET-only, and each finding needs local write access
14
+ that already grants the same data. None is a browser-reachable hole. They are
15
+ containment bugs worth closing, not emergencies.
16
+
17
+ ### Fixed — three containment gaps
18
+
19
+ - **`sessionDetail` followed symlinks out of the coordination root.** The check
20
+ compared the *joined* path, which for a symlink is the link's own location —
21
+ inside the root, so it passed — while the target was anywhere on disk. It
22
+ returned real `task.md` / `handoff.md` / `status.md` excerpts from outside.
23
+ - **`resolveStatic` followed symlinks out of `STATIC_DIR`.** Same class:
24
+ `path.resolve` does not resolve symlinks, so a lexically-contained link passed
25
+ the guard and `readFile` followed it. `/etc/hosts` was readable through it.
26
+ - **An empty or absent `Host` header bypassed the DNS-rebinding guard.** The
27
+ condition read `reqHost !== '' && ...`, so a missing Host short-circuited the
28
+ whole check to false. An HTTP/1.0 request reached the private-data APIs with
29
+ `200 OK`. Browsers always send Host, so this was never browser-reachable.
30
+
31
+ All three now canonicalize with `realpathSync` and re-check against the real
32
+ destination; the Host guard denies by default.
33
+
34
+ ### Fixed — `Host` comparison is now case-insensitive
35
+
36
+ Hostnames are case-insensitive per RFC 3986, so `Host: LOCALHOST` was a
37
+ legitimate spelling being rejected.
38
+
39
+ ### Fixed — arena recall ignored scope
40
+
41
+ Every arena memory carries the tag `arena`, and recall is BM25 — so a query
42
+ mentioning the arena matched **all** of them regardless of origin. The first
43
+ dashboard run was handed all 10 `scripts/terse` findings and told they were
44
+ *"confirmed here previously."* That is false, and it would have sent EVIL hunting
45
+ for `compress.js` bugs in an HTTP server. Recall is now filtered to memories
46
+ whose files actually live in the scope.
47
+
48
+ ### Added — `access-control` bug class
49
+
50
+ The Host-header bypass classified as `uncategorized`. Its guard advice: *deny by
51
+ default — an allowlist, with every absent or empty case treated as invalid rather
52
+ than waved through.*
53
+
54
+ ### Compound learning is earning its keep
55
+
56
+ `filesystem-symlink` is now confirmed **4 times across 3 files** — `compress.js`,
57
+ `data.js`, and `server.js`. Every one is the same mistake: a lexical containment
58
+ check that a symlink walks straight through. The guard proposal says what to do
59
+ about it — a shared path-safety helper, rather than a fourth spot fix.
60
+
61
+ **535 tests passing**, up from 525.
62
+
5
63
  ## v2.10.0 — Arena dashboard tab + docs (phases 5 & 6) (August 2026)
6
64
 
7
65
  ### Added — Arena tab in the dashboard
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/ → 525 passing tests across 29 test files
29
+ tests/ → 535 passing tests across 29 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.10.0
1
+ 2.11.0
@@ -501,8 +501,14 @@ if (args[0] === 'god' || args[0] === 'evil' || args[0] === 'arena') {
501
501
  try {
502
502
  const learn = require(path.join(ROOT, 'scripts', 'arena', 'learn.js'));
503
503
  const memStore = require(path.join(ROOT, 'scripts', 'memory', 'store.js'));
504
- const hits = memStore.recall(`arena ${scopeArg} ${task}`, { limit: 20 })
505
- .filter(m => (m.source || '') === 'arena');
504
+ // Filter to the scope. BM25 matches every arena memory on the word
505
+ // "arena" alone, so without this a dashboard run is handed terse findings
506
+ // and told they were confirmed in this very scope.
507
+ const hits = learn.filterToScope(
508
+ memStore.recall(`arena ${scopeArg} ${task}`, { limit: 60 })
509
+ .filter(m => (m.source || '') === 'arena'),
510
+ scopeArg,
511
+ ).slice(0, 20);
506
512
  recalledCount = hits.length;
507
513
  priorKnowledge = learn.priorKnowledgeBrief(hits);
508
514
  } catch { /* memory is optional — a missing store must never block a run */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 194 skills, 97 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 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",
@@ -43,6 +43,7 @@ const CLASSES = [
43
43
  ['semantic-corruption', /\bsemantic|meaning|threshold|negation|inverts?|widen/i],
44
44
  ['idempotency', /\bidempoten|fixed point|second run|re-?run\b/i],
45
45
  ['input-validation', /validat|wrong shape|silently accept|malformed|type ?error|unsanitiz/i],
46
+ ['access-control', /\bbypass(?:es|ed)? the|rebinding|host header|allowlist|authoriz|access control\b/i],
46
47
  ['supply-chain', /\btyposquat|lockfile|install script|dependency confusion\b/i],
47
48
  ];
48
49
 
@@ -169,6 +170,25 @@ function priorKnowledgeBrief(memories = [], { limit = 8 } = {}) {
169
170
  return lines.join('\n');
170
171
  }
171
172
 
173
+ // Recall is BM25 over the whole memory store, and every arena memory carries the
174
+ // tag "arena" — so a query mentioning the arena matches ALL of them regardless of
175
+ // which scope they came from. Left unfiltered, a run against scripts/dashboard is
176
+ // told that bugs in scripts/terse were "confirmed here previously", which is false
177
+ // and sends EVIL hunting for the wrong thing in the wrong file.
178
+ //
179
+ // A memory belongs to this scope only if it actually points at a file inside it.
180
+ function filterToScope(memories = [], scope) {
181
+ if (!scope || scope === '.' || scope === './') return memories;
182
+ const norm = String(scope).replace(/^\.\//, '').replace(/\/+$/, '');
183
+ if (!norm) return memories;
184
+ return memories.filter(m =>
185
+ (m.files || []).some(f => {
186
+ const file = String(f).replace(/^\.\//, '');
187
+ return file === norm || file.startsWith(norm + '/');
188
+ }),
189
+ );
190
+ }
191
+
172
192
  // ── Recurring classes → evolve proposals ────────────────────────────────────
173
193
  //
174
194
  // One bug is an incident. The same class across several runs is a gap in the
@@ -210,6 +230,7 @@ const GUARD_ADVICE = {
210
230
  'semantic-corruption': 'Add golden tests asserting that meaning-bearing tokens survive transformation.',
211
231
  'idempotency': 'Assert f(f(x)) === f(x) in the test suite for every transform.',
212
232
  'input-validation': 'Validate argument shape at every public boundary and throw — never silently coerce to a plausible default.',
233
+ 'access-control': 'Deny by default: an allowlist of permitted values, with every absent or empty case treated as invalid rather than waved through.',
213
234
  'supply-chain': 'Pin and verify dependencies; add a lockfile-drift check to CI.',
214
235
  };
215
236
 
@@ -265,6 +286,7 @@ module.exports = {
265
286
  refutedToMemory,
266
287
  runToMemories,
267
288
  priorKnowledgeBrief,
289
+ filterToScope,
268
290
  recurringClasses,
269
291
  buildGuardProposalMarkdown,
270
292
  guardProposalId,
@@ -307,8 +307,28 @@ function sessionDetail({ session, coordRoot = defaultCoordRoot() } = {}) {
307
307
  if (!session || session === '..' || session === '.') return null;
308
308
  const dir = path.join(coordRoot, session);
309
309
  // Containment check — ensure the resolved path stays within coordRoot.
310
+ // path.join normalises "..", so a textual escape is caught here.
310
311
  if (!dir.startsWith(coordRoot + path.sep) && dir !== coordRoot) return null;
311
312
  if (!fs.existsSync(dir)) return null;
313
+
314
+ // ...but a SYMLINK is not a textual escape: the link itself sits inside
315
+ // coordRoot and passes the check above, while its target does not. Reading
316
+ // through it hands the API task/handoff/status excerpts from anywhere on
317
+ // disk. realpath resolves the link so containment is checked against the
318
+ // real destination, which is the only location that matters.
319
+ let realDir;
320
+ try {
321
+ realDir = fs.realpathSync(dir);
322
+ } catch {
323
+ return null; // dangling or unreadable link
324
+ }
325
+ let realRoot;
326
+ try {
327
+ realRoot = fs.realpathSync(coordRoot);
328
+ } catch {
329
+ realRoot = coordRoot; // root itself may legitimately not be a link
330
+ }
331
+ if (!realDir.startsWith(realRoot + path.sep) && realDir !== realRoot) return null;
312
332
  const workers = safeReadDir(dir).filter(e => e.isDirectory());
313
333
  return {
314
334
  session,
@@ -147,12 +147,32 @@ function serveStaticFile(res, filePath) {
147
147
  }
148
148
 
149
149
  // Defensive — block path traversal, only allow files under STATIC_DIR.
150
- function resolveStatic(reqPath) {
150
+ // baseDir is injectable so the containment logic can be tested against a real
151
+ // symlink without planting one in the shipped static/ directory.
152
+ function resolveStatic(reqPath, baseDir = STATIC_DIR) {
151
153
  const decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
152
154
  if (decoded.includes('..')) return null;
153
- if (decoded === '' || decoded === '/') return path.join(STATIC_DIR, 'index.html');
154
- const abs = path.resolve(STATIC_DIR, decoded);
155
- if (!abs.startsWith(STATIC_DIR + path.sep) && abs !== path.join(STATIC_DIR, 'index.html')) return null;
155
+ if (decoded === '' || decoded === '/') return path.join(baseDir, 'index.html');
156
+ const abs = path.resolve(baseDir, decoded);
157
+ if (!abs.startsWith(baseDir + path.sep) && abs !== path.join(baseDir, 'index.html')) return null;
158
+
159
+ // The check above is purely lexical, and path.resolve does not resolve
160
+ // symlinks — so a link sitting lexically inside baseDir passes it while
161
+ // readFile follows it to a target anywhere on disk. Canonicalize and re-check
162
+ // against the real destination, which is the only one that matters.
163
+ let real;
164
+ try {
165
+ real = fs.realpathSync(abs);
166
+ } catch {
167
+ return null; // missing file or dangling link — 404 either way
168
+ }
169
+ let realBase;
170
+ try {
171
+ realBase = fs.realpathSync(baseDir);
172
+ } catch {
173
+ realBase = baseDir;
174
+ }
175
+ if (!real.startsWith(realBase + path.sep) && real !== path.join(realBase, 'index.html')) return null;
156
176
  return abs;
157
177
  }
158
178
 
@@ -160,8 +180,15 @@ function resolveStatic(reqPath) {
160
180
 
161
181
  function handleRequest(req, res) {
162
182
  // DNS-rebinding defence: only respond to requests targeting localhost.
163
- const reqHost = (req.headers.host || '').split(':')[0];
164
- if (reqHost !== '' && reqHost !== '127.0.0.1' && reqHost !== 'localhost') {
183
+ // A missing or empty Host is treated as INVALID, not as valid. The original
184
+ // `reqHost !== ''` short-circuited the whole condition whenever the header was
185
+ // absent, so an HTTP/1.0 request — or any raw socket writing "Host:" with no
186
+ // value — sailed past the rebinding guard and got the private-data APIs.
187
+ // Deny by default: only an explicit localhost Host is allowed through.
188
+ // Hostnames are case-insensitive (RFC 3986), so LOCALHOST is a legitimate
189
+ // spelling. Node already strips OWS around the field value.
190
+ const reqHost = (req.headers.host || '').split(':')[0].toLowerCase();
191
+ if (reqHost !== '127.0.0.1' && reqHost !== 'localhost') {
165
192
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
166
193
  return res.end(JSON.stringify({ ok: false, error: 'bad request' }));
167
194
  }