kodelyth-ecc 2.14.0 → 2.16.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 +114 -0
- package/CLAUDE.md +1 -1
- package/README.md +5 -5
- package/VERSION +1 -1
- package/agents/code-reviewer.md +10 -0
- package/agents/release-captain.md +25 -0
- package/agents/security-reviewer.md +24 -0
- package/package.json +1 -1
- package/scripts/arena/learn.js +1 -1
- package/scripts/evolve/proposals.js +13 -1
- package/scripts/lib/safe-fs.js +44 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,120 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to Kodelyth ECC are documented here.
|
|
4
4
|
|
|
5
|
+
## v2.16.0 — Arena run #4: three path-escapes in evolve (September 2026)
|
|
6
|
+
|
|
7
|
+
### Fixed — `applyProposalToDisk` wrote wherever the proposal told it to
|
|
8
|
+
|
|
9
|
+
`evolve accept <id>` applies a proposal to disk. The target came straight from
|
|
10
|
+
proposal data with no containment check:
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
const abs = path.resolve(repoRoot, proposal.proposal.target_path);
|
|
14
|
+
fs.writeFileSync(abs, proposal.proposal.diff);
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Three vectors, all reproduced:
|
|
18
|
+
|
|
19
|
+
- `../../escaped.md` wrote to `/tmp/escaped.md`
|
|
20
|
+
- an **absolute** `target_path` discards `repoRoot` entirely — `path.resolve(root, '/abs')` returns `/abs`
|
|
21
|
+
- with `overwrite: true`, a real user file **outside** the root was overwritten
|
|
22
|
+
|
|
23
|
+
This is the fifth occurrence of the containment class the arena has confirmed,
|
|
24
|
+
in the one file that never adopted the guard.
|
|
25
|
+
|
|
26
|
+
### Added — `safeFs.resolveContainedForWrite`
|
|
27
|
+
|
|
28
|
+
The existing `resolveContained` could not answer this case: it canonicalises the
|
|
29
|
+
target, which fails for a file about to be *created*. The new function walks up
|
|
30
|
+
to the nearest existing ancestor, canonicalises that, and re-checks — so a
|
|
31
|
+
symlinked parent directory still cannot be used to escape. Absolute candidates
|
|
32
|
+
are rejected up front rather than joined and hoped about.
|
|
33
|
+
|
|
34
|
+
### Fixed — the classifier missed naturally-phrased findings, again
|
|
35
|
+
|
|
36
|
+
All three findings filed as `uncategorized`. The `path-traversal` pattern wanted
|
|
37
|
+
the literal words *traversal* / *confinement*, while the findings said "writes
|
|
38
|
+
outside repoRoot" and "discards repoRoot entirely". Widened to match
|
|
39
|
+
`\w*root` forms — `repoRoot`, `projectRoot`, `coordRoot` — and the stored
|
|
40
|
+
memories re-tagged.
|
|
41
|
+
|
|
42
|
+
That is the third generic-vs-specific gap in this classifier (`permission`,
|
|
43
|
+
`memory`, now `root`). The pattern is consistent: it matches jargon and misses
|
|
44
|
+
the plain phrasing people actually write.
|
|
45
|
+
|
|
46
|
+
### Removed — the ecc-web dispatch workflow
|
|
47
|
+
|
|
48
|
+
It fired a `repository_dispatch` at ecc-web on every push for instant deploys,
|
|
49
|
+
needed a cross-repo PAT, and had been returning HTTP 403 while reporting green.
|
|
50
|
+
Measured on the ecc-web side across 12 consecutive deploys: **12 from its own
|
|
51
|
+
10-minute cron, 0 from the dispatch.** It was contributing nothing but an
|
|
52
|
+
expiring secret and a red check.
|
|
53
|
+
|
|
54
|
+
Removed rather than repaired. ecc-web keeps its `repository_dispatch` trigger,
|
|
55
|
+
so restoring instant deploys is just a matter of adding a fine-grained PAT with
|
|
56
|
+
`Contents: read-write`. The rationale is recorded in that repo's `deploy.yml` so
|
|
57
|
+
it does not get rebuilt. The cost is latency the site already had.
|
|
58
|
+
|
|
59
|
+
**576 tests passing. All 9 CI checks green.**
|
|
60
|
+
|
|
61
|
+
## v2.15.0 — Green CI, and the agents learn what the arena found (September 2026)
|
|
62
|
+
|
|
63
|
+
### Fixed — CI had been red since v2.8.0
|
|
64
|
+
|
|
65
|
+
Eight tests asserted POSIX file semantics on the Windows runner. Every release
|
|
66
|
+
from 2.8.0 through 2.14.0 shipped with a failing badge, because `npm test` was
|
|
67
|
+
only ever run on macOS — a green local run beside a red CI is exactly the trap
|
|
68
|
+
this release also teaches `release-captain` to catch.
|
|
69
|
+
|
|
70
|
+
On Windows `chmod` only toggles the read-only bit, `statSync().mode` is
|
|
71
|
+
synthesized rather than real, `umask` is meaningless, and `symlinkSync` needs
|
|
72
|
+
administrator rights or Developer Mode. The eight tests assert precisely those
|
|
73
|
+
semantics.
|
|
74
|
+
|
|
75
|
+
They are **skipped there with a stated reason, not deleted** — the behaviour they
|
|
76
|
+
guard (mode preservation, umask independence, `O_EXCL` against planted symlinks)
|
|
77
|
+
is real on Linux and macOS, which is where CI proves it.
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
tests/lib/safe-fs.test.js 5 guarded
|
|
81
|
+
tests/terse/compress.test.js 3 guarded
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
All nine CI jobs now pass: Node 18/20/22 across Linux, macOS, and Windows.
|
|
85
|
+
|
|
86
|
+
### Changed — three agents learned from three arena runs
|
|
87
|
+
|
|
88
|
+
The arena confirmed the same defects repeatedly, and none of the 70 agents knew
|
|
89
|
+
to look for them. Each addition below is a bug that was actually reproduced, not
|
|
90
|
+
a hypothetical.
|
|
91
|
+
|
|
92
|
+
**`security-reviewer`** gained three hunt blocks:
|
|
93
|
+
|
|
94
|
+
- **Lexical-only path containment** — `path.join`/`resolve` normalise `..` but do
|
|
95
|
+
not resolve symlinks, so a link inside the root passes a `startsWith` check
|
|
96
|
+
while pointing anywhere on disk. Confirmed four times across three files.
|
|
97
|
+
- **Prototype keys as map keys** — `map['constructor']` returns a truthy
|
|
98
|
+
function, so `if (!map[k])` never fires. Needs no attacker: one document
|
|
99
|
+
containing the word "constructor" bricked the memory store.
|
|
100
|
+
- **Truncate-then-write on persistent state** — `writeFileSync` opens with `'w'`.
|
|
101
|
+
A 6.3 MB store was measured at 0 bytes mid-rewrite, 32 torn reads in 1423
|
|
102
|
+
samples.
|
|
103
|
+
|
|
104
|
+
**`code-reviewer`** gained the same three as checklist items under Security.
|
|
105
|
+
|
|
106
|
+
**`release-captain`** gained **Phase 1.5 — Prove the build is green where it
|
|
107
|
+
actually runs**, with the `gh run list` / `gh run view --log-failed` commands and
|
|
108
|
+
a table of one-platform traps (POSIX modes, umask, symlinks, path separators,
|
|
109
|
+
BSD vs GNU flags). It states plainly: never cut a release on a red CI, and never
|
|
110
|
+
report "all tests passing" when only your own platform is passing.
|
|
111
|
+
|
|
112
|
+
### Fixed — repository metadata
|
|
113
|
+
|
|
114
|
+
The GitHub About sidebar still advertised `194 skills · 97 commands`. Corrected
|
|
115
|
+
to 196 / 102, and the arena added to the description.
|
|
116
|
+
|
|
117
|
+
**572 tests passing on every supported platform.**
|
|
118
|
+
|
|
5
119
|
## v2.14.0 — A false recurring class, and five more atomic writes (August 2026)
|
|
6
120
|
|
|
7
121
|
### Fixed — the guard proposal was pointing at the wrong thing
|
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/ → 576 passing tests across 30 test files
|
|
30
30
|
```
|
|
31
31
|
|
|
32
32
|
## Running Tests
|
package/README.md
CHANGED
|
@@ -72,7 +72,7 @@ You never typed `use debug-detective`. You didn't have to. The toolkit read the
|
|
|
72
72
|
|---|---|---|
|
|
73
73
|
| **Intent routing** | Plain-language → right specialist via 10-tier priority rules | Mostly missing — you memorize names |
|
|
74
74
|
| **70 agents** | Specialists with playbooks, severity calibration, real commands | Often persona-only ("you are a senior engineer...") |
|
|
75
|
-
| **
|
|
75
|
+
| **196 skills** | Domain knowledge files agents read on demand | Rarely separated from agents |
|
|
76
76
|
| **102 commands** | Slash workflows (`/tdd`, `/arena`, `/devil-mode`, `/team-review`) | Limited or none |
|
|
77
77
|
| **8 parallel commands** | Fire 3-8 agents simultaneously, aggregate results | Rare |
|
|
78
78
|
| **Compound memory** | BM25 local recall + auto-inject + project lessons | Cloud-only or absent |
|
|
@@ -118,7 +118,7 @@ kodelythecc --target claude-code --codebase-graph
|
|
|
118
118
|
That's it. This single flow:
|
|
119
119
|
|
|
120
120
|
1. Installs both binaries (`kodelyth-ecc` and short-form `kodelythecc`) to your PATH
|
|
121
|
-
2. Copies 70 agents +
|
|
121
|
+
2. Copies 70 agents + 196 skills + 102 commands + 22 hooks + 14 rules into your AI tool's config dir
|
|
122
122
|
3. Auto-installs **RTK** binary and wires its PreToolUse hook (input compression starts on next AI restart)
|
|
123
123
|
4. Installs **Terse mode** skill + `/terse` and `/terse-compress` slash commands (dormant — user types `/terse` to activate)
|
|
124
124
|
5. Auto-installs **codebase-memory-mcp** and registers its MCP entries in your AI tool (with `--codebase-graph`)
|
|
@@ -220,7 +220,7 @@ npx kodelyth-ecc --bundle red-team # Security engineer — devil-mode + a
|
|
|
220
220
|
npx kodelyth-ecc --bundle enterprise # Compliance / audit team — SBOM, license, supply chain
|
|
221
221
|
```
|
|
222
222
|
|
|
223
|
-
Each bundle installs the full ECC toolkit (all 70 agents,
|
|
223
|
+
Each bundle installs the full ECC toolkit (all 70 agents, 196 skills, 102 commands, 22+ hooks), adds a `BUNDLE.md` cheat sheet, and biases the AI toward audience-fit workflows on every session.
|
|
224
224
|
|
|
225
225
|
Combine with any target:
|
|
226
226
|
|
|
@@ -417,7 +417,7 @@ What's inside:
|
|
|
417
417
|
| **Overview** | Agent count, memory stats, session count, recent activity |
|
|
418
418
|
| **Memory** | Browse, search, and manage your local BM25 memory store |
|
|
419
419
|
| **Evolve** | Self-improving memory — review AI-proposed refinements |
|
|
420
|
-
| **Catalog** | Full searchable index of all 70 agents,
|
|
420
|
+
| **Catalog** | Full searchable index of all 70 agents, 196 skills, 102 commands |
|
|
421
421
|
| **Sessions** | **Live IDE activity** (Claude Code, Windsurf, Windsurf-Next, Cursor, Antigravity) + orchestration/swarm sessions |
|
|
422
422
|
|
|
423
423
|
Real-time:
|
|
@@ -888,7 +888,7 @@ The intent router will route you to the right one. The AI announces who's taking
|
|
|
888
888
|
| Source | Destination | What it does |
|
|
889
889
|
|---|---|---|
|
|
890
890
|
| `agents/` | `~/.claude/agents/` | All 70 subagents available globally |
|
|
891
|
-
| `skills/` | `~/.claude/skills/` | All
|
|
891
|
+
| `skills/` | `~/.claude/skills/` | All 196 skills loadable via commands |
|
|
892
892
|
| `hooks/hooks.json` | `~/.claude/hooks/` | Automated quality gates |
|
|
893
893
|
| `rules/` | `~/.claude/rules/` | Always-on standards + intent routing |
|
|
894
894
|
| `commands/` | `~/.claude/commands/` | Slash commands (`/tdd`, `/plan`, etc.) |
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.16.0
|
package/agents/code-reviewer.md
CHANGED
|
@@ -37,6 +37,16 @@ These MUST be flagged — they can cause real damage:
|
|
|
37
37
|
- **SQL injection** — String concatenation in queries instead of parameterized queries
|
|
38
38
|
- **XSS vulnerabilities** — Unescaped user input rendered in HTML/JSX
|
|
39
39
|
- **Path traversal** — User-controlled file paths without sanitization
|
|
40
|
+
- **Lexical-only containment** — `startsWith(root)` after `path.join` catches `..` but NOT
|
|
41
|
+
symlinks: `path.resolve` does not resolve them, so a link inside the root passes the check
|
|
42
|
+
while pointing anywhere on disk. Demand `realpath` on both sides before the comparison.
|
|
43
|
+
- **Prototype keys as map keys** — `map['constructor']` returns a truthy function, so
|
|
44
|
+
`if (!map[k])` never fires and the next line reads a property off it. Any object keyed by
|
|
45
|
+
user text (tokens, tags, headers, filenames) must be `Object.create(null)`. Words like
|
|
46
|
+
"constructor" and "toString" are ordinary vocabulary — this needs no attacker.
|
|
47
|
+
- **Truncate-then-write on state worth keeping** — `fs.writeFileSync` opens with `'w'` and
|
|
48
|
+
truncates to zero before writing. A crash, a full disk, or a concurrent reader sees an
|
|
49
|
+
empty file. Config files, registries, ledgers and indexes need temp + rename.
|
|
40
50
|
- **CSRF vulnerabilities** — State-changing endpoints without CSRF protection
|
|
41
51
|
- **Authentication bypasses** — Missing auth checks on protected routes
|
|
42
52
|
- **Insecure dependencies** — Known vulnerable packages
|
|
@@ -40,6 +40,31 @@ Read the diff since last tag. Classify each change:
|
|
|
40
40
|
|
|
41
41
|
Be **strict** about MAJOR. Most teams under-call breaking changes and lose user trust.
|
|
42
42
|
|
|
43
|
+
### Phase 1.5 — Prove the build is green where it actually runs
|
|
44
|
+
|
|
45
|
+
A local `npm test` proves the suite passes **on your machine**. It says nothing
|
|
46
|
+
about the other platforms CI covers, and a green local run beside a red badge is
|
|
47
|
+
how a project ships eight broken releases in a row without noticing.
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
gh run list --workflow=CI --limit 5 # is the badge actually green?
|
|
51
|
+
gh run view <id> --log-failed # if not, what fails and on which OS?
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
**Never cut a release on a red CI.** If the failure is platform-specific, fix or
|
|
55
|
+
explicitly guard it — do not delete the test, and do not tell the user "all tests
|
|
56
|
+
passing" when only your platform is passing.
|
|
57
|
+
|
|
58
|
+
Common one-platform traps:
|
|
59
|
+
|
|
60
|
+
| Assumption | Breaks on |
|
|
61
|
+
|---|---|
|
|
62
|
+
| `chmod` / `statSync().mode` carry POSIX bits | Windows — only the read-only bit exists |
|
|
63
|
+
| `process.umask()` is meaningful | Windows |
|
|
64
|
+
| `symlinkSync` just works | Windows — needs admin or Developer Mode |
|
|
65
|
+
| Paths use `/` | Windows — compare with `path.sep` |
|
|
66
|
+
| `timeout`, `stat -f`, GNU flags exist | macOS ships BSD variants; Windows ships neither |
|
|
67
|
+
|
|
43
68
|
### Phase 2 — Generate the changelog
|
|
44
69
|
|
|
45
70
|
Group entries by category, in this order:
|
|
@@ -55,6 +55,30 @@ rg -n 'Math\.random\(\)' # non-CSPRNG f
|
|
|
55
55
|
|
|
56
56
|
# ── Unsafe deserialization + prototype pollution (HIGH) ─────────────────────
|
|
57
57
|
rg -n 'pickle\.loads|yaml\.load\(|Marshal\.load|JSON\.parse\([^)]*req\.|_\.merge\(\{\}|Object\.assign\(target' # yaml.load: confirm it lacks SafeLoader
|
|
58
|
+
|
|
59
|
+
# ── Lexical-only path containment (HIGH) ────────────────────────────────────
|
|
60
|
+
# path.join/resolve normalise ".." but do NOT resolve symlinks. A link sitting
|
|
61
|
+
# lexically inside the root passes a startsWith() check while its target is
|
|
62
|
+
# anywhere on disk, and the read follows it. Confirmed four times across three
|
|
63
|
+
# unrelated files in this codebase before the guard existed.
|
|
64
|
+
rg -n 'startsWith\(.*(?:ROOT|DIR|BASE|root|base|dir).*sep|startsWith\(.*\+ .?/.?\)' # then check: is there a realpathSync nearby?
|
|
65
|
+
rg -n 'readFileSync|createReadStream|readdirSync' --context 3 | rg -n 'path\.(join|resolve)' # read after a lexical check = the bug
|
|
66
|
+
# The fix is realpath on BOTH sides before comparing, or an existing helper.
|
|
67
|
+
|
|
68
|
+
# ── Prototype keys used as map keys (HIGH) ──────────────────────────────────
|
|
69
|
+
# map['constructor'] returns Object.prototype.constructor — TRUTHY — so an
|
|
70
|
+
# `if (!map[k])` guard never fires and the next line reads a property off a
|
|
71
|
+
# function. "constructor" and "toString" are ordinary vocabulary, so this needs
|
|
72
|
+
# no attacker: one document containing the word is enough.
|
|
73
|
+
rg -n 'if \(!\w+\[\w+\]\)|\w+\[\w+\] = \w+\[\w+\] \|\|' # any map keyed by user text
|
|
74
|
+
rg -n '= \{\};' --context 2 | rg -n 'token|term|word|tag|key|freq|count' # should be Object.create(null)
|
|
75
|
+
|
|
76
|
+
# ── Truncate-then-write on persistent state (HIGH) ──────────────────────────
|
|
77
|
+
# fs.writeFileSync opens with 'w', truncating to zero BEFORE writing. A crash,
|
|
78
|
+
# a full disk, or a concurrent reader sees an empty file. Measured: a 6.3 MB
|
|
79
|
+
# store observed at 0 bytes mid-rewrite, 32 torn reads in 1423 samples.
|
|
80
|
+
rg -n 'writeFileSync\(' | rg -v 'tmp|\.tmp|test' # then ask: does this file hold state worth keeping?
|
|
81
|
+
# Config files, registries, ledgers, and indexes need temp+rename, not writeFileSync.
|
|
58
82
|
```
|
|
59
83
|
|
|
60
84
|
Report every confirmed hit with: file:line, severity, the exact fix, and (for secrets) "rotate immediately."
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kodelyth-ecc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.16.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],
|
|
@@ -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
|
}
|
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,
|