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 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/ → 668 passing tests across 43 test files
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 | ✓ 668 tests | ✗ | ✗ | ✗ |
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
+ [![Star on GitHub](https://img.shields.io/github/stars/sifxprime/kodelyth-ecc?style=social)](https://github.com/sifxprime/kodelyth-ecc)
1167
+
1168
+ </div>
1169
+
1170
+ ---
1171
+
1172
+ <div align="center">
1173
+
1160
1174
  [![npm](https://img.shields.io/badge/npm-kodelyth--ecc-red.svg)](https://www.npmjs.com/package/kodelyth-ecc)
1161
1175
  [![GitHub](https://img.shields.io/badge/GitHub-sifxprime%2Fkodelyth--ecc-181717.svg)](https://github.com/sifxprime/kodelyth-ecc)
1162
1176
  [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.24.4",
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 };
@@ -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
- const wdir = path.join(dir, w.slug);
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');