@principles/pd-cli 1.134.0 → 1.135.1

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.
Files changed (54) hide show
  1. package/dist/commands/console.d.ts.map +1 -1
  2. package/dist/commands/console.js +2 -0
  3. package/dist/commands/console.js.map +1 -1
  4. package/dist/commands/legacy-cleanup.d.ts.map +1 -1
  5. package/dist/commands/legacy-cleanup.js +19 -2
  6. package/dist/commands/legacy-cleanup.js.map +1 -1
  7. package/dist/commands/pain-evidence.d.ts +3 -1
  8. package/dist/commands/pain-evidence.d.ts.map +1 -1
  9. package/dist/commands/pain-evidence.js +12 -3
  10. package/dist/commands/pain-evidence.js.map +1 -1
  11. package/dist/commands/rulecode.d.ts +13 -0
  12. package/dist/commands/rulecode.d.ts.map +1 -1
  13. package/dist/commands/rulecode.js +23 -2
  14. package/dist/commands/rulecode.js.map +1 -1
  15. package/dist/resolve-workspace.d.ts.map +1 -1
  16. package/dist/resolve-workspace.js +33 -12
  17. package/dist/resolve-workspace.js.map +1 -1
  18. package/dist/services/console-launcher.d.ts +14 -1
  19. package/dist/services/console-launcher.d.ts.map +1 -1
  20. package/dist/services/console-launcher.js +45 -3
  21. package/dist/services/console-launcher.js.map +1 -1
  22. package/dist/services/pd-config-loader.d.ts.map +1 -1
  23. package/dist/services/pd-config-loader.js +34 -1
  24. package/dist/services/pd-config-loader.js.map +1 -1
  25. package/dist/services/quality-scorecard/strong-model-gate.d.ts +19 -0
  26. package/dist/services/quality-scorecard/strong-model-gate.d.ts.map +1 -1
  27. package/dist/services/quality-scorecard/strong-model-gate.js +44 -2
  28. package/dist/services/quality-scorecard/strong-model-gate.js.map +1 -1
  29. package/dist/utils/path-security.d.ts +60 -0
  30. package/dist/utils/path-security.d.ts.map +1 -0
  31. package/dist/utils/path-security.js +90 -0
  32. package/dist/utils/path-security.js.map +1 -0
  33. package/package.json +1 -1
  34. package/src/commands/console.ts +1 -0
  35. package/src/commands/legacy-cleanup.ts +19 -2
  36. package/src/commands/pain-evidence.ts +11 -3
  37. package/src/commands/rulecode.ts +25 -2
  38. package/src/resolve-workspace.ts +41 -17
  39. package/src/services/console-launcher.ts +51 -3
  40. package/src/services/pd-config-loader.ts +35 -1
  41. package/src/services/quality-scorecard/strong-model-gate.ts +44 -2
  42. package/src/utils/path-security.ts +96 -0
  43. package/tests/commands/console-open.test.ts +213 -35
  44. package/tests/commands/legacy-cleanup.test.ts +148 -0
  45. package/tests/commands/pain-evidence.test.ts +37 -0
  46. package/tests/commands/pri-393-runtime-config-unification.test.ts +5 -1
  47. package/tests/commands/product-path-regression.test.ts +9 -4
  48. package/tests/commands/rulecode.test.ts +135 -0
  49. package/tests/commands/runtime-diagnostics-export.test.ts +6 -2
  50. package/tests/resolve-workspace.test.ts +21 -0
  51. package/tests/services/console-launcher.test.ts +114 -0
  52. package/tests/services/pd-config-loader.test.ts +8 -1
  53. package/tests/services/quality-scorecard/strong-model-gate.test.ts +133 -0
  54. package/tests/utils/path-security.test.ts +180 -0
@@ -31,6 +31,7 @@
31
31
  import * as path from 'node:path';
32
32
  import * as fs from 'node:fs';
33
33
  import type { Command } from 'commander';
34
+ import { isPathInside } from '../utils/path-security.js';
34
35
  import {
35
36
  RULECODE_SPEC_TEXT,
36
37
  checkForbiddenPatterns,
@@ -113,11 +114,33 @@ function isGoldenTraceCaseInput(value: unknown): value is GoldenTraceCaseInput {
113
114
  /**
114
115
  * Load and validate golden trace cases from a JSON file.
115
116
  * Returns either the validated cases or a structured error.
117
+ * Exported for boundary regression tests (internal test surface).
116
118
  */
117
- function loadGoldenTraceCases(filePath: string): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
119
+ export function loadGoldenTraceCases(
120
+ filePath: string,
121
+ workspaceDir?: string,
122
+ ): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
118
123
  let raw: string;
119
124
  try {
125
+ if (!filePath || filePath.trim().length === 0) {
126
+ throw new Error('golden trace path is empty');
127
+ }
120
128
  const resolved = path.resolve(filePath);
129
+ // CWE-22 boundary: canonical containment against the workspace root
130
+ // (rejects sibling-prefix and traversal escapes; relative workspaces
131
+ // canonicalize consistently). Filesystem-root targets are rejected by
132
+ // containment when a workspace root is supplied.
133
+ const normalized = path.normalize(resolved);
134
+ if (normalized.split(/[\\/]/).includes('..')) {
135
+ throw new Error('golden trace path contains parent traversal');
136
+ }
137
+ if (workspaceDir) {
138
+ if (!isPathInside(workspaceDir, resolved)) {
139
+ throw new Error('golden trace path must be inside the workspace directory');
140
+ }
141
+ } else if (normalized === path.parse(normalized).root) {
142
+ throw new Error('golden trace path resolves to filesystem root');
143
+ }
121
144
  raw = fs.readFileSync(resolved, 'utf8');
122
145
  } catch (err) {
123
146
  const reason = err instanceof Error ? err.message : String(err);
@@ -286,7 +309,7 @@ export async function handleRulecodeReplay(opts: ReplayOptions): Promise<void> {
286
309
  return;
287
310
  }
288
311
 
289
- const traceResult = loadGoldenTraceCases(opts.goldenTrace);
312
+ const traceResult = loadGoldenTraceCases(opts.goldenTrace, opts.workspace);
290
313
  if (traceResult.error || traceResult.cases === undefined) {
291
314
  const { error } = traceResult;
292
315
  const output: RulecodeReplayOutput = {
@@ -15,15 +15,24 @@
15
15
 
16
16
  import * as path from 'path';
17
17
  import { discoverWorkspaceDefault } from './services/pd-config-loader.js';
18
+ import { assertSafeDirectoryRoot } from './utils/path-security.js';
18
19
 
19
20
  /** Environment variable name for workspace directory. */
20
21
  export const WORKSPACE_ENV = 'PD_WORKSPACE_DIR';
21
22
 
22
23
  // ── Internal helpers ────────────────────────────────────────────────────────
23
24
 
24
- /** Normalize path to forward slashes for cross-platform comparison. */
25
+ /**
26
+ * Normalize a path to forward slashes for cross-platform string comparison.
27
+ *
28
+ * Comparison-only helper: it never resolves against the filesystem and never
29
+ * feeds a filesystem operation, so it intentionally uses `path.normalize`
30
+ * (pure string normalization) instead of `path.resolve`. Callers compare two
31
+ * paths for equality after normalization; the workspace root itself is
32
+ * validated by `assertWorkspaceDirInside` before any IO uses it.
33
+ */
25
34
  function normalizePath(p: string): string {
26
- return path.resolve(p).replace(/\\/g, '/');
35
+ return path.normalize(p).replace(/\\/g, '/');
27
36
  }
28
37
 
29
38
  /** Emit workspace warnings to stderr. */
@@ -31,6 +40,31 @@ function emitWarning(msg: string): void {
31
40
  process.stderr.write(`[PD:workspace] WARNING: ${msg}\n`);
32
41
  }
33
42
 
43
+ /**
44
+ * Validate an operator-supplied workspace root before it is used as an IO
45
+ * root. Delegates to the shared canonical-root validator (empty, parent
46
+ * traversal, filesystem root). Returns nothing; callers keep the original
47
+ * value so downstream resolution semantics are unchanged.
48
+ */
49
+ function assertWorkspaceDirInside(p: string, source: string): void {
50
+ assertSafeDirectoryRoot(p, source);
51
+ }
52
+
53
+ /** Emit a warning when an explicit override differs from config default. */
54
+ function emitWarningIfDiffers(
55
+ override: string,
56
+ configDefault: string | undefined,
57
+ discovered: { configPath?: string } | null,
58
+ ): void {
59
+ if (configDefault && normalizePath(override) !== normalizePath(configDefault)) {
60
+ emitWarning(
61
+ `"${override}" differs from config default "${configDefault}" ` +
62
+ `(source: ${discovered?.configPath}). Using explicit override. ` +
63
+ `Consider updating workspace.default in config.`,
64
+ );
65
+ }
66
+ }
67
+
34
68
  // ── Public API ──────────────────────────────────────────────────────────────
35
69
 
36
70
  /**
@@ -46,34 +80,24 @@ export function resolveWorkspaceDir(workspaceDir?: string): string {
46
80
 
47
81
  // Step 2: Check --workspace flag (highest priority)
48
82
  if (workspaceDir) {
49
- if (configDefault && normalizePath(workspaceDir) !== normalizePath(configDefault)) {
50
- emitWarning(
51
- `--workspace "${workspaceDir}" differs from config default "${configDefault}" ` +
52
- `(source: ${discovered.configPath}). Using explicit flag. ` +
53
- `Consider updating workspace.default in config.`,
54
- );
55
- }
83
+ assertWorkspaceDirInside(workspaceDir, '--workspace');
84
+ emitWarningIfDiffers(workspaceDir, configDefault, discovered);
56
85
  return workspaceDir;
57
86
  }
58
87
 
59
88
  // Step 3: Check PD_WORKSPACE_DIR env var
60
89
  const envWorkspace = process.env.PD_WORKSPACE_DIR?.trim();
61
90
  if (envWorkspace) {
62
- if (configDefault && normalizePath(envWorkspace) !== normalizePath(configDefault)) {
63
- emitWarning(
64
- `PD_WORKSPACE_DIR "${envWorkspace}" differs from config default "${configDefault}" ` +
65
- `(source: ${discovered.configPath}). Using env var. ` +
66
- `Consider aligning or updating workspace.default.`,
67
- );
68
- }
91
+ assertWorkspaceDirInside(envWorkspace, WORKSPACE_ENV);
92
+ emitWarningIfDiffers(envWorkspace, configDefault, discovered);
69
93
  return envWorkspace;
70
94
  }
71
95
 
72
96
  // Step 4: Use discovered config default
73
97
  if (configDefault) {
98
+ assertWorkspaceDirInside(configDefault, 'workspace.default');
74
99
  return configDefault;
75
100
  }
76
-
77
101
  // Step 5: No resolution possible — throw (preserves current behavior)
78
102
  throw new Error(
79
103
  'No workspace directory configured. Set --workspace <path>, ' +
@@ -34,6 +34,12 @@ export interface ConsoleLaunchResult {
34
34
  reused: boolean;
35
35
  /** True when a browser should/has been opened (skipped in --json mode). */
36
36
  browserOpened: boolean;
37
+ /**
38
+ * PID of the freshly spawned console server process. Present only when
39
+ * status === 'started'; absent on 'reused' (the server was started by
40
+ * another process, so this launcher cannot know its PID).
41
+ */
42
+ serverPid?: number;
37
43
  }
38
44
 
39
45
  export interface ConsoleLaunchOptions {
@@ -212,20 +218,62 @@ export async function findAvailablePort(
212
218
 
213
219
  // ─── Browser opener (best-effort, no throw) ──────────────────────────────────
214
220
 
221
+ /**
222
+ * Validate a browser URL before it is handed to a system opener.
223
+ * Only http/https targets are allowed — other schemes (file:, javascript:,
224
+ * custom protocols) could be abused. This is the primary defense for the
225
+ * win32 opener path, which no longer routes through a shell.
226
+ */
227
+ function assertSafeBrowserUrl(rawUrl: string): string {
228
+ if (!rawUrl || rawUrl.trim().length === 0) {
229
+ throw new Error('browser URL is empty');
230
+ }
231
+ let url: URL;
232
+ try {
233
+ url = new URL(rawUrl);
234
+ } catch {
235
+ throw new Error(`invalid browser URL: "${rawUrl}"`);
236
+ }
237
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
238
+ throw new Error(`invalid browser URL: protocol must be http(s), got "${url.protocol}"`);
239
+ }
240
+ return url.toString();
241
+ }
242
+
215
243
  /**
216
244
  * Open the system browser. Best-effort — failures are reported but do not
217
245
  * crash the launcher.
246
+ *
247
+ * Security: the URL is validated (http/https only) and every opener is
248
+ * invoked with a parameterized `spawn` (no shell). In particular the win32
249
+ * path uses `explorer.exe` with an argument array instead of the previous
250
+ * `cmd.exe /c start "" <url>` form, which ran the URL through the cmd shell
251
+ * and allowed shell metacharacters in the URL to be interpreted as commands
252
+ * (command injection).
218
253
  */
219
- export async function openBrowser(url: string): Promise<{ opened: boolean; reason?: string; nextAction?: string }> {
254
+ export async function openBrowser(rawUrl: string): Promise<{ opened: boolean; reason?: string; nextAction?: string }> {
220
255
  const { spawn } = await import('child_process');
221
256
  const { platform } = process;
222
257
 
258
+ let url: string;
259
+ try {
260
+ url = assertSafeBrowserUrl(rawUrl);
261
+ } catch (err) {
262
+ return {
263
+ opened: false,
264
+ reason: err instanceof Error ? err.message : String(err),
265
+ nextAction: 'Use an http:// or https:// URL.',
266
+ };
267
+ }
268
+
223
269
  let cmd: string;
224
270
  let args: string[];
225
271
 
226
272
  if (platform === 'win32') {
227
- cmd = process.env.ComSpec || 'cmd.exe';
228
- args = ['/c', 'start', '""', url];
273
+ // Parameterized spawn (no shell): explorer.exe receives the validated
274
+ // URL as a plain argument and opens it in the default browser.
275
+ cmd = 'explorer.exe';
276
+ args = [url];
229
277
  } else if (platform === 'darwin') {
230
278
  cmd = 'open';
231
279
  args = [url];
@@ -15,6 +15,7 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import * as yaml from 'js-yaml';
18
+ import { canonicalPath, isPathInside } from '../utils/path-security.js';
18
19
  import {
19
20
  validatePdConfig,
20
21
  computeEffectivePdConfig,
@@ -316,6 +317,32 @@ function loadOpenClawPluginConfig(): { workspace?: string } | null {
316
317
  return null;
317
318
  }
318
319
 
320
+ /**
321
+ * Validate a candidate config directory before it is joined into a config
322
+ * path for filesystem reads. Candidate dirs are operator-supplied
323
+ * (PD_WORKSPACE_DIR / plugin config / home default), so we normalize with
324
+ * `path.normalize` (pure string, no filesystem access), then reject empty,
325
+ * parent-traversal, or root paths. This keeps the subsequent
326
+ * `path.join(dir, PD_CONFIG_DIR, ...)` inside the intended directory
327
+ * boundary (CWE-22 mitigation).
328
+ *
329
+ * Platform note: no `path.isAbsolute` check — absolute-ness is
330
+ * platform-dependent (a Windows-style path is not absolute on POSIX
331
+ * runners) and relative dirs resolve inside cwd without traversal risk.
332
+ */
333
+ function assertConfigDirBoundary(dir: string, source: string): void {
334
+ if (!dir || dir.trim().length === 0) {
335
+ throw new Error(`Invalid config search dir (${source}): path is empty`);
336
+ }
337
+ const normalized = path.normalize(dir);
338
+ if (normalized.split(/[\\/]/).includes('..')) {
339
+ throw new Error(`Invalid config search dir (${source}): "${dir}" contains parent traversal`);
340
+ }
341
+ if (normalized === path.parse(normalized).root) {
342
+ throw new Error(`Invalid config search dir (${source}): "${dir}" resolves to filesystem root`);
343
+ }
344
+ }
345
+
319
346
  /**
320
347
  * Search known locations for a .pd/config.yaml that contains a workspace.default field.
321
348
  * This runs BEFORE workspace resolution and does NOT require knowing the workspace dir.
@@ -346,7 +373,14 @@ export function discoverWorkspaceDefault(): WorkspaceDiscoveryResult | null {
346
373
 
347
374
  // Search each candidate for .pd/config.yaml with workspace.default
348
375
  for (const { dir, source } of candidates) {
349
- const configPath = path.join(dir, PD_CONFIG_DIR, PD_CONFIG_FILENAME);
376
+ assertConfigDirBoundary(dir, source);
377
+ // CWE-22: resolve the candidate root once, then verify the joined config
378
+ // path stays inside that root before any filesystem access.
379
+ const candidateRoot = canonicalPath(dir);
380
+ const configPath = path.resolve(candidateRoot, PD_CONFIG_DIR, PD_CONFIG_FILENAME);
381
+ if (!isPathInside(candidateRoot, configPath)) {
382
+ throw new Error(`Invalid config search dir (${source}): "${dir}" escapes its boundary`);
383
+ }
350
384
  if (fs.existsSync(configPath)) {
351
385
  const workspaceDefault = extractWorkspaceDefault(configPath);
352
386
  if (workspaceDefault) {
@@ -60,6 +60,40 @@ Flags: ${localEval.flags.length > 0 ? localEval.flags.join(', ') : 'none'}
60
60
  Do NOT output anything other than this JSON object.`;
61
61
  }
62
62
 
63
+ /**
64
+ * CWE-918 (SSRF) mitigation for operator-supplied LLM API base URLs.
65
+ *
66
+ * Threat model: OPENAI_BASE_URL is explicitly configured by the Owner /
67
+ * operator in the environment or Runtime Profile — it is trusted operator
68
+ * configuration, NOT untrusted remote input. Local and private-network
69
+ * OpenAI-compatible endpoints (llama.cpp, LM Studio, local gateways,
70
+ * intranet model servers) are legitimate PD runtime targets and must keep
71
+ * working.
72
+ *
73
+ * What stays blocked:
74
+ * - non-http(s) schemes (file:, javascript:, data:, ...)
75
+ * - malformed / unparseable URLs
76
+ * - credentials embedded in the URL (secrets leak into logs/errors)
77
+ *
78
+ * Callers must not allow the host to be rewritten from untrusted input
79
+ * after this validation; the endpoint is derived from this URL only.
80
+ */
81
+ export function assertSafeLlmBaseUrl(rawBaseUrl: string): URL {
82
+ let url: URL;
83
+ try {
84
+ url = new URL(rawBaseUrl);
85
+ } catch {
86
+ throw new Error(`Invalid OPENAI_BASE_URL: "${rawBaseUrl}" is not a valid URL`);
87
+ }
88
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') {
89
+ throw new Error(`Invalid OPENAI_BASE_URL: protocol must be http or https, got "${url.protocol}"`);
90
+ }
91
+ if (url.username || url.password) {
92
+ throw new Error(`Invalid OPENAI_BASE_URL: credentials must not be embedded in the URL`);
93
+ }
94
+ return url;
95
+ }
96
+
63
97
  export async function adjudicate(
64
98
  episode: PainEpisode,
65
99
  localEval: LocalEvaluation,
@@ -67,7 +101,11 @@ export async function adjudicate(
67
101
  ): Promise<StrongModelAdjudication> {
68
102
  const { modelId: strongModelId, log } = config;
69
103
  const prompt = buildAdjudicationPrompt(episode, localEval);
70
- const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1';
104
+ // CWE-918 (SSRF): validate the operator-supplied base URL before any
105
+ // network request — http(s) only, no embedded credentials. Local and
106
+ // private OpenAI-compatible endpoints remain valid (trusted operator
107
+ // configuration; llama.cpp / LM Studio / local gateways are supported).
108
+ const baseUrl = assertSafeLlmBaseUrl(process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1');
71
109
  const apiKey = process.env.OPENAI_API_KEY;
72
110
 
73
111
  if (!apiKey) {
@@ -82,7 +120,11 @@ export async function adjudicate(
82
120
  }
83
121
 
84
122
  try {
85
- const resp = await fetch(`${baseUrl}/chat/completions`, {
123
+ // Build the endpoint on the validated URL: append the chat completions
124
+ // path to the base URL's (possibly empty) path component.
125
+ const endpoint = new URL(baseUrl);
126
+ endpoint.pathname = endpoint.pathname.replace(/\/+$/, '') + '/chat/completions';
127
+ const resp = await fetch(endpoint.toString(), {
86
128
  method: 'POST',
87
129
  headers: {
88
130
  'Content-Type': 'application/json',
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Path containment primitives (CWE-22 boundary guards).
3
+ *
4
+ * Single source of truth for "is this filesystem target inside that root?"
5
+ * across pd-cli security boundaries. All containment decisions compare
6
+ * CANONICAL (fully resolved) paths via `path.relative`, never by string
7
+ * prefix — a string `startsWith` on a possibly-relative root is wrong on
8
+ * two counts: (1) a relative root never prefixes an absolute target, and
9
+ * (2) `/work/foo` is a prefix of `/work/foobar` without being a boundary.
10
+ *
11
+ * ── Symlink policy ────────────────────────────────────────────────────────
12
+ * The guarantee provided here is LEXICAL containment: `path.resolve` +
13
+ * `path.relative`, without resolving symlinks. We deliberately do NOT
14
+ * `realpath` the target before containment because:
15
+ * 1. PD's IO roots are operator-supplied workspace directories; symlinks
16
+ * inside the workspace are created by the owner and treated as trusted
17
+ * content.
18
+ * 2. On Windows, junction points (worktree junctions, `node_modules`
19
+ * junctions) resolve to a *different physical location* via `realpath`;
20
+ * realpath-based containment would reject legitimate local workflows
21
+ * (e.g. a worktree whose `node_modules` is junctioned to the main
22
+ * checkout).
23
+ * If a future caller must constrain the physical read target (e.g. reading a
24
+ * file whose path could be a symlink to an untrusted location), that caller
25
+ * must realpath the target FIRST and then run containment on the resolved
26
+ * path — do not weaken this module's contract.
27
+ */
28
+
29
+ import * as path from 'node:path';
30
+
31
+ /**
32
+ * Canonicalize a user/operator-supplied path once. Every derived filesystem
33
+ * target must be compared against this canonical root.
34
+ */
35
+ export function canonicalPath(p: string): string {
36
+ return path.resolve(p);
37
+ }
38
+
39
+ /**
40
+ * True when `candidate` is strictly inside `parent` (canonical comparison).
41
+ *
42
+ * - Both arguments are resolved against cwd first, so relative inputs work.
43
+ * - `candidate === parent` returns false (strict containment). Callers that
44
+ * want to allow the root itself should check equality separately.
45
+ * - Sibling-prefix attacks (`/work/foobar` vs parent `/work/foo`) cannot
46
+ * pass because `path.relative` yields a non-`..`-prefixed path only for
47
+ * real descendants.
48
+ */
49
+ export function isPathInside(parent: string, candidate: string): boolean {
50
+ const root = path.resolve(parent);
51
+ const target = path.resolve(candidate);
52
+ const rel = path.relative(root, target);
53
+ return (
54
+ rel !== '' &&
55
+ rel !== '..' &&
56
+ !rel.startsWith(`..${path.sep}`) &&
57
+ !path.isAbsolute(rel)
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Throw unless `candidate` is strictly inside `parent`. `label` names the
63
+ * candidate in the error message (e.g. "--workspace").
64
+ */
65
+ export function assertPathInside(parent: string, candidate: string, label: string): void {
66
+ if (!isPathInside(parent, candidate)) {
67
+ throw new Error(`Invalid ${label}: "${candidate}" is outside "${parent}"`);
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Validate an operator-supplied directory root before it is used as an IO
73
+ * root: rejects empty values, residual parent-traversal segments, and
74
+ * filesystem-root results. Returns the canonical root.
75
+ *
76
+ * No `path.isAbsolute` requirement: absolute-ness is platform-dependent (a
77
+ * Windows-style path like `Z:\work` is not absolute on POSIX runners) and
78
+ * relative paths resolve inside cwd, so they carry no traversal risk. The
79
+ * guards that matter are: empty, parent traversal, and filesystem root.
80
+ */
81
+ export function assertSafeDirectoryRoot(input: string, label: string): string {
82
+ if (!input || input.trim().length === 0) {
83
+ throw new Error(`Invalid ${label}: path is empty`);
84
+ }
85
+ // Un-normalized `..` segments that survive normalize() mean the input
86
+ // escaped a parent boundary (e.g. "..\\..\\evil") — reject rather than
87
+ // trust them. Foldable segments ("a/../b") canonicalize safely.
88
+ if (path.normalize(input).split(/[\\/]/).includes('..')) {
89
+ throw new Error(`Invalid ${label}: "${input}" contains parent traversal`);
90
+ }
91
+ const root = canonicalPath(input);
92
+ if (root === path.parse(root).root) {
93
+ throw new Error(`Invalid ${label}: "${input}" resolves to filesystem root`);
94
+ }
95
+ return root;
96
+ }