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 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
- - **194 skills** — domain knowledge, patterns, testing, security, intent routing, local memory, swarm orchestration, MCP integration
11
- - **97 commands** — slash workflows (`/tdd`, `/plan`, `/code-review`, `/team-review`, `/devil-mode`, `/debug-blitz`, `/security-audit`, ...)
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/ → 97 slash commands (8 parallel multi-agent, rest single-agent)
22
- skills/ → 194 workflow + domain knowledge files (loadable via slash commands)
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/ → 516 passing tests across 27 test files
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), 194 skills, 97 commands**, a god-tier **semantic intent-routing system**, local self-learning memory, MCP server, swarm orchestrator, and an observability dashboard — all local, zero telemetry.
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
- | **97 commands** | Slash workflows (`/tdd`, `/devil-mode`, `/team-review`) | Limited or none |
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.9.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.9.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",
@@ -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;
@@ -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
- 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
  }
@@ -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 &lt;run-id&gt; --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(); }