@principles/pd-cli 1.135.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 (49) hide show
  1. package/dist/commands/legacy-cleanup.d.ts.map +1 -1
  2. package/dist/commands/legacy-cleanup.js +19 -2
  3. package/dist/commands/legacy-cleanup.js.map +1 -1
  4. package/dist/commands/pain-evidence.d.ts +3 -1
  5. package/dist/commands/pain-evidence.d.ts.map +1 -1
  6. package/dist/commands/pain-evidence.js +12 -3
  7. package/dist/commands/pain-evidence.js.map +1 -1
  8. package/dist/commands/rulecode.d.ts +13 -0
  9. package/dist/commands/rulecode.d.ts.map +1 -1
  10. package/dist/commands/rulecode.js +23 -2
  11. package/dist/commands/rulecode.js.map +1 -1
  12. package/dist/resolve-workspace.d.ts.map +1 -1
  13. package/dist/resolve-workspace.js +33 -12
  14. package/dist/resolve-workspace.js.map +1 -1
  15. package/dist/services/console-launcher.d.ts +8 -1
  16. package/dist/services/console-launcher.d.ts.map +1 -1
  17. package/dist/services/console-launcher.js +45 -3
  18. package/dist/services/console-launcher.js.map +1 -1
  19. package/dist/services/pd-config-loader.d.ts.map +1 -1
  20. package/dist/services/pd-config-loader.js +34 -1
  21. package/dist/services/pd-config-loader.js.map +1 -1
  22. package/dist/services/quality-scorecard/strong-model-gate.d.ts +19 -0
  23. package/dist/services/quality-scorecard/strong-model-gate.d.ts.map +1 -1
  24. package/dist/services/quality-scorecard/strong-model-gate.js +44 -2
  25. package/dist/services/quality-scorecard/strong-model-gate.js.map +1 -1
  26. package/dist/utils/path-security.d.ts +60 -0
  27. package/dist/utils/path-security.d.ts.map +1 -0
  28. package/dist/utils/path-security.js +90 -0
  29. package/dist/utils/path-security.js.map +1 -0
  30. package/package.json +1 -1
  31. package/src/commands/legacy-cleanup.ts +19 -2
  32. package/src/commands/pain-evidence.ts +11 -3
  33. package/src/commands/rulecode.ts +25 -2
  34. package/src/resolve-workspace.ts +41 -17
  35. package/src/services/console-launcher.ts +45 -3
  36. package/src/services/pd-config-loader.ts +35 -1
  37. package/src/services/quality-scorecard/strong-model-gate.ts +44 -2
  38. package/src/utils/path-security.ts +96 -0
  39. package/tests/commands/legacy-cleanup.test.ts +148 -0
  40. package/tests/commands/pain-evidence.test.ts +37 -0
  41. package/tests/commands/pri-393-runtime-config-unification.test.ts +5 -1
  42. package/tests/commands/product-path-regression.test.ts +9 -4
  43. package/tests/commands/rulecode.test.ts +135 -0
  44. package/tests/commands/runtime-diagnostics-export.test.ts +6 -2
  45. package/tests/resolve-workspace.test.ts +21 -0
  46. package/tests/services/console-launcher.test.ts +114 -0
  47. package/tests/services/pd-config-loader.test.ts +8 -1
  48. package/tests/services/quality-scorecard/strong-model-gate.test.ts +133 -0
  49. package/tests/utils/path-security.test.ts +180 -0
@@ -172,4 +172,25 @@ describe('resolveWorkspaceDir', () => {
172
172
  mockDiscover.mockReturnValue(null);
173
173
  expect(() => resolveWorkspaceDir()).toThrow('workspace.default');
174
174
  });
175
+
176
+ // 12. CWE-22 boundary guard: parent traversal is rejected
177
+ it('rejects parent-traversal workspace paths', () => {
178
+ mockDiscover.mockReturnValue(null);
179
+ expect(() => resolveWorkspaceDir('../evil')).toThrow('parent traversal');
180
+ expect(() => resolveWorkspaceDir('a/../../b')).toThrow('parent traversal');
181
+ });
182
+
183
+ // 13. CWE-22 boundary guard: filesystem root is rejected
184
+ it('rejects filesystem-root workspace paths', () => {
185
+ mockDiscover.mockReturnValue(null);
186
+ expect(() => resolveWorkspaceDir('/')).toThrow('filesystem root');
187
+ });
188
+
189
+ // 14. Platform-agnostic: a Windows-style path is accepted everywhere.
190
+ // On POSIX runners "Z:\\work" is not absolute; the boundary guard must
191
+ // NOT treat that as invalid (regression for CI failure on Linux).
192
+ it('accepts Windows-style workspace paths (platform-agnostic)', () => {
193
+ mockDiscover.mockReturnValue(null);
194
+ expect(resolveWorkspaceDir('Z:\\pd-nonexistent-workspace-12345')).toBe('Z:\\pd-nonexistent-workspace-12345');
195
+ });
175
196
  });
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Tests for console-launcher openBrowser command-injection hardening (PRI-547).
3
+ *
4
+ * The win32 opener must never route the URL through a shell
5
+ * (`cmd.exe /c start "" <url>`). It must use a parameterized spawn of
6
+ * `explorer.exe` with an argument array, and the URL must be validated as
7
+ * http(s) before any spawn.
8
+ *
9
+ * Covers:
10
+ * - http(s) URLs accepted
11
+ * - non-http(s) schemes rejected before spawn (no child process created)
12
+ * - shell metacharacters cannot reach a shell (spawn called with arg array)
13
+ */
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+ import * as path from 'node:path';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const launcherPath = path.resolve(
19
+ path.dirname(fileURLToPath(import.meta.url)),
20
+ '..',
21
+ '..',
22
+ 'src',
23
+ 'services',
24
+ 'console-launcher.js',
25
+ );
26
+
27
+ describe('openBrowser URL validation and no-shell spawn', () => {
28
+ let spawnMock: ReturnType<typeof vi.fn>;
29
+ let platformSpy: ReturnType<typeof vi.spyOn>;
30
+
31
+ const fakeChild = () => {
32
+ const child: any = { on: vi.fn(), unref: vi.fn() };
33
+ return child;
34
+ };
35
+
36
+ beforeEach(() => {
37
+ spawnMock = vi.fn(() => fakeChild());
38
+ vi.doMock('child_process', () => ({ spawn: spawnMock, execFile: vi.fn(), execFileSync: vi.fn() }));
39
+ platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('win32' as any);
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.doUnmock('child_process');
44
+ platformSpy.mockRestore();
45
+ vi.resetModules();
46
+ });
47
+
48
+ async function loadOpenBrowser() {
49
+ const mod = await import(launcherPath);
50
+ return mod.openBrowser as (url: string) => Promise<{ opened: boolean; reason?: string; nextAction?: string }>;
51
+ }
52
+
53
+ it('opens an https URL via parameterized explorer.exe spawn (no shell)', async () => {
54
+ const openBrowser = await loadOpenBrowser();
55
+ const result = await openBrowser('https://localhost:8123');
56
+
57
+ expect(result.opened).toBe(true);
58
+ expect(spawnMock).toHaveBeenCalledTimes(1);
59
+ const [cmd, args, opts] = spawnMock.mock.calls[0];
60
+ expect(cmd).toBe('explorer.exe');
61
+ // Argument array — never a shell command string
62
+ expect(Array.isArray(args)).toBe(true);
63
+ expect(args).toEqual(['https://localhost:8123/']);
64
+ // No shell option
65
+ expect(opts.shell).not.toBeDefined();
66
+ });
67
+
68
+ it('accepts http URL on localhost (legitimate local console)', async () => {
69
+ const openBrowser = await loadOpenBrowser();
70
+ const result = await openBrowser('http://127.0.0.1:8123');
71
+
72
+ expect(result.opened).toBe(true);
73
+ expect(spawnMock.mock.calls[0][1]).toEqual(['http://127.0.0.1:8123/']);
74
+ });
75
+
76
+ it('rejects javascript: scheme without spawning', async () => {
77
+ const openBrowser = await loadOpenBrowser();
78
+ const result = await openBrowser('javascript:alert(1)');
79
+
80
+ expect(result.opened).toBe(false);
81
+ expect(result.reason).toContain('protocol');
82
+ expect(spawnMock).not.toHaveBeenCalled();
83
+ });
84
+
85
+ it('rejects file: scheme without spawning', async () => {
86
+ const openBrowser = await loadOpenBrowser();
87
+ const result = await openBrowser('file:///etc/passwd');
88
+
89
+ expect(result.opened).toBe(false);
90
+ expect(spawnMock).not.toHaveBeenCalled();
91
+ });
92
+
93
+ it('rejects empty URL without spawning', async () => {
94
+ const openBrowser = await loadOpenBrowser();
95
+ const result = await openBrowser('');
96
+
97
+ expect(result.opened).toBe(false);
98
+ expect(result.reason).toContain('empty');
99
+ expect(spawnMock).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it('shell metacharacters in a valid http URL stay as data (arg array, not shell)', async () => {
103
+ const openBrowser = await loadOpenBrowser();
104
+ const sneaky = 'http://localhost:8123/path?a=1&b=2;calc.exe';
105
+ const result = await openBrowser(sneaky);
106
+
107
+ expect(result.opened).toBe(true);
108
+ const args = spawnMock.mock.calls[0][1] as string[];
109
+ expect(args.length).toBe(1);
110
+ // The whole URL is a single argument — the spawn call has no shell so
111
+ // metacharacters cannot be interpreted as commands.
112
+ expect(args[0]).toContain(';calc.exe');
113
+ });
114
+ });
@@ -38,7 +38,14 @@ function rmTmpDir(dir: string): void {
38
38
  }
39
39
 
40
40
  function writeConfig(workspaceDir: string, content: string): void {
41
- const configDir = path.join(workspaceDir, PD_CONFIG_DIR);
41
+ // CWE-22 boundary guard for test helpers: every config path is derived
42
+ // from an mkdtemp dir under os.tmpdir(); verify that invariant before IO.
43
+ const tmpRoot = path.resolve(os.tmpdir());
44
+ const wsRoot = path.resolve(workspaceDir);
45
+ if (!wsRoot.startsWith(tmpRoot + path.sep)) {
46
+ throw new Error(`Test helper refuses non-tmpdir workspace: ${workspaceDir}`);
47
+ }
48
+ const configDir = path.join(wsRoot, PD_CONFIG_DIR);
42
49
  fs.mkdirSync(configDir, { recursive: true });
43
50
  fs.writeFileSync(path.join(configDir, PD_CONFIG_FILENAME), content, 'utf8');
44
51
  }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tests for strong-model-gate URL validation (PRI-361 / PRI-547).
3
+ *
4
+ * Threat model: OPENAI_BASE_URL is operator configuration (trusted), so
5
+ * local/private OpenAI-compatible endpoints must work. What stays blocked:
6
+ * non-http(s) schemes, malformed URLs, embedded credentials.
7
+ *
8
+ * Covers:
9
+ * - Public HTTPS endpoint allowed
10
+ * - Explicit local HTTP endpoint allowed (localhost / 127.0.0.1 / private LAN)
11
+ * - Non-http(s) schemes rejected (file:, javascript:, data:)
12
+ * - Malformed URL rejected
13
+ * - Embedded credentials rejected
14
+ * - Endpoint derivation appends /chat/completions correctly
15
+ */
16
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
17
+ import { assertSafeLlmBaseUrl } from '../../../src/services/quality-scorecard/strong-model-gate.js';
18
+
19
+ // ── assertSafeLlmBaseUrl ────────────────────────────────────────────────────
20
+
21
+ describe('assertSafeLlmBaseUrl — operator-configured provider URLs', () => {
22
+ it('allows public HTTPS endpoint', () => {
23
+ const url = assertSafeLlmBaseUrl('https://api.openai.com/v1');
24
+ expect(url.protocol).toBe('https:');
25
+ expect(url.hostname).toBe('api.openai.com');
26
+ });
27
+
28
+ it('allows public HTTP endpoint (operator choice)', () => {
29
+ const url = assertSafeLlmBaseUrl('http://model.example.com/v1');
30
+ expect(url.protocol).toBe('http:');
31
+ });
32
+
33
+ it('allows localhost local LLM endpoint (llama.cpp / LM Studio)', () => {
34
+ const url = assertSafeLlmBaseUrl('http://localhost:1234/v1');
35
+ expect(url.hostname).toBe('localhost');
36
+ expect(url.port).toBe('1234');
37
+ });
38
+
39
+ it('allows 127.0.0.1 loopback local LLM endpoint', () => {
40
+ const url = assertSafeLlmBaseUrl('http://127.0.0.1:8080/v1');
41
+ expect(url.hostname).toBe('127.0.0.1');
42
+ });
43
+
44
+ it('allows private LAN OpenAI-compatible endpoint', () => {
45
+ const url = assertSafeLlmBaseUrl('http://192.168.1.50:8000/v1');
46
+ expect(url.hostname).toBe('192.168.1.50');
47
+ });
48
+
49
+ it('allows 10.x private endpoint', () => {
50
+ const url = assertSafeLlmBaseUrl('http://10.0.0.5:9000/v1');
51
+ expect(url.hostname).toBe('10.0.0.5');
52
+ });
53
+
54
+ it('rejects file: scheme', () => {
55
+ expect(() => assertSafeLlmBaseUrl('file:///tmp/foo')).toThrow(/protocol must be http or https/);
56
+ });
57
+
58
+ it('rejects javascript: scheme', () => {
59
+ expect(() => assertSafeLlmBaseUrl('javascript:alert(1)')).toThrow(/protocol must be http or https/);
60
+ });
61
+
62
+ it('rejects data: scheme', () => {
63
+ expect(() => assertSafeLlmBaseUrl('data:text/plain,hello')).toThrow(/protocol must be http or https/);
64
+ });
65
+
66
+ it('rejects malformed URL', () => {
67
+ expect(() => assertSafeLlmBaseUrl('not a url at all')).toThrow(/not a valid URL/);
68
+ expect(() => assertSafeLlmBaseUrl('http://')).toThrow(/not a valid URL/);
69
+ });
70
+
71
+ it('rejects embedded credentials in URL', () => {
72
+ expect(() => assertSafeLlmBaseUrl('https://user:pass@api.openai.com/v1')).toThrow(
73
+ /credentials must not be embedded/,
74
+ );
75
+ });
76
+
77
+ it('rejects empty string', () => {
78
+ expect(() => assertSafeLlmBaseUrl('')).toThrow(/not a valid URL/);
79
+ });
80
+ });
81
+
82
+ // ── Endpoint derivation (integration via adjudicate) ────────────────────────
83
+
84
+ describe('strong-model-gate adjudicate endpoint derivation', () => {
85
+ const origEnv = { ...process.env };
86
+
87
+ beforeEach(() => {
88
+ vi.resetModules();
89
+ vi.stubGlobal('fetch', vi.fn());
90
+ });
91
+
92
+ afterEach(() => {
93
+ process.env = { ...origEnv };
94
+ vi.unstubAllGlobals();
95
+ });
96
+
97
+ it('derives /chat/completions from a local base URL with trailing slash', async () => {
98
+ process.env.OPENAI_BASE_URL = 'http://localhost:1234/v1/';
99
+ process.env.OPENAI_API_KEY = 'test-key';
100
+
101
+ const fetchMock = vi.fn().mockResolvedValue({
102
+ ok: true,
103
+ json: async () => ({
104
+ choices: [{ message: { content: '{"scores":{"G1":2,"G2":2,"G3":2,"G4":2,"G5":2,"G6":2,"G7":2},"rationale":"ok","verdict":"pass"}' } }],
105
+ }),
106
+ });
107
+ vi.stubGlobal('fetch', fetchMock);
108
+
109
+ const { adjudicate } = await import('../../../src/services/quality-scorecard/strong-model-gate.js');
110
+ const result = await adjudicate(
111
+ {
112
+ episodeId: 'ep-1',
113
+ source: 'tool_failure',
114
+ score: 80,
115
+ severity: 'high',
116
+ summary: 'test episode',
117
+ evolutionTaskResolution: null,
118
+ linkedPrinciples: [],
119
+ } as any,
120
+ {
121
+ model: 'local',
122
+ dimensionScores: { G1: 2, G2: 2, G3: 2, G4: 2, G5: 2, G6: 2, G7: 2 },
123
+ dimensionRationales: {},
124
+ flags: [],
125
+ } as any,
126
+ { modelId: 'gpt-test', log: () => {} },
127
+ );
128
+
129
+ const calledUrl = fetchMock.mock.calls[0][0] as string;
130
+ expect(calledUrl).toBe('http://localhost:1234/v1/chat/completions');
131
+ expect(result.adjudicationStatus).toBe('pass');
132
+ });
133
+ });
@@ -0,0 +1,180 @@
1
+ /**
2
+ * Tests for path-security primitives (isPathInside, assertSafeDirectoryRoot, canonicalPath).
3
+ *
4
+ * Covers:
5
+ * - Relative workspace containment (regression: startsWith on relative root)
6
+ * - Absolute path containment
7
+ * - Sibling-prefix attack (/work/foo vs /work/foobar)
8
+ * - Traversal escape rejection
9
+ * - Filesystem root rejection
10
+ * - Empty path rejection
11
+ * - Windows path cross-platform semantics
12
+ * - POSIX path cross-platform semantics
13
+ */
14
+ import { describe, it, expect } from 'vitest';
15
+ import * as path from 'node:path';
16
+ import { isPathInside, assertSafeDirectoryRoot, canonicalPath } from '../../src/utils/path-security.js';
17
+
18
+ // ── canonicalPath ───────────────────────────────────────────────────────────
19
+
20
+ describe('canonicalPath', () => {
21
+ it('resolves relative paths to absolute', () => {
22
+ const result = canonicalPath('./relative/path');
23
+ expect(path.isAbsolute(result)).toBe(true);
24
+ expect(result).toContain('relative');
25
+ });
26
+
27
+ it('keeps absolute paths unchanged in effect', () => {
28
+ const result = canonicalPath('/tmp/workspace');
29
+ expect(path.isAbsolute(result)).toBe(true);
30
+ });
31
+
32
+ it('collapses parent traversal', () => {
33
+ const result = canonicalPath('/tmp/a/../b');
34
+ // After resolve, /tmp/a/../b = /tmp/b
35
+ expect(result).toBe(path.resolve('/tmp/b'));
36
+ });
37
+
38
+ it('handles empty string as cwd', () => {
39
+ const result = canonicalPath('');
40
+ expect(result).toBe(path.resolve(''));
41
+ });
42
+ });
43
+
44
+ // ── isPathInside ────────────────────────────────────────────────────────────
45
+
46
+ describe('isPathInside', () => {
47
+ it('returns true for a file inside parent (relative parent)', () => {
48
+ // Regression: relative parent, absolute child
49
+ const parent = './relative-workspace';
50
+ const child = path.resolve('./relative-workspace/memory/logs/SYSTEM_2026-06-08.log');
51
+ expect(isPathInside(parent, child)).toBe(true);
52
+ });
53
+
54
+ it('returns true for a file inside parent (absolute parent)', () => {
55
+ const parent = '/tmp/workspace';
56
+ const child = '/tmp/workspace/memory/logs/X.log';
57
+ expect(isPathInside(parent, child)).toBe(true);
58
+ });
59
+
60
+ it('returns true for a nested subdirectory', () => {
61
+ const parent = '/tmp/workspace';
62
+ const child = '/tmp/workspace/sub/dir/file.txt';
63
+ expect(isPathInside(parent, child)).toBe(true);
64
+ });
65
+
66
+ it('returns false when candidate === parent (strict containment)', () => {
67
+ const parent = '/tmp/workspace';
68
+ const child = parent;
69
+ expect(isPathInside(parent, child)).toBe(false);
70
+ });
71
+
72
+ it('returns false for sibling-prefix attack', () => {
73
+ // /work/foo should NOT contain /work/foobar
74
+ const parent = '/tmp/work/foo';
75
+ const child = '/tmp/work/foobar/evil.txt';
76
+ expect(isPathInside(parent, child)).toBe(false);
77
+ });
78
+
79
+ it('returns false for upward traversal', () => {
80
+ const parent = '/tmp/workspace';
81
+ const child = '/tmp/workspace/../../etc/passwd';
82
+ expect(isPathInside(parent, child)).toBe(false);
83
+ });
84
+
85
+ it('returns false for paths outside parent', () => {
86
+ const parent = '/tmp/workspace';
87
+ const child = '/tmp/other/file.txt';
88
+ expect(isPathInside(parent, child)).toBe(false);
89
+ });
90
+
91
+ it('returns false for sibling in parallel directory', () => {
92
+ const parent = '/tmp/workspace/a';
93
+ const child = '/tmp/workspace/b/file.txt';
94
+ expect(isPathInside(parent, child)).toBe(false);
95
+ });
96
+
97
+ it('handles Windows-style paths (cross-platform, resolve-based)', () => {
98
+ // On Windows, path.resolve('C:\\workspace') works; on POSIX it's a relative path.
99
+ // Either way, the comparison is canonical-vs-canonical.
100
+ const parent = 'C:\\workspace';
101
+ const child = path.resolve('C:\\workspace\\memory\\logs\\X.log');
102
+ // On POSIX, both resolve to cwd-based paths; the containment should still
103
+ // be consistent: child must be inside parent after canonicalization.
104
+ // This test verifies no crash and consistent behavior.
105
+ expect(() => isPathInside(parent, child)).not.toThrow();
106
+ });
107
+
108
+ it('requires parent to be a path prefix of child (not reverse)', () => {
109
+ const parent = '/tmp/workspace/sub';
110
+ const child = '/tmp/workspace';
111
+ expect(isPathInside(parent, child)).toBe(false);
112
+ });
113
+
114
+ it('handles dot as current directory', () => {
115
+ const parent = '.';
116
+ const child = path.resolve('./some-file.txt');
117
+ expect(isPathInside(parent, child)).toBe(true);
118
+ });
119
+ });
120
+
121
+ // ── assertSafeDirectoryRoot ─────────────────────────────────────────────────
122
+
123
+ describe('assertSafeDirectoryRoot', () => {
124
+ it('returns canonical path for a valid relative root', () => {
125
+ const result = assertSafeDirectoryRoot('./relative-workspace', 'test');
126
+ expect(path.isAbsolute(result)).toBe(true);
127
+ expect(result).toContain('relative-workspace');
128
+ });
129
+
130
+ it('returns canonical path for a valid absolute root', () => {
131
+ const result = assertSafeDirectoryRoot('/tmp/workspace', 'test');
132
+ expect(path.isAbsolute(result)).toBe(true);
133
+ });
134
+
135
+ it('throws for empty path', () => {
136
+ expect(() => assertSafeDirectoryRoot('', 'test')).toThrow('path is empty');
137
+ });
138
+
139
+ it('throws for whitespace-only path', () => {
140
+ expect(() => assertSafeDirectoryRoot(' ', 'test')).toThrow('path is empty');
141
+ });
142
+
143
+ it('throws for parent traversal', () => {
144
+ expect(() => assertSafeDirectoryRoot('../etc/passwd', 'test')).toThrow('parent traversal');
145
+ });
146
+
147
+ it('throws for deep parent traversal', () => {
148
+ expect(() => assertSafeDirectoryRoot('a/../../b', 'test')).toThrow('parent traversal');
149
+ });
150
+
151
+ it('throws for filesystem root (POSIX)', () => {
152
+ // Skip on Windows where this is a drive letter
153
+ expect(() => assertSafeDirectoryRoot('/', 'test')).toThrow('filesystem root');
154
+ });
155
+
156
+ it('throws for filesystem root (Windows drive)', () => {
157
+ // On Windows, 'C:\\' is a root; on POSIX, it resolves to cwd/C:\ which is not root.
158
+ // The test validates no crash and consistent behavior.
159
+ expect(() => {
160
+ try {
161
+ assertSafeDirectoryRoot('C:\\', 'test');
162
+ // On some platforms C:\ might resolve to a non-root path, so no throw
163
+ } catch (e) {
164
+ expect((e as Error).message).toMatch(/root|traversal/i);
165
+ }
166
+ }).not.toThrow();
167
+ });
168
+
169
+ it('allows sibling-level paths (not traversal)', () => {
170
+ // a/../b becomes 'b' after normalize (foldable), then resolves to cwd/b
171
+ // This is safe because it resolves inside cwd, no traversal.
172
+ const result = assertSafeDirectoryRoot('a/../b', 'test');
173
+ expect(path.isAbsolute(result)).toBe(true);
174
+ });
175
+
176
+ it('allows a workspace path with dots in name', () => {
177
+ const result = assertSafeDirectoryRoot('./my.project/v1', 'test');
178
+ expect(result).toContain('my.project');
179
+ });
180
+ });