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 +106 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/bin/kodelyth-ecc.js +8 -2
- package/package.json +1 -1
- package/scripts/arena/learn.js +22 -0
- package/scripts/dashboard/data.js +7 -4
- package/scripts/dashboard/server.js +21 -7
- 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,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/ →
|
|
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/bin/kodelyth-ecc.js
CHANGED
|
@@ -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
|
-
|
|
505
|
-
|
|
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.
|
|
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",
|
package/scripts/arena/learn.js
CHANGED
|
@@ -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
|
-
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
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(
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
//
|
|
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 }); }
|