kodelyth-ecc 2.10.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,112 @@
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
+
53
+ ## v2.11.0 — Arena run #2: three containment bugs in the dashboard (August 2026)
54
+
55
+ Pointed the arena at `scripts/dashboard` — the localhost HTTP server that serves
56
+ static files and returns your private memory store. Round 1 found **3 findings,
57
+ all 3 confirmed by executed repro**. Round 2 attacked the fixes across 7 vectors
58
+ and found **nothing new**.
59
+
60
+ All three are **low severity** and the reasoning matters: the server is
61
+ localhost-only, read-only, GET-only, and each finding needs local write access
62
+ that already grants the same data. None is a browser-reachable hole. They are
63
+ containment bugs worth closing, not emergencies.
64
+
65
+ ### Fixed — three containment gaps
66
+
67
+ - **`sessionDetail` followed symlinks out of the coordination root.** The check
68
+ compared the *joined* path, which for a symlink is the link's own location —
69
+ inside the root, so it passed — while the target was anywhere on disk. It
70
+ returned real `task.md` / `handoff.md` / `status.md` excerpts from outside.
71
+ - **`resolveStatic` followed symlinks out of `STATIC_DIR`.** Same class:
72
+ `path.resolve` does not resolve symlinks, so a lexically-contained link passed
73
+ the guard and `readFile` followed it. `/etc/hosts` was readable through it.
74
+ - **An empty or absent `Host` header bypassed the DNS-rebinding guard.** The
75
+ condition read `reqHost !== '' && ...`, so a missing Host short-circuited the
76
+ whole check to false. An HTTP/1.0 request reached the private-data APIs with
77
+ `200 OK`. Browsers always send Host, so this was never browser-reachable.
78
+
79
+ All three now canonicalize with `realpathSync` and re-check against the real
80
+ destination; the Host guard denies by default.
81
+
82
+ ### Fixed — `Host` comparison is now case-insensitive
83
+
84
+ Hostnames are case-insensitive per RFC 3986, so `Host: LOCALHOST` was a
85
+ legitimate spelling being rejected.
86
+
87
+ ### Fixed — arena recall ignored scope
88
+
89
+ Every arena memory carries the tag `arena`, and recall is BM25 — so a query
90
+ mentioning the arena matched **all** of them regardless of origin. The first
91
+ dashboard run was handed all 10 `scripts/terse` findings and told they were
92
+ *"confirmed here previously."* That is false, and it would have sent EVIL hunting
93
+ for `compress.js` bugs in an HTTP server. Recall is now filtered to memories
94
+ whose files actually live in the scope.
95
+
96
+ ### Added — `access-control` bug class
97
+
98
+ The Host-header bypass classified as `uncategorized`. Its guard advice: *deny by
99
+ default — an allowlist, with every absent or empty case treated as invalid rather
100
+ than waved through.*
101
+
102
+ ### Compound learning is earning its keep
103
+
104
+ `filesystem-symlink` is now confirmed **4 times across 3 files** — `compress.js`,
105
+ `data.js`, and `server.js`. Every one is the same mistake: a lexical containment
106
+ check that a symlink walks straight through. The guard proposal says what to do
107
+ about it — a shared path-safety helper, rather than a fourth spot fix.
108
+
109
+ **535 tests passing**, up from 525.
110
+
5
111
  ## v2.10.0 — Arena dashboard tab + docs (phases 5 & 6) (August 2026)
6
112
 
7
113
  ### Added — Arena tab in the dashboard
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
26
26
  bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
27
27
  actions/ → GitHub Action (CI/CD integration for PR review)
28
28
  docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
29
- tests/ → 525 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.10.0
1
+ 2.12.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.10.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",
@@ -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,
@@ -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,10 +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
- if (!dir.startsWith(coordRoot + path.sep) && dir !== coordRoot) return null;
311
- if (!fs.existsSync(dir)) 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;
312
315
  const workers = safeReadDir(dir).filter(e => e.isDirectory());
313
316
  return {
314
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');
@@ -147,21 +148,34 @@ function serveStaticFile(res, filePath) {
147
148
  }
148
149
 
149
150
  // Defensive — block path traversal, only allow files under STATIC_DIR.
150
- function resolveStatic(reqPath) {
151
+ // baseDir is injectable so the containment logic can be tested against a real
152
+ // symlink without planting one in the shipped static/ directory.
153
+ function resolveStatic(reqPath, baseDir = STATIC_DIR) {
151
154
  const decoded = decodeURIComponent(reqPath.replace(/^\/+/, ''));
152
155
  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;
156
- return abs;
156
+ if (decoded === '' || decoded === '/') return path.join(baseDir, 'index.html');
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
+ });
157
164
  }
158
165
 
159
166
  // ── route handlers ───────────────────────────────────────────────────────────
160
167
 
161
168
  function handleRequest(req, res) {
162
169
  // 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') {
170
+ // A missing or empty Host is treated as INVALID, not as valid. The original
171
+ // `reqHost !== ''` short-circuited the whole condition whenever the header was
172
+ // absent, so an HTTP/1.0 request — or any raw socket writing "Host:" with no
173
+ // value — sailed past the rebinding guard and got the private-data APIs.
174
+ // Deny by default: only an explicit localhost Host is allowed through.
175
+ // Hostnames are case-insensitive (RFC 3986), so LOCALHOST is a legitimate
176
+ // spelling. Node already strips OWS around the field value.
177
+ const reqHost = (req.headers.host || '').split(':')[0].toLowerCase();
178
+ if (reqHost !== '127.0.0.1' && reqHost !== 'localhost') {
165
179
  res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
166
180
  return res.end(JSON.stringify({ ok: false, error: 'bad request' }));
167
181
  }
@@ -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 }); }