@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
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
* Canonicalize a user/operator-supplied path once. Every derived filesystem
|
|
30
|
+
* target must be compared against this canonical root.
|
|
31
|
+
*/
|
|
32
|
+
export declare function canonicalPath(p: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* True when `candidate` is strictly inside `parent` (canonical comparison).
|
|
35
|
+
*
|
|
36
|
+
* - Both arguments are resolved against cwd first, so relative inputs work.
|
|
37
|
+
* - `candidate === parent` returns false (strict containment). Callers that
|
|
38
|
+
* want to allow the root itself should check equality separately.
|
|
39
|
+
* - Sibling-prefix attacks (`/work/foobar` vs parent `/work/foo`) cannot
|
|
40
|
+
* pass because `path.relative` yields a non-`..`-prefixed path only for
|
|
41
|
+
* real descendants.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isPathInside(parent: string, candidate: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Throw unless `candidate` is strictly inside `parent`. `label` names the
|
|
46
|
+
* candidate in the error message (e.g. "--workspace").
|
|
47
|
+
*/
|
|
48
|
+
export declare function assertPathInside(parent: string, candidate: string, label: string): void;
|
|
49
|
+
/**
|
|
50
|
+
* Validate an operator-supplied directory root before it is used as an IO
|
|
51
|
+
* root: rejects empty values, residual parent-traversal segments, and
|
|
52
|
+
* filesystem-root results. Returns the canonical root.
|
|
53
|
+
*
|
|
54
|
+
* No `path.isAbsolute` requirement: absolute-ness is platform-dependent (a
|
|
55
|
+
* Windows-style path like `Z:\work` is not absolute on POSIX runners) and
|
|
56
|
+
* relative paths resolve inside cwd, so they carry no traversal risk. The
|
|
57
|
+
* guards that matter are: empty, parent traversal, and filesystem root.
|
|
58
|
+
*/
|
|
59
|
+
export declare function assertSafeDirectoryRoot(input: string, label: string): string;
|
|
60
|
+
//# sourceMappingURL=path-security.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-security.d.ts","sourceRoot":"","sources":["../../src/utils/path-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAIH;;;GAGG;AACH,wBAAgB,aAAa,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAE/C;AAED;;;;;;;;;GASG;AACH,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAUvE;AAED;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAIvF;AAED;;;;;;;;;GASG;AACH,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAe5E"}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
import * as path from 'node:path';
|
|
29
|
+
/**
|
|
30
|
+
* Canonicalize a user/operator-supplied path once. Every derived filesystem
|
|
31
|
+
* target must be compared against this canonical root.
|
|
32
|
+
*/
|
|
33
|
+
export function canonicalPath(p) {
|
|
34
|
+
return path.resolve(p);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* True when `candidate` is strictly inside `parent` (canonical comparison).
|
|
38
|
+
*
|
|
39
|
+
* - Both arguments are resolved against cwd first, so relative inputs work.
|
|
40
|
+
* - `candidate === parent` returns false (strict containment). Callers that
|
|
41
|
+
* want to allow the root itself should check equality separately.
|
|
42
|
+
* - Sibling-prefix attacks (`/work/foobar` vs parent `/work/foo`) cannot
|
|
43
|
+
* pass because `path.relative` yields a non-`..`-prefixed path only for
|
|
44
|
+
* real descendants.
|
|
45
|
+
*/
|
|
46
|
+
export function isPathInside(parent, candidate) {
|
|
47
|
+
const root = path.resolve(parent);
|
|
48
|
+
const target = path.resolve(candidate);
|
|
49
|
+
const rel = path.relative(root, target);
|
|
50
|
+
return (rel !== '' &&
|
|
51
|
+
rel !== '..' &&
|
|
52
|
+
!rel.startsWith(`..${path.sep}`) &&
|
|
53
|
+
!path.isAbsolute(rel));
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Throw unless `candidate` is strictly inside `parent`. `label` names the
|
|
57
|
+
* candidate in the error message (e.g. "--workspace").
|
|
58
|
+
*/
|
|
59
|
+
export function assertPathInside(parent, candidate, label) {
|
|
60
|
+
if (!isPathInside(parent, candidate)) {
|
|
61
|
+
throw new Error(`Invalid ${label}: "${candidate}" is outside "${parent}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Validate an operator-supplied directory root before it is used as an IO
|
|
66
|
+
* root: rejects empty values, residual parent-traversal segments, and
|
|
67
|
+
* filesystem-root results. Returns the canonical root.
|
|
68
|
+
*
|
|
69
|
+
* No `path.isAbsolute` requirement: absolute-ness is platform-dependent (a
|
|
70
|
+
* Windows-style path like `Z:\work` is not absolute on POSIX runners) and
|
|
71
|
+
* relative paths resolve inside cwd, so they carry no traversal risk. The
|
|
72
|
+
* guards that matter are: empty, parent traversal, and filesystem root.
|
|
73
|
+
*/
|
|
74
|
+
export function assertSafeDirectoryRoot(input, label) {
|
|
75
|
+
if (!input || input.trim().length === 0) {
|
|
76
|
+
throw new Error(`Invalid ${label}: path is empty`);
|
|
77
|
+
}
|
|
78
|
+
// Un-normalized `..` segments that survive normalize() mean the input
|
|
79
|
+
// escaped a parent boundary (e.g. "..\\..\\evil") — reject rather than
|
|
80
|
+
// trust them. Foldable segments ("a/../b") canonicalize safely.
|
|
81
|
+
if (path.normalize(input).split(/[\\/]/).includes('..')) {
|
|
82
|
+
throw new Error(`Invalid ${label}: "${input}" contains parent traversal`);
|
|
83
|
+
}
|
|
84
|
+
const root = canonicalPath(input);
|
|
85
|
+
if (root === path.parse(root).root) {
|
|
86
|
+
throw new Error(`Invalid ${label}: "${input}" resolves to filesystem root`);
|
|
87
|
+
}
|
|
88
|
+
return root;
|
|
89
|
+
}
|
|
90
|
+
//# sourceMappingURL=path-security.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"path-security.js","sourceRoot":"","sources":["../../src/utils/path-security.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAEH,OAAO,KAAK,IAAI,MAAM,WAAW,CAAC;AAElC;;;GAGG;AACH,MAAM,UAAU,aAAa,CAAC,CAAS;IACrC,OAAO,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;AACzB,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,YAAY,CAAC,MAAc,EAAE,SAAiB;IAC5D,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;IACvC,MAAM,GAAG,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC;IACxC,OAAO,CACL,GAAG,KAAK,EAAE;QACV,GAAG,KAAK,IAAI;QACZ,CAAC,GAAG,CAAC,UAAU,CAAC,KAAK,IAAI,CAAC,GAAG,EAAE,CAAC;QAChC,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,CAAC,CACtB,CAAC;AACJ,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,gBAAgB,CAAC,MAAc,EAAE,SAAiB,EAAE,KAAa;IAC/E,IAAI,CAAC,YAAY,CAAC,MAAM,EAAE,SAAS,CAAC,EAAE,CAAC;QACrC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,SAAS,iBAAiB,MAAM,GAAG,CAAC,CAAC;IAC7E,CAAC;AACH,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,uBAAuB,CAAC,KAAa,EAAE,KAAa;IAClE,IAAI,CAAC,KAAK,IAAI,KAAK,CAAC,IAAI,EAAE,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QACxC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,iBAAiB,CAAC,CAAC;IACrD,CAAC;IACD,sEAAsE;IACtE,uEAAuE;IACvE,gEAAgE;IAChE,IAAI,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,EAAE,CAAC;QACxD,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,KAAK,6BAA6B,CAAC,CAAC;IAC5E,CAAC;IACD,MAAM,IAAI,GAAG,aAAa,CAAC,KAAK,CAAC,CAAC;IAClC,IAAI,IAAI,KAAK,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,IAAI,EAAE,CAAC;QACnC,MAAM,IAAI,KAAK,CAAC,WAAW,KAAK,MAAM,KAAK,+BAA+B,CAAC,CAAC;IAC9E,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC"}
|
package/package.json
CHANGED
|
@@ -27,6 +27,7 @@ import * as path from 'path';
|
|
|
27
27
|
import * as os from 'os';
|
|
28
28
|
import type { Database } from 'better-sqlite3';
|
|
29
29
|
import { RuntimeStateManager } from '@principles/core/runtime-v2';
|
|
30
|
+
import { assertSafeDirectoryRoot, isPathInside } from '../utils/path-security.js';
|
|
30
31
|
|
|
31
32
|
// ── Types ────────────────────────────────────────────────────────────────────
|
|
32
33
|
|
|
@@ -257,9 +258,21 @@ function glob(pattern: string): string[] {
|
|
|
257
258
|
return results;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Validate a workspace root before cleanup scans derive paths from it.
|
|
263
|
+
* Returns the canonical root so all derived targets are absolute and
|
|
264
|
+
* containment checks are canonical-vs-canonical (relative workspaces work).
|
|
265
|
+
*/
|
|
266
|
+
function assertCleanupWorkspaceRoot(workspacePath: string): string {
|
|
267
|
+
return assertSafeDirectoryRoot(workspacePath, 'workspace path');
|
|
268
|
+
}
|
|
269
|
+
|
|
260
270
|
function findLegacyTargets(workspacePath: string): CleanupTarget[] {
|
|
261
271
|
const targets: CleanupTarget[] = [];
|
|
262
|
-
|
|
272
|
+
// CWE-22: resolve the workspace root once and verify it is a valid,
|
|
273
|
+
// non-root directory so every derived path stays inside the boundary.
|
|
274
|
+
const workspaceRoot = assertCleanupWorkspaceRoot(workspacePath);
|
|
275
|
+
const stateDir = path.join(workspaceRoot, '.state');
|
|
263
276
|
const archiveTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
264
277
|
const archiveDir = path.join(stateDir, 'legacy-archive', archiveTimestamp);
|
|
265
278
|
|
|
@@ -288,7 +301,11 @@ function findLegacyTargets(workspacePath: string): CleanupTarget[] {
|
|
|
288
301
|
if (fs.existsSync(sessionsDir)) {
|
|
289
302
|
for (const file of fs.readdirSync(sessionsDir)) {
|
|
290
303
|
if (!file.endsWith('.json')) continue;
|
|
291
|
-
const filePath = path.
|
|
304
|
+
const filePath = path.resolve(sessionsDir, file);
|
|
305
|
+
// CWE-22: verify the canonical path stays inside the canonical
|
|
306
|
+
// sessions dir before any filesystem access (readdir results are
|
|
307
|
+
// trusted, but defense-in-depth).
|
|
308
|
+
if (!isPathInside(sessionsDir, filePath)) continue;
|
|
292
309
|
try {
|
|
293
310
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
294
311
|
const session = JSON.parse(content);
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as path from 'path';
|
|
21
21
|
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
22
|
+
import { assertSafeDirectoryRoot, isPathInside } from '../utils/path-security.js';
|
|
22
23
|
|
|
23
24
|
interface EvidenceOptions {
|
|
24
25
|
workspace?: string;
|
|
@@ -40,11 +41,14 @@ interface TriggerDecisionEntry {
|
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
|
-
* Get the memory/logs directory for a workspace.
|
|
44
|
+
* Get the memory/logs directory for a workspace (canonical root).
|
|
44
45
|
* SystemLogger writes to <workspace>/memory/logs/SYSTEM_YYYY-MM-DD.log.
|
|
46
|
+
* The workspace root is canonicalized once here so downstream containment
|
|
47
|
+
* checks compare canonical paths (relative --workspace inputs work).
|
|
45
48
|
*/
|
|
46
49
|
function getLogDir(workspaceDir: string): string {
|
|
47
|
-
|
|
50
|
+
const root = assertSafeDirectoryRoot(workspaceDir, 'workspace path');
|
|
51
|
+
return path.join(root, 'memory', 'logs');
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/**
|
|
@@ -108,7 +112,11 @@ function readRecentDecisions(logDir: string, limit: number): TriggerDecisionEntr
|
|
|
108
112
|
for (const logFile of logFiles) {
|
|
109
113
|
if (allEntries.length >= limit) break;
|
|
110
114
|
|
|
111
|
-
|
|
115
|
+
// CWE-22: verify the canonical log path stays inside the canonical
|
|
116
|
+
// logDir before reading (canonical-vs-canonical containment, so relative
|
|
117
|
+
// workspace roots work).
|
|
118
|
+
const filePath = path.resolve(logDir, logFile);
|
|
119
|
+
if (!isPathInside(logDir, filePath)) continue;
|
|
112
120
|
try {
|
|
113
121
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
114
122
|
const entries = parseTriggerDecisions(content);
|
package/src/commands/rulecode.ts
CHANGED
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
import * as path from 'node:path';
|
|
32
32
|
import * as fs from 'node:fs';
|
|
33
33
|
import type { Command } from 'commander';
|
|
34
|
+
import { isPathInside } from '../utils/path-security.js';
|
|
34
35
|
import {
|
|
35
36
|
RULECODE_SPEC_TEXT,
|
|
36
37
|
checkForbiddenPatterns,
|
|
@@ -113,11 +114,33 @@ function isGoldenTraceCaseInput(value: unknown): value is GoldenTraceCaseInput {
|
|
|
113
114
|
/**
|
|
114
115
|
* Load and validate golden trace cases from a JSON file.
|
|
115
116
|
* Returns either the validated cases or a structured error.
|
|
117
|
+
* Exported for boundary regression tests (internal test surface).
|
|
116
118
|
*/
|
|
117
|
-
function loadGoldenTraceCases(
|
|
119
|
+
export function loadGoldenTraceCases(
|
|
120
|
+
filePath: string,
|
|
121
|
+
workspaceDir?: string,
|
|
122
|
+
): { cases?: GoldenTraceCaseInput[]; error?: { reason: string; nextAction: string } } {
|
|
118
123
|
let raw: string;
|
|
119
124
|
try {
|
|
125
|
+
if (!filePath || filePath.trim().length === 0) {
|
|
126
|
+
throw new Error('golden trace path is empty');
|
|
127
|
+
}
|
|
120
128
|
const resolved = path.resolve(filePath);
|
|
129
|
+
// CWE-22 boundary: canonical containment against the workspace root
|
|
130
|
+
// (rejects sibling-prefix and traversal escapes; relative workspaces
|
|
131
|
+
// canonicalize consistently). Filesystem-root targets are rejected by
|
|
132
|
+
// containment when a workspace root is supplied.
|
|
133
|
+
const normalized = path.normalize(resolved);
|
|
134
|
+
if (normalized.split(/[\\/]/).includes('..')) {
|
|
135
|
+
throw new Error('golden trace path contains parent traversal');
|
|
136
|
+
}
|
|
137
|
+
if (workspaceDir) {
|
|
138
|
+
if (!isPathInside(workspaceDir, resolved)) {
|
|
139
|
+
throw new Error('golden trace path must be inside the workspace directory');
|
|
140
|
+
}
|
|
141
|
+
} else if (normalized === path.parse(normalized).root) {
|
|
142
|
+
throw new Error('golden trace path resolves to filesystem root');
|
|
143
|
+
}
|
|
121
144
|
raw = fs.readFileSync(resolved, 'utf8');
|
|
122
145
|
} catch (err) {
|
|
123
146
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -286,7 +309,7 @@ export async function handleRulecodeReplay(opts: ReplayOptions): Promise<void> {
|
|
|
286
309
|
return;
|
|
287
310
|
}
|
|
288
311
|
|
|
289
|
-
const traceResult = loadGoldenTraceCases(opts.goldenTrace);
|
|
312
|
+
const traceResult = loadGoldenTraceCases(opts.goldenTrace, opts.workspace);
|
|
290
313
|
if (traceResult.error || traceResult.cases === undefined) {
|
|
291
314
|
const { error } = traceResult;
|
|
292
315
|
const output: RulecodeReplayOutput = {
|
|
@@ -45,29 +45,38 @@ interface ActivationDispatchOptions {
|
|
|
45
45
|
}
|
|
46
46
|
|
|
47
47
|
function mapRolloutDecision(reviewDecision: string | undefined): RolloutActivationDecision {
|
|
48
|
+
// P0-E (MVP_CORE_LOOP_CONTRACT INV-04): needs_revision 绝不映射 require_approval。
|
|
49
|
+
// 旧映射让"需修改"的评审结论伪装成正常审批进入 approval 队列(审计 ISSUE-027)。
|
|
50
|
+
// needs_revision 的出边是 revision loop(自动 reopen 修订目标),由
|
|
51
|
+
// rollout-reviewer-runner.handleRevisionRouting / auto-consumer 承担;
|
|
52
|
+
// CLI dispatch 对 needs_revision artifact 一律 refuse(structured reason + nextAction)。
|
|
48
53
|
if (!reviewDecision) return 'require_approval';
|
|
49
54
|
if (reviewDecision === 'approve_rollout') return 'auto_activate';
|
|
50
|
-
if (reviewDecision === 'needs_revision') return 'require_approval';
|
|
51
55
|
if (reviewDecision === 'reject') return 'reject';
|
|
52
56
|
return 'require_approval';
|
|
53
57
|
}
|
|
54
58
|
|
|
55
|
-
|
|
59
|
+
/** 提取 artifact 上的原始 review decision(未映射),用于 needs_revision 拒绝分支 */
|
|
60
|
+
function extractRawRolloutReviewDecision(artifact: PIArtifactRecord): string | null {
|
|
56
61
|
try {
|
|
57
62
|
const parsed = JSON.parse(artifact.contentJson) as Record<string, unknown>;
|
|
58
63
|
if (parsed && typeof parsed === 'object') {
|
|
59
64
|
const review = parsed.review as Record<string, unknown> | undefined;
|
|
60
65
|
if (review && typeof review.decision === 'string') {
|
|
61
|
-
return
|
|
66
|
+
return review.decision;
|
|
62
67
|
}
|
|
63
68
|
if (typeof parsed.rolloutDecision === 'string') {
|
|
64
|
-
return
|
|
69
|
+
return parsed.rolloutDecision;
|
|
65
70
|
}
|
|
66
71
|
}
|
|
67
72
|
} catch {
|
|
68
|
-
return
|
|
73
|
+
return null;
|
|
69
74
|
}
|
|
70
|
-
return
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extractRolloutDecisionFromArtifact(artifact: PIArtifactRecord): RolloutActivationDecision {
|
|
79
|
+
return mapRolloutDecision(extractRawRolloutReviewDecision(artifact) ?? undefined);
|
|
71
80
|
}
|
|
72
81
|
|
|
73
82
|
function toSnapshot(record: PIArtifactRecord): PIArtifactSnapshot {
|
|
@@ -158,6 +167,29 @@ export async function handleRuntimeActivationDispatch(opts: ActivationDispatchOp
|
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
const artifactSnapshot = toSnapshot(artifactRecord);
|
|
170
|
+
|
|
171
|
+
// P0-E: needs_revision artifact 不允许手动 dispatch 入 approval (INV-04)。
|
|
172
|
+
// 出边是 revision loop: auto-consumer 会自动 reopen 修订目标;
|
|
173
|
+
// 手动场景给出结构化 next action (cli-6)。
|
|
174
|
+
const rawReviewDecision = extractRawRolloutReviewDecision(artifactRecord);
|
|
175
|
+
if (rawReviewDecision === 'needs_revision') {
|
|
176
|
+
const refused: ActivationDecision = {
|
|
177
|
+
decision: 'refused',
|
|
178
|
+
reason: 'rollout_needs_revision_not_dispatchable',
|
|
179
|
+
nextAction: 'Revision is handled by the automatic revision loop (rollout_reviewer reopens scribe/artificer). Inspect: pd runtime internalization list --json; advance manually: pd runtime internalization run-once --runner rollout_reviewer',
|
|
180
|
+
channel,
|
|
181
|
+
riskLevel: channel === 'code_tool_hook' ? 'high' : channel === 'skill' ? 'medium' : 'low',
|
|
182
|
+
};
|
|
183
|
+
if (opts.json) {
|
|
184
|
+
console.log(JSON.stringify(refused, null, 2));
|
|
185
|
+
} else {
|
|
186
|
+
console.log(formatTextOutput(refused));
|
|
187
|
+
console.log(' nextAction: ' + refused.nextAction);
|
|
188
|
+
}
|
|
189
|
+
process.exitCode = 1;
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
const rolloutDecision = extractRolloutDecisionFromArtifact(artifactRecord);
|
|
162
194
|
|
|
163
195
|
const artifactReadModel = {
|
|
@@ -99,6 +99,54 @@ function mapCommitDecisionToAction(
|
|
|
99
99
|
reason: 'task_not_found: task disappeared between scan and commit',
|
|
100
100
|
nextAction: 'Re-scan or investigate concurrent deletion',
|
|
101
101
|
};
|
|
102
|
+
case 'blocked_by_revision':
|
|
103
|
+
return {
|
|
104
|
+
taskId: commitResult.sourceTaskId,
|
|
105
|
+
taskKind: '',
|
|
106
|
+
decision: 'skipped',
|
|
107
|
+
reason: `blocked_by_revision: ${commitResult.reason} (runnerDecision=${commitResult.runnerDecision}); revision loop owns the out-edge`,
|
|
108
|
+
nextAction: 'Inspect the seeded repair/revision task: pd runtime internalization list --json',
|
|
109
|
+
};
|
|
110
|
+
case 'blocked_by_rejection':
|
|
111
|
+
return {
|
|
112
|
+
taskId: commitResult.sourceTaskId,
|
|
113
|
+
taskKind: '',
|
|
114
|
+
decision: 'skipped',
|
|
115
|
+
reason: `blocked_by_rejection: ${commitResult.reason} (runnerDecision=${commitResult.runnerDecision}); no successor, no approval`,
|
|
116
|
+
nextAction: 'Terminal reject — inspect the reviewer output or re-run the stage if the verdict was spurious',
|
|
117
|
+
};
|
|
118
|
+
case 'revision_reopened':
|
|
119
|
+
return {
|
|
120
|
+
taskId: commitResult.sourceTaskId,
|
|
121
|
+
taskKind: '',
|
|
122
|
+
decision: 'successor_created',
|
|
123
|
+
successorKind: 'evaluator',
|
|
124
|
+
successorTaskId: commitResult.reopenedTaskId,
|
|
125
|
+
};
|
|
126
|
+
case 'successor_reopened':
|
|
127
|
+
return {
|
|
128
|
+
taskId: commitResult.sourceTaskId,
|
|
129
|
+
taskKind: '',
|
|
130
|
+
decision: 'successor_created',
|
|
131
|
+
successorKind: commitResult.successorKind,
|
|
132
|
+
successorTaskId: commitResult.reopenedTaskId,
|
|
133
|
+
};
|
|
134
|
+
case 'revision_reopen_noop':
|
|
135
|
+
return {
|
|
136
|
+
taskId: commitResult.sourceTaskId,
|
|
137
|
+
taskKind: '',
|
|
138
|
+
decision: 'skipped',
|
|
139
|
+
reason: `revision_reopen_noop: ${commitResult.reason}; transition already materialized (same revision cause)`,
|
|
140
|
+
nextAction: 'No action — idempotent replay',
|
|
141
|
+
};
|
|
142
|
+
case 'blocked_missing_verdict':
|
|
143
|
+
return {
|
|
144
|
+
taskId: commitResult.taskId,
|
|
145
|
+
taskKind: '',
|
|
146
|
+
decision: 'skipped',
|
|
147
|
+
reason: `blocked_missing_verdict: ${commitResult.reason}; durable runnerDecision and legacy runs verdict both absent (fail-closed)`,
|
|
148
|
+
nextAction: 'Re-run the stage runner (writes durable verdict) or reconcile via the pending-artifacts report',
|
|
149
|
+
};
|
|
102
150
|
}
|
|
103
151
|
}
|
|
104
152
|
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pd runtime internalization retry — needs_human_review 的 Owner 出边
|
|
3
|
+
* (MVP_CORE_LOOP_CONTRACT INV-03: inspect / retry / revise / reject-archive)。
|
|
4
|
+
*
|
|
5
|
+
* 修复前 needs_human_review 是 display-only 单向终态 (审计 ISSUE-006)。
|
|
6
|
+
* 本命令把 needs_human_review 任务重新入队 (→ pending, attemptCount 重置),
|
|
7
|
+
* 由 auto-consumer / run-once 重新驱动。
|
|
8
|
+
*
|
|
9
|
+
* Owner retry = 显式人类 authority reset,与 crash retry 严格区分:
|
|
10
|
+
* crash / lease recovery / automatic retry 保留 completionIntent(入口门
|
|
11
|
+
* resume 原 verdict,零 LLM);Owner retry 必须同时清空 runnerDecision 与
|
|
12
|
+
* completionIntent,允许新一轮 LLM verdict 成为 authority——否则入口门会
|
|
13
|
+
* resume/finalize 旧 verdict,LLM 永不运行,Owner retry 实际失效。
|
|
14
|
+
*
|
|
15
|
+
* 落库形态: status/attemptCount 与清空后的 metadata 在同一次 updateTask
|
|
16
|
+
* (SQLite 单条 UPDATE) 中原子生效——两个独立写之间失败会留下
|
|
17
|
+
* "authority 已清但任务仍 needs_human_review" 的 partial Owner action。
|
|
18
|
+
* metadata 不可 hydrate 时 fail closed (metadata_invalid),不得只改 status。
|
|
19
|
+
*
|
|
20
|
+
* CLI gate: 默认 dry-run;--confirm 才落地 (cli-4);JSON 模式严格单对象 (cli-1);
|
|
21
|
+
* 失败路径不产生任何状态变更 (cli-5)。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
import { RuntimeStateManager } from '@principles/core/runtime-v2';
|
|
26
|
+
import { hydratePITaskRecord, createPITaskDiagnosticJson, mergePITaskMetadata } from '@principles/core/runtime-v2';
|
|
27
|
+
import { resolveWorkspaceDir } from '../resolve-workspace.js';
|
|
28
|
+
|
|
29
|
+
export interface InternalizationRetryOptions {
|
|
30
|
+
workspace?: string;
|
|
31
|
+
taskId?: string;
|
|
32
|
+
confirm?: boolean;
|
|
33
|
+
json?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface InternalizationRetryOutput {
|
|
37
|
+
status: 'requeued' | 'dry_run' | 'skipped' | 'failed';
|
|
38
|
+
taskId: string;
|
|
39
|
+
taskKind?: string;
|
|
40
|
+
previousStatus?: string;
|
|
41
|
+
reason?: string;
|
|
42
|
+
nextAction?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emit(out: InternalizationRetryOutput, json?: boolean): void {
|
|
46
|
+
if (json) {
|
|
47
|
+
console.log(JSON.stringify(out, null, 2));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
console.log(`Retry: ${out.status}${out.previousStatus ? ` (was ${out.previousStatus})` : ''}`);
|
|
51
|
+
if (out.reason) console.log(` reason: ${out.reason}`);
|
|
52
|
+
if (out.nextAction) console.log(` nextAction: ${out.nextAction}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function handleRuntimeInternalizationRetry(opts: InternalizationRetryOptions): Promise<void> {
|
|
56
|
+
if (!opts.taskId) {
|
|
57
|
+
const out: InternalizationRetryOutput = {
|
|
58
|
+
status: 'failed',
|
|
59
|
+
taskId: '',
|
|
60
|
+
reason: 'task_id_required',
|
|
61
|
+
nextAction: 'Pass --task <taskId> (find ids via: pd runtime internalization queue --json or pd errors list)',
|
|
62
|
+
};
|
|
63
|
+
emit(out, opts.json);
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const workspaceDir = opts.workspace ? path.resolve(opts.workspace) : resolveWorkspaceDir();
|
|
69
|
+
const stateManager = new RuntimeStateManager({ workspaceDir });
|
|
70
|
+
try {
|
|
71
|
+
await stateManager.initialize();
|
|
72
|
+
const task = await stateManager.getTask(opts.taskId);
|
|
73
|
+
if (!task) {
|
|
74
|
+
const out: InternalizationRetryOutput = {
|
|
75
|
+
status: 'failed',
|
|
76
|
+
taskId: opts.taskId,
|
|
77
|
+
reason: 'task_not_found',
|
|
78
|
+
nextAction: 'Verify the task id and workspace',
|
|
79
|
+
};
|
|
80
|
+
emit(out, opts.json);
|
|
81
|
+
process.exitCode = 1;
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (task.status !== 'needs_human_review') {
|
|
86
|
+
const out: InternalizationRetryOutput = {
|
|
87
|
+
status: 'skipped',
|
|
88
|
+
taskId: opts.taskId,
|
|
89
|
+
taskKind: task.taskKind,
|
|
90
|
+
previousStatus: task.status,
|
|
91
|
+
reason: 'only_needs_human_review_tasks_are_retryable',
|
|
92
|
+
nextAction: 'This task is not in the owner attention queue; use run-once / enqueue-successors instead',
|
|
93
|
+
};
|
|
94
|
+
emit(out, opts.json);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!opts.confirm) {
|
|
99
|
+
const out: InternalizationRetryOutput = {
|
|
100
|
+
status: 'dry_run',
|
|
101
|
+
taskId: opts.taskId,
|
|
102
|
+
taskKind: task.taskKind,
|
|
103
|
+
previousStatus: task.status,
|
|
104
|
+
reason: 'dry_run_no_mutation',
|
|
105
|
+
nextAction: 'Re-run with --confirm to requeue this task',
|
|
106
|
+
};
|
|
107
|
+
emit(out, opts.json);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Owner retry = authority reset: runnerDecision 与 completionIntent 同时
|
|
112
|
+
// 清空 (保留 revisionCount / revisionCauseId / rolloutRevisionPayload /
|
|
113
|
+
// repairPayload / lineage — revision budget 证据不动)。
|
|
114
|
+
const piTask = hydratePITaskRecord(task);
|
|
115
|
+
if (!piTask) {
|
|
116
|
+
// fail closed: 只改 status 会把(可能损坏的)旧 authority 记录原样留在
|
|
117
|
+
// metadata 里,下一次 run 由它接管 —— 产生 partial retry。
|
|
118
|
+
const out: InternalizationRetryOutput = {
|
|
119
|
+
status: 'failed',
|
|
120
|
+
taskId: opts.taskId,
|
|
121
|
+
taskKind: task.taskKind,
|
|
122
|
+
previousStatus: task.status,
|
|
123
|
+
reason: 'metadata_invalid',
|
|
124
|
+
nextAction: 'Task metadata failed PI hydration; a retry now would risk a partial authority reset. Inspect: pd runtime internalization integrity --json',
|
|
125
|
+
};
|
|
126
|
+
emit(out, opts.json);
|
|
127
|
+
process.exitCode = 1;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// 原子单写: 同一 patch 同时落 status=pending / attemptCount=0 / 清空后的
|
|
131
|
+
// diagnosticJson。updateTask 抛错时 DB 行保持原样(单条 UPDATE),无 partial reset。
|
|
132
|
+
const merged = mergePITaskMetadata(piTask, {
|
|
133
|
+
runnerDecision: undefined,
|
|
134
|
+
completionIntent: undefined,
|
|
135
|
+
});
|
|
136
|
+
await stateManager.updateTask(opts.taskId, {
|
|
137
|
+
status: 'pending',
|
|
138
|
+
attemptCount: 0,
|
|
139
|
+
diagnosticJson: createPITaskDiagnosticJson(merged),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const out: InternalizationRetryOutput = {
|
|
143
|
+
status: 'requeued',
|
|
144
|
+
taskId: opts.taskId,
|
|
145
|
+
taskKind: task.taskKind,
|
|
146
|
+
previousStatus: task.status,
|
|
147
|
+
nextAction: 'Task requeued; it will be picked up by the auto-consumer cycle, or advance manually: pd runtime internalization run-once',
|
|
148
|
+
};
|
|
149
|
+
emit(out, opts.json);
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const out: InternalizationRetryOutput = {
|
|
152
|
+
status: 'failed',
|
|
153
|
+
taskId: opts.taskId,
|
|
154
|
+
reason: err instanceof Error ? err.message : String(err),
|
|
155
|
+
nextAction: 'Check workspace DB integrity (pd runtime internalization integrity)',
|
|
156
|
+
};
|
|
157
|
+
emit(out, opts.json);
|
|
158
|
+
process.exitCode = 1;
|
|
159
|
+
} finally {
|
|
160
|
+
await stateManager.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
package/src/index.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { handleRuntimeUat } from './commands/runtime-uat.js';
|
|
|
33
33
|
import { handleRuntimeInternalizationQueue } from './commands/runtime-internalization-queue.js';
|
|
34
34
|
import { handleRuntimeInternalizationWakeOnce } from './commands/runtime-internalization-wake-once.js';
|
|
35
35
|
import { handleRuntimeInternalizationRunOnce } from './commands/runtime-internalization-run-once.js';
|
|
36
|
+
import { handleRuntimeInternalizationRetry } from './commands/runtime-internalization-retry.js';
|
|
36
37
|
import { registerRunRuleHostCommand } from './commands/runtime-internalization-run-rulehost.js';
|
|
37
38
|
import { handleCandidateList, handleCandidateShow, handleCandidateIntake, handleCandidateAudit, handleCandidateRepair, handleCandidateRoute, handleCandidateInternalize, handleCandidateInternalizationBackfill } from './commands/candidate.js';
|
|
38
39
|
import { handleArtifactShow } from './commands/artifact.js';
|
|
@@ -554,6 +555,17 @@ internalizationCmd
|
|
|
554
555
|
await handleRuntimeInternalizationWakeOnce({ workspace: opts.workspace, dryRun: opts.dryRun, json: opts.json });
|
|
555
556
|
});
|
|
556
557
|
|
|
558
|
+
internalizationCmd
|
|
559
|
+
.command('retry')
|
|
560
|
+
.description('Requeue a needs_human_review task (owner attention queue out-edge)')
|
|
561
|
+
.option('-w, --workspace <path>', 'Workspace directory')
|
|
562
|
+
.requiredOption('--task <taskId>', 'Task id to requeue')
|
|
563
|
+
.option('--confirm', 'Actually requeue (default is dry-run)')
|
|
564
|
+
.option('--json', 'Output as JSON')
|
|
565
|
+
.action(async (opts) => {
|
|
566
|
+
await handleRuntimeInternalizationRetry({ workspace: opts.workspace, taskId: opts.task, confirm: opts.confirm, json: opts.json });
|
|
567
|
+
});
|
|
568
|
+
|
|
557
569
|
internalizationCmd
|
|
558
570
|
.command('run-once')
|
|
559
571
|
.description('Wake-and-run: lease the next PI task and execute it')
|