@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
@@ -496,14 +496,22 @@ describe('CLI command wiring (pd console open)', () => {
496
496
  workspaceRoot = path.resolve(__dirname, '../../../..');
497
497
  cliPath = path.join(workspaceRoot, 'packages', 'pd-cli', 'dist', 'index.js');
498
498
  tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-console-open-test-'));
499
- // Fake a console install: create dir + dist/server.js with a minimal HTTP server
500
- const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
501
- fs.mkdirSync(path.join(consoleDir, 'dist'), { recursive: true });
502
- // EP-06 regression guard: dist/web/index.html must exist (PR #1169 fix)
499
+ // Isolated fake HOME so the tests never read from or rmSync-destroy — a
500
+ // real ~/.openclaw install on developer machines. runPd() points the CLI
501
+ // child at this home via __PD_CONSOLE_TEST_FAKE_HOME.
502
+ const fakeHome = path.join(tmp, 'home');
503
+ process.env.__PD_CONSOLE_TEST_FAKE_HOME = fakeHome;
504
+ // Fake a console install: dir + ESM package.json + dist/server.js with a
505
+ // minimal HTTP server. The package.json "type": "module" makes the fake
506
+ // server's module system deterministic regardless of whether a real
507
+ // extension-root package.json exists above the console dir.
508
+ const consoleDir = path.join(fakeHome, '.openclaw', 'extensions', 'principles-disciple', 'console');
503
509
  fs.mkdirSync(path.join(consoleDir, 'dist', 'web'), { recursive: true });
510
+ fs.writeFileSync(path.join(consoleDir, 'package.json'), JSON.stringify({ name: 'fake-pd-console', version: '0.0.0', type: 'module' }, null, 2));
511
+ // EP-06 regression guard: dist/web/index.html must exist (PR #1169 fix)
504
512
  fs.writeFileSync(path.join(consoleDir, 'dist', 'web', 'index.html'), '<!DOCTYPE html><html></html>');
505
513
  fs.writeFileSync(path.join(consoleDir, 'dist', 'server.js'), `
506
- const http = require('http');
514
+ import http from 'node:http';
507
515
  const args = process.argv.slice(2);
508
516
  const portIdx = args.indexOf('--port');
509
517
  const port = portIdx >= 0 ? parseInt(args[portIdx + 1]) : 3100;
@@ -525,11 +533,8 @@ describe('CLI command wiring (pd console open)', () => {
525
533
  });
526
534
 
527
535
  afterEach(() => {
536
+ delete process.env.__PD_CONSOLE_TEST_FAKE_HOME;
528
537
  try { fs.rmSync(tmp, { recursive: true, force: true }); } catch { /* ignore */ }
529
- try {
530
- const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
531
- fs.rmSync(consoleDir, { recursive: true, force: true });
532
- } catch { /* ignore */ }
533
538
  });
534
539
 
535
540
  it('console open subcommand is registered (pd console open --help)', () => {
@@ -572,6 +577,58 @@ describe('CLI command wiring (pd console open)', () => {
572
577
  expect(parsed).toHaveProperty('browserOpened');
573
578
  }, 10_000);
574
579
 
580
+ it('pd console open --json fresh spawn includes a positive integer serverPid (PRI-526)', async () => {
581
+ // Async stream-parse + guaranteed teardown. execFileSync patterns are
582
+ // unreliable here on Windows: the CLI keeps running after printing JSON
583
+ // (fresh spawn) or may unexpectedly take another branch, and killing via
584
+ // timeout can leave the grandchild server holding pipe handles.
585
+ let run: CliJsonRun | undefined;
586
+ try {
587
+ run = await runPdUntilJson(
588
+ ['console', 'open', '--workspace', tmp, '--port', '49390', '--json', '--no-browser'],
589
+ workspaceRoot,
590
+ );
591
+ if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
592
+ expect(run.parsed.status).toBe('started');
593
+ expect(run.parsed.reused).toBe(false);
594
+ const serverPid = run.parsed.serverPid;
595
+ expect(typeof serverPid === 'number' && Number.isInteger(serverPid) && serverPid > 0).toBe(true);
596
+ } finally {
597
+ await teardownCliTree(run);
598
+ }
599
+ }, 20_000);
600
+
601
+ it('pd console open --json reused path does NOT include serverPid (PRI-526)', async () => {
602
+ // Stand up a fake healthy console in-process, then point the CLI at its
603
+ // port: planConsoleLaunch probes /api/health → 200 → reused, no spawn.
604
+ const server = http.createServer((req, res) => {
605
+ if (req.url === '/api/health') {
606
+ res.statusCode = 200;
607
+ res.end(JSON.stringify({ success: true }));
608
+ return;
609
+ }
610
+ res.statusCode = 404;
611
+ res.end();
612
+ });
613
+ await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', () => resolve()));
614
+ const addr = server.address();
615
+ if (typeof addr !== 'object' || !addr) throw new Error('no addr');
616
+ let run: CliJsonRun | undefined;
617
+ try {
618
+ run = await runPdUntilJson(
619
+ ['console', 'open', '--workspace', tmp, '--port', String(addr.port), '--json', '--no-browser'],
620
+ workspaceRoot,
621
+ );
622
+ if (!isRecord(run.parsed)) throw new Error('CLI JSON output was not an object');
623
+ expect(run.parsed.status).toBe('reused');
624
+ expect(run.parsed.reused).toBe(true);
625
+ expect(run.parsed).not.toHaveProperty('serverPid');
626
+ } finally {
627
+ await teardownCliTree(run);
628
+ server.close();
629
+ }
630
+ }, 20_000);
631
+
575
632
  it('pd console open --port 99999 --json returns a structured failure (invalid port)', () => {
576
633
  const out = runPd(['console', 'open', '--workspace', tmp, '--port', '99999', '--json', '--no-browser'], workspaceRoot);
577
634
  const parsed = JSON.parse(out);
@@ -669,11 +726,22 @@ describe('CLI command wiring (pd console open)', () => {
669
726
 
670
727
  it('sets browserOpened: false when browser fails to open', async () => {
671
728
  const { handleConsoleOpen } = await import('../../src/commands/console.js');
672
-
729
+
673
730
  const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as any);
674
731
  const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
675
732
  const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
676
733
 
734
+ // The in-process handler resolves the console dir via getConsoleDir(),
735
+ // which reads HOME/USERPROFILE directly. Point them at the isolated
736
+ // fake home — a clean CI runner has no real ~/.openclaw console, and
737
+ // relying on one (as this test implicitly did before) makes the result
738
+ // machine-dependent.
739
+ const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME ?? '';
740
+ const savedHome = process.env.HOME;
741
+ const savedUserProfile = process.env.USERPROFILE;
742
+ process.env.HOME = fakeHome;
743
+ process.env.USERPROFILE = fakeHome;
744
+
677
745
  const mockChild = new EventEmitter() as any;
678
746
  mockChild.unref = vi.fn();
679
747
  let spawnCalled = false;
@@ -701,23 +769,29 @@ describe('CLI command wiring (pd console open)', () => {
701
769
  };
702
770
  };
703
771
 
704
- await handleConsoleOpen({
705
- workspace: tmp,
706
- json: false,
707
- });
772
+ try {
773
+ await handleConsoleOpen({
774
+ workspace: tmp,
775
+ json: false,
776
+ });
708
777
 
709
- await new Promise(resolve => setTimeout(resolve, 150));
778
+ await new Promise(resolve => setTimeout(resolve, 150));
710
779
 
711
- const loggedOutput = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
712
- expect(exitSpy).not.toHaveBeenCalled();
713
- expect(spawnCalled).toBe(true);
714
-
715
- expect(loggedOutput).not.toContain('Browser opened');
716
- expect(loggedOutput).toContain('Open http://127.0.0.1:3100 in your browser');
780
+ const loggedOutput = logSpy.mock.calls.map(c => c.join(' ')).join('\n');
781
+ expect(exitSpy).not.toHaveBeenCalled();
782
+ expect(spawnCalled).toBe(true);
717
783
 
718
- exitSpy.mockRestore();
719
- logSpy.mockRestore();
720
- errorSpy.mockRestore();
784
+ expect(loggedOutput).not.toContain('Browser opened');
785
+ expect(loggedOutput).toContain('Open http://127.0.0.1:3100 in your browser');
786
+ } finally {
787
+ if (savedHome === undefined) delete process.env.HOME;
788
+ else process.env.HOME = savedHome;
789
+ if (savedUserProfile === undefined) delete process.env.USERPROFILE;
790
+ else process.env.USERPROFILE = savedUserProfile;
791
+ exitSpy.mockRestore();
792
+ logSpy.mockRestore();
793
+ errorSpy.mockRestore();
794
+ }
721
795
  });
722
796
  });
723
797
 
@@ -755,6 +829,13 @@ describe('CLI command wiring (pd console open)', () => {
755
829
  it('[::1] is accepted and normalized to ::1 (not refused)', () => {
756
830
  // May spawn a long-lived server, use timeout.
757
831
  const out = runPd(['console', 'open', '--workspace', tmp, '--host', '[::1]', '--json', '--no-browser'], workspaceRoot, 8_000);
832
+ if (out.trim() === '') {
833
+ // The CLI was still in its 15s ready-poll when execFileSync timed out.
834
+ // Known environment limitation: on Windows hosts where IPv6 loopback
835
+ // connections are refused (EACCES), the health probe on ::1 never
836
+ // succeeds. CI (Linux) is unaffected.
837
+ throw new Error('CLI produced no JSON within 8s — ::1 health probe never succeeded (IPv6 loopback may be blocked on this machine)');
838
+ }
758
839
  const parsed = JSON.parse(out);
759
840
  // Should NOT be refused — [::1] is loopback
760
841
  expect(parsed.status).not.toBe('refused');
@@ -767,8 +848,8 @@ describe('CLI command wiring (pd console open)', () => {
767
848
  it('returns console_web_ui_missing when dist/web/index.html does not exist', () => {
768
849
  // beforeEach creates the fake console dir WITH dist/web/index.html.
769
850
  // Remove it to simulate a corrupted install (the PR #1169 regression).
770
- const consoleDir = path.join(os.homedir(), '.openclaw', 'extensions', 'principles-disciple', 'console');
771
- const webIndex = path.join(consoleDir, 'dist', 'web', 'index.html');
851
+ const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME ?? '';
852
+ const webIndex = path.join(fakeHome, '.openclaw', 'extensions', 'principles-disciple', 'console', 'dist', 'web', 'index.html');
772
853
  fs.rmSync(webIndex, { force: true });
773
854
 
774
855
  const out = runPd(['console', 'open', '--workspace', tmp, '--json', '--no-browser'], workspaceRoot, 5_000);
@@ -782,18 +863,10 @@ describe('CLI command wiring (pd console open)', () => {
782
863
 
783
864
  function runPd(args: string[], cwd: string, timeoutMs?: number): string {
784
865
  try {
785
- const env: Record<string, string> = { ...process.env };
786
- if (!args.includes('--workspace') && !args.includes('--help') && !args.includes('-h')) {
787
- env.USERPROFILE = '/nonexistent';
788
- env.HOME = '/nonexistent';
789
- env.HOMEPATH = '/nonexistent';
790
- env.HOMEDRIVE = '/nonexistent';
791
- delete env.PD_WORKSPACE_DIR;
792
- }
793
866
  return execFileSync('node', [getBuiltPdCliPath(), ...args], {
794
867
  encoding: 'utf8',
795
868
  cwd,
796
- env,
869
+ env: buildChildEnv(),
797
870
  timeout: timeoutMs,
798
871
  });
799
872
  } catch (err: unknown) {
@@ -806,3 +879,108 @@ function runPd(args: string[], cwd: string, timeoutMs?: number): string {
806
879
  throw err;
807
880
  }
808
881
  }
882
+
883
+ /** Env for spawned CLI processes: isolated fake HOME, no workspace env leaks. */
884
+ function buildChildEnv(): Record<string, string> {
885
+ const env = { ...process.env } as Record<string, string>;
886
+ const fakeHome = process.env.__PD_CONSOLE_TEST_FAKE_HOME;
887
+ if (fakeHome) {
888
+ env.USERPROFILE = fakeHome;
889
+ env.HOME = fakeHome;
890
+ delete env.HOMEPATH;
891
+ delete env.HOMEDRIVE;
892
+ delete env.PD_WORKSPACE_DIR;
893
+ }
894
+ return env;
895
+ }
896
+
897
+ /** Runtime type guard for unknown CLI JSON output (rc-1/rc-2). */
898
+ function isRecord(value: unknown): value is Record<string, unknown> {
899
+ return typeof value === 'object' && value !== null;
900
+ }
901
+
902
+ // ─── Async CLI runner with guaranteed teardown (PRI-526 tests) ───────────────
903
+
904
+ interface CliJsonRun {
905
+ parsed: unknown;
906
+ child: childProcessModule.ChildProcess;
907
+ }
908
+
909
+ /**
910
+ * Spawn the CLI and resolve as soon as stdout contains one parseable JSON
911
+ * object. Fails fast (bounded timeout / early exit) with the collected
912
+ * stdout in the error message. The caller MUST call teardownCliTree(run)
913
+ * in a finally block — the CLI process may still be alive after the JSON
914
+ * is emitted (fresh-spawn mode keeps the console server attached).
915
+ */
916
+ async function runPdUntilJson(args: string[], cwd: string, timeoutMs = 12_000): Promise<CliJsonRun> {
917
+ const child = childProcessModule.spawn('node', [getBuiltPdCliPath(), ...args], {
918
+ cwd,
919
+ env: buildChildEnv(),
920
+ });
921
+ let stdout = '';
922
+ child.stdout?.on('data', (d: Buffer) => { stdout += d.toString('utf8'); });
923
+
924
+ const parsed: unknown = await new Promise<unknown>((resolve, reject) => {
925
+ let done = false;
926
+ const fail = (msg: string): void => {
927
+ if (done) return;
928
+ done = true;
929
+ reject(new Error(`${msg}; stdout so far: ${stdout.slice(0, 400)}`));
930
+ };
931
+ const timer = setTimeout(() => fail('timed out waiting for CLI JSON'), timeoutMs);
932
+ const tryParse = (): boolean => {
933
+ const trimmed = stdout.trim();
934
+ if (!trimmed.startsWith('{')) return false;
935
+ try {
936
+ const value = JSON.parse(trimmed) as unknown;
937
+ done = true;
938
+ clearTimeout(timer);
939
+ resolve(value);
940
+ return true;
941
+ } catch {
942
+ return false; // partial pretty-printed JSON — wait for more chunks
943
+ }
944
+ };
945
+ if (tryParse()) return;
946
+ const onData = (): void => {
947
+ if (tryParse()) child.stdout?.off('data', onData);
948
+ };
949
+ child.stdout?.on('data', onData);
950
+ child.on('error', (err: Error) => fail(`CLI spawn error: ${err.message}`));
951
+ child.on('exit', (code: number | null) => fail(`CLI exited with code ${code} before emitting JSON`));
952
+ });
953
+
954
+ return { parsed, child };
955
+ }
956
+
957
+ /**
958
+ * Deterministic teardown of a runPdUntilJson run: kill the reported console
959
+ * server PID (grandchild) first so the CLI can exit on its own, then wait a
960
+ * bounded time, then force-kill the whole CLI tree. Safe to call multiple
961
+ * times and with undefined.
962
+ */
963
+ async function teardownCliTree(run: CliJsonRun | undefined): Promise<void> {
964
+ if (!run) return;
965
+ const record = isRecord(run.parsed) ? run.parsed : undefined;
966
+ const serverPid = record !== undefined && typeof record.serverPid === 'number' ? record.serverPid : undefined;
967
+ if (serverPid !== undefined) {
968
+ try { process.kill(serverPid); } catch { /* already gone */ }
969
+ }
970
+ await new Promise<void>((resolve) => {
971
+ const t = setTimeout(() => { killTreeForce(run.child); resolve(); }, 4_000);
972
+ run.child.once('exit', () => { clearTimeout(t); resolve(); });
973
+ });
974
+ killTreeForce(run.child);
975
+ }
976
+
977
+ /**
978
+ * Last-resort kill of the CLI child process. The console server grandchild is
979
+ * reaped separately via its reported serverPid (see teardownCliTree), so a
980
+ * plain signal kill suffices — no shell/argv command wrapper needed.
981
+ */
982
+ function killTreeForce(child: childProcessModule.ChildProcess): void {
983
+ try {
984
+ child.kill('SIGKILL');
985
+ } catch { /* already gone */ }
986
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Tests for pd legacy cleanup command.
3
+ *
4
+ * Covers:
5
+ * - Relative workspace root works (regression: canonical containment)
6
+ * - Traversal escape rejected
7
+ * - Filesystem root rejected
8
+ * - Dry-run default with no artifacts found
9
+ * - Apply mode with legacy targets
10
+ * - V1 artifact identification
11
+ */
12
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
13
+ import * as fs from 'fs';
14
+ import * as path from 'path';
15
+ import os from 'os';
16
+ import {
17
+ handleLegacyCleanup,
18
+ isV1ArtificerArtifact,
19
+ } from '../../src/commands/legacy-cleanup.js';
20
+
21
+ // ── Pure logic: V1 artifact identification ─────────────────────────────────
22
+
23
+ describe('isV1ArtificerArtifact', () => {
24
+ it('returns false for V2 artifact (non-empty implementationCode)', () => {
25
+ const v2 = JSON.stringify({ id: 'a', implementationCode: 'code here', plan: 'plan' });
26
+ expect(isV1ArtificerArtifact(v2)).toBe(false);
27
+ });
28
+
29
+ it('returns true for V1 artifact (plan-only, no implementationCode)', () => {
30
+ const v1 = JSON.stringify({ id: 'b', plan: 'plan only', implementationCode: '' });
31
+ expect(isV1ArtificerArtifact(v1)).toBe(true);
32
+ });
33
+
34
+ it('returns false for invalid JSON', () => {
35
+ expect(isV1ArtificerArtifact('{not json')).toBe(false);
36
+ });
37
+
38
+ it('returns false for non-object JSON', () => {
39
+ expect(isV1ArtificerArtifact('"string"')).toBe(false);
40
+ expect(isV1ArtificerArtifact('42')).toBe(false);
41
+ });
42
+
43
+ it('returns false for null JSON', () => {
44
+ expect(isV1ArtificerArtifact('null')).toBe(false);
45
+ });
46
+ });
47
+
48
+ // ── Integration: relative workspace + boundary validation ─────────────────
49
+
50
+ describe('legacy cleanup workspace boundary', () => {
51
+ it('accepts a relative workspace root (regression: canonical containment)', async () => {
52
+ // A relative workspace must canonicalize consistently so cleanup scans
53
+ // inside it, without the old startsWith-on-relative-root failure.
54
+ const relTmp = fs.mkdtempSync(path.join(process.cwd(), '.tmp-rel-cleanup-'));
55
+ try {
56
+ // Create a legacy artifact the scanner looks for
57
+ const stateDir = path.join(relTmp, '.state');
58
+ fs.mkdirSync(stateDir, { recursive: true });
59
+ const legacyDb = path.join(stateDir, 'sessions.db');
60
+ fs.writeFileSync(legacyDb, 'not a real db', 'utf8');
61
+
62
+ const relWorkspace = path.relative(process.cwd(), relTmp);
63
+ expect(path.isAbsolute(relWorkspace)).toBe(false);
64
+
65
+ const result = await handleLegacyCleanup({
66
+ workspacePath: relWorkspace,
67
+ dryRun: true,
68
+ });
69
+
70
+ expect(result.status).toBe('ok');
71
+ expect(result.mode).toBe('dry-run');
72
+ } finally {
73
+ fs.rmSync(relTmp, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ it('rejects parent traversal escape', async () => {
78
+ await expect(
79
+ handleLegacyCleanup({ workspacePath: '../evil', dryRun: true }),
80
+ ).rejects.toThrow(/parent traversal/);
81
+ });
82
+
83
+ it('rejects empty workspace', async () => {
84
+ await expect(
85
+ handleLegacyCleanup({ workspacePath: '', dryRun: true }),
86
+ ).rejects.toThrow(/path is empty/);
87
+ });
88
+
89
+ it('rejects filesystem root', async () => {
90
+ await expect(
91
+ handleLegacyCleanup({ workspacePath: path.parse(process.cwd()).root, dryRun: true }),
92
+ ).rejects.toThrow(/filesystem root/);
93
+ });
94
+ });
95
+
96
+ // ── Integration: normal cleanup flow ───────────────────────────────────────
97
+
98
+ describe('legacy cleanup flow', () => {
99
+ let tmpDir: string;
100
+
101
+ beforeEach(() => {
102
+ // mkdtempSync: CodeQL-safe random directory under os.tmpdir
103
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-test-cleanup-'));
104
+ });
105
+
106
+ afterEach(() => {
107
+ fs.rmSync(tmpDir, { recursive: true, force: true });
108
+ });
109
+
110
+ it('dry-run with no artifacts returns ok with zero targets', async () => {
111
+ const result = await handleLegacyCleanup({ workspacePath: tmpDir, dryRun: true });
112
+ expect(result.status).toBe('ok');
113
+ expect(result.mode).toBe('dry-run');
114
+ expect(result.fileTargets).toEqual([]);
115
+ expect(result.errors).toEqual([]);
116
+ });
117
+
118
+ it('scans legacy session files under .state/sessions', async () => {
119
+ const sessionsDir = path.join(tmpDir, '.state', 'sessions');
120
+ fs.mkdirSync(sessionsDir, { recursive: true });
121
+ fs.writeFileSync(
122
+ path.join(sessionsDir, 'old-session.json'),
123
+ JSON.stringify({ sessionKey: 'cron:pd-empathy-optimizer-abc' }),
124
+ 'utf8',
125
+ );
126
+
127
+ const result = await handleLegacyCleanup({ workspacePath: tmpDir, dryRun: true });
128
+ expect(result.status).toBe('ok');
129
+ expect(result.fileTargets.length).toBeGreaterThanOrEqual(1);
130
+ expect(result.fileTargets.some((t) => t.path.endsWith('old-session.json'))).toBe(true);
131
+ });
132
+
133
+ it('apply mode deletes legacy session files', async () => {
134
+ const sessionsDir = path.join(tmpDir, '.state', 'sessions');
135
+ fs.mkdirSync(sessionsDir, { recursive: true });
136
+ const legacyFile = path.join(sessionsDir, 'old-session.json');
137
+ fs.writeFileSync(
138
+ legacyFile,
139
+ JSON.stringify({ sessionKey: 'cron:pd-empathy-optimizer-abc' }),
140
+ 'utf8',
141
+ );
142
+
143
+ const result = await handleLegacyCleanup({ workspacePath: tmpDir, apply: true });
144
+ expect(result.status).toBe('ok');
145
+ expect(result.mode).toBe('apply');
146
+ expect(fs.existsSync(legacyFile)).toBe(false);
147
+ });
148
+ });
@@ -271,6 +271,43 @@ describe('real SYSTEM log fixture', () => {
271
271
  logSpy.mockRestore();
272
272
  exitSpy.mockRestore();
273
273
  });
274
+
275
+ it('FIXTURE-06: relative --workspace reads SYSTEM logs (regression)', async () => {
276
+ // Regression: a relative workspace root must not break containment.
277
+ // getLogDir canonicalizes via assertSafeDirectoryRoot and the log-file
278
+ // check uses isPathInside (canonical-vs-canonical), so a relative root
279
+ // must resolve and contain its own log dir.
280
+ const { handlePainEvidence } = await import('../../src/commands/pain-evidence.js');
281
+
282
+ // Create a workspace under cwd so a true relative path is possible.
283
+ const relTmp = fs.mkdtempSync(path.join(process.cwd(), '.tmp-rel-ws-'));
284
+ try {
285
+ const relLogDir = path.join(relTmp, 'memory', 'logs');
286
+ fs.mkdirSync(relLogDir, { recursive: true });
287
+ fs.writeFileSync(path.join(relLogDir, 'SYSTEM_2026-06-08.log'), FULL_LOG_CONTENT, 'utf8');
288
+ const relWorkspace = path.relative(process.cwd(), relTmp);
289
+ expect(path.isAbsolute(relWorkspace)).toBe(false);
290
+
291
+ const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
292
+ const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as () => never);
293
+
294
+ await handlePainEvidence({ workspace: relWorkspace, limit: 10, json: true });
295
+
296
+ const jsonCall = logSpy.mock.calls.find((call) => {
297
+ try { JSON.parse(call[0] as string); return true; } catch { return false; }
298
+ });
299
+ expect(jsonCall).toBeDefined();
300
+ const output = JSON.parse(jsonCall![0] as string);
301
+ expect(output.count).toBe(3);
302
+ expect(output.searchedPath).toContain(path.join('memory', 'logs', 'SYSTEM_*.log'));
303
+ expect(output.decisions[0].outcome).toBe('manual_owner_admitted');
304
+
305
+ logSpy.mockRestore();
306
+ exitSpy.mockRestore();
307
+ } finally {
308
+ fs.rmSync(relTmp, { recursive: true, force: true });
309
+ }
310
+ });
274
311
  });
275
312
 
276
313
  // ── Commander Registration Tests ───────────────────────────────────────────
@@ -100,7 +100,11 @@ describe('PRI-393: runtime config unification', () => {
100
100
  ];
101
101
 
102
102
  for (const file of commandFiles) {
103
- const fullPath = path.resolve(file);
103
+ // CWE-22: resolve against the repo root (this test file lives at
104
+ // packages/pd-cli/tests/commands/) and refuse paths that escape it.
105
+ const repoRoot = path.resolve(__dirname, '../../..');
106
+ const fullPath = path.resolve(repoRoot, file);
107
+ if (!fullPath.startsWith(repoRoot + path.sep)) continue;
104
108
  if (!fs.existsSync(fullPath)) continue;
105
109
  const source = fs.readFileSync(fullPath, 'utf8');
106
110
 
@@ -2,7 +2,7 @@ import { describe, it, expect } from 'vitest';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import * as os from 'os';
5
- import { execSync } from 'child_process';
5
+ import { execFileSync } from 'child_process';
6
6
  import { fileURLToPath } from 'url';
7
7
 
8
8
  // Resolve __dirname in ESM
@@ -40,11 +40,16 @@ ui:
40
40
 
41
41
  // Resolve CLI binary path relative to this file to be workspace-independent
42
42
  const cliBin = path.resolve(__dirname, '../../dist/index.js');
43
- const cmd = `node "${cliBin}" pain record --reason "Regression test frustration" --json --workspace "${tmpDir}"`;
44
-
43
+ // Parameterized exec (no shell): tmpDir and reason are passed as separate
44
+ // argv entries, so shell metacharacters in them cannot be interpreted as
45
+ // commands (CWE-78 mitigation).
45
46
  let stdoutStr: string;
46
47
  try {
47
- stdoutStr = execSync(cmd, { encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'] });
48
+ stdoutStr = execFileSync(
49
+ process.execPath,
50
+ [cliBin, 'pain', 'record', '--reason', 'Regression test frustration', '--json', '--workspace', tmpDir],
51
+ { encoding: 'utf8', stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true },
52
+ );
48
53
  } finally {
49
54
  fs.rmSync(tmpDir, { recursive: true, force: true });
50
55
  }