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 +48 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/dashboard/data.js +7 -24
- package/scripts/dashboard/server.js +8 -21
- package/scripts/lib/safe-fs.js +162 -0
- package/scripts/terse/compress.js +7 -50
- package/scripts/terse/ledger.js +8 -2
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/ →
|
|
29
|
+
tests/ → 554 passing tests across 30 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.12.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
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
|
-
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
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
|
-
//
|
|
350
|
-
|
|
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
|
}
|
package/scripts/terse/ledger.js
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
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 }); }
|