Skip to content

Commit d7bee31

Browse files
authored
fix: normalize discovered paths, add a Windows CI job, and require Node 20 (#131)
Discovery normalizes every stored path to forward slashes, rule comparisons and the MiniClaw sandbox use the platform separator, chmod-dependent and POSIX-shell-backed tests skip on Windows, evidence packs redact native, forward-slash, and JSON-escaped paths and normalize separators, and test batches run through a glob-free Node runner. A windows-latest CI job runs typecheck, build, and the full suite. vitest 4 no longer starts on Node 18 (end of life April 2025), so CI runs Node 20 and 22 and engines.node is >=20. Fixes #125.
1 parent 202c818 commit d7bee31

15 files changed

Lines changed: 186 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ jobs:
2323
strategy:
2424
fail-fast: false
2525
matrix:
26-
node-version: [18, 20, 22]
26+
node-version: [20, 22]
2727

2828
steps:
2929
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -50,6 +50,37 @@ jobs:
5050
- name: Run tests
5151
run: npm test
5252

53+
verify-windows:
54+
name: Verify (Windows, Node 22)
55+
runs-on: windows-latest
56+
timeout-minutes: 25
57+
58+
defaults:
59+
run:
60+
shell: bash
61+
62+
steps:
63+
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
64+
with:
65+
persist-credentials: false
66+
67+
- name: Use Node.js
68+
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
69+
with:
70+
node-version: 22
71+
72+
- name: Install dependencies
73+
run: npm ci --ignore-scripts
74+
75+
- name: Type check
76+
run: npm run typecheck
77+
78+
- name: Build
79+
run: npm run build
80+
81+
- name: Run tests
82+
run: npm test
83+
5384
self-scan:
5485
name: Self-scan examples
5586
runs-on: ubuntu-latest

package.json

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121
"prepublishOnly": "npm run build",
2222
"dev": "tsx src/index.ts",
2323
"test": "npm run test:batch:core && npm run test:batch:analysis && npm run test:batch:miniclaw-a && npm run test:batch:miniclaw-b && npm run test:batch:misc",
24-
"test:batch:core": "vitest run tests/rules/*.test.ts tests/scanner/*.test.ts tests/reporter/*.test.ts",
25-
"test:batch:analysis": "vitest run tests/integration.test.ts tests/injection.test.ts tests/action.test.ts tests/action-policy.test.ts tests/action-supply-chain.test.ts tests/action-hardening.test.ts tests/action-promotion.test.ts",
26-
"test:batch:miniclaw-a": "vitest run tests/miniclaw/index.test.ts tests/miniclaw/server.test.ts",
27-
"test:batch:miniclaw-b": "vitest run tests/miniclaw/cli.test.ts tests/miniclaw/sandbox.test.ts",
28-
"test:batch:misc": "vitest run tests/corpus.test.ts tests/logger.test.ts tests/init/init.test.ts tests/taint/taint.test.ts tests/opus/*.test.ts tests/fixer/*.test.ts tests/types.test.ts tests/skills/*.test.ts tests/llm/*.test.ts tests/miniclaw/router.test.ts tests/miniclaw/tools.test.ts tests/miniclaw/types.test.ts tests/sandbox/sandbox.test.ts tests/threat-intel/*.test.ts tests/watch/*.test.ts tests/runtime/*.test.ts tests/baseline/*.test.ts tests/evidence-pack/*.test.ts tests/supply-chain/*.test.ts tests/policy/*.test.ts tests/sponsor-surface.test.ts",
24+
"test:batch:core": "node scripts/test-batch.mjs core",
25+
"test:batch:analysis": "node scripts/test-batch.mjs analysis",
26+
"test:batch:miniclaw-a": "node scripts/test-batch.mjs miniclaw-a",
27+
"test:batch:miniclaw-b": "node scripts/test-batch.mjs miniclaw-b",
28+
"test:batch:misc": "node scripts/test-batch.mjs misc",
2929
"test:coverage": "vitest --coverage",
3030
"lint": "eslint src/",
3131
"typecheck": "tsc --noEmit",
@@ -88,6 +88,6 @@
8888
"flatted": "^3.4.0"
8989
},
9090
"engines": {
91-
"node": ">=18"
91+
"node": ">=20"
9292
}
9393
}

scripts/test-batch.mjs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#!/usr/bin/env node
2+
// Runs one named vitest batch with an explicit file list so the batches do
3+
// not depend on shell glob expansion (npm runs scripts through cmd.exe on
4+
// Windows, which leaves *.test.ts untouched and vitest then finds nothing).
5+
import { spawnSync } from "node:child_process";
6+
import { globSync } from "glob";
7+
8+
const BATCHES = {
9+
core: ["tests/rules/*.test.ts", "tests/scanner/*.test.ts", "tests/reporter/*.test.ts"],
10+
analysis: [
11+
"tests/integration.test.ts",
12+
"tests/injection.test.ts",
13+
"tests/action.test.ts",
14+
"tests/action-policy.test.ts",
15+
"tests/action-supply-chain.test.ts",
16+
"tests/action-hardening.test.ts",
17+
"tests/action-promotion.test.ts",
18+
],
19+
"miniclaw-a": ["tests/miniclaw/index.test.ts", "tests/miniclaw/server.test.ts"],
20+
"miniclaw-b": ["tests/miniclaw/cli.test.ts", "tests/miniclaw/sandbox.test.ts"],
21+
misc: [
22+
"tests/corpus.test.ts",
23+
"tests/logger.test.ts",
24+
"tests/init/init.test.ts",
25+
"tests/taint/taint.test.ts",
26+
"tests/opus/*.test.ts",
27+
"tests/fixer/*.test.ts",
28+
"tests/types.test.ts",
29+
"tests/skills/*.test.ts",
30+
"tests/llm/*.test.ts",
31+
"tests/compliance/*.test.ts",
32+
"tests/miniclaw/router.test.ts",
33+
"tests/miniclaw/tools.test.ts",
34+
"tests/miniclaw/types.test.ts",
35+
"tests/sandbox/sandbox.test.ts",
36+
"tests/threat-intel/*.test.ts",
37+
"tests/watch/*.test.ts",
38+
"tests/runtime/*.test.ts",
39+
"tests/baseline/*.test.ts",
40+
"tests/evidence-pack/*.test.ts",
41+
"tests/supply-chain/*.test.ts",
42+
"tests/policy/*.test.ts",
43+
"tests/sponsor-surface.test.ts",
44+
],
45+
};
46+
47+
const name = process.argv[2];
48+
const patterns = BATCHES[name];
49+
if (!patterns) {
50+
console.error(`Unknown test batch "${name}". Known: ${Object.keys(BATCHES).join(", ")}`);
51+
process.exit(2);
52+
}
53+
54+
const files = [...new Set(patterns.flatMap((pattern) => globSync(pattern, { posix: true })))].sort();
55+
if (files.length === 0) {
56+
console.error(`Batch "${name}" matched no test files.`);
57+
process.exit(1);
58+
}
59+
60+
const vitest = process.platform === "win32" ? "npx.cmd" : "npx";
61+
const result = spawnSync(vitest, ["vitest", "run", ...files], { stdio: "inherit", shell: process.platform === "win32" });
62+
process.exit(result.status ?? 1);

src/evidence-pack/index.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,9 +1311,11 @@ function createRedactor(
13111311

13121312
const redactString = (value: string): string => {
13131313
if (!enabled) return value;
1314-
return replacements.reduce(
1315-
(redacted, [pattern, replacement]) => redacted.replace(pattern, replacement),
1316-
value
1314+
return normalizeRedactedPathSeparators(
1315+
replacements.reduce(
1316+
(redacted, [pattern, replacement]) => redacted.replace(pattern, replacement),
1317+
value
1318+
)
13171319
);
13181320
};
13191321

@@ -1335,12 +1337,12 @@ function buildReplacements(targetPath: string): ReadonlyArray<[RegExp, string]>
13351337
const home = homedir();
13361338
const targetReplacements: ReadonlyArray<[RegExp, string]> = targetPath
13371339
? [
1338-
[literalPattern(resolve(targetPath)), "<target-path>"],
1339-
[literalPattern(targetPath), "<target-path>"],
1340+
...pathPatterns(resolve(targetPath)).map((pattern): [RegExp, string] => [pattern, "<target-path>"]),
1341+
...pathPatterns(targetPath).map((pattern): [RegExp, string] => [pattern, "<target-path>"]),
13401342
]
13411343
: [];
13421344
const homeReplacements: ReadonlyArray<[RegExp, string]> = home && home !== "/"
1343-
? [[literalPattern(home), "<home>"]]
1345+
? pathPatterns(home).map((pattern): [RegExp, string] => [pattern, "<home>"])
13441346
: [];
13451347
const userNames: ReadonlyArray<string> = [
13461348
basename(home),
@@ -1381,6 +1383,29 @@ function literalPattern(value: string): RegExp {
13811383
return new RegExp(escapeRegExp(value), "g");
13821384
}
13831385

1386+
/**
1387+
* Patterns for a filesystem path as it may appear in report text: native
1388+
* form, forward-slash form, and the JSON-escaped form Windows paths take
1389+
* once serialized. Evidence packs must redact all three to stay portable.
1390+
*/
1391+
function pathPatterns(value: string): ReadonlyArray<RegExp> {
1392+
const backslash = String.fromCharCode(92);
1393+
const variants = new Set<string>([
1394+
value,
1395+
value.split(backslash).join("/"),
1396+
value.split(backslash).join(backslash + backslash),
1397+
]);
1398+
return [...variants].filter((variant) => variant.length > 0).map(literalPattern);
1399+
}
1400+
1401+
/** Forward-slash the remainder of any redacted path so packs match across platforms. */
1402+
function normalizeRedactedPathSeparators(text: string): string {
1403+
const backslash = String.fromCharCode(92);
1404+
const tail = new RegExp("(<target-path>|<home>)((?:" + backslash + backslash + backslash + backslash + "|" + backslash + backslash + ")[^\\s\"'<>]*)", "g");
1405+
const separators = new RegExp(backslash + backslash + backslash + backslash + "|" + backslash + backslash, "g");
1406+
return text.replace(tail, (_match, placeholder: string, rest: string) => placeholder + rest.replace(separators, "/"));
1407+
}
1408+
13841409
function escapeRegExp(value: string): string {
13851410
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
13861411
}

src/miniclaw/sandbox.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
*/
1010

1111
import { mkdir, rm, stat, realpath, access } from "node:fs/promises";
12-
import { join, resolve, relative, extname } from "node:path";
12+
import { join, resolve, relative, extname, sep } from "node:path";
1313
import { randomUUID } from "node:crypto";
1414
import type {
1515
SandboxConfig,
@@ -51,7 +51,7 @@ export async function validatePath(
5151
// WHY: This is the primary containment check. After resolution, any path
5252
// that doesn't start with the sandbox root has escaped.
5353
const normalizedSandbox = resolve(sandboxPath);
54-
if (!absoluteRequested.startsWith(normalizedSandbox + "/") && absoluteRequested !== normalizedSandbox) {
54+
if (!absoluteRequested.startsWith(normalizedSandbox + sep) && absoluteRequested !== normalizedSandbox) {
5555
return {
5656
valid: false,
5757
resolvedPath: absoluteRequested,
@@ -65,7 +65,7 @@ export async function validatePath(
6565
try {
6666
await access(absoluteRequested);
6767
const realPath = await realpath(absoluteRequested);
68-
if (!realPath.startsWith(normalizedSandbox + "/") && realPath !== normalizedSandbox) {
68+
if (!realPath.startsWith(normalizedSandbox + sep) && realPath !== normalizedSandbox) {
6969
return {
7070
valid: false,
7171
resolvedPath: realPath,
@@ -216,7 +216,7 @@ export async function destroySandbox(
216216
const normalizedSandbox = resolve(sandboxPath);
217217
const normalizedRoot = resolve(rootPath);
218218

219-
if (!normalizedSandbox.startsWith(normalizedRoot + "/")) {
219+
if (!normalizedSandbox.startsWith(normalizedRoot + sep)) {
220220
return {
221221
success: false,
222222
reason: `Sandbox path "${sandboxPath}" is not under root "${rootPath}" — refusing to delete`,
@@ -227,7 +227,7 @@ export async function destroySandbox(
227227
// WHY: Prevents deletion of the root itself or deeply nested system paths
228228
// that happen to share the root prefix
229229
const relativePath = relative(normalizedRoot, normalizedSandbox);
230-
if (relativePath.includes("/") || relativePath === "" || relativePath === "..") {
230+
if (relativePath.includes("/") || relativePath.includes("\\") || relativePath === "" || relativePath === "..") {
231231
return {
232232
success: false,
233233
reason: `Sandbox path must be a direct child of root — got relative path "${relativePath}"`,

src/rules/agents.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ function isSlashCommandConfig(file: ConfigFile, isStructuredDefinition: boolean)
176176
return (
177177
file.type === "command-md" &&
178178
isStructuredDefinition &&
179-
file.path.toLowerCase().includes("slash-commands/")
179+
normalizePath(file.path).includes("slash-commands/")
180180
);
181181
}
182182

src/rules/mcp.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ function findEnabledBooleanFlag(
7878
}
7979

8080
function isLikelyMcpTemplatePath(filePath: string): boolean {
81-
const normalized = filePath.toLowerCase();
81+
const normalized = filePath.replace(/\\/g, "/").toLowerCase();
8282
return (
8383
normalized.startsWith("mcp-configs/") ||
8484
normalized.includes("/mcp-configs/") ||
@@ -108,7 +108,7 @@ function classifyMcpRuntimeConfidence(file: ConfigFile): RuntimeConfidence {
108108
return "template-example";
109109
}
110110

111-
const normalizedPath = file.path.toLowerCase();
111+
const normalizedPath = file.path.replace(/\\/g, "/").toLowerCase();
112112
if (normalizedPath === "settings.local.json" || normalizedPath.endsWith("/settings.local.json")) {
113113
return "project-local-optional";
114114
}

src/rules/permissions.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
} from "./permission-entries.js";
1010

1111
function isHookManifestConfig(file: ConfigFile, config: unknown): boolean {
12-
if (!/(^|\/)hooks\/[^/]+\.json$/i.test(file.path)) return false;
12+
if (!/(^|[\\/])hooks[\\/][^\\/]+\.json$/i.test(file.path)) return false;
1313
if (!config || typeof config !== "object") return false;
1414
return "hooks" in config;
1515
}
@@ -1129,6 +1129,11 @@ export const permissionRules: ReadonlyArray<Rule> = [
11291129
check(file: ConfigFile): ReadonlyArray<Finding> {
11301130
if (file.type !== "claude-md") return [];
11311131

1132+
// NTFS has no POSIX mode bits. Node reports 0o666 for every writable
1133+
// file on Windows, so the check would flag every CLAUDE.md as
1134+
// world-writable. Skip it there instead of emitting a bogus finding.
1135+
if (process.platform === "win32") return [];
1136+
11321137
// Only check CLAUDE.md files that are likely to be the user's global
11331138
// config or project-level config — both are prompt injection surfaces.
11341139
const normalizedPath = file.path.replace(/\\/g, "/");

src/scanner/discovery.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Stats } from "node:fs";
33
import { join, basename, extname, relative } from "node:path";
44
import type { ConfigFile, ConfigFileType, DanglingSymlink, ScanTarget } from "../types.js";
55
import { isExampleLikePath } from "../source-context.js";
6+
import { toPosixPath } from "./paths.js";
67

78
const IGNORED_DIRS = new Set([
89
".dmux",
@@ -322,7 +323,7 @@ function scanClaudeRoot(
322323
if (entryStat === null) {
323324
if (isDanglingSymlink(entryPath)) {
324325
danglingSymlinks.push({
325-
path: relative(scanRoot, entryPath),
326+
path: toPosixPath(relative(scanRoot, entryPath)),
326327
target: readSymlinkTarget(entryPath),
327328
type,
328329
});
@@ -547,7 +548,7 @@ function addDiscoveredFile(
547548
files: ConfigFile[],
548549
seenFiles: Set<string>
549550
): void {
550-
const relativePath = relative(scanRoot, fullPath);
551+
const relativePath = toPosixPath(relative(scanRoot, fullPath));
551552
if (seenFiles.has(relativePath)) return;
552553

553554
const content = readFileSync(fullPath, "utf-8");

src/scanner/paths.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
/**
2+
* Path normalization for scan output.
3+
*
4+
* Policy: every path stored on a ConfigFile (`path`), on a DanglingSymlink
5+
* (`path`), or compared against a literal such as ".vscode/tasks.json" uses
6+
* forward slashes, regardless of host platform. Normalization happens at the
7+
* point the relative path is produced (right after `relative()`), so rules,
8+
* reporters, and tests can rely on a single separator. File system access
9+
* keeps using the native path returned by `join()`; Node's `path.resolve`
10+
* and `path.join` on Windows accept forward slashes when a consumer needs to
11+
* turn a stored path back into a native one.
12+
*/
13+
export function toPosixPath(filePath: string): string {
14+
return filePath.replace(/\\/g, "/");
15+
}

0 commit comments

Comments
 (0)