kodelyth-ecc 2.9.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 +100 -0
- package/CLAUDE.md +7 -6
- package/README.md +4 -2
- package/VERSION +1 -1
- package/bin/kodelyth-ecc.js +8 -2
- package/package.json +1 -1
- package/scripts/arena/arena.js +4 -0
- package/scripts/arena/learn.js +22 -0
- package/scripts/dashboard/data.js +131 -0
- package/scripts/dashboard/server.js +37 -6
- package/scripts/dashboard/static/index.html +126 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,106 @@
|
|
|
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
|
+
|
|
63
|
+
## v2.10.0 — Arena dashboard tab + docs (phases 5 & 6) (August 2026)
|
|
64
|
+
|
|
65
|
+
### Added — Arena tab in the dashboard
|
|
66
|
+
|
|
67
|
+
The tab answers one question: **did the attacker give up?**
|
|
68
|
+
|
|
69
|
+
- **Convergence trend** per run, drawn as a block-character sparkline — no chart
|
|
70
|
+
library, no CDN, legible at one round or twenty. Rounds that surface nothing
|
|
71
|
+
render green; a round worse than the last renders red.
|
|
72
|
+
- **Still open** — confirmed findings GOD has *not* answered for, ranked by real
|
|
73
|
+
risk (`severity × confidence × exploitability`).
|
|
74
|
+
- **Recurring bug classes** — the same class twice is flagged `recurring`, because
|
|
75
|
+
one is an incident and several is a process gap.
|
|
76
|
+
- Runs that began with recalled memories are marked `recalled`.
|
|
77
|
+
|
|
78
|
+
Read-only. The dashboard never writes to your memory store.
|
|
79
|
+
|
|
80
|
+
`GET /api/arena[?limit=N]` → `{ available, runs, open, classes, totals }`. Raw
|
|
81
|
+
findings are stripped from the wire payload — the page needs counts and the open
|
|
82
|
+
list, not every finding on every run.
|
|
83
|
+
|
|
84
|
+
### Fixed — a confirmed finding is not the same as an open one
|
|
85
|
+
|
|
86
|
+
`closeRound` recorded *how many* findings were left outstanding but not *which
|
|
87
|
+
ones*, so nothing downstream could tell a confirmed-and-fixed bug from a
|
|
88
|
+
confirmed-and-ignored one. The dashboard's first draft reported all ten fixed
|
|
89
|
+
findings as open risk — a healthy run reading as alarming, which is exactly
|
|
90
|
+
backwards. Rounds now persist `addressedIds`.
|
|
91
|
+
|
|
92
|
+
### Added — `docs/arena.md`
|
|
93
|
+
|
|
94
|
+
A full feature doc: why a loop beats a review pass, what makes a finding count,
|
|
95
|
+
how convergence is decided, the compound-learning return path, guard proposals,
|
|
96
|
+
and cost control. Wired into the sitemap, the docs index, and `dashboard.md`.
|
|
97
|
+
|
|
98
|
+
### Fixed — stale counts
|
|
99
|
+
|
|
100
|
+
The README and `CLAUDE.md` advertised 194 skills and 97 commands; the real
|
|
101
|
+
figures are 196 and 102. Both now match what is on disk.
|
|
102
|
+
|
|
103
|
+
**525 tests passing**, up from 516.
|
|
104
|
+
|
|
5
105
|
## v2.9.0 — Compound learning: the arena now remembers (phase 4) (August 2026)
|
|
6
106
|
|
|
7
107
|
A finished arena run used to be knowledge thrown away. Every new run started from
|
package/CLAUDE.md
CHANGED
|
@@ -7,8 +7,8 @@ Guidance for Claude Code when working with this repository.
|
|
|
7
7
|
**Kodelyth ECC** — a production-grade AI coding toolkit:
|
|
8
8
|
|
|
9
9
|
- **70 specialist agents** — debug-detective, incident-commander, load-tester, image-architect, kodelyth-memory, security-reviewer, plus 8 adversarial devil-mode agents
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
10
|
+
- **196 skills** — domain knowledge, patterns, testing, security, intent routing, local memory, swarm orchestration, MCP integration
|
|
11
|
+
- **102 commands** — slash workflows (`/tdd`, `/plan`, `/code-review`, `/team-review`, `/devil-mode`, `/debug-blitz`, `/security-audit`, ...)
|
|
12
12
|
- **22+ hooks** — quality gates, memory inject + capture, correction encoding, prompt-injection guard, token-budget enforcer
|
|
13
13
|
- **14 rules** — always-on coding standards + semantic intent routing + memory protocol + self-improvement
|
|
14
14
|
|
|
@@ -18,15 +18,15 @@ Works with Claude Code, Windsurf, Cursor, Codex CLI, Antigravity, OpenCode, Clin
|
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
agents/ → 70 specialist subagents (planner, code-reviewer, debug-detective, devil-mode crew, ...)
|
|
21
|
-
commands/ →
|
|
22
|
-
skills/ →
|
|
21
|
+
commands/ → 102 slash commands (8 parallel multi-agent, 1 adversarial loop, rest single-agent)
|
|
22
|
+
skills/ → 196 workflow + domain knowledge files (loadable via slash commands)
|
|
23
23
|
hooks/ → 22+ automations (pre-commit, session memory, prompt-injection guard, token-budget)
|
|
24
24
|
rules/ → 14 always-on guidelines (agent-intent-routing, self-improvement, memory-protocol, ...)
|
|
25
25
|
scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router, memory, supply-chain
|
|
26
26
|
bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
|
|
27
27
|
actions/ → GitHub Action (CI/CD integration for PR review)
|
|
28
|
-
docs/ → Feature docs (mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
|
|
29
|
-
tests/ →
|
|
28
|
+
docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
|
|
29
|
+
tests/ → 535 passing tests across 29 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
|
@@ -80,6 +80,7 @@ These fire multiple specialist agents simultaneously:
|
|
|
80
80
|
| `/pre-release` | release-captain + security-reviewer + code-reviewer | Go/no-go verdict before shipping |
|
|
81
81
|
| `/onboard` | code-explorer + architect + doc-updater | Understand any codebase in 15 minutes |
|
|
82
82
|
| `/devil-mode` | prompt-injection-hunter + supply-chain-auditor + secret-hunter + backdoor-hunter | Adversarial sweep (use `--all` for all 8) |
|
|
83
|
+
| `/arena` | GOD crew vs EVIL crew, looped | Ship something that must not break — runs until the attacker gives up |
|
|
83
84
|
|
|
84
85
|
## Key Commands
|
|
85
86
|
|
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
|
|
34
34
|
</div>
|
|
35
35
|
|
|
36
|
-
**Kodelyth ECC** is a production-grade AI coding toolkit — **70 specialist agents (incl. an 8-agent devil-mode adversarial crew),
|
|
36
|
+
**Kodelyth ECC** is a production-grade AI coding toolkit — **70 specialist agents (incl. an 8-agent devil-mode adversarial crew), 196 skills, 102 commands**, a god-tier **semantic intent-routing system**, local self-learning memory, MCP server, swarm orchestrator, and an observability dashboard — all local, zero telemetry.
|
|
37
37
|
|
|
38
38
|
Now bundled with:
|
|
39
39
|
|
|
@@ -73,7 +73,7 @@ You never typed `use debug-detective`. You didn't have to. The toolkit read the
|
|
|
73
73
|
| **Intent routing** | Plain-language → right specialist via 10-tier priority rules | Mostly missing — you memorize names |
|
|
74
74
|
| **70 agents** | Specialists with playbooks, severity calibration, real commands | Often persona-only ("you are a senior engineer...") |
|
|
75
75
|
| **194 skills** | Domain knowledge files agents read on demand | Rarely separated from agents |
|
|
76
|
-
| **
|
|
76
|
+
| **102 commands** | Slash workflows (`/tdd`, `/arena`, `/devil-mode`, `/team-review`) | Limited or none |
|
|
77
77
|
| **8 parallel commands** | Fire 3-8 agents simultaneously, aggregate results | Rare |
|
|
78
78
|
| **Compound memory** | BM25 local recall + auto-inject + project lessons | Cloud-only or absent |
|
|
79
79
|
| **22+ hooks** | Quality gates, secret scan, project-DNA detection | Often missing |
|
|
@@ -92,6 +92,7 @@ You never typed `use debug-detective`. You didn't have to. The toolkit read the
|
|
|
92
92
|
| **Local BM25 self-learning memory** | ✅ | ❌ | ❌ | ❌ |
|
|
93
93
|
| **Compound learning from corrections** | ✅ `tasks/lessons.md` | ❌ | ❌ | ❌ |
|
|
94
94
|
| **Adversarial / red-team agents** | ✅ 8 (devil-mode) | ❌ | ❌ | ❌ |
|
|
95
|
+
| **Adversarial build/attack loop** | ✅ `/arena` — scored, verified, converges | ❌ | ❌ | ❌ |
|
|
95
96
|
| Quality hooks | ✅ 22+ | Some | ❌ | ❌ |
|
|
96
97
|
| IDE platforms | **11** (Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, Roo Code, Aider, Kimi, Gemini CLI) | 1-2 | 1 | Varies |
|
|
97
98
|
| Telemetry | ❌ none | Varies | ❌ | Varies |
|
|
@@ -372,6 +373,7 @@ Eight commands fire multiple specialist agents simultaneously and aggregate thei
|
|
|
372
373
|
| `/pre-release` | release-captain + security-reviewer + code-reviewer | 30 min → 8 min |
|
|
373
374
|
| `/onboard` | code-explorer + architect + doc-updater | 45 min → 12 min |
|
|
374
375
|
| `/devil-mode` | 8 adversarial agents (see below) | Hours → 20 min |
|
|
376
|
+
| `/arena` | GOD crew vs EVIL crew, looped until the attacker gives up | Days → 1 session |
|
|
375
377
|
|
|
376
378
|
Each command waits for all agents to complete, then returns a single **Team Review Report** with findings bucketed by severity: CRITICAL → HIGH → MEDIUM → LOW.
|
|
377
379
|
|
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/arena.js
CHANGED
|
@@ -176,6 +176,10 @@ function closeRound(run) {
|
|
|
176
176
|
});
|
|
177
177
|
|
|
178
178
|
// Attach whether GOD actually finished its side of the round.
|
|
179
|
+
// Persist WHICH findings GOD answered for, not just how many are left over.
|
|
180
|
+
// Without the ids, nothing downstream can tell a confirmed-and-fixed finding
|
|
181
|
+
// from confirmed-and-ignored — the dashboard would report every fix as open risk.
|
|
182
|
+
verdict.addressedIds = [...(run.pending.addressedIds || [])];
|
|
179
183
|
verdict.godComplete = completion.complete;
|
|
180
184
|
verdict.unverifiedArtifacts = completion.unverifiedArtifacts;
|
|
181
185
|
verdict.outstandingFindings = completion.outstandingFindings;
|
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,
|
|
@@ -580,9 +600,120 @@ function tokenBudgetSnapshot({ budgetDir = defaultBudgetDir() } = {}) {
|
|
|
580
600
|
return { sessions: sessions.slice(0, 50), total_tokens: total };
|
|
581
601
|
}
|
|
582
602
|
|
|
603
|
+
// ── Arena ────────────────────────────────────────────────────────────────────
|
|
604
|
+
//
|
|
605
|
+
// The dashboard's job here is to make ONE thing legible: did the attacker give
|
|
606
|
+
// up? A run whose new-finding count falls to zero is converging. A flat or
|
|
607
|
+
// rising line means the code has deeper problems, or the scope is too broad for
|
|
608
|
+
// EVIL to ever exhaust — either way, look before shipping.
|
|
609
|
+
|
|
610
|
+
function arenaSnapshot({ runLimit = 20 } = {}) {
|
|
611
|
+
let arenaState = null;
|
|
612
|
+
let learn = null;
|
|
613
|
+
try { arenaState = require('../arena/state.js'); } catch { /* arena is optional */ }
|
|
614
|
+
try { learn = require('../arena/learn.js'); } catch { /* */ }
|
|
615
|
+
if (!arenaState) return { available: false, runs: [], classes: [], open: [], totals: {} };
|
|
616
|
+
|
|
617
|
+
let list = [];
|
|
618
|
+
try { list = arenaState.listRuns() || []; } catch { /* no runs yet */ }
|
|
619
|
+
|
|
620
|
+
const runs = list.slice(0, Math.max(1, Math.min(100, runLimit))).map(meta => {
|
|
621
|
+
let run = null;
|
|
622
|
+
try { run = arenaState.load(meta.runId); } catch { /* skip unreadable */ }
|
|
623
|
+
if (!run) return null;
|
|
624
|
+
|
|
625
|
+
const rounds = run.rounds || [];
|
|
626
|
+
const settled = [];
|
|
627
|
+
const seen = new Set();
|
|
628
|
+
for (const r of rounds) {
|
|
629
|
+
for (const f of r.findings || []) {
|
|
630
|
+
if (seen.has(f.id)) continue;
|
|
631
|
+
seen.add(f.id);
|
|
632
|
+
settled.push(f);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return {
|
|
637
|
+
runId: run.runId,
|
|
638
|
+
task: run.task,
|
|
639
|
+
scope: run.scope || '.',
|
|
640
|
+
status: run.status,
|
|
641
|
+
stopReason: run.stopReason || null,
|
|
642
|
+
startedAt: run.startedAt,
|
|
643
|
+
rounds: rounds.length,
|
|
644
|
+
tokens: run.spent?.tokens || 0,
|
|
645
|
+
// The trend IS the story: new findings per round, which should fall to zero.
|
|
646
|
+
trend: rounds.map(r => r.counts?.new || 0),
|
|
647
|
+
confirmed: settled.filter(f => f.verdict === 'confirmed').length,
|
|
648
|
+
refuted: settled.filter(f => f.verdict === 'refuted').length,
|
|
649
|
+
unverified: settled.filter(f => f.verdict === 'unverified').length,
|
|
650
|
+
artifacts: rounds.reduce((n, r) => n + (r.artifacts?.length || 0), 0),
|
|
651
|
+
recalled: run.priorKnowledge ? true : false,
|
|
652
|
+
findings: settled,
|
|
653
|
+
};
|
|
654
|
+
}).filter(Boolean);
|
|
655
|
+
|
|
656
|
+
// Still-open risk across every run, worst first. A confirmed finding nobody
|
|
657
|
+
// fixed is the single most useful thing this page can surface.
|
|
658
|
+
const open = [];
|
|
659
|
+
// Findings GOD answered for are not open risk. Counting a confirmed-and-fixed
|
|
660
|
+
// bug as outstanding would make a healthy run look alarming.
|
|
661
|
+
const addressed = new Set();
|
|
662
|
+
for (const meta of list.slice(0, runLimit)) {
|
|
663
|
+
try {
|
|
664
|
+
const full = arenaState.load(meta.runId);
|
|
665
|
+
for (const rd of full?.rounds || []) for (const id of rd.addressedIds || []) addressed.add(id);
|
|
666
|
+
} catch { /* */ }
|
|
667
|
+
}
|
|
668
|
+
for (const r of runs) {
|
|
669
|
+
for (const f of r.findings) {
|
|
670
|
+
if (f.verdict !== 'confirmed') continue;
|
|
671
|
+
if (addressed.has(f.id)) continue; // GOD answered for this one
|
|
672
|
+
open.push({
|
|
673
|
+
runId: r.runId, scope: r.scope, title: f.title,
|
|
674
|
+
file: f.file, line: f.line, severity: f.severity,
|
|
675
|
+
risk: f.risk || 0,
|
|
676
|
+
class: learn ? learn.classify(f) : null,
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
open.sort((a, b) => b.risk - a.risk);
|
|
681
|
+
|
|
682
|
+
// Which bug classes keep coming back — the signal that a guard belongs upstream.
|
|
683
|
+
const classCount = new Map();
|
|
684
|
+
if (learn) {
|
|
685
|
+
for (const r of runs) {
|
|
686
|
+
for (const f of r.findings) {
|
|
687
|
+
if (f.verdict !== 'confirmed') continue;
|
|
688
|
+
const c = learn.classify(f);
|
|
689
|
+
classCount.set(c, (classCount.get(c) || 0) + 1);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
const classes = [...classCount.entries()]
|
|
694
|
+
.map(([name, count]) => ({ name, count }))
|
|
695
|
+
.sort((a, b) => b.count - a.count);
|
|
696
|
+
|
|
697
|
+
const totals = {
|
|
698
|
+
runs: runs.length,
|
|
699
|
+
converged: runs.filter(r => r.status === 'converged').length,
|
|
700
|
+
confirmed: runs.reduce((n, r) => n + r.confirmed, 0),
|
|
701
|
+
refuted: runs.reduce((n, r) => n + r.refuted, 0),
|
|
702
|
+
artifacts: runs.reduce((n, r) => n + r.artifacts, 0),
|
|
703
|
+
tokens: runs.reduce((n, r) => n + r.tokens, 0),
|
|
704
|
+
};
|
|
705
|
+
|
|
706
|
+
// Drop the raw findings from the wire payload — the page needs the counts and
|
|
707
|
+
// the open list, not every finding on every run.
|
|
708
|
+
const wireRuns = runs.map(({ findings, ...rest }) => rest);
|
|
709
|
+
return { available: true, runs: wireRuns, open: open.slice(0, 40), classes, totals };
|
|
710
|
+
}
|
|
711
|
+
|
|
583
712
|
module.exports = {
|
|
584
713
|
// overview
|
|
585
714
|
overview,
|
|
715
|
+
// arena
|
|
716
|
+
arenaSnapshot,
|
|
586
717
|
// memory
|
|
587
718
|
memoryStats,
|
|
588
719
|
recentMemories,
|
|
@@ -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
|
}
|
|
@@ -304,6 +331,10 @@ function handleRequest(req, res) {
|
|
|
304
331
|
return jsonResponse(res, 200, { ok: true, ...s });
|
|
305
332
|
}
|
|
306
333
|
|
|
334
|
+
if (p === '/api/arena') {
|
|
335
|
+
return jsonResponse(res, 200, data.arenaSnapshot({ runLimit: Number(q.get('limit')) || 20 }));
|
|
336
|
+
}
|
|
337
|
+
|
|
307
338
|
if (p === '/api/codebase') {
|
|
308
339
|
const cb = require('../codebase/index.js');
|
|
309
340
|
return jsonResponse(res, 200, cb.dashboardSnapshot());
|
|
@@ -223,6 +223,7 @@
|
|
|
223
223
|
<button data-tab="rtk">Token Savings</button>
|
|
224
224
|
<button data-tab="memory">Memory</button>
|
|
225
225
|
<button data-tab="codebase">Codebase</button>
|
|
226
|
+
<button data-tab="arena">Arena</button>
|
|
226
227
|
<button data-tab="evolve">Evolve</button>
|
|
227
228
|
<button data-tab="catalog">Catalog</button>
|
|
228
229
|
<button data-tab="sessions">Sessions</button>
|
|
@@ -320,6 +321,50 @@ kodelyth-ecc codebase query detect_changes '{}'</pre>
|
|
|
320
321
|
</section>
|
|
321
322
|
|
|
322
323
|
<!-- ───────── EVOLVE ───────── -->
|
|
324
|
+
<!-- ───────── ARENA ───────── -->
|
|
325
|
+
<section data-panel="arena" hidden>
|
|
326
|
+
<div class="grid" id="arenaCards"></div>
|
|
327
|
+
<hr class="sep">
|
|
328
|
+
<div class="card">
|
|
329
|
+
<h2>Did the attacker give up?</h2>
|
|
330
|
+
<p class="muted" style="font-size:12.5px;margin:-4px 0 14px;">
|
|
331
|
+
New findings per round. Falling to zero means EVIL ran out of ideas.
|
|
332
|
+
A flat or rising line means stop and look — either the code has deeper
|
|
333
|
+
problems, or the scope is too broad to ever exhaust.
|
|
334
|
+
</p>
|
|
335
|
+
<div id="arenaRuns">loading…</div>
|
|
336
|
+
</div>
|
|
337
|
+
<hr class="sep">
|
|
338
|
+
<div class="row">
|
|
339
|
+
<div class="card">
|
|
340
|
+
<h2>Still open</h2>
|
|
341
|
+
<p class="muted" style="font-size:12.5px;margin:-4px 0 12px;">
|
|
342
|
+
Confirmed and not yet answered for, worst risk first.
|
|
343
|
+
</p>
|
|
344
|
+
<div id="arenaOpen">loading…</div>
|
|
345
|
+
</div>
|
|
346
|
+
<div class="card">
|
|
347
|
+
<h2>Recurring bug classes</h2>
|
|
348
|
+
<p class="muted" style="font-size:12.5px;margin:-4px 0 12px;">
|
|
349
|
+
One is an incident. Several is a process gap — the guard belongs upstream.
|
|
350
|
+
</p>
|
|
351
|
+
<div id="arenaClasses">loading…</div>
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
<hr class="sep">
|
|
355
|
+
<div class="card">
|
|
356
|
+
<h2>Compound learning</h2>
|
|
357
|
+
<p class="muted" style="font-size:12.5px;margin:-4px 0 10px;">
|
|
358
|
+
Store what a run proved so the next run on that scope starts informed:
|
|
359
|
+
</p>
|
|
360
|
+
<div><code>kodelyth-ecc arena learn <run-id> --commit</code></div>
|
|
361
|
+
<p class="muted" style="margin-top:12px;font-size:12.5px;">
|
|
362
|
+
Nothing is written without <code>--commit</code>. The dashboard NEVER
|
|
363
|
+
writes to your memory store.
|
|
364
|
+
</p>
|
|
365
|
+
</div>
|
|
366
|
+
</section>
|
|
367
|
+
|
|
323
368
|
<section data-panel="evolve" hidden>
|
|
324
369
|
<div class="grid" id="evolveCards"></div>
|
|
325
370
|
<hr class="sep">
|
|
@@ -527,6 +572,86 @@ kodelyth-ecc codebase query detect_changes '{}'</pre>
|
|
|
527
572
|
}
|
|
528
573
|
|
|
529
574
|
// ───── evolve ─────
|
|
575
|
+
// A compact sparkline made of block characters — no chart library, no CDN,
|
|
576
|
+
// and it stays legible when the run has one round or twenty.
|
|
577
|
+
function trendBar(trend) {
|
|
578
|
+
if (!trend || !trend.length) return '<span class="dim">no rounds yet</span>';
|
|
579
|
+
const max = Math.max(...trend, 1);
|
|
580
|
+
const blocks = '▁▂▃▄▅▆▇█';
|
|
581
|
+
return trend.map((n, i) => {
|
|
582
|
+
const idx = n === 0 ? 0 : Math.min(blocks.length - 1, Math.ceil((n / max) * (blocks.length - 1)));
|
|
583
|
+
const colour = n === 0 ? '#22c55e' : (i > 0 && n < trend[i - 1] ? '#eab308' : '#ef4444');
|
|
584
|
+
return `<span title="round ${i + 1}: ${n} new" style="color:${colour};font-size:18px;line-height:1;">${blocks[idx]}</span>`;
|
|
585
|
+
}).join('');
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function arenaStatusPill(status) {
|
|
589
|
+
const map = {
|
|
590
|
+
converged: ['ok', 'converged'],
|
|
591
|
+
running: ['', 'running'],
|
|
592
|
+
exhausted: ['pending', 'max rounds'],
|
|
593
|
+
aborted: ['pending', 'stopped'],
|
|
594
|
+
};
|
|
595
|
+
const [cls, label] = map[status] || ['', status || 'unknown'];
|
|
596
|
+
return `<span class="pill ${cls}">${escapeHtml(label)}</span>`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
async function loadArena() {
|
|
600
|
+
try {
|
|
601
|
+
const a = await api('/api/arena');
|
|
602
|
+
if (!a.available) {
|
|
603
|
+
$('#arenaCards').innerHTML = '';
|
|
604
|
+
$('#arenaRuns').innerHTML = '<div class="empty">Arena is not installed.</div>';
|
|
605
|
+
$('#arenaOpen').innerHTML = '';
|
|
606
|
+
$('#arenaClasses').innerHTML = '';
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
$('#arenaCards').innerHTML = [
|
|
611
|
+
statCard(a.totals.runs, 'Runs'),
|
|
612
|
+
statCard(a.totals.confirmed, 'Bugs confirmed'),
|
|
613
|
+
statCard(a.totals.refuted, 'False positives caught'),
|
|
614
|
+
statCard(a.open.length, 'Still open'),
|
|
615
|
+
statCard(a.totals.artifacts, 'Verified fixes'),
|
|
616
|
+
].join('');
|
|
617
|
+
|
|
618
|
+
$('#arenaRuns').innerHTML = a.runs.length
|
|
619
|
+
? `<div class="table-wrap"><table>
|
|
620
|
+
<thead><tr><th>Run</th><th>Scope</th><th>Trend</th><th>Rounds</th><th>Confirmed</th><th>Refuted</th><th>Tokens</th><th>Status</th></tr></thead>
|
|
621
|
+
<tbody>${a.runs.map(r => `<tr>
|
|
622
|
+
<td><span class="strong">${escapeHtml(r.task || r.runId)}</span>${r.recalled ? ' <span class="pill" title="started with recalled memories from past runs">recalled</span>' : ''}</td>
|
|
623
|
+
<td><code>${escapeHtml(r.scope)}</code></td>
|
|
624
|
+
<td>${trendBar(r.trend)}</td>
|
|
625
|
+
<td>${r.rounds}</td>
|
|
626
|
+
<td>${r.confirmed}</td>
|
|
627
|
+
<td>${r.refuted}</td>
|
|
628
|
+
<td>${(r.tokens || 0).toLocaleString()}</td>
|
|
629
|
+
<td>${arenaStatusPill(r.status)}</td>
|
|
630
|
+
</tr>`).join('')}</tbody></table></div>`
|
|
631
|
+
: '<div class="empty">No arena runs yet. Start one with <code>kodelyth-ecc arena start --task "..."</code></div>';
|
|
632
|
+
|
|
633
|
+
$('#arenaOpen').innerHTML = a.open.length
|
|
634
|
+
? a.open.map(f => `<div style="margin-bottom:12px;">
|
|
635
|
+
<div class="strong">${escapeHtml(f.title)}</div>
|
|
636
|
+
<div class="muted" style="font-size:12px;margin-top:3px;">
|
|
637
|
+
<span class="pill">${escapeHtml(f.severity)}</span>
|
|
638
|
+
risk ${f.risk}${f.class ? ` · ${escapeHtml(f.class)}` : ''}
|
|
639
|
+
${f.file ? ` · <code>${escapeHtml(f.file)}${f.line ? ':' + f.line : ''}</code>` : ''}
|
|
640
|
+
</div>
|
|
641
|
+
</div>`).join('')
|
|
642
|
+
: '<div class="empty">Nothing open. Every confirmed finding was either fixed or refuted.</div>';
|
|
643
|
+
|
|
644
|
+
$('#arenaClasses').innerHTML = a.classes.length
|
|
645
|
+
? a.classes.map(c => `<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
|
|
646
|
+
<span>${escapeHtml(c.name)}${c.count > 1 ? ' <span class="pill pending">recurring</span>' : ''}</span>
|
|
647
|
+
<span class="strong">${c.count}</span>
|
|
648
|
+
</div>`).join('')
|
|
649
|
+
: '<div class="empty">No confirmed findings yet.</div>';
|
|
650
|
+
} catch (err) {
|
|
651
|
+
$('#arenaRuns').innerHTML = `<div class="empty">Could not load arena data: ${escapeHtml(String(err.message || err))}</div>`;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
530
655
|
async function loadEvolve() {
|
|
531
656
|
try {
|
|
532
657
|
const e = await api('/api/evolve');
|
|
@@ -838,6 +963,7 @@ kodelyth-ecc codebase query detect_changes '{}'</pre>
|
|
|
838
963
|
if (tab === 'rtk') loadRtk();
|
|
839
964
|
if (tab === 'memory') loadMemory();
|
|
840
965
|
if (tab === 'codebase') loadCodebase();
|
|
966
|
+
if (tab === 'arena') loadArena();
|
|
841
967
|
if (tab === 'evolve') loadEvolve();
|
|
842
968
|
if (tab === 'catalog') loadCatalog();
|
|
843
969
|
if (tab === 'sessions') { loadIdeSessions(); loadSessions(); }
|