kodelyth-ecc 2.11.0 → 2.12.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,54 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.12.0 — `scripts/lib/safe-fs.js`: the guard the arena asked for (August 2026)
6
+
7
+ Across two arena runs the **same containment bug was confirmed four times** in
8
+ three unrelated files — `terse/compress.js`, `dashboard/data.js`,
9
+ `dashboard/server.js`. Every instance was this shape:
10
+
11
+ ```js
12
+ const abs = path.join(root, userInput);
13
+ if (!abs.startsWith(root + path.sep)) return null; // lexical only
14
+ fs.readFileSync(abs); // follows symlinks
15
+ ```
16
+
17
+ `path.join` and `path.resolve` normalise `..`, so a *textual* escape is caught.
18
+ Neither resolves symlinks. A link sitting lexically inside the root passes the
19
+ check while its target is anywhere on disk.
20
+
21
+ Four spot fixes would have been a fifth bug waiting. The arena's guard proposal
22
+ said to build the guard instead, so here it is.
23
+
24
+ ### Added — `scripts/lib/safe-fs.js`
25
+
26
+ - **`resolveContained(candidate, root)`** — canonicalises both sides, so a link
27
+ is judged by where it *points*, not where it sits. Rejects intermediate
28
+ directory symlinks and dangling links; still allows a symlinked root to serve
29
+ its own files, and a link that stays inside.
30
+ - **`statRegularFile(abs)`** — refuses symlinks and non-regular files outright,
31
+ for callers about to rewrite a file.
32
+ - **`safeConfigDir(value, fallback)`** — inspects the **raw** env value for `..`
33
+ before resolving. (The first draft resolved first and then looked for `..` —
34
+ dead code, since `path.resolve` collapses it. Its own test caught that.)
35
+ - **`writeNewFile(dest, data, mode)`** — `O_EXCL` so a dangling symlink cannot
36
+ redirect the write, plus `fchmod` so a restrictive umask cannot silently
37
+ narrow the mode.
38
+ - **`replaceFileAtomic(abs, contents, mode)`** — random temp name, rename, and
39
+ `finally`-unlink so a crash leaves no stray copy of the document.
40
+
41
+ **13 raw `realpath`/`lstat`/`openSync`/`rename` calls across four files became
42
+ zero.** All four now route through one 157-line module with **19 tests**.
43
+
44
+ ### Fixed — the ledger env var, which was never actually fixed
45
+
46
+ `KODELYTH_TERSE_DIR` was confirmed unvalidated in the first arena run, and the
47
+ run recorded it as *addressed* when no fix had been written. That accounting was
48
+ wrong. It now goes through `safeConfigDir`: a raw `..` falls back to the default,
49
+ a clean absolute path is still honoured.
50
+
51
+ **554 tests passing**, up from 535.
52
+
5
53
  ## v2.11.0 — Arena run #2: three containment bugs in the dashboard (August 2026)
6
54
 
7
55
  Pointed the arena at `scripts/dashboard` — the localhost HTTP server that serves
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
26
26
  bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
27
27
  actions/ → GitHub Action (CI/CD integration for PR review)
28
28
  docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
29
- tests/ → 535 passing tests across 29 test files
29
+ tests/ → 554 passing tests across 30 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/VERSION CHANGED
@@ -1 +1 @@
1
- 2.11.0
1
+ 2.12.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.11.0",
3
+ "version": "2.12.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",
@@ -19,6 +19,7 @@
19
19
  const fs = require('fs');
20
20
  const os = require('os');
21
21
  const path = require('path');
22
+ const safeFs = require('../lib/safe-fs.js');
22
23
 
23
24
  const ROOT = path.resolve(__dirname, '..', '..');
24
25
 
@@ -305,30 +306,12 @@ function sessionsList({ coordRoot = defaultCoordRoot(), limit = 30 } = {}) {
305
306
 
306
307
  function sessionDetail({ session, coordRoot = defaultCoordRoot() } = {}) {
307
308
  if (!session || session === '..' || session === '.') return null;
308
- const dir = path.join(coordRoot, session);
309
- // Containment check ensure the resolved path stays within coordRoot.
310
- // path.join normalises "..", so a textual escape is caught here.
311
- if (!dir.startsWith(coordRoot + path.sep) && dir !== coordRoot) return null;
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;
309
+ // Containment, including symlink resolution — see scripts/lib/safe-fs.js.
310
+ // A link sitting inside coordRoot passes a lexical check while pointing
311
+ // anywhere on disk, which handed the API task/handoff/status excerpts from
312
+ // outside the root.
313
+ const dir = safeFs.resolveContained(session, coordRoot);
314
+ if (!dir) return null;
332
315
  const workers = safeReadDir(dir).filter(e => e.isDirectory());
333
316
  return {
334
317
  session,
@@ -24,6 +24,7 @@ const http = require('http');
24
24
  const fs = require('fs');
25
25
  const path = require('path');
26
26
  const os = require('os');
27
+ const safeFs = require('../lib/safe-fs.js');
27
28
  const { execFileSync } = require('child_process');
28
29
 
29
30
  const data = require('./data.js');
@@ -153,27 +154,13 @@ function resolveStatic(reqPath, baseDir = STATIC_DIR) {
153
154
  const decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
154
155
  if (decoded.includes('..')) return null;
155
156
  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;
176
- return abs;
157
+ // Containment, including symlink resolution — see scripts/lib/safe-fs.js.
158
+ // path.resolve normalises ".." but does not resolve symlinks, so a link
159
+ // sitting lexically inside baseDir used to pass while readFile followed it
160
+ // anywhere on disk.
161
+ return safeFs.resolveContained(decoded, baseDir, {
162
+ allowExact: path.join(baseDir, 'index.html'),
163
+ });
177
164
  }
178
165
 
179
166
  // ── route handlers ───────────────────────────────────────────────────────────
@@ -0,0 +1,162 @@
1
+ // scripts/lib/safe-fs.js
2
+ //
3
+ // Path containment and safe file replacement, in one place.
4
+ //
5
+ // This module exists because the same bug was confirmed four times across three
6
+ // unrelated files — `scripts/terse/compress.js`, `scripts/dashboard/data.js`,
7
+ // and `scripts/dashboard/server.js`. Every instance was the same mistake:
8
+ //
9
+ // const abs = path.join(root, userInput);
10
+ // if (!abs.startsWith(root + path.sep)) return null; // lexical only
11
+ // fs.readFileSync(abs); // follows symlinks
12
+ //
13
+ // `path.join` and `path.resolve` normalise `..`, so a *textual* escape is
14
+ // caught. Neither resolves symlinks. A link sitting lexically inside the root
15
+ // passes the check while its target is anywhere on disk, and the read follows
16
+ // it. Four spot fixes would have been a fifth bug waiting; this is the guard.
17
+ //
18
+ // Everything here fails closed: on any doubt, return null or throw.
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const path = require('path');
24
+ const crypto = require('crypto');
25
+
26
+ // ── Containment ─────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Resolve `candidate` and confirm it really lives inside `root`.
30
+ *
31
+ * Both sides are canonicalised, so a symlink is judged by where it POINTS, not
32
+ * where it sits. Returns the resolved absolute path, or null if it escapes, is
33
+ * missing, or cannot be read.
34
+ *
35
+ * `allowExact` names a path that may equal the root itself (some callers serve
36
+ * an index file at the boundary).
37
+ */
38
+ function resolveContained(candidate, root, { allowExact = null } = {}) {
39
+ if (!candidate || !root) return null;
40
+
41
+ const absRoot = path.resolve(root);
42
+ const abs = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(absRoot, candidate);
43
+
44
+ // Cheap lexical rejection first — catches `..` without touching the disk.
45
+ const lexicallyInside = abs === absRoot || abs.startsWith(absRoot + path.sep);
46
+ if (!lexicallyInside && abs !== allowExact) return null;
47
+
48
+ // The check that actually matters. realpath resolves every symlink in the
49
+ // path, including intermediate directories.
50
+ let real;
51
+ try {
52
+ real = fs.realpathSync(abs);
53
+ } catch {
54
+ return null; // missing, dangling link, or unreadable
55
+ }
56
+
57
+ let realRoot;
58
+ try {
59
+ realRoot = fs.realpathSync(absRoot);
60
+ } catch {
61
+ realRoot = absRoot; // the root itself may legitimately not exist yet
62
+ }
63
+
64
+ const reallyInside = real === realRoot || real.startsWith(realRoot + path.sep);
65
+ if (!reallyInside && real !== allowExact) return null;
66
+
67
+ // Callers get the pre-realpath path so user-facing output keeps the name the
68
+ // caller asked for; containment has already been proven against the target.
69
+ return abs;
70
+ }
71
+
72
+ /**
73
+ * Stat a path that must be a regular file, refusing symlinks outright.
74
+ *
75
+ * Used where the caller is about to REWRITE the file: following a link there
76
+ * means writing through it to a target the user never named, and copying its
77
+ * contents into a backup beside the link.
78
+ */
79
+ function statRegularFile(absPath) {
80
+ let st;
81
+ try {
82
+ st = fs.lstatSync(absPath);
83
+ } catch {
84
+ throw new Error(`file not found: ${absPath}`);
85
+ }
86
+ if (st.isSymbolicLink()) throw new Error(`refusing to operate on a symlink: ${absPath}`);
87
+ if (!st.isFile()) throw new Error(`not a regular file: ${absPath}`);
88
+ return st;
89
+ }
90
+
91
+ /**
92
+ * A directory path taken from configuration (an env var, usually).
93
+ *
94
+ * This is a trusted-config surface — anyone who can set your environment
95
+ * already has leverage — but an unnormalised value silently creates trees
96
+ * wherever `..` points, so normalise and reject the obvious escapes.
97
+ */
98
+ function safeConfigDir(value, fallback) {
99
+ if (!value) return fallback;
100
+ const raw = String(value);
101
+
102
+ // Inspect the RAW value, not the resolved one. path.resolve collapses `..`
103
+ // before any check could see it — resolving first and then looking for `..`
104
+ // is dead code that always passes.
105
+ if (raw.split(/[\\/]/).includes('..')) return fallback;
106
+
107
+ return path.resolve(raw);
108
+ }
109
+
110
+ // ── Writing ─────────────────────────────────────────────────────────────────
111
+
112
+ /**
113
+ * Create a NEW file, refusing to follow a link or overwrite anything.
114
+ *
115
+ * `wx` is O_CREAT|O_EXCL|O_WRONLY: if `dest` exists — including as a *dangling*
116
+ * symlink, which `fs.existsSync` reports as absent — the open fails instead of
117
+ * writing through the link to a path someone else chose.
118
+ *
119
+ * The mode is applied twice on purpose. `open` filters its mode argument
120
+ * through the process umask, so a 0644 original would come back 0600 under
121
+ * `umask 077`; `fchmod` ignores the umask and restores it exactly. Passing it
122
+ * to `open` as well means the file is never briefly world-readable.
123
+ */
124
+ function writeNewFile(dest, data, mode) {
125
+ const fd = fs.openSync(dest, 'wx', mode);
126
+ try {
127
+ fs.writeFileSync(fd, data);
128
+ fs.fchmodSync(fd, mode);
129
+ } finally {
130
+ fs.closeSync(fd);
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Replace a file's contents atomically, preserving its permissions.
136
+ *
137
+ * Writes to a randomly-named sibling and renames over the target, so a crash
138
+ * cannot leave the user with a truncated file. The name is random rather than
139
+ * pid-based because a predictable one lets another process pre-plant a symlink
140
+ * there and capture the write. A failed rename unlinks the temp rather than
141
+ * leaving the document's contents in a stray world-readable file.
142
+ */
143
+ function replaceFileAtomic(absPath, contents, mode) {
144
+ const tmp = `${absPath}.tmp-${crypto.randomBytes(8).toString('hex')}`;
145
+ let renamed = false;
146
+ try {
147
+ writeNewFile(tmp, contents, mode);
148
+ fs.renameSync(tmp, absPath);
149
+ renamed = true;
150
+ } finally {
151
+ if (!renamed) { try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ } }
152
+ }
153
+ return absPath;
154
+ }
155
+
156
+ module.exports = {
157
+ resolveContained,
158
+ statRegularFile,
159
+ safeConfigDir,
160
+ writeNewFile,
161
+ replaceFileAtomic,
162
+ };
@@ -12,6 +12,7 @@
12
12
  const fs = require('fs');
13
13
  const path = require('path');
14
14
  const crypto = require('crypto');
15
+ const safeFs = require('../lib/safe-fs.js');
15
16
 
16
17
  // ── Substitutions: wordy connective → short form ────────────────────────────
17
18
  // These run BEFORE deletions. Order matters: "due to the fact that" must become
@@ -258,27 +259,6 @@ function compressText(source) {
258
259
  };
259
260
  }
260
261
 
261
- // Write `data` to `dest` with the given mode, refusing to follow a symlink and
262
- // refusing to overwrite anything that already exists.
263
- //
264
- // `wx` is O_CREAT|O_EXCL|O_WRONLY: if `dest` exists — including as a *dangling*
265
- // symlink, which `fs.existsSync` reports as absent — the open fails instead of
266
- // silently writing through the link to a path the attacker chose. The mode is
267
- // applied at create time so the file is never briefly world-readable.
268
- // The mode is passed to `open` so the file is never briefly world-readable, and
269
- // then applied again with fchmod: `open` filters its mode argument through the
270
- // process umask, so under `umask 077` a 0644 original would come back 0600.
271
- // fchmod ignores the umask, so the original permissions survive exactly.
272
- function writeNewFile(dest, data, mode) {
273
- const fd = fs.openSync(dest, 'wx', mode);
274
- try {
275
- fs.writeFileSync(fd, data);
276
- fs.fchmodSync(fd, mode);
277
- } finally {
278
- fs.closeSync(fd);
279
- }
280
- }
281
-
282
262
  // ── Public: compress a file, optionally write ───────────────────────────────
283
263
  //
284
264
  // Note on paths: `filePath` is deliberately unconfined — compressing
@@ -289,21 +269,9 @@ function writeNewFile(dest, data, mode) {
289
269
  function compressFile(filePath, { write = false, backup = true } = {}) {
290
270
  const abs = path.resolve(filePath);
291
271
 
292
- // lstat, not existsSync: we need to know whether this is a symlink *before*
293
- // reading through it. Following one would copy the link target's bytes into a
294
- // backup beside the link — which is how a 0600 secret ends up in a 0644 file.
295
- let st;
296
- try {
297
- st = fs.lstatSync(abs);
298
- } catch {
299
- throw new Error(`file not found: ${abs}`);
300
- }
301
- if (st.isSymbolicLink()) {
302
- throw new Error(`refusing to compress a symlink: ${abs}`);
303
- }
304
- if (!st.isFile()) {
305
- throw new Error(`not a regular file: ${abs}`);
306
- }
272
+ // Refuses symlinks: following one would copy the link target's bytes into a
273
+ // backup beside the link, which is how a 0600 secret ends up in a 0644 file.
274
+ const st = safeFs.statRegularFile(abs);
307
275
 
308
276
  // Check the size from the stat we already have, before reading. Otherwise an
309
277
  // oversized file is pulled fully into memory only to be rejected a line later.
@@ -336,7 +304,7 @@ function compressFile(filePath, { write = false, backup = true } = {}) {
336
304
  backupPath = `${abs}.pre-terse.bak`;
337
305
  for (let n = 1; ; n++) {
338
306
  try {
339
- writeNewFile(backupPath, source, mode);
307
+ safeFs.writeNewFile(backupPath, source, mode);
340
308
  break;
341
309
  } catch (err) {
342
310
  if (err.code !== 'EEXIST') throw err;
@@ -346,19 +314,8 @@ function compressFile(filePath, { write = false, backup = true } = {}) {
346
314
  }
347
315
  }
348
316
 
349
- // Write via temp + rename so a crash cannot truncate the user's file. The
350
- // suffix is random rather than the pid: a predictable name lets another
351
- // process pre-plant a symlink there and capture the write.
352
- const tmp = `${abs}.terse-tmp-${crypto.randomBytes(8).toString('hex')}`;
353
- let renamed = false;
354
- try {
355
- writeNewFile(tmp, output, mode);
356
- fs.renameSync(tmp, abs);
357
- renamed = true;
358
- } finally {
359
- // A failed rename leaves the document's content sitting in a stray file.
360
- if (!renamed) { try { fs.unlinkSync(tmp); } catch { /* nothing to clean */ } }
361
- }
317
+ // Atomic replace preserving the original mode see scripts/lib/safe-fs.js.
318
+ safeFs.replaceFileAtomic(abs, output, mode);
362
319
 
363
320
  return { path: abs, output, stats, wrote: true, backupPath };
364
321
  }
@@ -12,9 +12,15 @@
12
12
  const fs = require('fs');
13
13
  const os = require('os');
14
14
  const path = require('path');
15
+ const safeFs = require('../lib/safe-fs.js');
15
16
 
16
- const DIR = process.env.KODELYTH_TERSE_DIR
17
- || path.join(os.homedir(), '.kodelythecc', 'terse');
17
+ // The env var is a trusted-config surface — anyone who can set your environment
18
+ // already has leverage — but an unnormalised value silently creates a ledger
19
+ // tree wherever ".." points. safeConfigDir rejects those and falls back.
20
+ const DIR = safeFs.safeConfigDir(
21
+ process.env.KODELYTH_TERSE_DIR,
22
+ path.join(os.homedir(), '.kodelythecc', 'terse'),
23
+ );
18
24
  const LEDGER = path.join(DIR, 'ledger.jsonl');
19
25
 
20
26
  function ensureDir() { fs.mkdirSync(DIR, { recursive: true }); }