@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
package/src/resolve-workspace.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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>, ' +
|
|
@@ -100,6 +100,8 @@ function createMockStateManager(): {
|
|
|
100
100
|
} {
|
|
101
101
|
const createdTasks: TaskRecord[] = [];
|
|
102
102
|
const stateManager = {
|
|
103
|
+
getTask: vi.fn(async (taskId: string) =>
|
|
104
|
+
createdTasks.find((t) => t.taskId === taskId) ?? null),
|
|
103
105
|
createTask: vi.fn(async (record: Omit<TaskRecord, 'createdAt' | 'updatedAt'>) => {
|
|
104
106
|
const now = new Date().toISOString();
|
|
105
107
|
const task: TaskRecord = { ...record, createdAt: now, updatedAt: now };
|
|
@@ -185,7 +187,7 @@ describe('PRI-510 (DEFECT-004): createEvaluatorRunnerDeps wires repair loop into
|
|
|
185
187
|
expect(deps.isRepairLoopEnabled()).toBe(false);
|
|
186
188
|
});
|
|
187
189
|
|
|
188
|
-
it('flag absent in config → isRepairLoopEnabled() returns
|
|
190
|
+
it('flag absent in config → isRepairLoopEnabled() returns true (P0-D: default-on since core-loop closure)', () => {
|
|
189
191
|
const workspaceDir = createTempWorkspace(null);
|
|
190
192
|
tmpWorkspaces.push(workspaceDir);
|
|
191
193
|
const { stateManager } = createMockStateManager();
|
|
@@ -201,10 +203,11 @@ describe('PRI-510 (DEFECT-004): createEvaluatorRunnerDeps wires repair loop into
|
|
|
201
203
|
|
|
202
204
|
expect(typeof deps.isRepairLoopEnabled).toBe('function');
|
|
203
205
|
if (typeof deps.isRepairLoopEnabled !== 'function') throw new Error('isRepairLoopEnabled missing');
|
|
204
|
-
|
|
206
|
+
// 契约变更 (2026-08-18, INV-02): registry 默认 ON;flag 缺省 = 默认生效
|
|
207
|
+
expect(deps.isRepairLoopEnabled()).toBe(true);
|
|
205
208
|
});
|
|
206
209
|
|
|
207
|
-
it('malformed config → isRepairLoopEnabled()
|
|
210
|
+
it('malformed config → isRepairLoopEnabled() falls back to registry defaults (default-on), never throws', () => {
|
|
208
211
|
// CodeQL: use mkdtempSync for atomic, unpredictable temp dir creation.
|
|
209
212
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pd-pri-510-malformed-'));
|
|
210
213
|
tmpWorkspaces.push(tmpDir);
|
|
@@ -223,10 +226,11 @@ describe('PRI-510 (DEFECT-004): createEvaluatorRunnerDeps wires repair loop into
|
|
|
223
226
|
workspaceDir: tmpDir,
|
|
224
227
|
});
|
|
225
228
|
|
|
226
|
-
// Malformed config must NOT throw —
|
|
229
|
+
// Malformed config must NOT throw — falls back to registry defaults
|
|
230
|
+
// (P0-D: evaluator_artificer_repair_loop default-on since core-loop closure).
|
|
227
231
|
expect(typeof deps.isRepairLoopEnabled).toBe('function');
|
|
228
232
|
if (typeof deps.isRepairLoopEnabled !== 'function') throw new Error('isRepairLoopEnabled missing');
|
|
229
|
-
expect(deps.isRepairLoopEnabled()).toBe(
|
|
233
|
+
expect(deps.isRepairLoopEnabled()).toBe(true);
|
|
230
234
|
});
|
|
231
235
|
|
|
232
236
|
it('seedArtificerRepairTask → creates artificer task with repairPayload in diagnosticJson (rc-1, rc-6)', async () => {
|
|
@@ -286,7 +290,7 @@ describe('PRI-510 (DEFECT-004): createEvaluatorRunnerDeps wires repair loop into
|
|
|
286
290
|
expect(meta.inputArtifactRefs).toEqual(params.inheritedInputArtifactRefs);
|
|
287
291
|
});
|
|
288
292
|
|
|
289
|
-
it('seedArtificerRepairTask →
|
|
293
|
+
it('seedArtificerRepairTask → deterministic id + replay reuse (P0-4); 不同 iteration 不同 id (rc-7)', async () => {
|
|
290
294
|
const workspaceDir = createTempWorkspace(true);
|
|
291
295
|
tmpWorkspaces.push(workspaceDir);
|
|
292
296
|
const { stateManager } = createMockStateManager();
|
|
@@ -303,9 +307,17 @@ describe('PRI-510 (DEFECT-004): createEvaluatorRunnerDeps wires repair loop into
|
|
|
303
307
|
|
|
304
308
|
if (typeof deps.seedArtificerRepairTask !== 'function') throw new Error('seedArtificerRepairTask missing');
|
|
305
309
|
const id1 = await deps.seedArtificerRepairTask(params);
|
|
310
|
+
// P0-4: 同一 evaluator+iteration 的重放 (consumer 重复周期 / crash 恢复)
|
|
311
|
+
// reuse 同一确定性 id,不重复创建
|
|
312
|
+
expect(id1).toBe(`artificer-repair-${params.repairPayload.sourceEvaluatorTaskId}-r${params.repairPayload.repairIteration}`);
|
|
306
313
|
const id2 = await deps.seedArtificerRepairTask(params);
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
expect(id2).toBe(id1);
|
|
315
|
+
// 不同 iteration (下一逻辑修复轮) → 不同 id
|
|
316
|
+
const id3 = await deps.seedArtificerRepairTask({
|
|
317
|
+
...params,
|
|
318
|
+
repairPayload: { ...params.repairPayload, repairIteration: params.repairPayload.repairIteration + 1 },
|
|
319
|
+
});
|
|
320
|
+
expect(id3).not.toBe(id1);
|
|
309
321
|
});
|
|
310
322
|
|
|
311
323
|
it('deps spread contains all required base PeerRunnerDeps fields (EP-02: real path gets full deps)', () => {
|
|
@@ -218,20 +218,62 @@ export async function findAvailablePort(
|
|
|
218
218
|
|
|
219
219
|
// ─── Browser opener (best-effort, no throw) ──────────────────────────────────
|
|
220
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
|
+
|
|
221
243
|
/**
|
|
222
244
|
* Open the system browser. Best-effort — failures are reported but do not
|
|
223
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).
|
|
224
253
|
*/
|
|
225
|
-
export async function openBrowser(
|
|
254
|
+
export async function openBrowser(rawUrl: string): Promise<{ opened: boolean; reason?: string; nextAction?: string }> {
|
|
226
255
|
const { spawn } = await import('child_process');
|
|
227
256
|
const { platform } = process;
|
|
228
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
|
+
|
|
229
269
|
let cmd: string;
|
|
230
270
|
let args: string[];
|
|
231
271
|
|
|
232
272
|
if (platform === 'win32') {
|
|
233
|
-
|
|
234
|
-
|
|
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];
|
|
235
277
|
} else if (platform === 'darwin') {
|
|
236
278
|
cmd = 'open';
|
|
237
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
@@ -66,7 +66,7 @@ import type {
|
|
|
66
66
|
SeedArtificerRepairParams,
|
|
67
67
|
EvaluatorValidator,
|
|
68
68
|
} from '@principles/core/runtime-v2';
|
|
69
|
-
import {
|
|
69
|
+
import { createHash } from 'node:crypto';
|
|
70
70
|
import { loadPdConfig } from './pd-config-loader.js';
|
|
71
71
|
/* eslint-disable @typescript-eslint/no-use-before-define -- helpers declared after main, matching codebase convention */
|
|
72
72
|
import { compileDemoRule } from './demo-rule-compiler.js';
|
|
@@ -646,7 +646,10 @@ export function createEvaluatorRunnerDeps(inputs: CreateEvaluatorRunnerDepsInput
|
|
|
646
646
|
},
|
|
647
647
|
seedArtificerRepairTask: async (params: SeedArtificerRepairParams): Promise<string> => {
|
|
648
648
|
// rc-7: each call gets a fresh task ID — never reuse a cached ID.
|
|
649
|
-
|
|
649
|
+
// P0-4: deterministic revision identity + reuse on replay
|
|
650
|
+
const repairTaskId = `artificer-repair-${params.repairPayload.sourceEvaluatorTaskId}-r${params.repairPayload.repairIteration}`;
|
|
651
|
+
const existing = await stateManager.getTask(repairTaskId);
|
|
652
|
+
if (existing) return repairTaskId;
|
|
650
653
|
await stateManager.createTask({
|
|
651
654
|
taskId: repairTaskId,
|
|
652
655
|
// D1 (PRI-509): task kind is 'artificer' — reuses the artificer
|
|
@@ -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
|
+
}
|
|
@@ -126,4 +126,19 @@ describe('CLI command tree structure', () => {
|
|
|
126
126
|
const output = runPdHelp(['legacy', 'cleanup', '--help']);
|
|
127
127
|
expect(output).toContain('V1 Artificer');
|
|
128
128
|
});
|
|
129
|
+
|
|
130
|
+
// cli-7-test-wiring (PR #1358 known gap 补齐): retry 是 INV-03 的核心 Owner
|
|
131
|
+
// 出边,command-tree 注册必须有 wiring 证明,不能只靠 handler 测试。
|
|
132
|
+
it('internalization retry command exists under runtime internalization (pd runtime internalization retry --help)', () => {
|
|
133
|
+
const output = runPdHelp(['runtime', 'internalization', 'retry', '--help']);
|
|
134
|
+
expect(output).toContain('--task');
|
|
135
|
+
expect(output).toContain('--confirm');
|
|
136
|
+
expect(output).toContain('--workspace');
|
|
137
|
+
expect(output).toContain('--json');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('internalization subcommand list includes retry (pd runtime internalization --help)', () => {
|
|
141
|
+
const output = runPdHelp(['runtime', 'internalization', '--help']);
|
|
142
|
+
expect(output).toMatch(/retry\s/);
|
|
143
|
+
});
|
|
129
144
|
});
|
|
@@ -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
|
+
});
|