kodelyth-ecc 2.16.0 → 2.17.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,101 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.17.0 — ECC Immunity: the arena's findings, made executable (September 2026)
6
+
7
+ Four arena runs confirmed real bugs and stored them as prose. Prose does not
8
+ scan a codebase. This release turns each confirmed class into a **detector that
9
+ runs**, so a bug proven here can be found anywhere — including in repositories
10
+ that never ran the arena.
11
+
12
+ ```bash
13
+ kodelythecc immune # scan the current directory
14
+ kodelythecc immune scripts/ # scan a subtree
15
+ kodelythecc immune --json # machine-readable, exits 1 on any high finding
16
+ ```
17
+
18
+ ### How each detector earned its place
19
+
20
+ A detector ships only if it does **both**: FIRES on the exact source the arena
21
+ found the bug in, and goes SILENT on the source after the fix. Verified against
22
+ this repository's own history at v2.7.0, the last release before any arena fix:
23
+
24
+ ```
25
+ detector file BEFORE AFTER verdict
26
+ lexical-containment dashboard/data.js FIRES silent PROVEN
27
+ lexical-containment dashboard/server.js FIRES silent PROVEN
28
+ prototype-key-map memory/store.js FIRES silent PROVEN
29
+ truncate-then-write memory/store.js FIRES silent PROVEN
30
+ ```
31
+
32
+ `predictable-temp-name` is proven against a fixture rather than a commit: that
33
+ bug was caught by an EVIL agent in the working tree and fixed before it was ever
34
+ committed, so no historical version exists. Stated plainly rather than implied
35
+ to carry the same provenance.
36
+
37
+ ### Two classes were deliberately NOT shipped
38
+
39
+ This matters more than the four that were.
40
+
41
+ **ReDoS.** Real — 7.6 s on 293 KB. But arena run #1 *measured* five structurally
42
+ identical regexes in one file: fenced-code, inline-code, bare-URL and file-path
43
+ were all linear; only the link target was quadratic. The difference is whether
44
+ the closing delimiter is commonly absent in real input — a property of the data,
45
+ not the pattern. A detector would have flagged all five and been wrong about
46
+ four.
47
+
48
+ **Unbounded input.** Also real — 4.9 MB allocated ~417 MB. But the detectable
49
+ shape is "this module reads a file", which described **57 of 165 files** here.
50
+
51
+ A scanner that is wrong four times out of five gets muted, and a muted scanner
52
+ protects nothing.
53
+
54
+ ### What it found on its first real run
55
+
56
+ Pointed at ECC itself, in code four arena runs never audited:
57
+
58
+ - **`scripts/hooks/run-with-flags.js`** — lexical containment on a path that is
59
+ then **executed**. A symlink inside the plugin root would have run an
60
+ arbitrary script.
61
+ - **`scripts/lib/agent-compress.js`** — same class, on a read.
62
+ - **10 × `prototype-key-map`**, including `PACKAGE_MANAGERS['constructor']`,
63
+ which is truthy — so `setPreferredPackageManager('constructor')` skipped the
64
+ "Unknown package manager" throw entirely and persisted the bad name. Verified
65
+ live, then fixed with `Object.hasOwn`.
66
+ - **2 × durable state rewritten with `writeFileSync`** — the update-check cache
67
+ and the engagement state file.
68
+
69
+ It also caught a spot **I had missed**: `restampIndex` in the memory store still
70
+ used a raw `writeFileSync` after the 2.14.0 atomic-write sweep converted
71
+ `saveIndex` beside it.
72
+
73
+ ```
74
+ raw detectors 104 findings (12 high, 92 medium)
75
+ dropped 2 imprecise classes 12 findings (10 high, 2 medium)
76
+ fixed every real finding 0 findings
77
+ ```
78
+
79
+ ### Added
80
+
81
+ - `scripts/immune/detectors.js` — pure, executable detectors
82
+ - `scripts/immune/scan.js` — tree walker that skips `node_modules`, test
83
+ fixtures, and never follows a symlink out of the tree
84
+ - `kodelythecc immune` and `/immune`
85
+ - 28 tests, including one asserting the scanner does not flag its own
86
+ documentation, and one pinning the fixtures to real git history when it is
87
+ available
88
+
89
+ ### Fixed — found while building it
90
+
91
+ `stripNoise` originally stripped string literals too, and the apostrophe in
92
+ "don't" opened a span that swallowed regex literals several lines later. That
93
+ silenced a detector on a real, confirmed bug. Regex-based lexing of JavaScript
94
+ strings is not reliable enough to build detection on; only comments are stripped
95
+ now. And `walk`'s `maxFiles` cap was checked between directories but not within
96
+ one, so a single large directory blew straight past it — caught by its own test.
97
+
98
+ **604 tests passing**, up from 576.
99
+
5
100
  ## v2.16.0 — Arena run #4: three path-escapes in evolve (September 2026)
6
101
 
7
102
  ### Fixed — `applyProposalToDisk` wrote wherever the proposal told it to
package/CLAUDE.md CHANGED
@@ -8,7 +8,7 @@ Guidance for Claude Code when working with this repository.
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
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`, ...)
11
+ - **103 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,7 +18,7 @@ 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/ → 102 slash commands (8 parallel multi-agent, 1 adversarial loop, rest single-agent)
21
+ commands/ → 103 slash commands (8 parallel multi-agent, 1 adversarial loop, rest single-agent)
22
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, ...)
@@ -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/ → 576 passing tests across 30 test files
29
+ tests/ → 604 passing tests across 32 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.16.0
1
+ 2.17.0
@@ -431,6 +431,58 @@ if (args[0] === 'memory') {
431
431
  // kodelythecc arena list list past arena runs
432
432
  // kodelythecc arena learn <run-id> [--commit] remember what the run proved
433
433
  // kodelythecc arena report <run-id> show a run's report
434
+ // ── Subcommand: immune (scan for arena-confirmed bug classes) ────────────────
435
+ // kodelyth-ecc immune [path] [--json] [--tests]
436
+ if (args[0] === 'immune') {
437
+ const rest = args.slice(1);
438
+ const w = (m) => process.stdout.write(m + '\n');
439
+ const target = rest.find((a) => !a.startsWith('--')) || '.';
440
+ const { scanTree } = require(path.join(ROOT, 'scripts', 'immune', 'scan.js'));
441
+ const { DETECTORS } = require(path.join(ROOT, 'scripts', 'immune', 'detectors.js'));
442
+
443
+ let res;
444
+ try {
445
+ res = scanTree(target, { includeTests: rest.includes('--tests') });
446
+ } catch (err) {
447
+ process.stderr.write(`immune: cannot scan ${target}: ${err.message}\n`);
448
+ process.exit(2);
449
+ }
450
+
451
+ if (rest.includes('--json')) { w(JSON.stringify(res, null, 2)); process.exit(res.counts.high > 0 ? 1 : 0); }
452
+
453
+ w('');
454
+ w(`\x1b[1mECC Immunity\x1b[0m — ${DETECTORS.length} bug classes the arena confirmed, checked against \x1b[33m${target}\x1b[0m`);
455
+ w('─'.repeat(72));
456
+
457
+ if (!res.findings.length) {
458
+ w(`\x1b[32mClean.\x1b[0m ${res.scanned} files scanned, none matched a known class.`);
459
+ w('');
460
+ w('This is not proof of correctness — only that none of the specific classes');
461
+ w('the arena has confirmed are present. Run /arena to hunt for new ones.');
462
+ w('');
463
+ process.exit(0);
464
+ }
465
+
466
+ const colour = { high: '31', medium: '33', low: '90' };
467
+ for (const f of res.findings) {
468
+ w(`\x1b[${colour[f.severity] || '0'}m${f.severity.toUpperCase().padEnd(6)}\x1b[0m ${f.file}:${f.line}`);
469
+ w(` ${f.title}`);
470
+ w(` \x1b[90m${f.evidence}\x1b[0m`);
471
+ w(` \x1b[36mfix:\x1b[0m ${f.fix}`);
472
+ w('');
473
+ }
474
+
475
+ w('─'.repeat(72));
476
+ w(`${res.counts.total} finding(s) across ${res.scanned} files — ` +
477
+ `${res.counts.high} high, ${res.counts.medium} medium, ${res.counts.low} low`);
478
+ w('');
479
+ w('Each class here was reproduced with a real repro during an arena run, and');
480
+ w('each detector is pinned to the commit that proves it fires on the bug and');
481
+ w('stays silent on the fix.');
482
+ w('');
483
+ process.exit(res.counts.high > 0 ? 1 : 0);
484
+ }
485
+
434
486
  if (args[0] === 'god' || args[0] === 'evil' || args[0] === 'arena') {
435
487
  const mode = args[0];
436
488
  const rest = args.slice(1);
@@ -0,0 +1,89 @@
1
+ ---
2
+ description: Scan any codebase for the bug classes the arena has actually confirmed — executable detectors, each pinned to the commit that proves it.
3
+ argument-hint: "[path]"
4
+ ---
5
+
6
+ # /immune — the arena's findings, made executable
7
+
8
+ The arena finds bugs one repository at a time and remembers them as prose. Prose
9
+ does not scan a codebase. `/immune` turns each **confirmed** class into a
10
+ detector that runs, so a bug proven here can be found anywhere — including in
11
+ repositories that never ran the arena.
12
+
13
+ ```bash
14
+ kodelythecc immune # scan the current directory
15
+ kodelythecc immune scripts/ # scan a subtree
16
+ kodelythecc immune --json # machine-readable
17
+ kodelythecc immune --tests # include test files (normally skipped)
18
+ ```
19
+
20
+ Exits non-zero when anything **high** is found, so it drops straight into CI.
21
+
22
+ ## What it looks for
23
+
24
+ Every detector below encodes a class that was reproduced with a real repro
25
+ during an arena run. Nothing here is hypothetical.
26
+
27
+ | Detector | Confirmed | What it catches |
28
+ |---|---|---|
29
+ | `lexical-containment` | 5× | `startsWith(root)` with no `realpath` — a symlink inside the root passes while pointing anywhere on disk |
30
+ | `prototype-key-map` | 1× | `map['constructor']` is truthy, so `if (!map[k])` never fires |
31
+ | `truncate-then-write` | 3× | `writeFileSync` on durable state truncates to zero before writing |
32
+ | `predictable-temp-name` | 1× | a pid-derived temp path can be pre-planted with a symlink |
33
+
34
+ ## How each detector earns its place
35
+
36
+ A detector ships only if it does **both**:
37
+
38
+ 1. **FIRES** on the exact source the arena found the bug in
39
+ 2. Goes **SILENT** on the source after the fix
40
+
41
+ Both halves matter. A detector that misses the real bug is decoration; one that
42
+ still fires after the fix is a false positive. The fixtures in
43
+ `tests/immune/detectors.test.js` are real code quoted from this repository at
44
+ v2.7.0 — the last release before any arena fix landed.
45
+
46
+ ## Two classes were deliberately NOT shipped
47
+
48
+ This matters more than the four that were.
49
+
50
+ **ReDoS.** The class is real: an unbounded quantifier cost 7.6 s on 293 KB. But
51
+ arena run #1 *measured* five structurally identical regexes in one file — the
52
+ fenced-code, inline-code, bare-URL and file-path patterns were all linear, and
53
+ only the link-target pattern was quadratic. The difference is whether the
54
+ closing delimiter is commonly absent in real input: a property of the data, not
55
+ the pattern, and invisible to a scanner. A detector would have flagged all five
56
+ and been wrong about four.
57
+
58
+ **Unbounded input.** Also real — 4.9 MB allocated ~417 MB and OOMed under a
59
+ 256 MB heap. But the detectable shape is "this module reads a file", which
60
+ described 57 of 165 files here. The actual bug is reading *untrusted* input
61
+ without a cap, and a scanner cannot tell which reads are untrusted.
62
+
63
+ Both stay where they were actually caught: measurement under `/arena`, and human
64
+ review under `security-reviewer`.
65
+
66
+ > A scanner that is wrong four times out of five gets muted, and a muted scanner
67
+ > protects nothing. Precision over recall, deliberately.
68
+
69
+ ## What a clean result means
70
+
71
+ ```
72
+ Clean. 165 files scanned, none matched a known class.
73
+ ```
74
+
75
+ That is **not** proof of correctness. It means none of the specific classes the
76
+ arena has confirmed are present. New classes are found by running `/arena`, and
77
+ each new confirmation is a candidate for a new detector.
78
+
79
+ ## The loop this closes
80
+
81
+ ```
82
+ /arena ──▶ confirmed findings ──▶ memories ──▶ detectors ──▶ /immune
83
+ ▲ │
84
+ └────────────── new classes found in new code ◀────────────────────┘
85
+ ```
86
+
87
+ `/arena` is expensive and thorough — agents, rounds, real repros. `/immune` is
88
+ instant and narrow. Use the arena to *discover* a class; use immune to make sure
89
+ it never comes back, anywhere.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.16.0",
3
+ "version": "2.17.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",
@@ -135,7 +135,7 @@ function validateInstallManifests() {
135
135
  const expectedProfileIds = ['core', 'developer', 'security', 'research', 'full'];
136
136
 
137
137
  for (const profileId of expectedProfileIds) {
138
- if (!profiles[profileId]) {
138
+ if (!Object.hasOwn(profiles, profileId)) {
139
139
  console.error(`ERROR: Missing required install profile: ${profileId}`);
140
140
  hasErrors = true;
141
141
  }
@@ -8,6 +8,7 @@ const https = require('https');
8
8
  const fs = require('fs');
9
9
  const os = require('os');
10
10
  const path = require('path');
11
+ const safeFs = require('../lib/safe-fs.js');
11
12
 
12
13
  const CACHE_DIR = path.join(os.homedir(), '.kodelythecc');
13
14
  const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json');
@@ -23,7 +24,7 @@ function readCache() {
23
24
 
24
25
  function writeCache(latest) {
25
26
  try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch {}
26
- try { fs.writeFileSync(CACHE_FILE, JSON.stringify({ at: Date.now(), latest })); } catch {}
27
+ try { safeFs.replaceFilePreservingMode(CACHE_FILE, JSON.stringify({ at: Date.now(), latest })); } catch {}
27
28
  }
28
29
 
29
30
  function fetchLatest() {
@@ -10,6 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
+ const safeFs = require('../lib/safe-fs.js');
13
14
  const { spawnSync } = require('child_process');
14
15
  const { isHookEnabled } = require('../lib/hook-flags');
15
16
 
@@ -103,8 +104,10 @@ async function main() {
103
104
  const resolvedRoot = path.resolve(pluginRoot);
104
105
  const scriptPath = path.resolve(pluginRoot, relScriptPath);
105
106
 
106
- // Prevent path traversal outside the plugin root
107
- if (!scriptPath.startsWith(resolvedRoot + path.sep)) {
107
+ // Prevent path traversal outside the plugin root — including via a symlink.
108
+ // The lexical check alone passed for a link sitting inside the root, and this
109
+ // path is EXECUTED, so following one runs an arbitrary script.
110
+ if (!safeFs.resolveContained(scriptPath, resolvedRoot)) {
108
111
  process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
109
112
  process.stdout.write(raw);
110
113
  process.exit(0);
@@ -0,0 +1,220 @@
1
+ // scripts/immune/detectors.js
2
+ //
3
+ // Executable detectors for the bug classes the arena has actually confirmed.
4
+ //
5
+ // The arena finds bugs and remembers them as prose. Prose does not scan a
6
+ // codebase. Each detector below encodes one class that was reproduced with a
7
+ // real repro during an arena run, so the knowledge becomes something that can
8
+ // run against any repository instead of only informing the next conversation.
9
+ //
10
+ // Every detector is proven against git history: it must FIRE on the exact
11
+ // pre-fix source the arena found the bug in, and stay SILENT on the fixed
12
+ // source. A detector that cannot do both does not ship. See
13
+ // tests/immune/detectors.test.js, which pins each one to a real commit.
14
+ //
15
+ // Precision over recall, deliberately. A scanner that cries wolf gets muted,
16
+ // and a muted scanner protects nothing.
17
+
18
+ 'use strict';
19
+
20
+ // Strip comments and string literals before matching. Without this, every
21
+ // detector fires on its own documentation and on the changelog entry describing
22
+ // the bug it looks for — which is exactly how a scanner earns its mute button.
23
+ // Blank out COMMENTS ONLY, preserving line numbers.
24
+ //
25
+ // An earlier version also stripped string literals, which broke on the first
26
+ // apostrophe in ordinary prose ("don't") — the quote opened a span that ate
27
+ // everything to the next quote, including regex literals several lines later.
28
+ // The unbounded-quantifier detector went silent on a real, confirmed bug
29
+ // because of it. Regex-based lexing of JavaScript strings is not reliable
30
+ // enough to build detection on.
31
+ //
32
+ // Comments are the only noise that actually matters: a detector describing the
33
+ // bug it hunts would otherwise flag its own documentation.
34
+ function stripNoise(src) {
35
+ return src
36
+ // Block comments — keep newlines so line numbers stay correct.
37
+ .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
38
+ // Line comments, but not the "//" inside a URL like https://example.com.
39
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, (m, lead) => lead + ' '.repeat(m.length - lead.length));
40
+ }
41
+
42
+ function lineOf(src, index) {
43
+ return src.slice(0, index).split('\n').length;
44
+ }
45
+
46
+ // ── The classes ─────────────────────────────────────────────────────────────
47
+
48
+ const DETECTORS = [
49
+ {
50
+ id: 'lexical-containment',
51
+ severity: 'high',
52
+ confirmed: 5,
53
+ title: 'Path containment checked lexically, without resolving symlinks',
54
+ why:
55
+ 'path.join and path.resolve normalise ".." but do not resolve symlinks. A link ' +
56
+ 'sitting lexically inside the root passes a startsWith() check while its target is ' +
57
+ 'anywhere on disk, and the read or write follows it.',
58
+ fix: 'Canonicalise both sides with fs.realpathSync before comparing, or route through a shared guard.',
59
+ detect(src) {
60
+ const clean = stripNoise(src);
61
+ // A containment comparison against a root-ish identifier...
62
+ const guard = /\.startsWith\(\s*([A-Za-z_$][\w$]*(?:Root|Dir|DIR|Base|BASE|dir|base))\s*\+/g;
63
+ const out = [];
64
+ let m;
65
+ while ((m = guard.exec(clean))) {
66
+ // ...that is never accompanied by a realpath anywhere in the file.
67
+ if (/realpathSync|realpath\(/.test(clean)) continue;
68
+ // ...and the file actually touches the filesystem with that path.
69
+ if (!/fs\.(?:readFileSync|readdirSync|writeFileSync|createReadStream|openSync|statSync)/.test(clean)) continue;
70
+ out.push({ line: lineOf(clean, m.index), evidence: `startsWith(${m[1]} + ...) with no realpath in the file` });
71
+ }
72
+ return out;
73
+ },
74
+ },
75
+
76
+ {
77
+ id: 'prototype-key-map',
78
+ severity: 'high',
79
+ confirmed: 1,
80
+ title: 'Plain object used as a map keyed by user-controlled text',
81
+ why:
82
+ 'map["constructor"] returns Object.prototype.constructor, which is TRUTHY, so an ' +
83
+ 'if (!map[k]) guard never fires and the next line reads a property off a function. ' +
84
+ '"constructor" and "toString" are ordinary vocabulary — this needs no attacker.',
85
+ fix: 'Use Object.create(null) for any map keyed by tokens, tags, headers, or filenames.',
86
+ detect(src) {
87
+ const clean = stripNoise(src);
88
+ const out = [];
89
+ // The exact shape: guard-then-index on a dynamically keyed map.
90
+ const re = /if\s*\(\s*!\s*([A-Za-z_$][\w$.]*)\[\s*([A-Za-z_$][\w$]*)\s*\]\s*\)/g;
91
+ let m;
92
+ while ((m = re.exec(clean))) {
93
+ const [, map, key] = m;
94
+ // A numeric-looking key (i, idx, n) is an array index, not a text map.
95
+ if (/^(?:i|j|k|n|idx|index)$/.test(key)) continue;
96
+ // Fires only when the map is never given a null prototype.
97
+ if (new RegExp(`${map.split('.').pop()}\\s*=\\s*Object\\.create\\(null\\)`).test(clean)) continue;
98
+ if (/Object\.create\(null\)/.test(clean) && new RegExp(`${map.split('.').pop()}:\\s*nullMap`).test(clean)) continue;
99
+ out.push({ line: lineOf(clean, m.index), evidence: `if (!${map}[${key}]) — ${map} is keyed by a dynamic value and has a prototype` });
100
+ }
101
+ return out;
102
+ },
103
+ },
104
+
105
+ {
106
+ id: 'truncate-then-write',
107
+ severity: 'medium',
108
+ confirmed: 3,
109
+ title: 'fs.writeFileSync used to replace persistent state',
110
+ why:
111
+ "writeFileSync opens with 'w', truncating the file to zero BEFORE writing. A crash, " +
112
+ 'a full disk, or a concurrent reader sees an empty file — measured at 0 bytes on a ' +
113
+ '6.3 MB store, 32 torn reads in 1423 samples.',
114
+ fix: 'Write to a temp sibling and rename, or use safeFs.replaceFilePreservingMode.',
115
+ detect(src) {
116
+ const clean = stripNoise(src);
117
+ const out = [];
118
+ const re = /fs\.writeFileSync\(\s*([A-Za-z_$][\w$.]*)/g;
119
+ let m;
120
+ while ((m = re.exec(clean))) {
121
+ const target = m[1];
122
+ // A temp file being written before a rename is the CORRECT pattern.
123
+ if (/tmp|temp/i.test(target)) continue;
124
+ // Only a module-level constant or a PATHS.x member reads as durable
125
+ // user state. A local `filePath` loop variable is usually generated
126
+ // output — regenerable, and truncating it costs nothing. Matching those
127
+ // made this fire on half the CI scripts, which is how a scanner earns
128
+ // its mute button.
129
+ const durable = /^[A-Z][A-Z0-9_]*$/.test(target) || /^PATHS\.[a-z]/i.test(target);
130
+ if (!durable) continue;
131
+ if (/renameSync/.test(clean)) continue; // already doing temp+rename
132
+ out.push({ line: lineOf(clean, m.index), evidence: `fs.writeFileSync(${target}, ...) with no temp+rename in the file` });
133
+ }
134
+ return out;
135
+ },
136
+ },
137
+
138
+ // REMOVED: 'unbounded-quantifier' (ReDoS).
139
+ //
140
+ // The class is real — an unbounded negated class cost 7.6 s on 293 KB — but it
141
+ // cannot be detected statically with acceptable precision. Arena run #1
142
+ // MEASURED five structurally identical regexes in one file: the fenced-code,
143
+ // inline-code, bare-URL and file-path patterns were all linear, and only the
144
+ // link-target pattern was quadratic. The difference is whether the closing
145
+ // delimiter is commonly absent in real input — a property of the data, not of
146
+ // the pattern, and invisible to a scanner.
147
+ //
148
+ // A detector here would have flagged all five and been wrong about four. A
149
+ // scanner that is wrong four times out of five gets muted, and a muted scanner
150
+ // protects nothing. ReDoS stays where it was actually caught: measurement,
151
+ // under /arena, with a doubling ladder.
152
+
153
+ {
154
+ id: 'predictable-temp-name',
155
+ severity: 'low',
156
+ confirmed: 1,
157
+ title: 'Temp filename derived from the process id',
158
+ why:
159
+ 'A predictable temp name lets another process pre-plant a symlink there and capture ' +
160
+ 'the write. It also collides between concurrent runs.',
161
+ fix: 'Use crypto.randomBytes for the suffix and create with O_EXCL.',
162
+ detect(src) {
163
+ const clean = stripNoise(src);
164
+ const out = [];
165
+ const re = /process\.pid/g;
166
+ let m;
167
+ while ((m = re.exec(clean))) {
168
+ const around = clean.slice(Math.max(0, m.index - 160), m.index + 80);
169
+ if (!/tmp|temp|\.lock|pidfile|pidFile/i.test(around)) continue;
170
+ if (/pidFile|pidfile/i.test(around)) continue; // writing a real pidfile is fine
171
+ out.push({ line: lineOf(clean, m.index), evidence: 'temp path built from process.pid' });
172
+ }
173
+ return out;
174
+ },
175
+ },
176
+
177
+ // REMOVED: 'unbounded-input'.
178
+ //
179
+ // The class is real — 4.9 MB of dense input allocated ~417 MB and OOMed under
180
+ // a 256 MB heap — but the detectable shape is "this module reads a file",
181
+ // which described 57 of 165 files here. The actual bug is reading UNTRUSTED
182
+ // input with no cap, and a scanner cannot tell which reads are untrusted.
183
+ //
184
+ // Kept as a review question for security-reviewer and code-reviewer, where a
185
+ // human can judge the trust boundary, rather than as a detector that flags a
186
+ // third of every codebase it touches.
187
+ ];
188
+
189
+ function detectorById(id) {
190
+ return DETECTORS.find((d) => d.id === id) || null;
191
+ }
192
+
193
+ // Run every detector over one file's source.
194
+ function scanSource(src, file = '<source>') {
195
+ const findings = [];
196
+ for (const d of DETECTORS) {
197
+ let hits = [];
198
+ try {
199
+ hits = d.detect(src) || [];
200
+ } catch {
201
+ continue; // a detector must never take the scan down with it
202
+ }
203
+ for (const h of hits) {
204
+ findings.push({
205
+ detector: d.id,
206
+ severity: d.severity,
207
+ title: d.title,
208
+ file,
209
+ line: h.line,
210
+ evidence: h.evidence,
211
+ why: d.why,
212
+ fix: d.fix,
213
+ confirmedByArena: d.confirmed,
214
+ });
215
+ }
216
+ }
217
+ return findings;
218
+ }
219
+
220
+ module.exports = { DETECTORS, detectorById, scanSource, _internals: { stripNoise, lineOf } };
@@ -0,0 +1,99 @@
1
+ // scripts/immune/scan.js
2
+ //
3
+ // Walk a tree and apply every arena-derived detector to it.
4
+ //
5
+ // The arena finds bugs one repository at a time and remembers them as prose.
6
+ // This turns that memory into something executable, so a class confirmed here
7
+ // can be found anywhere — including in the repositories of people who never ran
8
+ // the arena at all.
9
+
10
+ 'use strict';
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { scanSource, DETECTORS } = require('./detectors');
15
+
16
+ const SKIP_DIRS = new Set([
17
+ 'node_modules', '.git', 'dist', 'build', 'coverage', '.next',
18
+ 'out', 'vendor', '__pycache__', '.cache', 'tmp',
19
+ ]);
20
+
21
+ // Tests deliberately contain the buggy shapes they guard against, so scanning
22
+ // them reports the fixtures rather than the code that ships.
23
+ function isSkippable(rel) {
24
+ return /(?:^|[\\/])(?:tests?|__tests__|spec)[\\/]/.test(rel) || /\.(?:test|spec)\.[cm]?js$/.test(rel);
25
+ }
26
+
27
+ function walk(root, { includeTests = false, maxFiles = 5000 } = {}) {
28
+ const out = [];
29
+ const stack = [root];
30
+ while (stack.length && out.length < maxFiles) {
31
+ const dir = stack.pop();
32
+ let entries;
33
+ try {
34
+ entries = fs.readdirSync(dir, { withFileTypes: true });
35
+ } catch {
36
+ continue; // unreadable directory is not a scan failure
37
+ }
38
+ for (const e of entries) {
39
+ const abs = path.join(dir, e.name);
40
+ if (e.isSymbolicLink()) continue; // never follow links out of the tree
41
+ if (e.isDirectory()) {
42
+ if (!SKIP_DIRS.has(e.name)) stack.push(abs);
43
+ continue;
44
+ }
45
+ if (!/\.[cm]?js$/.test(e.name)) continue;
46
+ const rel = path.relative(root, abs);
47
+ if (!includeTests && isSkippable(rel)) continue;
48
+ out.push(abs);
49
+ // The cap has to be enforced HERE, not only between directories — a single
50
+ // large directory would otherwise push every entry before the outer loop
51
+ // re-checked, and maxFiles would not actually cap anything.
52
+ if (out.length >= maxFiles) return out.sort();
53
+ }
54
+ }
55
+ return out.sort();
56
+ }
57
+
58
+ const SEVERITY_RANK = { high: 3, medium: 2, low: 1 };
59
+
60
+ function scanTree(root, opts = {}) {
61
+ const absRoot = path.resolve(root);
62
+ const files = walk(absRoot, opts);
63
+ const findings = [];
64
+ let scanned = 0;
65
+
66
+ for (const abs of files) {
67
+ let src;
68
+ try {
69
+ src = fs.readFileSync(abs, 'utf8');
70
+ } catch {
71
+ continue;
72
+ }
73
+ scanned++;
74
+ for (const f of scanSource(src, path.relative(absRoot, abs))) findings.push(f);
75
+ }
76
+
77
+ findings.sort((a, b) =>
78
+ (SEVERITY_RANK[b.severity] || 0) - (SEVERITY_RANK[a.severity] || 0) ||
79
+ a.file.localeCompare(b.file) || a.line - b.line);
80
+
81
+ const byDetector = {};
82
+ for (const f of findings) byDetector[f.detector] = (byDetector[f.detector] || 0) + 1;
83
+
84
+ return {
85
+ root: absRoot,
86
+ scanned,
87
+ detectors: DETECTORS.length,
88
+ findings,
89
+ byDetector,
90
+ counts: {
91
+ total: findings.length,
92
+ high: findings.filter((f) => f.severity === 'high').length,
93
+ medium: findings.filter((f) => f.severity === 'medium').length,
94
+ low: findings.filter((f) => f.severity === 'low').length,
95
+ },
96
+ };
97
+ }
98
+
99
+ module.exports = { scanTree, walk, isSkippable, SKIP_DIRS };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const safeFs = require('./safe-fs.js');
5
6
 
6
7
  /**
7
8
  * Parse YAML frontmatter from a markdown string.
@@ -222,9 +223,11 @@ function lazyLoadAgent(agentsDir, agentName) {
222
223
 
223
224
  const filePath = path.resolve(agentsDir, `${agentName}.md`);
224
225
 
225
- // Verify the resolved path is still within agentsDir
226
- const resolvedAgentsDir = path.resolve(agentsDir);
227
- if (!filePath.startsWith(resolvedAgentsDir + path.sep)) {
226
+ // Containment, including symlinks. The previous check compared the JOINED
227
+ // path, which for a symlink is the link's own location — inside agentsDir, so
228
+ // it passed — while the target could be anywhere. Found by `kodelythecc
229
+ // immune`, which encodes this class from five prior arena confirmations.
230
+ if (!safeFs.resolveContained(filePath, agentsDir)) {
228
231
  return null;
229
232
  }
230
233
 
@@ -239,7 +239,7 @@ function getPackageManager(options = {}) {
239
239
  * Set user's preferred package manager (global)
240
240
  */
241
241
  function setPreferredPackageManager(pmName) {
242
- if (!PACKAGE_MANAGERS[pmName]) {
242
+ if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
243
243
  throw new Error(`Unknown package manager: ${pmName}`);
244
244
  }
245
245
 
@@ -260,7 +260,7 @@ function setPreferredPackageManager(pmName) {
260
260
  * Set project's preferred package manager
261
261
  */
262
262
  function setProjectPackageManager(pmName, projectDir = process.cwd()) {
263
- if (!PACKAGE_MANAGERS[pmName]) {
263
+ if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
264
264
  throw new Error(`Unknown package manager: ${pmName}`);
265
265
  }
266
266
 
@@ -281,7 +281,7 @@ function listAliases(options = {}) {
281
281
  function deleteAlias(alias) {
282
282
  const data = loadAliases();
283
283
 
284
- if (!data.aliases[alias]) {
284
+ if (!Object.hasOwn(data.aliases, alias)) {
285
285
  return { success: false, error: `Alias '${alias}' not found` };
286
286
  }
287
287
 
@@ -308,7 +308,7 @@ function deleteAlias(alias) {
308
308
  function renameAlias(oldAlias, newAlias) {
309
309
  const data = loadAliases();
310
310
 
311
- if (!data.aliases[oldAlias]) {
311
+ if (!Object.hasOwn(data.aliases, oldAlias)) {
312
312
  return { success: false, error: `Alias '${oldAlias}' not found` };
313
313
  }
314
314
 
@@ -386,7 +386,7 @@ function updateAliasTitle(alias, title) {
386
386
 
387
387
  const data = loadAliases();
388
388
 
389
- if (!data.aliases[alias]) {
389
+ if (!Object.hasOwn(data.aliases, alias)) {
390
390
  return { success: false, error: `Alias '${alias}' not found` };
391
391
  }
392
392
 
@@ -15,7 +15,7 @@ function rankCounts(values) {
15
15
  function summarizeVariantRuns(records) {
16
16
  return records.reduce((accumulator, record) => {
17
17
  const key = record.run && record.run.variant ? record.run.variant : 'baseline';
18
- if (!accumulator[key]) {
18
+ if (!Object.hasOwn(accumulator, key)) {
19
19
  accumulator[key] = { runs: 0, successes: 0, failures: 0 };
20
20
  }
21
21
 
@@ -80,7 +80,7 @@ function addServer({ name, command, args = [], env = {}, description = '' }) {
80
80
 
81
81
  function removeServer(name) {
82
82
  const reg = loadRegistry();
83
- if (!reg.servers[name]) return false;
83
+ if (!Object.hasOwn(reg.servers, name)) return false;
84
84
  delete reg.servers[name];
85
85
  saveRegistry(reg);
86
86
  return true;
@@ -154,7 +154,7 @@ function restampIndex() {
154
154
  if (!fs.existsSync(PATHS.index)) return;
155
155
  const idx = JSON.parse(fs.readFileSync(PATHS.index, 'utf8'));
156
156
  idx.logSize = logSize();
157
- fs.writeFileSync(PATHS.index, JSON.stringify(idx, null, 2));
157
+ safeFs.replaceFilePreservingMode(PATHS.index, JSON.stringify(idx, null, 2));
158
158
  } catch { /* a broken index is rebuilt on the next load anyway */ }
159
159
  }
160
160
 
@@ -113,7 +113,7 @@ function listAvailable() {
113
113
  }
114
114
 
115
115
  function setGlobal(pmName) {
116
- if (!PACKAGE_MANAGERS[pmName]) {
116
+ if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
117
117
  console.error(`Error: Unknown package manager "${pmName}"`);
118
118
  console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
119
119
  process.exit(1);
@@ -136,7 +136,7 @@ function setGlobal(pmName) {
136
136
  }
137
137
 
138
138
  function setProject(pmName) {
139
- if (!PACKAGE_MANAGERS[pmName]) {
139
+ if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
140
140
  console.error(`Error: Unknown package manager "${pmName}"`);
141
141
  console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
142
142
  process.exit(1);