kodelyth-ecc 2.24.3 → 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,115 @@
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
+
45
+ ## v2.24.4 — fix catastrophic backtracking in the prompt-injection guard (October 2026)
46
+
47
+ **Security fix.** The guard that scans untrusted text for prompt injection could
48
+ be hung indefinitely by the text it was scanning.
49
+
50
+ ### What was wrong
51
+
52
+ `override-prior-instructions` — the critical-severity pattern matching the single
53
+ most common injection phrase — was written as
54
+
55
+ \s+ (group)? \s* (group)? \s*
56
+
57
+ Three whitespace quantifiers with optional groups between them. With both groups
58
+ matching empty they sat adjacent, so a run of N spaces could be partitioned
59
+ between them in O(N^2) ways, and every partition was retried once the trailing
60
+ noun failed to match. On `"ignore" + N spaces + "x"`:
61
+
62
+ | N | time |
63
+ |---|---|
64
+ | 2,000 | 1.5s |
65
+ | 4,000 | 12.5s |
66
+ | 8,000 | never observed to finish |
67
+ | 19,000 | never observed to finish |
68
+
69
+ The guard caps input at 20,000 characters, so the cap does not help: the blow-up
70
+ begins around N=2,000, well inside it. Confirmed end to end through the hook's own
71
+ stdin path with the payload delivered in `tool_response`, which is where
72
+ untrusted file and network content arrives.
73
+
74
+ ### Why it is more than a hang
75
+
76
+ This hook runs on every tool call and is fail-open by design. In the default
77
+ `warn` mode the session stalls until the harness kills the hook, and no warning is
78
+ ever emitted. In `block` mode it is worse: a guard that never returns never exits
79
+ 2, so the critical pattern it exists to catch goes unblocked. The denial of
80
+ service doubles as a bypass of the control itself.
81
+
82
+ ### The fix
83
+
84
+ Each optional segment now carries its own leading `\s+` inside the group —
85
+ `(?:\s+X)?` rather than `\s+(X)?\s*` — so a whitespace run has exactly one valid
86
+ partition and there is nothing to backtrack through. The pattern now takes 4.8ms
87
+ at the full 20,000-character cap, and stays linear beyond it: 3.9ms on a million
88
+ spaces.
89
+
90
+ `system-prompt-leak` carried the same shape one group shallower, making it
91
+ quadratic rather than catastrophic (~590ms at 32,000 spaces, ~200ms at the cap).
92
+ Not a denial of service today, but the identical defect and one optional group
93
+ away from becoming one, so it is rewritten the same way. The other ten patterns
94
+ were swept with trigger words they actually match and are unaffected (4-5ms at
95
+ the cap).
96
+
97
+ ### Detection is unchanged
98
+
99
+ Verified rather than assumed. Both rewrites were checked against every phrase
100
+ their grammars generate — 1,140 for the first, 483 for the second — and agree
101
+ with the originals on all 1,623, with no phrase matched by one and not the other.
102
+
103
+ New tests cover the detections that must still fire, the ordinary text that must
104
+ not, and the timing. The timing test runs in a child process with a hard timeout
105
+ on purpose: a catastrophic regex is synchronous and CPU-bound, so node:test's own
106
+ timeout cannot interrupt it — against the old pattern the assertion was never
107
+ reached and the suite simply hung, which is a red build with no explanation. The
108
+ child turns the same regression into a named failure. Verified in both
109
+ directions: the old regex is killed at 15s with `ETIMEDOUT`, the new one finishes
110
+ in 59ms.
111
+
112
+ **668 tests across 43 files.**
113
+
5
114
  ## v2.24.3 — remove decorative emoji from docs and catalog (October 2026)
6
115
 
7
116
  Documentation only. No code, no behaviour change.
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/ → 665 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 | ✓ 665 tests | ✗ | ✗ | ✗ |
121
+ | Test coverage | ✓ 672 tests | ✗ | ✗ | ✗ |
122
122
  | Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
123
123
 
124
124
  ---
@@ -20,13 +20,28 @@ const PATTERNS = [
20
20
  {
21
21
  id: 'override-prior-instructions',
22
22
  severity: 'critical',
23
- regex: /\b(ignore|disregard|forget)\s+(all|any|the|your|my|all\s+(?:prior|previous|above|earlier))?\s*(prior|previous|above|earlier|prev|preceding)?\s*(instructions?|rules?|prompt|system\s*prompt|directives?)\b/i,
23
+ // Each optional segment carries its own leading \s+, so a run of whitespace
24
+ // has exactly ONE valid partition. The previous form was
25
+ // \s+ (group)? \s* (group)? \s*
26
+ // — three whitespace quantifiers with optional groups between them. When both
27
+ // groups matched empty they sat adjacent, so N spaces could be split between
28
+ // them in O(N^2) ways, and every split was retried once the final noun failed
29
+ // to match. Measured on "ignore" + N spaces + "x": 1.5s at N=2000, 12.5s at
30
+ // N=4000, over 40s at N=8000 — all well inside this hook's own 20000-char
31
+ // input cap, and reachable through tool_response, which carries untrusted
32
+ // file and network content. Verified equivalent across 1140 generated phrases.
33
+ regex: /\b(?:ignore|disregard|forget)(?:\s+(?:all|any|the|your|my))?(?:\s+(?:prior|previous|above|earlier|prev|preceding))?\s+(?:instructions?|rules?|system\s*prompt|prompt|directives?)\b/i,
24
34
  why: 'Classic instruction-override prompt injection.',
25
35
  },
26
36
  {
27
37
  id: 'system-prompt-leak',
28
38
  severity: 'critical',
29
- regex: /\b(reveal|print|show|repeat|output|dump|leak|expose)\s+(your|the|all|any)?\s*(system\s*prompt|hidden\s*prompt|initial\s*(prompt|instructions?)|original\s*(prompt|instructions?)|first\s*(message|prompt|instruction))/i,
39
+ // Same adjacent-quantifier shape as above, one group shallower, so quadratic
40
+ // rather than catastrophic: ~590ms at 32000 spaces, ~200ms at the 20000 cap.
41
+ // Not a denial of service today, but the identical defect, and it would become
42
+ // one the moment another optional group is added. Verified equivalent across
43
+ // 483 generated phrases.
44
+ regex: /\b(?:reveal|print|show|repeat|output|dump|leak|expose)(?:\s+(?:your|the|all|any))?\s+(?:system\s*prompt|hidden\s*prompt|initial\s*(?:prompt|instructions?)|original\s*(?:prompt|instructions?)|first\s*(?:message|prompt|instruction))/i,
30
45
  why: 'Explicit attempt to extract the system prompt.',
31
46
  },
32
47
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.24.3",
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');