claudecode-linter 2.1.148-patch.2 → 2.1.148-patch.4
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/README.md +87 -0
- package/dist/config.js +13 -8
- package/dist/formatters/human.js +3 -2
- package/dist/index.js +72 -12
- package/dist/plugin-schema.js +10 -8
- package/dist/utils/asset-path.js +56 -0
- package/dist/utils/frontmatter.js +1 -1
- package/dist/utils/safe-write.js +36 -0
- package/dist/utils/terminal.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,17 @@ Or run directly:
|
|
|
23
23
|
npx claudecode-linter ~/projects/my-plugin/
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
+
Or build it from a clone of this repository — there is no `dist/` until you build:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/retif/claudecode-linter
|
|
30
|
+
cd claudecode-linter
|
|
31
|
+
npm ci && npm run build # install dependencies, compile to dist/
|
|
32
|
+
node dist/index.js path/to/plugin/
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Commands elsewhere in this README are written as `claudecode-linter …` (the global / `npx` install); from a clone, that is `node dist/index.js …`.
|
|
36
|
+
|
|
26
37
|
## Usage
|
|
27
38
|
|
|
28
39
|
### Lint
|
|
@@ -211,6 +222,82 @@ Formatting is powered by [prettier](https://prettier.io/) for consistent JSON an
|
|
|
211
222
|
| 1 | Lint errors found |
|
|
212
223
|
| 2 | Fatal error |
|
|
213
224
|
|
|
225
|
+
## Running on untrusted plugins
|
|
226
|
+
|
|
227
|
+
claudecode-linter is a **static analyzer** — it parses and validates the artifacts it inspects, it never executes them. There is no `eval`, no `child_process`, no declared hooks are run, and no MCP servers are spawned. Linting **trusted** code needs no special isolation.
|
|
228
|
+
|
|
229
|
+
For **untrusted** plugins — especially with `--fix`, which writes files back to disk — run the linter sandboxed. claudecode-linter is verified to run correctly fully confined: no network, a read-only root filesystem, all Linux capabilities dropped, `no-new-privileges`, a non-root UID, and only the target directory mounted.
|
|
230
|
+
|
|
231
|
+
### The Docker image
|
|
232
|
+
|
|
233
|
+
Two multi-arch (`linux/amd64`, `linux/arm64`) images are published to the GitHub Container Registry — two separate packages, each with its own `:latest` rolling tag and `:<version>` tag:
|
|
234
|
+
|
|
235
|
+
| Image | Built from | Notes |
|
|
236
|
+
|-------|-----------|-------|
|
|
237
|
+
| `ghcr.io/retif/node-claudecode-linter` | `Dockerfile` — `node:24-alpine` | default |
|
|
238
|
+
| `ghcr.io/retif/bun-claudecode-linter` | `Dockerfile.compile` — `bun build --compile` single executable | smaller (~44 MB compressed) |
|
|
239
|
+
|
|
240
|
+
**Pull a published image:**
|
|
241
|
+
|
|
242
|
+
```bash
|
|
243
|
+
docker pull ghcr.io/retif/node-claudecode-linter # default (node:24-alpine)
|
|
244
|
+
docker pull ghcr.io/retif/bun-claudecode-linter # smaller (bun --compile)
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Or build it locally** from a checkout of this repo:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
docker build -t node-claudecode-linter . # default (Dockerfile)
|
|
251
|
+
docker build -f Dockerfile.compile -t bun-claudecode-linter . # smaller variant
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
Both images behave identically. The `docker run` recipes below use `ghcr.io/retif/node-claudecode-linter`; substitute `ghcr.io/retif/bun-claudecode-linter` or a locally-built tag as you prefer.
|
|
255
|
+
|
|
256
|
+
### Sandboxed invocation
|
|
257
|
+
|
|
258
|
+
**Docker — read-only lint:**
|
|
259
|
+
|
|
260
|
+
```bash
|
|
261
|
+
docker run --rm --network none --read-only --tmpfs /tmp \
|
|
262
|
+
--user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \
|
|
263
|
+
-v "$PWD":/work:ro -w /work ghcr.io/retif/node-claudecode-linter /work
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
**Docker — `--fix`:** the mount must be read-write so fixes can be written back. Otherwise identical, plus the `--fix` flag:
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
docker run --rm --network none --read-only --tmpfs /tmp \
|
|
270
|
+
--user "$(id -u):$(id -g)" --cap-drop ALL --security-opt no-new-privileges \
|
|
271
|
+
-v "$PWD":/work -w /work ghcr.io/retif/node-claudecode-linter --fix /work
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
All four recipes here are verified. On Linux without Docker, [bubblewrap](https://github.com/containers/bubblewrap) (`bwrap`) gives the equivalent boundary: `--unshare-all` cuts network (confirmed: `ECONNREFUSED` inside the sandbox), and nothing is writable except — for `--fix` — the target directory (confirmed: a write outside it is refused).
|
|
275
|
+
|
|
276
|
+
**bwrap — read-only (lint):**
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
bwrap \
|
|
280
|
+
--ro-bind / / --dev /dev --proc /proc --tmpfs /tmp \
|
|
281
|
+
--unshare-all --die-with-parent \
|
|
282
|
+
--chdir "$PWD" \
|
|
283
|
+
claudecode-linter "$PWD"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
**bwrap — read-write (`--fix`):** the later `--bind` overrides the read-only root for just the target directory:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
bwrap \
|
|
290
|
+
--ro-bind / / --bind "$PWD" "$PWD" \
|
|
291
|
+
--dev /dev --proc /proc --tmpfs /tmp \
|
|
292
|
+
--unshare-all --die-with-parent \
|
|
293
|
+
--chdir "$PWD" \
|
|
294
|
+
claudecode-linter --fix "$PWD"
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`--ro-bind / /` can be replaced with explicit per-path `--ro-bind` entries (e.g. just `/usr`, `/nix`, and the target) for least-read-authority.
|
|
298
|
+
|
|
299
|
+
See [`SECURITY.md`](SECURITY.md) for the full security model, the audited input-handling hardening, and how to report a vulnerability.
|
|
300
|
+
|
|
214
301
|
## Versioning
|
|
215
302
|
|
|
216
303
|
This linter's version tracks the Claude Code version it was extracted from:
|
package/dist/config.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { readFileSync, existsSync } from "node:fs";
|
|
2
|
-
import {
|
|
3
|
-
import { fileURLToPath } from "node:url";
|
|
2
|
+
import { join } from "node:path";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
4
|
import { parse as parseYaml } from "yaml";
|
|
6
|
-
|
|
5
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
7
6
|
const DEFAULT_CONFIG = {
|
|
8
7
|
rules: {},
|
|
9
8
|
};
|
|
@@ -13,7 +12,7 @@ export function loadConfig(configPath) {
|
|
|
13
12
|
return DEFAULT_CONFIG;
|
|
14
13
|
try {
|
|
15
14
|
const content = readFileSync(path, "utf-8");
|
|
16
|
-
const parsed = parseYaml(content);
|
|
15
|
+
const parsed = parseYaml(content, { maxAliasCount: 100 });
|
|
17
16
|
if (!parsed || typeof parsed !== "object")
|
|
18
17
|
return DEFAULT_CONFIG;
|
|
19
18
|
const config = { rules: {} };
|
|
@@ -47,10 +46,16 @@ function findConfigFile() {
|
|
|
47
46
|
if (existsSync(homePath))
|
|
48
47
|
return homePath;
|
|
49
48
|
}
|
|
50
|
-
// 3. Fall back to bundled defaults
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
// 3. Fall back to bundled defaults. Resolved relative to import.meta.url
|
|
50
|
+
// first (Node / npm package), then relative to process.execPath as a
|
|
51
|
+
// fallback for the `bun build --compile` single-executable variant.
|
|
52
|
+
for (const bundled of assetCandidates(import.meta.url, [
|
|
53
|
+
"..",
|
|
54
|
+
".claudecode-lint.defaults.yaml",
|
|
55
|
+
])) {
|
|
56
|
+
if (existsSync(bundled))
|
|
57
|
+
return bundled;
|
|
58
|
+
}
|
|
54
59
|
return undefined;
|
|
55
60
|
}
|
|
56
61
|
export function mergeCliRules(config, enable, disable) {
|
package/dist/formatters/human.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import pc from "picocolors";
|
|
2
|
+
import { sanitizeForTerminal } from "../utils/terminal.js";
|
|
2
3
|
const SEVERITY_ICONS = {
|
|
3
4
|
error: pc.red("error"),
|
|
4
5
|
warning: pc.yellow("warn "),
|
|
@@ -16,12 +17,12 @@ export function formatHuman(results, quiet) {
|
|
|
16
17
|
if (filtered.length === 0)
|
|
17
18
|
continue;
|
|
18
19
|
lines.push("");
|
|
19
|
-
lines.push(pc.underline(result.file));
|
|
20
|
+
lines.push(pc.underline(sanitizeForTerminal(result.file)));
|
|
20
21
|
for (const d of filtered) {
|
|
21
22
|
const loc = d.line
|
|
22
23
|
? pc.dim(`:${d.line}${d.column ? `:${d.column}` : ""}`)
|
|
23
24
|
: "";
|
|
24
|
-
lines.push(` ${SEVERITY_ICONS[d.severity]} ${d.message} ${pc.dim(d.rule)}${loc}`);
|
|
25
|
+
lines.push(` ${SEVERITY_ICONS[d.severity]} ${sanitizeForTerminal(d.message)} ${pc.dim(d.rule)}${loc}`);
|
|
25
26
|
if (d.severity === "error")
|
|
26
27
|
errorCount++;
|
|
27
28
|
else if (d.severity === "warning")
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync, writeFileSync, copyFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { readFileSync, writeFileSync, copyFileSync, existsSync, statSync, } from "node:fs";
|
|
3
3
|
import { resolve, relative, dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import sade from "sade";
|
|
6
6
|
import pc from "picocolors";
|
|
7
7
|
import { loadConfig, mergeCliRules } from "./config.js";
|
|
8
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
9
|
+
import { writeBlockedReason } from "./utils/safe-write.js";
|
|
10
|
+
import { sanitizeForTerminal } from "./utils/terminal.js";
|
|
8
11
|
import { discoverArtifacts, detectArtifactTypes } from "./discovery.js";
|
|
9
12
|
import { formatHuman } from "./formatters/human.js";
|
|
10
13
|
import { formatJson } from "./formatters/json.js";
|
|
@@ -71,6 +74,12 @@ const ALL_RULES = [
|
|
|
71
74
|
...MONITORS_JSON_RULES,
|
|
72
75
|
...MISPLACED_FILE_RULES,
|
|
73
76
|
];
|
|
77
|
+
/**
|
|
78
|
+
* Hard cap on artifact file size. Real Claude Code artifacts are KB-scale;
|
|
79
|
+
* this only exists to reject pathological / malicious inputs (e.g. a
|
|
80
|
+
* multi-gigabyte file crafted to exhaust memory).
|
|
81
|
+
*/
|
|
82
|
+
const MAX_ARTIFACT_BYTES = 5 * 1024 * 1024;
|
|
74
83
|
function simpleDiff(oldContent, newContent, filePath) {
|
|
75
84
|
if (oldContent === newContent)
|
|
76
85
|
return "";
|
|
@@ -106,7 +115,21 @@ function simpleDiff(oldContent, newContent, filePath) {
|
|
|
106
115
|
}
|
|
107
116
|
return lines.join("\n");
|
|
108
117
|
}
|
|
109
|
-
|
|
118
|
+
function readPkgVersion() {
|
|
119
|
+
// package.json ships beside dist/ (Node) or beside the executable
|
|
120
|
+
// (bun-compiled single-executable). Try every candidate; fall back to
|
|
121
|
+
// "0.0.0" if none resolve (e.g. an unexpected layout) rather than crashing.
|
|
122
|
+
for (const p of assetCandidates(import.meta.url, ["..", "package.json"])) {
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(readFileSync(p, "utf8")).version;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
// try next candidate
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return "0.0.0";
|
|
131
|
+
}
|
|
132
|
+
const pkgVersion = readPkgVersion();
|
|
110
133
|
sade("claudecode-linter", true)
|
|
111
134
|
.version(pkgVersion)
|
|
112
135
|
.describe("Linter for Claude Code plugin artifacts")
|
|
@@ -133,9 +156,11 @@ sade("claudecode-linter", true)
|
|
|
133
156
|
const fixDryRun = !!opts["fix-dry-run"];
|
|
134
157
|
try {
|
|
135
158
|
if (opts.init !== undefined) {
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
159
|
+
const defaultsFile = assetCandidates(import.meta.url, [
|
|
160
|
+
"..",
|
|
161
|
+
".claudecode-lint.defaults.yaml",
|
|
162
|
+
]).find((p) => existsSync(p)) ??
|
|
163
|
+
join(dirname(dirname(fileURLToPath(import.meta.url))), ".claudecode-lint.defaults.yaml");
|
|
139
164
|
const targetDir = typeof opts.init === "string" ? resolve(opts.init) : process.cwd();
|
|
140
165
|
const targetFile = join(targetDir, ".claudecode-lint.yaml");
|
|
141
166
|
if (existsSync(targetFile)) {
|
|
@@ -197,10 +222,33 @@ sade("claudecode-linter", true)
|
|
|
197
222
|
ignore: ignorePatterns,
|
|
198
223
|
});
|
|
199
224
|
if (artifacts.length === 0) {
|
|
200
|
-
process.stderr.write(pc.yellow(`No plugin artifacts found in ${targetPath}\n`));
|
|
225
|
+
process.stderr.write(pc.yellow(`No plugin artifacts found in ${sanitizeForTerminal(targetPath)}\n`));
|
|
201
226
|
continue;
|
|
202
227
|
}
|
|
228
|
+
// All --fix / --format writes for this target must stay inside
|
|
229
|
+
// rootDir. If targetPath is a file, rootDir is its parent dir.
|
|
230
|
+
const resolvedTarget = resolve(targetPath);
|
|
231
|
+
let rootDir = resolvedTarget;
|
|
232
|
+
try {
|
|
233
|
+
if (!statSync(resolvedTarget).isDirectory()) {
|
|
234
|
+
rootDir = dirname(resolvedTarget);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
rootDir = dirname(resolvedTarget);
|
|
239
|
+
}
|
|
203
240
|
for (const artifact of artifacts) {
|
|
241
|
+
let sizeBytes;
|
|
242
|
+
try {
|
|
243
|
+
sizeBytes = statSync(artifact.filePath).size;
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
sizeBytes = 0;
|
|
247
|
+
}
|
|
248
|
+
if (sizeBytes > MAX_ARTIFACT_BYTES) {
|
|
249
|
+
process.stderr.write(pc.yellow(`Skipping ${sanitizeForTerminal(artifact.filePath)}: file exceeds ${MAX_ARTIFACT_BYTES}-byte limit (${sizeBytes} bytes)\n`));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
204
252
|
let content = readFileSync(artifact.filePath, "utf-8");
|
|
205
253
|
const relPath = relative(process.cwd(), artifact.filePath);
|
|
206
254
|
if (opts.format) {
|
|
@@ -208,8 +256,14 @@ sade("claudecode-linter", true)
|
|
|
208
256
|
if (fixer) {
|
|
209
257
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
210
258
|
if (fixedContent !== content) {
|
|
211
|
-
|
|
212
|
-
|
|
259
|
+
const blocked = writeBlockedReason(artifact.filePath, rootDir);
|
|
260
|
+
if (blocked) {
|
|
261
|
+
process.stderr.write(pc.red(`Refusing to write ${sanitizeForTerminal(artifact.filePath)}: ${blocked}\n`));
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
writeFileSync(artifact.filePath, fixedContent);
|
|
265
|
+
formatted.push(relPath);
|
|
266
|
+
}
|
|
213
267
|
}
|
|
214
268
|
}
|
|
215
269
|
continue;
|
|
@@ -221,9 +275,15 @@ sade("claudecode-linter", true)
|
|
|
221
275
|
if (fixer) {
|
|
222
276
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
223
277
|
if (fixedContent !== content) {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
278
|
+
const blocked = writeBlockedReason(artifact.filePath, rootDir);
|
|
279
|
+
if (blocked) {
|
|
280
|
+
process.stderr.write(pc.red(`Refusing to write ${sanitizeForTerminal(artifact.filePath)}: ${blocked}\n`));
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
writeFileSync(artifact.filePath, fixedContent);
|
|
284
|
+
content = fixedContent;
|
|
285
|
+
fixed = 1;
|
|
286
|
+
}
|
|
227
287
|
}
|
|
228
288
|
}
|
|
229
289
|
}
|
|
@@ -233,7 +293,7 @@ sade("claudecode-linter", true)
|
|
|
233
293
|
const fixedContent = await fixer.fix(artifact.filePath, content, config);
|
|
234
294
|
if (fixedContent !== content) {
|
|
235
295
|
const diff = simpleDiff(content, fixedContent, artifact.filePath);
|
|
236
|
-
process.stdout.write(diff + "\n");
|
|
296
|
+
process.stdout.write(sanitizeForTerminal(diff) + "\n");
|
|
237
297
|
}
|
|
238
298
|
}
|
|
239
299
|
}
|
package/dist/plugin-schema.js
CHANGED
|
@@ -8,11 +8,10 @@
|
|
|
8
8
|
* resolver if the file is being run from an alternate layout (e.g. monorepo).
|
|
9
9
|
*/
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
|
-
import { dirname, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath } from "node:url";
|
|
13
11
|
import { Ajv2020 } from "ajv/dist/2020.js";
|
|
14
12
|
// biome-ignore lint/style/useImportType: runtime import; types only.
|
|
15
13
|
import * as addFormatsNs from "ajv-formats";
|
|
14
|
+
import { assetCandidates } from "./utils/asset-path.js";
|
|
16
15
|
// ajv-formats ships as CJS with a default function export. Under Node16
|
|
17
16
|
// ESM resolution we have to reach in for `.default`.
|
|
18
17
|
const addFormats = addFormatsNs.default;
|
|
@@ -29,10 +28,9 @@ function getAjv() {
|
|
|
29
28
|
function loadCompiledSchema(fileName) {
|
|
30
29
|
if (compiledCache.has(fileName))
|
|
31
30
|
return compiledCache.get(fileName) ?? null;
|
|
32
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
33
31
|
const candidates = [
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
...assetCandidates(import.meta.url, ["..", "contracts", fileName]),
|
|
33
|
+
...assetCandidates(import.meta.url, ["..", "..", "contracts", fileName]),
|
|
36
34
|
];
|
|
37
35
|
let raw = null;
|
|
38
36
|
for (const p of candidates) {
|
|
@@ -84,10 +82,14 @@ export function loadCommandFrontmatterSchema() {
|
|
|
84
82
|
export function loadPluginSchema() {
|
|
85
83
|
if (cached)
|
|
86
84
|
return cached;
|
|
87
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
88
85
|
const candidates = [
|
|
89
|
-
|
|
90
|
-
|
|
86
|
+
...assetCandidates(import.meta.url, ["..", "contracts", "plugin.schema.json"]),
|
|
87
|
+
...assetCandidates(import.meta.url, [
|
|
88
|
+
"..",
|
|
89
|
+
"..",
|
|
90
|
+
"contracts",
|
|
91
|
+
"plugin.schema.json",
|
|
92
|
+
]),
|
|
91
93
|
];
|
|
92
94
|
let raw = null;
|
|
93
95
|
for (const p of candidates) {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve runtime assets (contracts/*.schema.json, .claudecode-lint.defaults.yaml,
|
|
3
|
+
* package.json) that ship alongside the package on disk.
|
|
4
|
+
*
|
|
5
|
+
* Normally these are found relative to `import.meta.url` — the location of the
|
|
6
|
+
* compiled `.js` file inside `dist/`. That works for the Node build and for the
|
|
7
|
+
* published npm package.
|
|
8
|
+
*
|
|
9
|
+
* Inside a `bun build --compile` single-executable, `import.meta.url` points
|
|
10
|
+
* into Bun's virtual embedded filesystem (`/$bunfs/...`), so disk reads of
|
|
11
|
+
* sibling assets fail. To support that variant, we ALSO emit candidates
|
|
12
|
+
* relative to `process.execPath` (the real on-disk path of the running
|
|
13
|
+
* executable). The compiled-binary image ships `contracts/` and
|
|
14
|
+
* `.claudecode-lint.defaults.yaml` next to the executable, so those fallback
|
|
15
|
+
* candidates resolve there.
|
|
16
|
+
*
|
|
17
|
+
* For the Node runtime, the `process.execPath`-relative candidates simply point
|
|
18
|
+
* at the `node` binary's directory and won't match — harmless extra lookups
|
|
19
|
+
* appended AFTER the existing ones, so Node resolution is byte-for-byte
|
|
20
|
+
* unchanged.
|
|
21
|
+
*/
|
|
22
|
+
import { dirname, resolve } from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
/**
|
|
25
|
+
* Build a list of candidate paths for an asset shipped with the package.
|
|
26
|
+
*
|
|
27
|
+
* @param importMetaUrl `import.meta.url` of the calling module.
|
|
28
|
+
* @param segments Path segments, relative to a base directory, that
|
|
29
|
+
* locate the asset (e.g. `["..", "contracts", "x.json"]`
|
|
30
|
+
* for a module under `dist/`).
|
|
31
|
+
* @returns Ordered candidate paths: `import.meta.url`-relative first (existing
|
|
32
|
+
* behavior), then `process.execPath`-relative fallbacks.
|
|
33
|
+
*/
|
|
34
|
+
export function assetCandidates(importMetaUrl, segments) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
// 1. import.meta.url-relative — the existing, primary resolution.
|
|
37
|
+
const here = dirname(fileURLToPath(importMetaUrl));
|
|
38
|
+
candidates.push(resolve(here, ...segments));
|
|
39
|
+
// 2. process.execPath-relative fallbacks for the compiled single-executable.
|
|
40
|
+
// The binary lives next to `contracts/` and the defaults YAML, so we try
|
|
41
|
+
// both the executable's own directory and one level up (mirroring the
|
|
42
|
+
// dist/ -> package-root step the segments encode).
|
|
43
|
+
try {
|
|
44
|
+
const execDir = dirname(process.execPath);
|
|
45
|
+
// Drop leading ".." segments: assets sit directly beside the executable.
|
|
46
|
+
const beside = segments.filter((s) => s !== "..");
|
|
47
|
+
candidates.push(resolve(execDir, ...beside));
|
|
48
|
+
candidates.push(resolve(execDir, ...segments));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// process.execPath unavailable — ignore.
|
|
52
|
+
}
|
|
53
|
+
// De-duplicate while preserving order.
|
|
54
|
+
return [...new Set(candidates)];
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=asset-path.js.map
|
|
@@ -32,7 +32,7 @@ export function parseFrontmatter(content) {
|
|
|
32
32
|
const body = lines.slice(closingIndex + 1).join("\n");
|
|
33
33
|
const bodyStartLine = closingIndex + 2; // 1-based
|
|
34
34
|
try {
|
|
35
|
-
const data = parseYaml(frontmatterRaw);
|
|
35
|
+
const data = parseYaml(frontmatterRaw, { maxAliasCount: 100 });
|
|
36
36
|
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
37
37
|
return {
|
|
38
38
|
data: {},
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { lstatSync, realpathSync } from "node:fs";
|
|
2
|
+
import { sep } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether writing to `filePath` (a fix/format target) is safe.
|
|
5
|
+
*
|
|
6
|
+
* The linter may write fixes back to artifact paths supplied by a plugin.
|
|
7
|
+
* A malicious plugin can ship an artifact path that is actually a symlink
|
|
8
|
+
* pointing outside the target tree (`~/.bashrc`, an SSH key, a CI secret);
|
|
9
|
+
* a bare `writeFileSync` would then clobber the symlink's target.
|
|
10
|
+
*
|
|
11
|
+
* Returns a human-readable reason to REFUSE the write, or `null` if the
|
|
12
|
+
* write is safe. The check fails closed: if anything throws (path missing,
|
|
13
|
+
* permission error, etc.) a refusal reason is returned.
|
|
14
|
+
*
|
|
15
|
+
* Refuses when:
|
|
16
|
+
* - `filePath` itself is a symbolic link, or
|
|
17
|
+
* - the real path of `filePath` is not `rootDir` itself and not located
|
|
18
|
+
* under `rootDir + path.sep`.
|
|
19
|
+
*/
|
|
20
|
+
export function writeBlockedReason(filePath, rootDir) {
|
|
21
|
+
try {
|
|
22
|
+
if (lstatSync(filePath).isSymbolicLink()) {
|
|
23
|
+
return "path is a symlink";
|
|
24
|
+
}
|
|
25
|
+
const realRoot = realpathSync(rootDir);
|
|
26
|
+
const realPath = realpathSync(filePath);
|
|
27
|
+
if (realPath !== realRoot && !realPath.startsWith(realRoot + sep)) {
|
|
28
|
+
return "path resolves outside the target directory";
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return "path could not be safely resolved";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
//# sourceMappingURL=safe-write.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip C0 control characters and DEL from a string before it is written to
|
|
3
|
+
* a terminal, while preserving tab and newline.
|
|
4
|
+
*
|
|
5
|
+
* Diagnostic messages, file paths and `--fix-dry-run` diffs embed untrusted
|
|
6
|
+
* strings (rule content, field values, file content, plugin-controlled file
|
|
7
|
+
* and directory names). Without sanitization an attacker-supplied artifact
|
|
8
|
+
* could smuggle ANSI/control sequences into the user's terminal.
|
|
9
|
+
*
|
|
10
|
+
* Strips U+0000-U+0008, U+000B-U+001F and U+007F (DEL) - every C0 control
|
|
11
|
+
* char and DEL except U+0009 (tab) and U+000A (newline), which are kept.
|
|
12
|
+
* The stripped set includes U+000D (CR) and U+001B (ESC) by design.
|
|
13
|
+
*/
|
|
14
|
+
export function sanitizeForTerminal(s) {
|
|
15
|
+
// eslint-disable-next-line no-control-regex
|
|
16
|
+
return s.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, "");
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=terminal.js.map
|