kodelyth-ecc 2.24.4 → 2.24.5

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,46 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.24.5 — fix path traversal in session bundles (October 2026)
6
+
7
+ **Security fix.** A session bundle is a portable, shareable artifact — that is the
8
+ entire point of the format — so every string in one is untrusted input from
9
+ whoever built it. `validateBundle` type-checked `session` and each worker `slug`
10
+ and nothing else, and both are used to build filesystem paths:
11
+
12
+ targetDir = path.join(coordRoot, bundle.session) -> fs.rmSync(recursive, force)
13
+ wdir = path.join(targetDir, w.slug) -> mkdirSync + 3x writeFileSync
14
+
15
+ ### Arbitrary file write
16
+
17
+ A slug of `../../fake-claude/rules/common/injected` wrote `task.md`, `handoff.md`
18
+ and `status.md` with bundle-chosen contents three levels outside the import
19
+ target. Aimed at a real install that path is an always-on rule file, so a shared
20
+ bundle plants standing instructions into every future session. The CLI printed
21
+ `imported bundle into: .../.orchestration/looks-normal` and then printed the
22
+ escaped path in its own `inspect:` hint.
23
+
24
+ ### Arbitrary recursive delete
25
+
26
+ A session of `../important-work` with `--overwrite` reached
27
+ `fs.rmSync(dir, { recursive: true, force: true })` and recursively deleted an
28
+ unrelated project directory. It printed success for that too.
29
+
30
+ Both were confirmed by running the real CLI against a sandboxed victim tree, not
31
+ by reading the code.
32
+
33
+ ### The fix
34
+
35
+ `session` and every `slug` must now be a single path segment. `exportBundle` only
36
+ ever emits those — `session` is a `path.basename`, each `slug` a `readdirSync`
37
+ entry name that `slugify` constrains to `[a-z0-9-]+` — so this rejects nothing a
38
+ genuine bundle contains, which a real export/import round-trip confirms with
39
+ identical trees. A containment check was added at the write site as a backstop for
40
+ future callers; the comment states plainly that it guards no currently reachable
41
+ vector, because `importBundle` always creates the directory it writes into.
42
+
43
+ **672 tests across 43 files.**
44
+
5
45
  ## v2.24.4 — fix catastrophic backtracking in the prompt-injection guard (October 2026)
6
46
 
7
47
  **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
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.24.4",
3
+ "version": "2.24.5",
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",
@@ -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');