kodelyth-ecc 2.15.0 → 2.16.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 +56 -0
- package/CLAUDE.md +1 -1
- package/VERSION +1 -1
- package/package.json +1 -1
- package/scripts/arena/learn.js +1 -1
- package/scripts/evolve/proposals.js +13 -1
- package/scripts/lib/safe-fs.js +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,62 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Kodelyth ECC are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.16.0 — Arena run #4: three path-escapes in evolve (September 2026)
|
|
6
|
+
|
|
7
|
+
### Fixed — `applyProposalToDisk` wrote wherever the proposal told it to
|
|
8
|
+
|
|
9
|
+
`evolve accept <id>` applies a proposal to disk. The target came straight from
|
|
10
|
+
proposal data with no containment check:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
const abs = path.resolve(repoRoot, proposal.proposal.target_path);
|
|
14
|
+
fs.writeFileSync(abs, proposal.proposal.diff);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Three vectors, all reproduced:
|
|
18
|
+
|
|
19
|
+
- `../../escaped.md` wrote to `/tmp/escaped.md`
|
|
20
|
+
- an **absolute** `target_path` discards `repoRoot` entirely — `path.resolve(root, '/abs')` returns `/abs`
|
|
21
|
+
- with `overwrite: true`, a real user file **outside** the root was overwritten
|
|
22
|
+
|
|
23
|
+
This is the fifth occurrence of the containment class the arena has confirmed,
|
|
24
|
+
in the one file that never adopted the guard.
|
|
25
|
+
|
|
26
|
+
### Added — `safeFs.resolveContainedForWrite`
|
|
27
|
+
|
|
28
|
+
The existing `resolveContained` could not answer this case: it canonicalises the
|
|
29
|
+
target, which fails for a file about to be *created*. The new function walks up
|
|
30
|
+
to the nearest existing ancestor, canonicalises that, and re-checks — so a
|
|
31
|
+
symlinked parent directory still cannot be used to escape. Absolute candidates
|
|
32
|
+
are rejected up front rather than joined and hoped about.
|
|
33
|
+
|
|
34
|
+
### Fixed — the classifier missed naturally-phrased findings, again
|
|
35
|
+
|
|
36
|
+
All three findings filed as `uncategorized`. The `path-traversal` pattern wanted
|
|
37
|
+
the literal words *traversal* / *confinement*, while the findings said "writes
|
|
38
|
+
outside repoRoot" and "discards repoRoot entirely". Widened to match
|
|
39
|
+
`\w*root` forms — `repoRoot`, `projectRoot`, `coordRoot` — and the stored
|
|
40
|
+
memories re-tagged.
|
|
41
|
+
|
|
42
|
+
That is the third generic-vs-specific gap in this classifier (`permission`,
|
|
43
|
+
`memory`, now `root`). The pattern is consistent: it matches jargon and misses
|
|
44
|
+
the plain phrasing people actually write.
|
|
45
|
+
|
|
46
|
+
### Removed — the ecc-web dispatch workflow
|
|
47
|
+
|
|
48
|
+
It fired a `repository_dispatch` at ecc-web on every push for instant deploys,
|
|
49
|
+
needed a cross-repo PAT, and had been returning HTTP 403 while reporting green.
|
|
50
|
+
Measured on the ecc-web side across 12 consecutive deploys: **12 from its own
|
|
51
|
+
10-minute cron, 0 from the dispatch.** It was contributing nothing but an
|
|
52
|
+
expiring secret and a red check.
|
|
53
|
+
|
|
54
|
+
Removed rather than repaired. ecc-web keeps its `repository_dispatch` trigger,
|
|
55
|
+
so restoring instant deploys is just a matter of adding a fine-grained PAT with
|
|
56
|
+
`Contents: read-write`. The rationale is recorded in that repo's `deploy.yml` so
|
|
57
|
+
it does not get rebuilt. The cost is latency the site already had.
|
|
58
|
+
|
|
59
|
+
**576 tests passing. All 9 CI checks green.**
|
|
60
|
+
|
|
5
61
|
## v2.15.0 — Green CI, and the agents learn what the arena found (September 2026)
|
|
6
62
|
|
|
7
63
|
### Fixed — CI had been red since v2.8.0
|
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/ → 576 passing tests across 30 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.16.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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
|
@@ -33,7 +33,7 @@ const CLASSES = [
|
|
|
33
33
|
['filesystem-symlink', /\bsymlink|lstat|O_NOFOLLOW|hard ?link|dangling\b/i],
|
|
34
34
|
['file-permissions', /\bchmod|umask|0600|0644|0444|world-readable|file mode|file permission|permissions? (?:not )?preserved\b/i],
|
|
35
35
|
['redos', /\bredos|backtrack|quadratic|catastrophic|O\(n\^?2\)|unanchored\b/i],
|
|
36
|
-
['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|
|
|
36
|
+
['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|escapes? (?:the )?\w*root|outside (?:the )?\w*(?:root|repo\w*)|writes? outside|discards? (?:the )?\w*root/i],
|
|
37
37
|
['race-condition', /\btoctou|race condition|check.to.use\b/i],
|
|
38
38
|
['resource-exhaustion', /\bheap\b|\brss\b|\boom\b|amplification|exhaust|out of memory|memory (?:leak|usage|growth|pressure)|allocates? ~?\d/i],
|
|
39
39
|
['missing-limit', /\bno (?:input )?(?:size )?cap|unbounded|no limit|missing limit\b/i],
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const os = require('os');
|
|
23
|
+
const safeFs = require('../lib/safe-fs.js');
|
|
23
24
|
|
|
24
25
|
const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
|
|
25
26
|
|| path.join(os.homedir(), '.kodelythecc', 'evolve');
|
|
@@ -139,7 +140,18 @@ function applyProposalToDisk(proposal, { repoRoot, overwrite = false } = {}) {
|
|
|
139
140
|
throw new Error('applyProposalToDisk: proposal lacks diff or target_path');
|
|
140
141
|
}
|
|
141
142
|
if (!repoRoot) throw new Error('applyProposalToDisk: repoRoot is required');
|
|
142
|
-
|
|
143
|
+
|
|
144
|
+
// target_path comes from proposal data, not from the caller, so it is not
|
|
145
|
+
// trusted. path.resolve normalises ".." but still returns a path OUTSIDE the
|
|
146
|
+
// root when the target escapes — and an ABSOLUTE target_path discards the root
|
|
147
|
+
// entirely. Confirmed writing to /tmp/escaped.md and overwriting a real file
|
|
148
|
+
// outside the root with overwrite:true.
|
|
149
|
+
const abs = safeFs.resolveContainedForWrite(proposal.proposal.target_path, repoRoot);
|
|
150
|
+
if (!abs) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`applyProposalToDisk: refusing to write outside repoRoot — target_path "${proposal.proposal.target_path}" escapes ${repoRoot}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
143
155
|
if (fs.existsSync(abs) && !overwrite) {
|
|
144
156
|
throw new Error(`applyProposalToDisk: refusing to overwrite existing file at ${abs} (pass overwrite=true)`);
|
|
145
157
|
}
|
package/scripts/lib/safe-fs.js
CHANGED
|
@@ -69,6 +69,49 @@ function resolveContained(candidate, root, { allowExact = null } = {}) {
|
|
|
69
69
|
return abs;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Confirm a path that does not exist yet still resolves inside `root`.
|
|
74
|
+
*
|
|
75
|
+
* `resolveContained` cannot answer this: it realpaths the target, which fails
|
|
76
|
+
* for a file about to be created. So walk up to the nearest ancestor that DOES
|
|
77
|
+
* exist, canonicalise that, and re-check — which still catches a symlinked
|
|
78
|
+
* parent directory pointing out of the root.
|
|
79
|
+
*
|
|
80
|
+
* Returns the resolved absolute path, or null if it escapes.
|
|
81
|
+
*/
|
|
82
|
+
function resolveContainedForWrite(candidate, root) {
|
|
83
|
+
if (!candidate || !root) return null;
|
|
84
|
+
|
|
85
|
+
const absRoot = path.resolve(root);
|
|
86
|
+
// path.resolve(root, '/abs/path') returns '/abs/path' — an absolute candidate
|
|
87
|
+
// discards the root entirely, so it must be rejected up front rather than
|
|
88
|
+
// joined and hoped about.
|
|
89
|
+
const abs = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(absRoot, candidate);
|
|
90
|
+
|
|
91
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + path.sep)) return null;
|
|
92
|
+
|
|
93
|
+
// The lexical check above is defeated by a symlinked ancestor, so canonicalise
|
|
94
|
+
// the deepest directory that actually exists and confirm it is still inside.
|
|
95
|
+
let probe = path.dirname(abs);
|
|
96
|
+
while (probe !== path.dirname(probe) && !fs.existsSync(probe)) probe = path.dirname(probe);
|
|
97
|
+
|
|
98
|
+
let realProbe;
|
|
99
|
+
try {
|
|
100
|
+
realProbe = fs.realpathSync(probe);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
let realRoot;
|
|
105
|
+
try {
|
|
106
|
+
realRoot = fs.realpathSync(absRoot);
|
|
107
|
+
} catch {
|
|
108
|
+
realRoot = absRoot;
|
|
109
|
+
}
|
|
110
|
+
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) return null;
|
|
111
|
+
|
|
112
|
+
return abs;
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
/**
|
|
73
116
|
* Stat a path that must be a regular file, refusing symlinks outright.
|
|
74
117
|
*
|
|
@@ -173,6 +216,7 @@ function replaceFilePreservingMode(absPath, contents, fallbackMode = 0o644) {
|
|
|
173
216
|
|
|
174
217
|
module.exports = {
|
|
175
218
|
resolveContained,
|
|
219
|
+
resolveContainedForWrite,
|
|
176
220
|
statRegularFile,
|
|
177
221
|
safeConfigDir,
|
|
178
222
|
writeNewFile,
|