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 +109 -0
- package/CLAUDE.md +1 -1
- package/README.md +1 -1
- package/hooks/safety/lib/patterns.js +17 -2
- package/package.json +1 -1
- package/scripts/replay/bundle.js +39 -1
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/ →
|
|
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
|
---
|
|
@@ -20,13 +20,28 @@ const PATTERNS = [
|
|
|
20
20
|
{
|
|
21
21
|
id: 'override-prior-instructions',
|
|
22
22
|
severity: 'critical',
|
|
23
|
-
|
|
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
|
-
|
|
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
|
+
"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",
|
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');
|