@principles/pd-cli 1.135.0 → 1.135.2
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/dist/commands/legacy-cleanup.d.ts.map +1 -1
- package/dist/commands/legacy-cleanup.js +19 -2
- package/dist/commands/legacy-cleanup.js.map +1 -1
- package/dist/commands/pain-evidence.d.ts +3 -1
- package/dist/commands/pain-evidence.d.ts.map +1 -1
- package/dist/commands/pain-evidence.js +12 -3
- package/dist/commands/pain-evidence.js.map +1 -1
- package/dist/commands/rulecode.d.ts +13 -0
- package/dist/commands/rulecode.d.ts.map +1 -1
- package/dist/commands/rulecode.js +23 -2
- package/dist/commands/rulecode.js.map +1 -1
- package/dist/commands/runtime-activation.d.ts.map +1 -1
- package/dist/commands/runtime-activation.js +36 -7
- package/dist/commands/runtime-activation.js.map +1 -1
- package/dist/commands/runtime-internalization-enqueue-successors.d.ts.map +1 -1
- package/dist/commands/runtime-internalization-enqueue-successors.js +48 -0
- package/dist/commands/runtime-internalization-enqueue-successors.js.map +1 -1
- package/dist/commands/runtime-internalization-retry.d.ts +38 -0
- package/dist/commands/runtime-internalization-retry.d.ts.map +1 -0
- package/dist/commands/runtime-internalization-retry.js +143 -0
- package/dist/commands/runtime-internalization-retry.js.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/resolve-workspace.d.ts.map +1 -1
- package/dist/resolve-workspace.js +33 -12
- package/dist/resolve-workspace.js.map +1 -1
- package/dist/services/__tests__/evaluator-runner-deps.test.js +19 -7
- package/dist/services/__tests__/evaluator-runner-deps.test.js.map +1 -1
- package/dist/services/console-launcher.d.ts +8 -1
- package/dist/services/console-launcher.d.ts.map +1 -1
- package/dist/services/console-launcher.js +45 -3
- package/dist/services/console-launcher.js.map +1 -1
- package/dist/services/pd-config-loader.d.ts.map +1 -1
- package/dist/services/pd-config-loader.js +34 -1
- package/dist/services/pd-config-loader.js.map +1 -1
- package/dist/services/quality-scorecard/strong-model-gate.d.ts +19 -0
- package/dist/services/quality-scorecard/strong-model-gate.d.ts.map +1 -1
- package/dist/services/quality-scorecard/strong-model-gate.js +44 -2
- package/dist/services/quality-scorecard/strong-model-gate.js.map +1 -1
- package/dist/services/rulehost-pipeline-runner.d.ts.map +1 -1
- package/dist/services/rulehost-pipeline-runner.js +6 -2
- package/dist/services/rulehost-pipeline-runner.js.map +1 -1
- package/dist/utils/path-security.d.ts +60 -0
- package/dist/utils/path-security.d.ts.map +1 -0
- package/dist/utils/path-security.js +90 -0
- package/dist/utils/path-security.js.map +1 -0
- package/package.json +1 -1
- package/src/commands/legacy-cleanup.ts +19 -2
- package/src/commands/pain-evidence.ts +11 -3
- package/src/commands/rulecode.ts +25 -2
- package/src/commands/runtime-activation.ts +38 -6
- package/src/commands/runtime-internalization-enqueue-successors.ts +48 -0
- package/src/commands/runtime-internalization-retry.ts +163 -0
- package/src/index.ts +12 -0
- package/src/resolve-workspace.ts +41 -17
- package/src/services/__tests__/evaluator-runner-deps.test.ts +20 -8
- package/src/services/console-launcher.ts +45 -3
- package/src/services/pd-config-loader.ts +35 -1
- package/src/services/quality-scorecard/strong-model-gate.ts +44 -2
- package/src/services/rulehost-pipeline-runner.ts +5 -2
- package/src/utils/path-security.ts +96 -0
- package/tests/commands/cli-command-tree.test.ts +15 -0
- package/tests/commands/legacy-cleanup.test.ts +148 -0
- package/tests/commands/pain-evidence.test.ts +37 -0
- package/tests/commands/pri-393-runtime-config-unification.test.ts +5 -1
- package/tests/commands/product-path-regression.test.ts +9 -4
- package/tests/commands/rulecode.test.ts +135 -0
- package/tests/commands/runtime-diagnostics-export.test.ts +6 -2
- package/tests/commands/runtime-internalization-retry-owner-authority.test.ts +431 -0
- package/tests/resolve-workspace.test.ts +21 -0
- package/tests/services/console-launcher.test.ts +114 -0
- package/tests/services/pd-config-loader.test.ts +8 -1
- package/tests/services/quality-scorecard/strong-model-gate.test.ts +133 -0
- 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
|
-
|
|
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
|
+
});
|