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 +58 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/bin/kodelyth-ecc.js +8 -2
- package/package.json +1 -1
- package/scripts/arena/learn.js +22 -0
- package/scripts/dashboard/data.js +20 -0
- package/scripts/dashboard/server.js +33 -6
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/ →
|
|
29
|
+
tests/ → 535 passing tests across 29 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.11.0
|
package/bin/kodelyth-ecc.js
CHANGED
|
@@ -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
|
-
|
|
505
|
-
|
|
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.
|
|
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",
|
package/scripts/arena/learn.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
154
|
-
const abs = path.resolve(
|
|
155
|
-
if (!abs.startsWith(
|
|
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
|
-
|
|
164
|
-
|
|
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
|
}
|