kodelyth-ecc 2.24.4 → 2.24.6
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 +96 -0
- package/CLAUDE.md +1 -1
- package/README.md +15 -1
- package/package.json +1 -1
- package/scripts/brand/export-assets.js +135 -0
- package/scripts/brand/sync-asset-version.js +100 -0
- package/scripts/replay/bundle.js +39 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,102 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Kodelyth ECC are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.24.6 — brand assets advertised the wrong version and stale counts (October 2026)
|
|
6
|
+
|
|
7
|
+
Assets and documentation only. No code changes to the toolkit.
|
|
8
|
+
|
|
9
|
+
### What was wrong
|
|
10
|
+
|
|
11
|
+
Every social and brand SVG carries its version and headline counts as literal
|
|
12
|
+
artwork, and nothing kept them in step with releases.
|
|
13
|
+
|
|
14
|
+
ECC's 23 SVGs said `v2.24.1`, four releases behind. The website's copies said
|
|
15
|
+
`v2.18.0` — seven behind — and the two sets had drifted apart, so the marketing
|
|
16
|
+
images on the site advertised a different version from the ones in the README.
|
|
17
|
+
|
|
18
|
+
The counts were worse, and were nearly missed: grepping for the *correct* values
|
|
19
|
+
(70 agents, 196 skills) found them and suggested the counts were fine. Rendering
|
|
20
|
+
`github-social-preview.png` showed otherwise — **97 COMMANDS** and **22+ HOOKS**
|
|
21
|
+
against an actual 103 and 44, on the flagship image used for the repository's
|
|
22
|
+
social card. Fixed across 11 SVGs per repo: 97 to 103 commands, 22+/20+ to 44
|
|
23
|
+
hooks, 384 to 672 tests, 14 to 15 rules.
|
|
24
|
+
|
|
25
|
+
The PNG exports were worse still, at `v2.7.0`.
|
|
26
|
+
|
|
27
|
+
### Root cause
|
|
28
|
+
|
|
29
|
+
`.gitignore` names `node brand/convert.js` as the way to regenerate the exports.
|
|
30
|
+
That script covers only the `brand/` lockups from a hardcoded list, never the 49
|
|
31
|
+
`social/` exports — and it cannot run at all, because `puppeteer-core` is neither
|
|
32
|
+
installed nor declared in `package.json`. The documented regeneration path was
|
|
33
|
+
broken, and because the PNGs are gitignored build artifacts the drift was
|
|
34
|
+
invisible.
|
|
35
|
+
|
|
36
|
+
### What ships
|
|
37
|
+
|
|
38
|
+
Two scripts, so this cannot recur silently:
|
|
39
|
+
|
|
40
|
+
- `scripts/brand/sync-asset-version.js` stamps the version from `package.json`
|
|
41
|
+
into every SVG. Takes `--root` so the website's copies are driven from the same
|
|
42
|
+
source of truth, and `--check` exits non-zero when anything is stale.
|
|
43
|
+
- `scripts/brand/export-assets.js` renders all 49 exports at 7680px, taking each
|
|
44
|
+
height from the SVG's own viewBox. `--check` compares mtimes and fails when a
|
|
45
|
+
PNG is older than its source.
|
|
46
|
+
|
|
47
|
+
Counts are deliberately NOT stamped by script. They are laid out as artwork, and
|
|
48
|
+
some numbers that look like catalog counts are not: "20+ rules stacked" counts
|
|
49
|
+
accumulated lessons, and "4 agents" / "8 agents" describe how many a given
|
|
50
|
+
parallel command fires. A blanket replace would rewrite those into nonsense, so
|
|
51
|
+
each count fix here was read in context first and those were left alone.
|
|
52
|
+
|
|
53
|
+
### Also
|
|
54
|
+
|
|
55
|
+
The README now asks for a star. The post-install CLI asked once; the README —
|
|
56
|
+
which is also the npm package page, and the first thing most people see — never
|
|
57
|
+
did.
|
|
58
|
+
|
|
59
|
+
**672 tests across 43 files.**
|
|
60
|
+
|
|
61
|
+
## v2.24.5 — fix path traversal in session bundles (October 2026)
|
|
62
|
+
|
|
63
|
+
**Security fix.** A session bundle is a portable, shareable artifact — that is the
|
|
64
|
+
entire point of the format — so every string in one is untrusted input from
|
|
65
|
+
whoever built it. `validateBundle` type-checked `session` and each worker `slug`
|
|
66
|
+
and nothing else, and both are used to build filesystem paths:
|
|
67
|
+
|
|
68
|
+
targetDir = path.join(coordRoot, bundle.session) -> fs.rmSync(recursive, force)
|
|
69
|
+
wdir = path.join(targetDir, w.slug) -> mkdirSync + 3x writeFileSync
|
|
70
|
+
|
|
71
|
+
### Arbitrary file write
|
|
72
|
+
|
|
73
|
+
A slug of `../../fake-claude/rules/common/injected` wrote `task.md`, `handoff.md`
|
|
74
|
+
and `status.md` with bundle-chosen contents three levels outside the import
|
|
75
|
+
target. Aimed at a real install that path is an always-on rule file, so a shared
|
|
76
|
+
bundle plants standing instructions into every future session. The CLI printed
|
|
77
|
+
`imported bundle into: .../.orchestration/looks-normal` and then printed the
|
|
78
|
+
escaped path in its own `inspect:` hint.
|
|
79
|
+
|
|
80
|
+
### Arbitrary recursive delete
|
|
81
|
+
|
|
82
|
+
A session of `../important-work` with `--overwrite` reached
|
|
83
|
+
`fs.rmSync(dir, { recursive: true, force: true })` and recursively deleted an
|
|
84
|
+
unrelated project directory. It printed success for that too.
|
|
85
|
+
|
|
86
|
+
Both were confirmed by running the real CLI against a sandboxed victim tree, not
|
|
87
|
+
by reading the code.
|
|
88
|
+
|
|
89
|
+
### The fix
|
|
90
|
+
|
|
91
|
+
`session` and every `slug` must now be a single path segment. `exportBundle` only
|
|
92
|
+
ever emits those — `session` is a `path.basename`, each `slug` a `readdirSync`
|
|
93
|
+
entry name that `slugify` constrains to `[a-z0-9-]+` — so this rejects nothing a
|
|
94
|
+
genuine bundle contains, which a real export/import round-trip confirms with
|
|
95
|
+
identical trees. A containment check was added at the write site as a backstop for
|
|
96
|
+
future callers; the comment states plainly that it guards no currently reachable
|
|
97
|
+
vector, because `importBundle` always creates the directory it writes into.
|
|
98
|
+
|
|
99
|
+
**672 tests across 43 files.**
|
|
100
|
+
|
|
5
101
|
## v2.24.4 — fix catastrophic backtracking in the prompt-injection guard (October 2026)
|
|
6
102
|
|
|
7
103
|
**Security fix.** The guard that scans untrusted text for prompt injection could
|
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/ → 672 passing tests across 43 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/README.md
CHANGED
|
@@ -118,7 +118,7 @@ Most "AI agent kits" are folders of markdown files you have to remember the name
|
|
|
118
118
|
| Quality hooks | ✓ 44 | Some | ✗ | ✗ |
|
|
119
119
|
| IDE platforms | **11** (Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, Roo Code, Aider, Kimi, Gemini CLI) | 1-2 | 1 | Varies |
|
|
120
120
|
| Telemetry | **None** | Varies | None | Varies |
|
|
121
|
-
| Test coverage | ✓
|
|
121
|
+
| Test coverage | ✓ 672 tests | ✗ | ✗ | ✗ |
|
|
122
122
|
| Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
|
|
123
123
|
|
|
124
124
|
---
|
|
@@ -1157,6 +1157,20 @@ command you ran, the full error, and your `kodelythecc doctor` output.
|
|
|
1157
1157
|
|
|
1158
1158
|
<div align="center">
|
|
1159
1159
|
|
|
1160
|
+
### If ECC saved you time
|
|
1161
|
+
|
|
1162
|
+
Starring the repo is the main way other developers find it — the project has no
|
|
1163
|
+
marketing budget and no telemetry, so visibility is the only signal that it is
|
|
1164
|
+
worth anyone's time.
|
|
1165
|
+
|
|
1166
|
+
[](https://github.com/sifxprime/kodelyth-ecc)
|
|
1167
|
+
|
|
1168
|
+
</div>
|
|
1169
|
+
|
|
1170
|
+
---
|
|
1171
|
+
|
|
1172
|
+
<div align="center">
|
|
1173
|
+
|
|
1160
1174
|
[](https://www.npmjs.com/package/kodelyth-ecc)
|
|
1161
1175
|
[](https://github.com/sifxprime/kodelyth-ecc)
|
|
1162
1176
|
[](LICENSE)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.24.
|
|
3
|
+
"version": "2.24.6",
|
|
4
4
|
"description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 196 skills, 103 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 on macOS, Linux and Windows 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",
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Render every social SVG to its 8K PNG export.
|
|
6
|
+
*
|
|
7
|
+
* The PNGs under social/exports/8k/ are rasterised copies of the SVGs, used where
|
|
8
|
+
* an SVG will not do — GitHub's social preview, and most social platforms, accept
|
|
9
|
+
* only raster uploads. Nothing regenerated them, so they drifted badly: by v2.24.5
|
|
10
|
+
* the exports still read v2.7.0, 97 commands and 22+ hooks, against an actual
|
|
11
|
+
* 103 and 44. The SVGs had been corrected several times; the PNGs never were.
|
|
12
|
+
*
|
|
13
|
+
* Every existing export is 7680px wide with the aspect ratio taken from the SVG's
|
|
14
|
+
* own viewBox, so that is what this reproduces.
|
|
15
|
+
*
|
|
16
|
+
* node scripts/brand/export-assets.js # render all
|
|
17
|
+
* node scripts/brand/export-assets.js --check # report stale, change nothing
|
|
18
|
+
* node scripts/brand/export-assets.js card-main # render one, by name
|
|
19
|
+
*
|
|
20
|
+
* --check compares mtimes and exits non-zero when any PNG is older than its SVG,
|
|
21
|
+
* so a release step can catch exports that no longer match their source.
|
|
22
|
+
*
|
|
23
|
+
* Requires rsvg-convert (librsvg). It is not installed by default anywhere, so its
|
|
24
|
+
* absence is reported as a clear instruction rather than a spawn error.
|
|
25
|
+
*
|
|
26
|
+
* On the choice of renderer: brand/convert.js already renders the brand/ lockups
|
|
27
|
+
* with puppeteer-core, chosen there for font fidelity, and consistency would argue
|
|
28
|
+
* for using it here too. It is not used because it cannot run — puppeteer-core is
|
|
29
|
+
* neither installed nor declared in package.json, so `node brand/convert.js` fails
|
|
30
|
+
* on a clean checkout. That is most likely why these exports went stale in the
|
|
31
|
+
* first place: the tool named in .gitignore for regenerating them does not work.
|
|
32
|
+
*
|
|
33
|
+
* rsvg-convert was checked against the output it replaces rather than assumed
|
|
34
|
+
* equivalent: re-rendering github-social-preview.png produced 491,233 bytes against
|
|
35
|
+
* the previous Chrome-rendered 491,564 at identical dimensions, and the two were
|
|
36
|
+
* compared side by side — same fonts, same geometry, differing only in the text
|
|
37
|
+
* that was actually corrected. If puppeteer-core is ever added as a real
|
|
38
|
+
* dependency, moving this to the same renderer would be a reasonable tidy-up.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const { execFileSync, spawnSync } = require('child_process');
|
|
44
|
+
|
|
45
|
+
const ROOT = path.join(__dirname, '..', '..');
|
|
46
|
+
const SVG_DIR = path.join(ROOT, 'social');
|
|
47
|
+
const PNG_DIR = path.join(SVG_DIR, 'exports', '8k');
|
|
48
|
+
const WIDTH = 7680;
|
|
49
|
+
|
|
50
|
+
function haveRenderer() {
|
|
51
|
+
try {
|
|
52
|
+
execFileSync('rsvg-convert', ['--version'], { stdio: 'ignore' });
|
|
53
|
+
return true;
|
|
54
|
+
} catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function listPairs(filter) {
|
|
60
|
+
if (!fs.existsSync(SVG_DIR)) return [];
|
|
61
|
+
return fs
|
|
62
|
+
.readdirSync(SVG_DIR)
|
|
63
|
+
.filter((f) => f.endsWith('.svg'))
|
|
64
|
+
.map((f) => path.basename(f, '.svg'))
|
|
65
|
+
.filter((name) => !filter || name === filter)
|
|
66
|
+
.sort()
|
|
67
|
+
.map((name) => ({
|
|
68
|
+
name,
|
|
69
|
+
svg: path.join(SVG_DIR, `${name}.svg`),
|
|
70
|
+
png: path.join(PNG_DIR, `${name}.png`),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A PNG is stale when it is missing or older than the SVG it came from. */
|
|
75
|
+
function isStale({ svg, png }) {
|
|
76
|
+
if (!fs.existsSync(png)) return true;
|
|
77
|
+
return fs.statSync(png).mtimeMs < fs.statSync(svg).mtimeMs;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function run({ check = false, only = null } = {}) {
|
|
81
|
+
const pairs = listPairs(only);
|
|
82
|
+
if (pairs.length === 0) {
|
|
83
|
+
console.error(only ? `no such SVG: social/${only}.svg` : `no SVGs in ${SVG_DIR}`);
|
|
84
|
+
return 1;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const stale = pairs.filter(isStale);
|
|
88
|
+
|
|
89
|
+
if (check) {
|
|
90
|
+
for (const p of stale) console.log(` STALE ${p.name}.png`);
|
|
91
|
+
console.log(
|
|
92
|
+
stale.length === 0
|
|
93
|
+
? ` all ${pairs.length} exports are newer than their SVG`
|
|
94
|
+
: ` ${stale.length} of ${pairs.length} export(s) stale`
|
|
95
|
+
);
|
|
96
|
+
return stale.length === 0 ? 0 : 1;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!haveRenderer()) {
|
|
100
|
+
console.error(' rsvg-convert not found. Install librsvg:');
|
|
101
|
+
console.error(' macOS: brew install librsvg');
|
|
102
|
+
console.error(' Debian: apt-get install librsvg2-bin');
|
|
103
|
+
return 1;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
fs.mkdirSync(PNG_DIR, { recursive: true });
|
|
107
|
+
|
|
108
|
+
let ok = 0;
|
|
109
|
+
const failed = [];
|
|
110
|
+
for (const p of pairs) {
|
|
111
|
+
// Width only — rsvg-convert derives the height from the viewBox, so each
|
|
112
|
+
// card keeps its own aspect ratio without hardcoding 49 different heights.
|
|
113
|
+
const r = spawnSync('rsvg-convert', ['-w', String(WIDTH), p.svg, '-o', p.png], {
|
|
114
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
115
|
+
});
|
|
116
|
+
if (r.status === 0 && fs.existsSync(p.png) && fs.statSync(p.png).size > 0) {
|
|
117
|
+
ok += 1;
|
|
118
|
+
} else {
|
|
119
|
+
failed.push(`${p.name}: ${String(r.stderr || '').trim() || `exit ${r.status}`}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.log(` rendered ${ok}/${pairs.length} at ${WIDTH}px wide`);
|
|
124
|
+
for (const f of failed) console.error(` FAILED ${f}`);
|
|
125
|
+
return failed.length === 0 ? 0 : 1;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (require.main === module) {
|
|
129
|
+
const args = process.argv.slice(2);
|
|
130
|
+
const check = args.includes('--check');
|
|
131
|
+
const only = args.find((a) => !a.startsWith('--')) || null;
|
|
132
|
+
process.exit(run({ check, only }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = { run, listPairs, isStale, WIDTH };
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stamp the current package version into every brand and social SVG.
|
|
6
|
+
*
|
|
7
|
+
* These assets carry a literal "v2.24.1" in their artwork, and nothing kept them
|
|
8
|
+
* in step with releases. By 2.24.5 the ECC copies were four releases behind and
|
|
9
|
+
* the website's copies were SEVEN behind, at v2.18.0 — the two sets had drifted
|
|
10
|
+
* apart as well, so the marketing images on the site advertised a different
|
|
11
|
+
* version from the ones in the README.
|
|
12
|
+
*
|
|
13
|
+
* Only the version token is touched. Counts (70 agents, 196 skills, …) are laid
|
|
14
|
+
* out as artwork and change rarely, so they stay a deliberate edit; a script that
|
|
15
|
+
* silently reflowed them would do more harm than the staleness it fixed.
|
|
16
|
+
*
|
|
17
|
+
* node scripts/brand/sync-asset-version.js # ECC's own assets
|
|
18
|
+
* node scripts/brand/sync-asset-version.js --check # report, change nothing
|
|
19
|
+
* node scripts/brand/sync-asset-version.js --root ../ecc-web/public
|
|
20
|
+
*
|
|
21
|
+
* --check exits non-zero when anything is stale, so a release step can refuse to
|
|
22
|
+
* ship assets that advertise the wrong version.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const fs = require('fs');
|
|
26
|
+
const path = require('path');
|
|
27
|
+
|
|
28
|
+
const ROOT = path.join(__dirname, '..', '..');
|
|
29
|
+
const VERSION = require(path.join(ROOT, 'package.json')).version;
|
|
30
|
+
|
|
31
|
+
// A version token in artwork: "v" followed by semver, as a whole word. Anchored
|
|
32
|
+
// on the leading "v" so it cannot match an IP address — section-dashboard.svg
|
|
33
|
+
// contains "127.0.0.1", which a bare semver pattern happily mangles.
|
|
34
|
+
const VERSION_TOKEN = /\bv\d+\.\d+\.\d+\b/g;
|
|
35
|
+
|
|
36
|
+
function collectSvgs(root) {
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const dir of ['social', 'brand']) {
|
|
39
|
+
const full = path.join(root, dir);
|
|
40
|
+
if (!fs.existsSync(full)) continue;
|
|
41
|
+
for (const name of fs.readdirSync(full)) {
|
|
42
|
+
if (name.endsWith('.svg')) out.push(path.join(full, name));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return out.sort();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function run({ root, check }) {
|
|
49
|
+
const files = collectSvgs(root);
|
|
50
|
+
if (files.length === 0) {
|
|
51
|
+
console.error(`no social/ or brand/ SVGs under ${root}`);
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const stale = [];
|
|
56
|
+
let changedFiles = 0;
|
|
57
|
+
let changedTokens = 0;
|
|
58
|
+
|
|
59
|
+
for (const file of files) {
|
|
60
|
+
const before = fs.readFileSync(file, 'utf8');
|
|
61
|
+
const found = before.match(VERSION_TOKEN);
|
|
62
|
+
if (!found) continue;
|
|
63
|
+
|
|
64
|
+
const outdated = found.filter((t) => t !== `v${VERSION}`);
|
|
65
|
+
if (outdated.length === 0) continue;
|
|
66
|
+
|
|
67
|
+
stale.push({ file, versions: [...new Set(outdated)], count: outdated.length });
|
|
68
|
+
changedTokens += outdated.length;
|
|
69
|
+
changedFiles += 1;
|
|
70
|
+
|
|
71
|
+
if (!check) {
|
|
72
|
+
fs.writeFileSync(file, before.replace(VERSION_TOKEN, `v${VERSION}`), 'utf8');
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const rel = (f) => path.relative(root, f);
|
|
77
|
+
for (const s of stale) {
|
|
78
|
+
console.log(` ${check ? 'STALE' : 'updated'} ${rel(s.file).padEnd(40)} ${s.versions.join(', ')} -> v${VERSION}`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (stale.length === 0) {
|
|
82
|
+
console.log(` all ${files.length} SVGs already at v${VERSION}`);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
console.log(
|
|
87
|
+
` ${check ? 'stale' : 'synced'}: ${changedFiles} file(s), ${changedTokens} token(s) -> v${VERSION}`
|
|
88
|
+
);
|
|
89
|
+
return check ? 1 : 0;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (require.main === module) {
|
|
93
|
+
const args = process.argv.slice(2);
|
|
94
|
+
const check = args.includes('--check');
|
|
95
|
+
const rootFlag = args.indexOf('--root');
|
|
96
|
+
const root = rootFlag >= 0 ? path.resolve(args[rootFlag + 1]) : ROOT;
|
|
97
|
+
process.exit(run({ root, check }));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
module.exports = { run, collectSvgs, VERSION_TOKEN };
|
package/scripts/replay/bundle.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
|
|
36
36
|
const fs = require('fs');
|
|
37
37
|
const path = require('path');
|
|
38
|
+
const { resolveContainedForWrite } = require('../lib/safe-fs');
|
|
38
39
|
|
|
39
40
|
const BUNDLE_SCHEMA = 'kodelyth.session-bundle/v1';
|
|
40
41
|
const BUNDLE_VERSION = '1.7.0';
|
|
@@ -94,6 +95,21 @@ function readBundle(bundlePath) {
|
|
|
94
95
|
return validateBundle(raw);
|
|
95
96
|
}
|
|
96
97
|
|
|
98
|
+
// One path segment: no separator, no traversal, no leading dot. This matches what
|
|
99
|
+
// exportBundle actually emits — slugify yields [a-z0-9-]+ and session is a
|
|
100
|
+
// basename — so it rejects nothing a genuine bundle contains.
|
|
101
|
+
const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
102
|
+
const MAX_SEGMENT = 128;
|
|
103
|
+
|
|
104
|
+
function assertSafeSegment(value, label) {
|
|
105
|
+
if (typeof value !== 'string' || value.length > MAX_SEGMENT || !SAFE_SEGMENT.test(value)) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`bundle: ${label} must be a single path segment matching ${SAFE_SEGMENT} ` +
|
|
108
|
+
`(got ${JSON.stringify(String(value).slice(0, 60))})`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
97
113
|
function validateBundle(raw) {
|
|
98
114
|
if (!raw || typeof raw !== 'object') throw new Error('bundle: not an object');
|
|
99
115
|
if (raw.schema !== BUNDLE_SCHEMA) {
|
|
@@ -102,11 +118,26 @@ function validateBundle(raw) {
|
|
|
102
118
|
if (typeof raw.session !== 'string' || !raw.session) {
|
|
103
119
|
throw new Error('bundle: missing "session"');
|
|
104
120
|
}
|
|
121
|
+
// A bundle exists to be SHARED — that is the whole point of the format — so every
|
|
122
|
+
// string in it is untrusted input. exportBundle only ever emits single path
|
|
123
|
+
// segments here: `session` is a path.basename and each `slug` is a readdirSync
|
|
124
|
+
// entry name. Nothing legitimate contains a separator, so anything that does is
|
|
125
|
+
// an attempt to escape the directory the importer chose.
|
|
126
|
+
//
|
|
127
|
+
// Unchecked, `session` became the import target via path.join(coordRoot, session),
|
|
128
|
+
// and with --overwrite that target is handed to fs.rmSync(recursive, force):
|
|
129
|
+
// "../important-work" recursively deleted an unrelated project while the CLI
|
|
130
|
+
// printed success. Each `slug` became path.join(targetDir, slug) for a
|
|
131
|
+
// mkdirSync(recursive) plus three writeFileSync calls, so
|
|
132
|
+
// "../../.claude/rules/common/x" planted an always-on rule file whose contents
|
|
133
|
+
// the bundle author chose.
|
|
134
|
+
assertSafeSegment(raw.session, 'session');
|
|
105
135
|
if (!Array.isArray(raw.workers) || raw.workers.length === 0) {
|
|
106
136
|
throw new Error('bundle: "workers" must be a non-empty array');
|
|
107
137
|
}
|
|
108
138
|
for (const w of raw.workers) {
|
|
109
139
|
if (!w || typeof w.slug !== 'string') throw new Error('bundle: each worker needs a string "slug"');
|
|
140
|
+
assertSafeSegment(w.slug, 'worker slug');
|
|
110
141
|
}
|
|
111
142
|
return raw;
|
|
112
143
|
}
|
|
@@ -122,7 +153,14 @@ function importBundle(bundle, { targetDir, overwrite = false } = {}) {
|
|
|
122
153
|
}
|
|
123
154
|
fs.mkdirSync(dir, { recursive: true });
|
|
124
155
|
for (const w of bundle.workers) {
|
|
125
|
-
|
|
156
|
+
// Backstop, not the primary guard: validateBundle already rejected every slug
|
|
157
|
+
// containing a separator, and `dir` was just created by this function (an
|
|
158
|
+
// existing targetDir is either refused or rmSync'd above), so no attacker-planted
|
|
159
|
+
// symlink can survive into this loop today. The check costs nothing and keeps the
|
|
160
|
+
// invariant true for any future caller that hands importBundle a directory it
|
|
161
|
+
// did not create — which is the only way the symlink case becomes reachable.
|
|
162
|
+
const wdir = resolveContainedForWrite(path.join(dir, w.slug), dir);
|
|
163
|
+
if (!wdir) throw new Error(`importBundle: worker slug escapes targetDir: ${w.slug}`);
|
|
126
164
|
fs.mkdirSync(wdir, { recursive: true });
|
|
127
165
|
if (typeof w.task === 'string') fs.writeFileSync(path.join(wdir, 'task.md'), w.task, 'utf8');
|
|
128
166
|
if (typeof w.handoff === 'string') fs.writeFileSync(path.join(wdir, 'handoff.md'), w.handoff, 'utf8');
|