kodelyth-ecc 2.15.0 → 2.17.0
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 +151 -0
- package/CLAUDE.md +3 -3
- package/VERSION +1 -1
- package/bin/kodelyth-ecc.js +52 -0
- package/commands/immune.md +89 -0
- package/package.json +1 -1
- package/scripts/arena/learn.js +1 -1
- package/scripts/ci/validate-install-manifests.js +1 -1
- package/scripts/cli/update-check.js +2 -1
- package/scripts/evolve/proposals.js +13 -1
- package/scripts/hooks/run-with-flags.js +5 -2
- package/scripts/immune/detectors.js +220 -0
- package/scripts/immune/scan.js +99 -0
- package/scripts/lib/agent-compress.js +6 -3
- package/scripts/lib/package-manager.js +2 -2
- package/scripts/lib/safe-fs.js +44 -0
- package/scripts/lib/session-aliases.js +3 -3
- package/scripts/lib/skill-improvement/health.js +1 -1
- package/scripts/mcp/client.js +1 -1
- package/scripts/memory/store.js +1 -1
- package/scripts/setup-package-manager.js +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,157 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Kodelyth ECC are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.17.0 — ECC Immunity: the arena's findings, made executable (September 2026)
|
|
6
|
+
|
|
7
|
+
Four arena runs confirmed real bugs and stored them as prose. Prose does not
|
|
8
|
+
scan a codebase. This release turns each confirmed class into a **detector that
|
|
9
|
+
runs**, so a bug proven here can be found anywhere — including in repositories
|
|
10
|
+
that never ran the arena.
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
kodelythecc immune # scan the current directory
|
|
14
|
+
kodelythecc immune scripts/ # scan a subtree
|
|
15
|
+
kodelythecc immune --json # machine-readable, exits 1 on any high finding
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
### How each detector earned its place
|
|
19
|
+
|
|
20
|
+
A detector ships only if it does **both**: FIRES on the exact source the arena
|
|
21
|
+
found the bug in, and goes SILENT on the source after the fix. Verified against
|
|
22
|
+
this repository's own history at v2.7.0, the last release before any arena fix:
|
|
23
|
+
|
|
24
|
+
```
|
|
25
|
+
detector file BEFORE AFTER verdict
|
|
26
|
+
lexical-containment dashboard/data.js FIRES silent PROVEN
|
|
27
|
+
lexical-containment dashboard/server.js FIRES silent PROVEN
|
|
28
|
+
prototype-key-map memory/store.js FIRES silent PROVEN
|
|
29
|
+
truncate-then-write memory/store.js FIRES silent PROVEN
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`predictable-temp-name` is proven against a fixture rather than a commit: that
|
|
33
|
+
bug was caught by an EVIL agent in the working tree and fixed before it was ever
|
|
34
|
+
committed, so no historical version exists. Stated plainly rather than implied
|
|
35
|
+
to carry the same provenance.
|
|
36
|
+
|
|
37
|
+
### Two classes were deliberately NOT shipped
|
|
38
|
+
|
|
39
|
+
This matters more than the four that were.
|
|
40
|
+
|
|
41
|
+
**ReDoS.** Real — 7.6 s on 293 KB. But arena run #1 *measured* five structurally
|
|
42
|
+
identical regexes in one file: fenced-code, inline-code, bare-URL and file-path
|
|
43
|
+
were all linear; only the link target was quadratic. The difference is whether
|
|
44
|
+
the closing delimiter is commonly absent in real input — a property of the data,
|
|
45
|
+
not the pattern. A detector would have flagged all five and been wrong about
|
|
46
|
+
four.
|
|
47
|
+
|
|
48
|
+
**Unbounded input.** Also real — 4.9 MB allocated ~417 MB. But the detectable
|
|
49
|
+
shape is "this module reads a file", which described **57 of 165 files** here.
|
|
50
|
+
|
|
51
|
+
A scanner that is wrong four times out of five gets muted, and a muted scanner
|
|
52
|
+
protects nothing.
|
|
53
|
+
|
|
54
|
+
### What it found on its first real run
|
|
55
|
+
|
|
56
|
+
Pointed at ECC itself, in code four arena runs never audited:
|
|
57
|
+
|
|
58
|
+
- **`scripts/hooks/run-with-flags.js`** — lexical containment on a path that is
|
|
59
|
+
then **executed**. A symlink inside the plugin root would have run an
|
|
60
|
+
arbitrary script.
|
|
61
|
+
- **`scripts/lib/agent-compress.js`** — same class, on a read.
|
|
62
|
+
- **10 × `prototype-key-map`**, including `PACKAGE_MANAGERS['constructor']`,
|
|
63
|
+
which is truthy — so `setPreferredPackageManager('constructor')` skipped the
|
|
64
|
+
"Unknown package manager" throw entirely and persisted the bad name. Verified
|
|
65
|
+
live, then fixed with `Object.hasOwn`.
|
|
66
|
+
- **2 × durable state rewritten with `writeFileSync`** — the update-check cache
|
|
67
|
+
and the engagement state file.
|
|
68
|
+
|
|
69
|
+
It also caught a spot **I had missed**: `restampIndex` in the memory store still
|
|
70
|
+
used a raw `writeFileSync` after the 2.14.0 atomic-write sweep converted
|
|
71
|
+
`saveIndex` beside it.
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
raw detectors 104 findings (12 high, 92 medium)
|
|
75
|
+
dropped 2 imprecise classes 12 findings (10 high, 2 medium)
|
|
76
|
+
fixed every real finding 0 findings
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
- `scripts/immune/detectors.js` — pure, executable detectors
|
|
82
|
+
- `scripts/immune/scan.js` — tree walker that skips `node_modules`, test
|
|
83
|
+
fixtures, and never follows a symlink out of the tree
|
|
84
|
+
- `kodelythecc immune` and `/immune`
|
|
85
|
+
- 28 tests, including one asserting the scanner does not flag its own
|
|
86
|
+
documentation, and one pinning the fixtures to real git history when it is
|
|
87
|
+
available
|
|
88
|
+
|
|
89
|
+
### Fixed — found while building it
|
|
90
|
+
|
|
91
|
+
`stripNoise` originally stripped string literals too, and the apostrophe in
|
|
92
|
+
"don't" opened a span that swallowed regex literals several lines later. That
|
|
93
|
+
silenced a detector on a real, confirmed bug. Regex-based lexing of JavaScript
|
|
94
|
+
strings is not reliable enough to build detection on; only comments are stripped
|
|
95
|
+
now. And `walk`'s `maxFiles` cap was checked between directories but not within
|
|
96
|
+
one, so a single large directory blew straight past it — caught by its own test.
|
|
97
|
+
|
|
98
|
+
**604 tests passing**, up from 576.
|
|
99
|
+
|
|
100
|
+
## v2.16.0 — Arena run #4: three path-escapes in evolve (September 2026)
|
|
101
|
+
|
|
102
|
+
### Fixed — `applyProposalToDisk` wrote wherever the proposal told it to
|
|
103
|
+
|
|
104
|
+
`evolve accept <id>` applies a proposal to disk. The target came straight from
|
|
105
|
+
proposal data with no containment check:
|
|
106
|
+
|
|
107
|
+
```js
|
|
108
|
+
const abs = path.resolve(repoRoot, proposal.proposal.target_path);
|
|
109
|
+
fs.writeFileSync(abs, proposal.proposal.diff);
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Three vectors, all reproduced:
|
|
113
|
+
|
|
114
|
+
- `../../escaped.md` wrote to `/tmp/escaped.md`
|
|
115
|
+
- an **absolute** `target_path` discards `repoRoot` entirely — `path.resolve(root, '/abs')` returns `/abs`
|
|
116
|
+
- with `overwrite: true`, a real user file **outside** the root was overwritten
|
|
117
|
+
|
|
118
|
+
This is the fifth occurrence of the containment class the arena has confirmed,
|
|
119
|
+
in the one file that never adopted the guard.
|
|
120
|
+
|
|
121
|
+
### Added — `safeFs.resolveContainedForWrite`
|
|
122
|
+
|
|
123
|
+
The existing `resolveContained` could not answer this case: it canonicalises the
|
|
124
|
+
target, which fails for a file about to be *created*. The new function walks up
|
|
125
|
+
to the nearest existing ancestor, canonicalises that, and re-checks — so a
|
|
126
|
+
symlinked parent directory still cannot be used to escape. Absolute candidates
|
|
127
|
+
are rejected up front rather than joined and hoped about.
|
|
128
|
+
|
|
129
|
+
### Fixed — the classifier missed naturally-phrased findings, again
|
|
130
|
+
|
|
131
|
+
All three findings filed as `uncategorized`. The `path-traversal` pattern wanted
|
|
132
|
+
the literal words *traversal* / *confinement*, while the findings said "writes
|
|
133
|
+
outside repoRoot" and "discards repoRoot entirely". Widened to match
|
|
134
|
+
`\w*root` forms — `repoRoot`, `projectRoot`, `coordRoot` — and the stored
|
|
135
|
+
memories re-tagged.
|
|
136
|
+
|
|
137
|
+
That is the third generic-vs-specific gap in this classifier (`permission`,
|
|
138
|
+
`memory`, now `root`). The pattern is consistent: it matches jargon and misses
|
|
139
|
+
the plain phrasing people actually write.
|
|
140
|
+
|
|
141
|
+
### Removed — the ecc-web dispatch workflow
|
|
142
|
+
|
|
143
|
+
It fired a `repository_dispatch` at ecc-web on every push for instant deploys,
|
|
144
|
+
needed a cross-repo PAT, and had been returning HTTP 403 while reporting green.
|
|
145
|
+
Measured on the ecc-web side across 12 consecutive deploys: **12 from its own
|
|
146
|
+
10-minute cron, 0 from the dispatch.** It was contributing nothing but an
|
|
147
|
+
expiring secret and a red check.
|
|
148
|
+
|
|
149
|
+
Removed rather than repaired. ecc-web keeps its `repository_dispatch` trigger,
|
|
150
|
+
so restoring instant deploys is just a matter of adding a fine-grained PAT with
|
|
151
|
+
`Contents: read-write`. The rationale is recorded in that repo's `deploy.yml` so
|
|
152
|
+
it does not get rebuilt. The cost is latency the site already had.
|
|
153
|
+
|
|
154
|
+
**576 tests passing. All 9 CI checks green.**
|
|
155
|
+
|
|
5
156
|
## v2.15.0 — Green CI, and the agents learn what the arena found (September 2026)
|
|
6
157
|
|
|
7
158
|
### Fixed — CI had been red since v2.8.0
|
package/CLAUDE.md
CHANGED
|
@@ -8,7 +8,7 @@ Guidance for Claude Code when working with this repository.
|
|
|
8
8
|
|
|
9
9
|
- **70 specialist agents** — debug-detective, incident-commander, load-tester, image-architect, kodelyth-memory, security-reviewer, plus 8 adversarial devil-mode agents
|
|
10
10
|
- **196 skills** — domain knowledge, patterns, testing, security, intent routing, local memory, swarm orchestration, MCP integration
|
|
11
|
-
- **
|
|
11
|
+
- **103 commands** — slash workflows (`/tdd`, `/plan`, `/code-review`, `/team-review`, `/devil-mode`, `/debug-blitz`, `/security-audit`, ...)
|
|
12
12
|
- **22+ hooks** — quality gates, memory inject + capture, correction encoding, prompt-injection guard, token-budget enforcer
|
|
13
13
|
- **14 rules** — always-on coding standards + semantic intent routing + memory protocol + self-improvement
|
|
14
14
|
|
|
@@ -18,7 +18,7 @@ Works with Claude Code, Windsurf, Cursor, Codex CLI, Antigravity, OpenCode, Clin
|
|
|
18
18
|
|
|
19
19
|
```
|
|
20
20
|
agents/ → 70 specialist subagents (planner, code-reviewer, debug-detective, devil-mode crew, ...)
|
|
21
|
-
commands/ →
|
|
21
|
+
commands/ → 103 slash commands (8 parallel multi-agent, 1 adversarial loop, rest single-agent)
|
|
22
22
|
skills/ → 196 workflow + domain knowledge files (loadable via slash commands)
|
|
23
23
|
hooks/ → 22+ automations (pre-commit, session memory, prompt-injection guard, token-budget)
|
|
24
24
|
rules/ → 14 always-on guidelines (agent-intent-routing, self-improvement, memory-protocol, ...)
|
|
@@ -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/ → 604 passing tests across 32 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.17.0
|
package/bin/kodelyth-ecc.js
CHANGED
|
@@ -431,6 +431,58 @@ if (args[0] === 'memory') {
|
|
|
431
431
|
// kodelythecc arena list list past arena runs
|
|
432
432
|
// kodelythecc arena learn <run-id> [--commit] remember what the run proved
|
|
433
433
|
// kodelythecc arena report <run-id> show a run's report
|
|
434
|
+
// ── Subcommand: immune (scan for arena-confirmed bug classes) ────────────────
|
|
435
|
+
// kodelyth-ecc immune [path] [--json] [--tests]
|
|
436
|
+
if (args[0] === 'immune') {
|
|
437
|
+
const rest = args.slice(1);
|
|
438
|
+
const w = (m) => process.stdout.write(m + '\n');
|
|
439
|
+
const target = rest.find((a) => !a.startsWith('--')) || '.';
|
|
440
|
+
const { scanTree } = require(path.join(ROOT, 'scripts', 'immune', 'scan.js'));
|
|
441
|
+
const { DETECTORS } = require(path.join(ROOT, 'scripts', 'immune', 'detectors.js'));
|
|
442
|
+
|
|
443
|
+
let res;
|
|
444
|
+
try {
|
|
445
|
+
res = scanTree(target, { includeTests: rest.includes('--tests') });
|
|
446
|
+
} catch (err) {
|
|
447
|
+
process.stderr.write(`immune: cannot scan ${target}: ${err.message}\n`);
|
|
448
|
+
process.exit(2);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (rest.includes('--json')) { w(JSON.stringify(res, null, 2)); process.exit(res.counts.high > 0 ? 1 : 0); }
|
|
452
|
+
|
|
453
|
+
w('');
|
|
454
|
+
w(`\x1b[1mECC Immunity\x1b[0m — ${DETECTORS.length} bug classes the arena confirmed, checked against \x1b[33m${target}\x1b[0m`);
|
|
455
|
+
w('─'.repeat(72));
|
|
456
|
+
|
|
457
|
+
if (!res.findings.length) {
|
|
458
|
+
w(`\x1b[32mClean.\x1b[0m ${res.scanned} files scanned, none matched a known class.`);
|
|
459
|
+
w('');
|
|
460
|
+
w('This is not proof of correctness — only that none of the specific classes');
|
|
461
|
+
w('the arena has confirmed are present. Run /arena to hunt for new ones.');
|
|
462
|
+
w('');
|
|
463
|
+
process.exit(0);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const colour = { high: '31', medium: '33', low: '90' };
|
|
467
|
+
for (const f of res.findings) {
|
|
468
|
+
w(`\x1b[${colour[f.severity] || '0'}m${f.severity.toUpperCase().padEnd(6)}\x1b[0m ${f.file}:${f.line}`);
|
|
469
|
+
w(` ${f.title}`);
|
|
470
|
+
w(` \x1b[90m${f.evidence}\x1b[0m`);
|
|
471
|
+
w(` \x1b[36mfix:\x1b[0m ${f.fix}`);
|
|
472
|
+
w('');
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
w('─'.repeat(72));
|
|
476
|
+
w(`${res.counts.total} finding(s) across ${res.scanned} files — ` +
|
|
477
|
+
`${res.counts.high} high, ${res.counts.medium} medium, ${res.counts.low} low`);
|
|
478
|
+
w('');
|
|
479
|
+
w('Each class here was reproduced with a real repro during an arena run, and');
|
|
480
|
+
w('each detector is pinned to the commit that proves it fires on the bug and');
|
|
481
|
+
w('stays silent on the fix.');
|
|
482
|
+
w('');
|
|
483
|
+
process.exit(res.counts.high > 0 ? 1 : 0);
|
|
484
|
+
}
|
|
485
|
+
|
|
434
486
|
if (args[0] === 'god' || args[0] === 'evil' || args[0] === 'arena') {
|
|
435
487
|
const mode = args[0];
|
|
436
488
|
const rest = args.slice(1);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Scan any codebase for the bug classes the arena has actually confirmed — executable detectors, each pinned to the commit that proves it.
|
|
3
|
+
argument-hint: "[path]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# /immune — the arena's findings, made executable
|
|
7
|
+
|
|
8
|
+
The arena finds bugs one repository at a time and remembers them as prose. Prose
|
|
9
|
+
does not scan a codebase. `/immune` turns each **confirmed** class into a
|
|
10
|
+
detector that runs, so a bug proven here can be found anywhere — including in
|
|
11
|
+
repositories that never ran the arena.
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
kodelythecc immune # scan the current directory
|
|
15
|
+
kodelythecc immune scripts/ # scan a subtree
|
|
16
|
+
kodelythecc immune --json # machine-readable
|
|
17
|
+
kodelythecc immune --tests # include test files (normally skipped)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Exits non-zero when anything **high** is found, so it drops straight into CI.
|
|
21
|
+
|
|
22
|
+
## What it looks for
|
|
23
|
+
|
|
24
|
+
Every detector below encodes a class that was reproduced with a real repro
|
|
25
|
+
during an arena run. Nothing here is hypothetical.
|
|
26
|
+
|
|
27
|
+
| Detector | Confirmed | What it catches |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| `lexical-containment` | 5× | `startsWith(root)` with no `realpath` — a symlink inside the root passes while pointing anywhere on disk |
|
|
30
|
+
| `prototype-key-map` | 1× | `map['constructor']` is truthy, so `if (!map[k])` never fires |
|
|
31
|
+
| `truncate-then-write` | 3× | `writeFileSync` on durable state truncates to zero before writing |
|
|
32
|
+
| `predictable-temp-name` | 1× | a pid-derived temp path can be pre-planted with a symlink |
|
|
33
|
+
|
|
34
|
+
## How each detector earns its place
|
|
35
|
+
|
|
36
|
+
A detector ships only if it does **both**:
|
|
37
|
+
|
|
38
|
+
1. **FIRES** on the exact source the arena found the bug in
|
|
39
|
+
2. Goes **SILENT** on the source after the fix
|
|
40
|
+
|
|
41
|
+
Both halves matter. A detector that misses the real bug is decoration; one that
|
|
42
|
+
still fires after the fix is a false positive. The fixtures in
|
|
43
|
+
`tests/immune/detectors.test.js` are real code quoted from this repository at
|
|
44
|
+
v2.7.0 — the last release before any arena fix landed.
|
|
45
|
+
|
|
46
|
+
## Two classes were deliberately NOT shipped
|
|
47
|
+
|
|
48
|
+
This matters more than the four that were.
|
|
49
|
+
|
|
50
|
+
**ReDoS.** The class is real: an unbounded quantifier cost 7.6 s on 293 KB. But
|
|
51
|
+
arena run #1 *measured* five structurally identical regexes in one file — the
|
|
52
|
+
fenced-code, inline-code, bare-URL and file-path patterns were all linear, and
|
|
53
|
+
only the link-target pattern was quadratic. The difference is whether the
|
|
54
|
+
closing delimiter is commonly absent in real input: a property of the data, not
|
|
55
|
+
the pattern, and invisible to a scanner. A detector would have flagged all five
|
|
56
|
+
and been wrong about four.
|
|
57
|
+
|
|
58
|
+
**Unbounded input.** Also real — 4.9 MB allocated ~417 MB and OOMed under a
|
|
59
|
+
256 MB heap. But the detectable shape is "this module reads a file", which
|
|
60
|
+
described 57 of 165 files here. The actual bug is reading *untrusted* input
|
|
61
|
+
without a cap, and a scanner cannot tell which reads are untrusted.
|
|
62
|
+
|
|
63
|
+
Both stay where they were actually caught: measurement under `/arena`, and human
|
|
64
|
+
review under `security-reviewer`.
|
|
65
|
+
|
|
66
|
+
> A scanner that is wrong four times out of five gets muted, and a muted scanner
|
|
67
|
+
> protects nothing. Precision over recall, deliberately.
|
|
68
|
+
|
|
69
|
+
## What a clean result means
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
Clean. 165 files scanned, none matched a known class.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
That is **not** proof of correctness. It means none of the specific classes the
|
|
76
|
+
arena has confirmed are present. New classes are found by running `/arena`, and
|
|
77
|
+
each new confirmation is a candidate for a new detector.
|
|
78
|
+
|
|
79
|
+
## The loop this closes
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
/arena ──▶ confirmed findings ──▶ memories ──▶ detectors ──▶ /immune
|
|
83
|
+
▲ │
|
|
84
|
+
└────────────── new classes found in new code ◀────────────────────┘
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`/arena` is expensive and thorough — agents, rounds, real repros. `/immune` is
|
|
88
|
+
instant and narrow. Use the arena to *discover* a class; use immune to make sure
|
|
89
|
+
it never comes back, anywhere.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.17.0",
|
|
4
4
|
"description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 194 skills, 97 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 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/arena/learn.js
CHANGED
|
@@ -33,7 +33,7 @@ const CLASSES = [
|
|
|
33
33
|
['filesystem-symlink', /\bsymlink|lstat|O_NOFOLLOW|hard ?link|dangling\b/i],
|
|
34
34
|
['file-permissions', /\bchmod|umask|0600|0644|0444|world-readable|file mode|file permission|permissions? (?:not )?preserved\b/i],
|
|
35
35
|
['redos', /\bredos|backtrack|quadratic|catastrophic|O\(n\^?2\)|unanchored\b/i],
|
|
36
|
-
['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|
|
|
36
|
+
['path-traversal', /\btraversal|confinement|arbitrary (?:write|path)|escapes? (?:the )?\w*root|outside (?:the )?\w*(?:root|repo\w*)|writes? outside|discards? (?:the )?\w*root/i],
|
|
37
37
|
['race-condition', /\btoctou|race condition|check.to.use\b/i],
|
|
38
38
|
['resource-exhaustion', /\bheap\b|\brss\b|\boom\b|amplification|exhaust|out of memory|memory (?:leak|usage|growth|pressure)|allocates? ~?\d/i],
|
|
39
39
|
['missing-limit', /\bno (?:input )?(?:size )?cap|unbounded|no limit|missing limit\b/i],
|
|
@@ -135,7 +135,7 @@ function validateInstallManifests() {
|
|
|
135
135
|
const expectedProfileIds = ['core', 'developer', 'security', 'research', 'full'];
|
|
136
136
|
|
|
137
137
|
for (const profileId of expectedProfileIds) {
|
|
138
|
-
if (!profiles
|
|
138
|
+
if (!Object.hasOwn(profiles, profileId)) {
|
|
139
139
|
console.error(`ERROR: Missing required install profile: ${profileId}`);
|
|
140
140
|
hasErrors = true;
|
|
141
141
|
}
|
|
@@ -8,6 +8,7 @@ const https = require('https');
|
|
|
8
8
|
const fs = require('fs');
|
|
9
9
|
const os = require('os');
|
|
10
10
|
const path = require('path');
|
|
11
|
+
const safeFs = require('../lib/safe-fs.js');
|
|
11
12
|
|
|
12
13
|
const CACHE_DIR = path.join(os.homedir(), '.kodelythecc');
|
|
13
14
|
const CACHE_FILE = path.join(CACHE_DIR, 'update-check.json');
|
|
@@ -23,7 +24,7 @@ function readCache() {
|
|
|
23
24
|
|
|
24
25
|
function writeCache(latest) {
|
|
25
26
|
try { fs.mkdirSync(CACHE_DIR, { recursive: true }); } catch {}
|
|
26
|
-
try {
|
|
27
|
+
try { safeFs.replaceFilePreservingMode(CACHE_FILE, JSON.stringify({ at: Date.now(), latest })); } catch {}
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function fetchLatest() {
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const os = require('os');
|
|
23
|
+
const safeFs = require('../lib/safe-fs.js');
|
|
23
24
|
|
|
24
25
|
const DEFAULT_DIR = process.env.KODELYTH_EVOLVE_DIR
|
|
25
26
|
|| path.join(os.homedir(), '.kodelythecc', 'evolve');
|
|
@@ -139,7 +140,18 @@ function applyProposalToDisk(proposal, { repoRoot, overwrite = false } = {}) {
|
|
|
139
140
|
throw new Error('applyProposalToDisk: proposal lacks diff or target_path');
|
|
140
141
|
}
|
|
141
142
|
if (!repoRoot) throw new Error('applyProposalToDisk: repoRoot is required');
|
|
142
|
-
|
|
143
|
+
|
|
144
|
+
// target_path comes from proposal data, not from the caller, so it is not
|
|
145
|
+
// trusted. path.resolve normalises ".." but still returns a path OUTSIDE the
|
|
146
|
+
// root when the target escapes — and an ABSOLUTE target_path discards the root
|
|
147
|
+
// entirely. Confirmed writing to /tmp/escaped.md and overwriting a real file
|
|
148
|
+
// outside the root with overwrite:true.
|
|
149
|
+
const abs = safeFs.resolveContainedForWrite(proposal.proposal.target_path, repoRoot);
|
|
150
|
+
if (!abs) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`applyProposalToDisk: refusing to write outside repoRoot — target_path "${proposal.proposal.target_path}" escapes ${repoRoot}`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
143
155
|
if (fs.existsSync(abs) && !overwrite) {
|
|
144
156
|
throw new Error(`applyProposalToDisk: refusing to overwrite existing file at ${abs} (pass overwrite=true)`);
|
|
145
157
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
const fs = require('fs');
|
|
12
12
|
const path = require('path');
|
|
13
|
+
const safeFs = require('../lib/safe-fs.js');
|
|
13
14
|
const { spawnSync } = require('child_process');
|
|
14
15
|
const { isHookEnabled } = require('../lib/hook-flags');
|
|
15
16
|
|
|
@@ -103,8 +104,10 @@ async function main() {
|
|
|
103
104
|
const resolvedRoot = path.resolve(pluginRoot);
|
|
104
105
|
const scriptPath = path.resolve(pluginRoot, relScriptPath);
|
|
105
106
|
|
|
106
|
-
// Prevent path traversal outside the plugin root
|
|
107
|
-
|
|
107
|
+
// Prevent path traversal outside the plugin root — including via a symlink.
|
|
108
|
+
// The lexical check alone passed for a link sitting inside the root, and this
|
|
109
|
+
// path is EXECUTED, so following one runs an arbitrary script.
|
|
110
|
+
if (!safeFs.resolveContained(scriptPath, resolvedRoot)) {
|
|
108
111
|
process.stderr.write(`[Hook] Path traversal rejected for ${hookId}: ${scriptPath}\n`);
|
|
109
112
|
process.stdout.write(raw);
|
|
110
113
|
process.exit(0);
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// scripts/immune/detectors.js
|
|
2
|
+
//
|
|
3
|
+
// Executable detectors for the bug classes the arena has actually confirmed.
|
|
4
|
+
//
|
|
5
|
+
// The arena finds bugs and remembers them as prose. Prose does not scan a
|
|
6
|
+
// codebase. Each detector below encodes one class that was reproduced with a
|
|
7
|
+
// real repro during an arena run, so the knowledge becomes something that can
|
|
8
|
+
// run against any repository instead of only informing the next conversation.
|
|
9
|
+
//
|
|
10
|
+
// Every detector is proven against git history: it must FIRE on the exact
|
|
11
|
+
// pre-fix source the arena found the bug in, and stay SILENT on the fixed
|
|
12
|
+
// source. A detector that cannot do both does not ship. See
|
|
13
|
+
// tests/immune/detectors.test.js, which pins each one to a real commit.
|
|
14
|
+
//
|
|
15
|
+
// Precision over recall, deliberately. A scanner that cries wolf gets muted,
|
|
16
|
+
// and a muted scanner protects nothing.
|
|
17
|
+
|
|
18
|
+
'use strict';
|
|
19
|
+
|
|
20
|
+
// Strip comments and string literals before matching. Without this, every
|
|
21
|
+
// detector fires on its own documentation and on the changelog entry describing
|
|
22
|
+
// the bug it looks for — which is exactly how a scanner earns its mute button.
|
|
23
|
+
// Blank out COMMENTS ONLY, preserving line numbers.
|
|
24
|
+
//
|
|
25
|
+
// An earlier version also stripped string literals, which broke on the first
|
|
26
|
+
// apostrophe in ordinary prose ("don't") — the quote opened a span that ate
|
|
27
|
+
// everything to the next quote, including regex literals several lines later.
|
|
28
|
+
// The unbounded-quantifier detector went silent on a real, confirmed bug
|
|
29
|
+
// because of it. Regex-based lexing of JavaScript strings is not reliable
|
|
30
|
+
// enough to build detection on.
|
|
31
|
+
//
|
|
32
|
+
// Comments are the only noise that actually matters: a detector describing the
|
|
33
|
+
// bug it hunts would otherwise flag its own documentation.
|
|
34
|
+
function stripNoise(src) {
|
|
35
|
+
return src
|
|
36
|
+
// Block comments — keep newlines so line numbers stay correct.
|
|
37
|
+
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
38
|
+
// Line comments, but not the "//" inside a URL like https://example.com.
|
|
39
|
+
.replace(/(^|[^:\\])\/\/[^\n]*/g, (m, lead) => lead + ' '.repeat(m.length - lead.length));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lineOf(src, index) {
|
|
43
|
+
return src.slice(0, index).split('\n').length;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── The classes ─────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
const DETECTORS = [
|
|
49
|
+
{
|
|
50
|
+
id: 'lexical-containment',
|
|
51
|
+
severity: 'high',
|
|
52
|
+
confirmed: 5,
|
|
53
|
+
title: 'Path containment checked lexically, without resolving symlinks',
|
|
54
|
+
why:
|
|
55
|
+
'path.join and path.resolve normalise ".." but do not resolve symlinks. A link ' +
|
|
56
|
+
'sitting lexically inside the root passes a startsWith() check while its target is ' +
|
|
57
|
+
'anywhere on disk, and the read or write follows it.',
|
|
58
|
+
fix: 'Canonicalise both sides with fs.realpathSync before comparing, or route through a shared guard.',
|
|
59
|
+
detect(src) {
|
|
60
|
+
const clean = stripNoise(src);
|
|
61
|
+
// A containment comparison against a root-ish identifier...
|
|
62
|
+
const guard = /\.startsWith\(\s*([A-Za-z_$][\w$]*(?:Root|Dir|DIR|Base|BASE|dir|base))\s*\+/g;
|
|
63
|
+
const out = [];
|
|
64
|
+
let m;
|
|
65
|
+
while ((m = guard.exec(clean))) {
|
|
66
|
+
// ...that is never accompanied by a realpath anywhere in the file.
|
|
67
|
+
if (/realpathSync|realpath\(/.test(clean)) continue;
|
|
68
|
+
// ...and the file actually touches the filesystem with that path.
|
|
69
|
+
if (!/fs\.(?:readFileSync|readdirSync|writeFileSync|createReadStream|openSync|statSync)/.test(clean)) continue;
|
|
70
|
+
out.push({ line: lineOf(clean, m.index), evidence: `startsWith(${m[1]} + ...) with no realpath in the file` });
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
id: 'prototype-key-map',
|
|
78
|
+
severity: 'high',
|
|
79
|
+
confirmed: 1,
|
|
80
|
+
title: 'Plain object used as a map keyed by user-controlled text',
|
|
81
|
+
why:
|
|
82
|
+
'map["constructor"] returns Object.prototype.constructor, which is TRUTHY, so an ' +
|
|
83
|
+
'if (!map[k]) guard never fires and the next line reads a property off a function. ' +
|
|
84
|
+
'"constructor" and "toString" are ordinary vocabulary — this needs no attacker.',
|
|
85
|
+
fix: 'Use Object.create(null) for any map keyed by tokens, tags, headers, or filenames.',
|
|
86
|
+
detect(src) {
|
|
87
|
+
const clean = stripNoise(src);
|
|
88
|
+
const out = [];
|
|
89
|
+
// The exact shape: guard-then-index on a dynamically keyed map.
|
|
90
|
+
const re = /if\s*\(\s*!\s*([A-Za-z_$][\w$.]*)\[\s*([A-Za-z_$][\w$]*)\s*\]\s*\)/g;
|
|
91
|
+
let m;
|
|
92
|
+
while ((m = re.exec(clean))) {
|
|
93
|
+
const [, map, key] = m;
|
|
94
|
+
// A numeric-looking key (i, idx, n) is an array index, not a text map.
|
|
95
|
+
if (/^(?:i|j|k|n|idx|index)$/.test(key)) continue;
|
|
96
|
+
// Fires only when the map is never given a null prototype.
|
|
97
|
+
if (new RegExp(`${map.split('.').pop()}\\s*=\\s*Object\\.create\\(null\\)`).test(clean)) continue;
|
|
98
|
+
if (/Object\.create\(null\)/.test(clean) && new RegExp(`${map.split('.').pop()}:\\s*nullMap`).test(clean)) continue;
|
|
99
|
+
out.push({ line: lineOf(clean, m.index), evidence: `if (!${map}[${key}]) — ${map} is keyed by a dynamic value and has a prototype` });
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
{
|
|
106
|
+
id: 'truncate-then-write',
|
|
107
|
+
severity: 'medium',
|
|
108
|
+
confirmed: 3,
|
|
109
|
+
title: 'fs.writeFileSync used to replace persistent state',
|
|
110
|
+
why:
|
|
111
|
+
"writeFileSync opens with 'w', truncating the file to zero BEFORE writing. A crash, " +
|
|
112
|
+
'a full disk, or a concurrent reader sees an empty file — measured at 0 bytes on a ' +
|
|
113
|
+
'6.3 MB store, 32 torn reads in 1423 samples.',
|
|
114
|
+
fix: 'Write to a temp sibling and rename, or use safeFs.replaceFilePreservingMode.',
|
|
115
|
+
detect(src) {
|
|
116
|
+
const clean = stripNoise(src);
|
|
117
|
+
const out = [];
|
|
118
|
+
const re = /fs\.writeFileSync\(\s*([A-Za-z_$][\w$.]*)/g;
|
|
119
|
+
let m;
|
|
120
|
+
while ((m = re.exec(clean))) {
|
|
121
|
+
const target = m[1];
|
|
122
|
+
// A temp file being written before a rename is the CORRECT pattern.
|
|
123
|
+
if (/tmp|temp/i.test(target)) continue;
|
|
124
|
+
// Only a module-level constant or a PATHS.x member reads as durable
|
|
125
|
+
// user state. A local `filePath` loop variable is usually generated
|
|
126
|
+
// output — regenerable, and truncating it costs nothing. Matching those
|
|
127
|
+
// made this fire on half the CI scripts, which is how a scanner earns
|
|
128
|
+
// its mute button.
|
|
129
|
+
const durable = /^[A-Z][A-Z0-9_]*$/.test(target) || /^PATHS\.[a-z]/i.test(target);
|
|
130
|
+
if (!durable) continue;
|
|
131
|
+
if (/renameSync/.test(clean)) continue; // already doing temp+rename
|
|
132
|
+
out.push({ line: lineOf(clean, m.index), evidence: `fs.writeFileSync(${target}, ...) with no temp+rename in the file` });
|
|
133
|
+
}
|
|
134
|
+
return out;
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
|
|
138
|
+
// REMOVED: 'unbounded-quantifier' (ReDoS).
|
|
139
|
+
//
|
|
140
|
+
// The class is real — an unbounded negated class cost 7.6 s on 293 KB — but it
|
|
141
|
+
// cannot be detected statically with acceptable precision. Arena run #1
|
|
142
|
+
// MEASURED five structurally identical regexes in one file: the fenced-code,
|
|
143
|
+
// inline-code, bare-URL and file-path patterns were all linear, and only the
|
|
144
|
+
// link-target pattern was quadratic. The difference is whether the closing
|
|
145
|
+
// delimiter is commonly absent in real input — a property of the data, not of
|
|
146
|
+
// the pattern, and invisible to a scanner.
|
|
147
|
+
//
|
|
148
|
+
// A detector here would have flagged all five and been wrong about four. A
|
|
149
|
+
// scanner that is wrong four times out of five gets muted, and a muted scanner
|
|
150
|
+
// protects nothing. ReDoS stays where it was actually caught: measurement,
|
|
151
|
+
// under /arena, with a doubling ladder.
|
|
152
|
+
|
|
153
|
+
{
|
|
154
|
+
id: 'predictable-temp-name',
|
|
155
|
+
severity: 'low',
|
|
156
|
+
confirmed: 1,
|
|
157
|
+
title: 'Temp filename derived from the process id',
|
|
158
|
+
why:
|
|
159
|
+
'A predictable temp name lets another process pre-plant a symlink there and capture ' +
|
|
160
|
+
'the write. It also collides between concurrent runs.',
|
|
161
|
+
fix: 'Use crypto.randomBytes for the suffix and create with O_EXCL.',
|
|
162
|
+
detect(src) {
|
|
163
|
+
const clean = stripNoise(src);
|
|
164
|
+
const out = [];
|
|
165
|
+
const re = /process\.pid/g;
|
|
166
|
+
let m;
|
|
167
|
+
while ((m = re.exec(clean))) {
|
|
168
|
+
const around = clean.slice(Math.max(0, m.index - 160), m.index + 80);
|
|
169
|
+
if (!/tmp|temp|\.lock|pidfile|pidFile/i.test(around)) continue;
|
|
170
|
+
if (/pidFile|pidfile/i.test(around)) continue; // writing a real pidfile is fine
|
|
171
|
+
out.push({ line: lineOf(clean, m.index), evidence: 'temp path built from process.pid' });
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
|
|
177
|
+
// REMOVED: 'unbounded-input'.
|
|
178
|
+
//
|
|
179
|
+
// The class is real — 4.9 MB of dense input allocated ~417 MB and OOMed under
|
|
180
|
+
// a 256 MB heap — but the detectable shape is "this module reads a file",
|
|
181
|
+
// which described 57 of 165 files here. The actual bug is reading UNTRUSTED
|
|
182
|
+
// input with no cap, and a scanner cannot tell which reads are untrusted.
|
|
183
|
+
//
|
|
184
|
+
// Kept as a review question for security-reviewer and code-reviewer, where a
|
|
185
|
+
// human can judge the trust boundary, rather than as a detector that flags a
|
|
186
|
+
// third of every codebase it touches.
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
function detectorById(id) {
|
|
190
|
+
return DETECTORS.find((d) => d.id === id) || null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Run every detector over one file's source.
|
|
194
|
+
function scanSource(src, file = '<source>') {
|
|
195
|
+
const findings = [];
|
|
196
|
+
for (const d of DETECTORS) {
|
|
197
|
+
let hits = [];
|
|
198
|
+
try {
|
|
199
|
+
hits = d.detect(src) || [];
|
|
200
|
+
} catch {
|
|
201
|
+
continue; // a detector must never take the scan down with it
|
|
202
|
+
}
|
|
203
|
+
for (const h of hits) {
|
|
204
|
+
findings.push({
|
|
205
|
+
detector: d.id,
|
|
206
|
+
severity: d.severity,
|
|
207
|
+
title: d.title,
|
|
208
|
+
file,
|
|
209
|
+
line: h.line,
|
|
210
|
+
evidence: h.evidence,
|
|
211
|
+
why: d.why,
|
|
212
|
+
fix: d.fix,
|
|
213
|
+
confirmedByArena: d.confirmed,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return findings;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = { DETECTORS, detectorById, scanSource, _internals: { stripNoise, lineOf } };
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// scripts/immune/scan.js
|
|
2
|
+
//
|
|
3
|
+
// Walk a tree and apply every arena-derived detector to it.
|
|
4
|
+
//
|
|
5
|
+
// The arena finds bugs one repository at a time and remembers them as prose.
|
|
6
|
+
// This turns that memory into something executable, so a class confirmed here
|
|
7
|
+
// can be found anywhere — including in the repositories of people who never ran
|
|
8
|
+
// the arena at all.
|
|
9
|
+
|
|
10
|
+
'use strict';
|
|
11
|
+
|
|
12
|
+
const fs = require('fs');
|
|
13
|
+
const path = require('path');
|
|
14
|
+
const { scanSource, DETECTORS } = require('./detectors');
|
|
15
|
+
|
|
16
|
+
const SKIP_DIRS = new Set([
|
|
17
|
+
'node_modules', '.git', 'dist', 'build', 'coverage', '.next',
|
|
18
|
+
'out', 'vendor', '__pycache__', '.cache', 'tmp',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
// Tests deliberately contain the buggy shapes they guard against, so scanning
|
|
22
|
+
// them reports the fixtures rather than the code that ships.
|
|
23
|
+
function isSkippable(rel) {
|
|
24
|
+
return /(?:^|[\\/])(?:tests?|__tests__|spec)[\\/]/.test(rel) || /\.(?:test|spec)\.[cm]?js$/.test(rel);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function walk(root, { includeTests = false, maxFiles = 5000 } = {}) {
|
|
28
|
+
const out = [];
|
|
29
|
+
const stack = [root];
|
|
30
|
+
while (stack.length && out.length < maxFiles) {
|
|
31
|
+
const dir = stack.pop();
|
|
32
|
+
let entries;
|
|
33
|
+
try {
|
|
34
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
35
|
+
} catch {
|
|
36
|
+
continue; // unreadable directory is not a scan failure
|
|
37
|
+
}
|
|
38
|
+
for (const e of entries) {
|
|
39
|
+
const abs = path.join(dir, e.name);
|
|
40
|
+
if (e.isSymbolicLink()) continue; // never follow links out of the tree
|
|
41
|
+
if (e.isDirectory()) {
|
|
42
|
+
if (!SKIP_DIRS.has(e.name)) stack.push(abs);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (!/\.[cm]?js$/.test(e.name)) continue;
|
|
46
|
+
const rel = path.relative(root, abs);
|
|
47
|
+
if (!includeTests && isSkippable(rel)) continue;
|
|
48
|
+
out.push(abs);
|
|
49
|
+
// The cap has to be enforced HERE, not only between directories — a single
|
|
50
|
+
// large directory would otherwise push every entry before the outer loop
|
|
51
|
+
// re-checked, and maxFiles would not actually cap anything.
|
|
52
|
+
if (out.length >= maxFiles) return out.sort();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out.sort();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const SEVERITY_RANK = { high: 3, medium: 2, low: 1 };
|
|
59
|
+
|
|
60
|
+
function scanTree(root, opts = {}) {
|
|
61
|
+
const absRoot = path.resolve(root);
|
|
62
|
+
const files = walk(absRoot, opts);
|
|
63
|
+
const findings = [];
|
|
64
|
+
let scanned = 0;
|
|
65
|
+
|
|
66
|
+
for (const abs of files) {
|
|
67
|
+
let src;
|
|
68
|
+
try {
|
|
69
|
+
src = fs.readFileSync(abs, 'utf8');
|
|
70
|
+
} catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
scanned++;
|
|
74
|
+
for (const f of scanSource(src, path.relative(absRoot, abs))) findings.push(f);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
findings.sort((a, b) =>
|
|
78
|
+
(SEVERITY_RANK[b.severity] || 0) - (SEVERITY_RANK[a.severity] || 0) ||
|
|
79
|
+
a.file.localeCompare(b.file) || a.line - b.line);
|
|
80
|
+
|
|
81
|
+
const byDetector = {};
|
|
82
|
+
for (const f of findings) byDetector[f.detector] = (byDetector[f.detector] || 0) + 1;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
root: absRoot,
|
|
86
|
+
scanned,
|
|
87
|
+
detectors: DETECTORS.length,
|
|
88
|
+
findings,
|
|
89
|
+
byDetector,
|
|
90
|
+
counts: {
|
|
91
|
+
total: findings.length,
|
|
92
|
+
high: findings.filter((f) => f.severity === 'high').length,
|
|
93
|
+
medium: findings.filter((f) => f.severity === 'medium').length,
|
|
94
|
+
low: findings.filter((f) => f.severity === 'low').length,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
module.exports = { scanTree, walk, isSkippable, SKIP_DIRS };
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require('fs');
|
|
4
4
|
const path = require('path');
|
|
5
|
+
const safeFs = require('./safe-fs.js');
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Parse YAML frontmatter from a markdown string.
|
|
@@ -222,9 +223,11 @@ function lazyLoadAgent(agentsDir, agentName) {
|
|
|
222
223
|
|
|
223
224
|
const filePath = path.resolve(agentsDir, `${agentName}.md`);
|
|
224
225
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
226
|
+
// Containment, including symlinks. The previous check compared the JOINED
|
|
227
|
+
// path, which for a symlink is the link's own location — inside agentsDir, so
|
|
228
|
+
// it passed — while the target could be anywhere. Found by `kodelythecc
|
|
229
|
+
// immune`, which encodes this class from five prior arena confirmations.
|
|
230
|
+
if (!safeFs.resolveContained(filePath, agentsDir)) {
|
|
228
231
|
return null;
|
|
229
232
|
}
|
|
230
233
|
|
|
@@ -239,7 +239,7 @@ function getPackageManager(options = {}) {
|
|
|
239
239
|
* Set user's preferred package manager (global)
|
|
240
240
|
*/
|
|
241
241
|
function setPreferredPackageManager(pmName) {
|
|
242
|
-
if (!PACKAGE_MANAGERS
|
|
242
|
+
if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
|
|
243
243
|
throw new Error(`Unknown package manager: ${pmName}`);
|
|
244
244
|
}
|
|
245
245
|
|
|
@@ -260,7 +260,7 @@ function setPreferredPackageManager(pmName) {
|
|
|
260
260
|
* Set project's preferred package manager
|
|
261
261
|
*/
|
|
262
262
|
function setProjectPackageManager(pmName, projectDir = process.cwd()) {
|
|
263
|
-
if (!PACKAGE_MANAGERS
|
|
263
|
+
if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
|
|
264
264
|
throw new Error(`Unknown package manager: ${pmName}`);
|
|
265
265
|
}
|
|
266
266
|
|
package/scripts/lib/safe-fs.js
CHANGED
|
@@ -69,6 +69,49 @@ function resolveContained(candidate, root, { allowExact = null } = {}) {
|
|
|
69
69
|
return abs;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* Confirm a path that does not exist yet still resolves inside `root`.
|
|
74
|
+
*
|
|
75
|
+
* `resolveContained` cannot answer this: it realpaths the target, which fails
|
|
76
|
+
* for a file about to be created. So walk up to the nearest ancestor that DOES
|
|
77
|
+
* exist, canonicalise that, and re-check — which still catches a symlinked
|
|
78
|
+
* parent directory pointing out of the root.
|
|
79
|
+
*
|
|
80
|
+
* Returns the resolved absolute path, or null if it escapes.
|
|
81
|
+
*/
|
|
82
|
+
function resolveContainedForWrite(candidate, root) {
|
|
83
|
+
if (!candidate || !root) return null;
|
|
84
|
+
|
|
85
|
+
const absRoot = path.resolve(root);
|
|
86
|
+
// path.resolve(root, '/abs/path') returns '/abs/path' — an absolute candidate
|
|
87
|
+
// discards the root entirely, so it must be rejected up front rather than
|
|
88
|
+
// joined and hoped about.
|
|
89
|
+
const abs = path.isAbsolute(candidate) ? path.resolve(candidate) : path.resolve(absRoot, candidate);
|
|
90
|
+
|
|
91
|
+
if (abs !== absRoot && !abs.startsWith(absRoot + path.sep)) return null;
|
|
92
|
+
|
|
93
|
+
// The lexical check above is defeated by a symlinked ancestor, so canonicalise
|
|
94
|
+
// the deepest directory that actually exists and confirm it is still inside.
|
|
95
|
+
let probe = path.dirname(abs);
|
|
96
|
+
while (probe !== path.dirname(probe) && !fs.existsSync(probe)) probe = path.dirname(probe);
|
|
97
|
+
|
|
98
|
+
let realProbe;
|
|
99
|
+
try {
|
|
100
|
+
realProbe = fs.realpathSync(probe);
|
|
101
|
+
} catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
let realRoot;
|
|
105
|
+
try {
|
|
106
|
+
realRoot = fs.realpathSync(absRoot);
|
|
107
|
+
} catch {
|
|
108
|
+
realRoot = absRoot;
|
|
109
|
+
}
|
|
110
|
+
if (realProbe !== realRoot && !realProbe.startsWith(realRoot + path.sep)) return null;
|
|
111
|
+
|
|
112
|
+
return abs;
|
|
113
|
+
}
|
|
114
|
+
|
|
72
115
|
/**
|
|
73
116
|
* Stat a path that must be a regular file, refusing symlinks outright.
|
|
74
117
|
*
|
|
@@ -173,6 +216,7 @@ function replaceFilePreservingMode(absPath, contents, fallbackMode = 0o644) {
|
|
|
173
216
|
|
|
174
217
|
module.exports = {
|
|
175
218
|
resolveContained,
|
|
219
|
+
resolveContainedForWrite,
|
|
176
220
|
statRegularFile,
|
|
177
221
|
safeConfigDir,
|
|
178
222
|
writeNewFile,
|
|
@@ -281,7 +281,7 @@ function listAliases(options = {}) {
|
|
|
281
281
|
function deleteAlias(alias) {
|
|
282
282
|
const data = loadAliases();
|
|
283
283
|
|
|
284
|
-
if (!data.aliases
|
|
284
|
+
if (!Object.hasOwn(data.aliases, alias)) {
|
|
285
285
|
return { success: false, error: `Alias '${alias}' not found` };
|
|
286
286
|
}
|
|
287
287
|
|
|
@@ -308,7 +308,7 @@ function deleteAlias(alias) {
|
|
|
308
308
|
function renameAlias(oldAlias, newAlias) {
|
|
309
309
|
const data = loadAliases();
|
|
310
310
|
|
|
311
|
-
if (!data.aliases
|
|
311
|
+
if (!Object.hasOwn(data.aliases, oldAlias)) {
|
|
312
312
|
return { success: false, error: `Alias '${oldAlias}' not found` };
|
|
313
313
|
}
|
|
314
314
|
|
|
@@ -386,7 +386,7 @@ function updateAliasTitle(alias, title) {
|
|
|
386
386
|
|
|
387
387
|
const data = loadAliases();
|
|
388
388
|
|
|
389
|
-
if (!data.aliases
|
|
389
|
+
if (!Object.hasOwn(data.aliases, alias)) {
|
|
390
390
|
return { success: false, error: `Alias '${alias}' not found` };
|
|
391
391
|
}
|
|
392
392
|
|
|
@@ -15,7 +15,7 @@ function rankCounts(values) {
|
|
|
15
15
|
function summarizeVariantRuns(records) {
|
|
16
16
|
return records.reduce((accumulator, record) => {
|
|
17
17
|
const key = record.run && record.run.variant ? record.run.variant : 'baseline';
|
|
18
|
-
if (!accumulator
|
|
18
|
+
if (!Object.hasOwn(accumulator, key)) {
|
|
19
19
|
accumulator[key] = { runs: 0, successes: 0, failures: 0 };
|
|
20
20
|
}
|
|
21
21
|
|
package/scripts/mcp/client.js
CHANGED
|
@@ -80,7 +80,7 @@ function addServer({ name, command, args = [], env = {}, description = '' }) {
|
|
|
80
80
|
|
|
81
81
|
function removeServer(name) {
|
|
82
82
|
const reg = loadRegistry();
|
|
83
|
-
if (!reg.servers
|
|
83
|
+
if (!Object.hasOwn(reg.servers, name)) return false;
|
|
84
84
|
delete reg.servers[name];
|
|
85
85
|
saveRegistry(reg);
|
|
86
86
|
return true;
|
package/scripts/memory/store.js
CHANGED
|
@@ -154,7 +154,7 @@ function restampIndex() {
|
|
|
154
154
|
if (!fs.existsSync(PATHS.index)) return;
|
|
155
155
|
const idx = JSON.parse(fs.readFileSync(PATHS.index, 'utf8'));
|
|
156
156
|
idx.logSize = logSize();
|
|
157
|
-
|
|
157
|
+
safeFs.replaceFilePreservingMode(PATHS.index, JSON.stringify(idx, null, 2));
|
|
158
158
|
} catch { /* a broken index is rebuilt on the next load anyway */ }
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -113,7 +113,7 @@ function listAvailable() {
|
|
|
113
113
|
}
|
|
114
114
|
|
|
115
115
|
function setGlobal(pmName) {
|
|
116
|
-
if (!PACKAGE_MANAGERS
|
|
116
|
+
if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
|
|
117
117
|
console.error(`Error: Unknown package manager "${pmName}"`);
|
|
118
118
|
console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
|
|
119
119
|
process.exit(1);
|
|
@@ -136,7 +136,7 @@ function setGlobal(pmName) {
|
|
|
136
136
|
}
|
|
137
137
|
|
|
138
138
|
function setProject(pmName) {
|
|
139
|
-
if (!PACKAGE_MANAGERS
|
|
139
|
+
if (!Object.hasOwn(PACKAGE_MANAGERS, pmName)) {
|
|
140
140
|
console.error(`Error: Unknown package manager "${pmName}"`);
|
|
141
141
|
console.error(`Available: ${Object.keys(PACKAGE_MANAGERS).join(', ')}`);
|
|
142
142
|
process.exit(1);
|