@principles/pd-cli 1.114.0 → 1.115.0
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/services/demo-rule-compiler.d.ts.map +1 -1
- package/dist/services/demo-rule-compiler.js +30 -6
- package/dist/services/demo-rule-compiler.js.map +1 -1
- package/package.json +1 -1
- package/src/services/demo-rule-compiler.ts +35 -15
- package/tests/commands/run-rulehost-handler.test.ts +253 -0
- package/tests/services/demo-rule-compiler.test.ts +242 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"demo-rule-compiler.d.ts","sourceRoot":"","sources":["../../src/services/demo-rule-compiler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"demo-rule-compiler.d.ts","sourceRoot":"","sources":["../../src/services/demo-rule-compiler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAKH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,6BAA6B,CAAC;AAsCpE;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,gBAAgB,CA2BnF"}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* `loadRuleImplementationModule` for production code_tool_hook evaluation.
|
|
15
15
|
*/
|
|
16
16
|
import * as vm from 'node:vm';
|
|
17
|
-
import { safeStringifyPreview } from '@principles/core/runtime-v2';
|
|
17
|
+
import { safeStringifyPreview, validateCorrectionProposal } from '@principles/core/runtime-v2';
|
|
18
18
|
function normalizeSource(sourceCode) {
|
|
19
19
|
const withoutExports = sourceCode
|
|
20
20
|
.replace(/export\s+const\s+meta\s*=/, 'const meta =')
|
|
@@ -25,6 +25,29 @@ globalThis.__pdRuleModule = {
|
|
|
25
25
|
evaluate: typeof evaluate === 'undefined' ? undefined : evaluate,
|
|
26
26
|
};`;
|
|
27
27
|
}
|
|
28
|
+
function isRecord(value) {
|
|
29
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
30
|
+
}
|
|
31
|
+
function isRuleEvaluator(value) {
|
|
32
|
+
return typeof value === 'function';
|
|
33
|
+
}
|
|
34
|
+
function isRuleHostResult(value) {
|
|
35
|
+
if (!isRecord(value))
|
|
36
|
+
return false;
|
|
37
|
+
const decision = Object.hasOwn(value, 'decision') ? value.decision : undefined;
|
|
38
|
+
const matched = Object.hasOwn(value, 'matched') ? value.matched : undefined;
|
|
39
|
+
const reason = Object.hasOwn(value, 'reason') ? value.reason : undefined;
|
|
40
|
+
if (decision !== 'allow' && decision !== 'block' && decision !== 'requireApproval' && decision !== 'auto_correct') {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
if (typeof matched !== 'boolean' || typeof reason !== 'string')
|
|
44
|
+
return false;
|
|
45
|
+
if (decision === 'auto_correct') {
|
|
46
|
+
const proposal = Object.hasOwn(value, 'correctionProposal') ? value.correctionProposal : undefined;
|
|
47
|
+
return validateCorrectionProposal(proposal).valid;
|
|
48
|
+
}
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
28
51
|
/**
|
|
29
52
|
* Compile rule implementation code and return a typed evaluate function.
|
|
30
53
|
* Mirrors `createReplayEvaluateFromCode` in openclaw-plugin.
|
|
@@ -33,18 +56,19 @@ globalThis.__pdRuleModule = {
|
|
|
33
56
|
*/
|
|
34
57
|
export function compileDemoRule(code, sourceLabel) {
|
|
35
58
|
const context = vm.createContext(Object.create(null));
|
|
36
|
-
const script = new vm.Script(normalizeSource(code), {
|
|
59
|
+
const script = new vm.Script(normalizeSource(code), {
|
|
60
|
+
filename: sourceLabel,
|
|
61
|
+
});
|
|
37
62
|
script.runInContext(context, { timeout: 1000, displayErrors: true });
|
|
38
|
-
const moduleExports = context
|
|
39
|
-
.__pdRuleModule;
|
|
63
|
+
const moduleExports = context.__pdRuleModule;
|
|
40
64
|
delete context.__pdRuleModule;
|
|
41
|
-
if (!moduleExports ||
|
|
65
|
+
if (!moduleExports || !isRuleEvaluator(moduleExports.evaluate)) {
|
|
42
66
|
throw new Error(`[compileDemoRule] ${sourceLabel}: compiled module has no evaluate function`);
|
|
43
67
|
}
|
|
44
68
|
const evaluateFn = moduleExports.evaluate;
|
|
45
69
|
return (input, helpers) => {
|
|
46
70
|
const result = evaluateFn(input, helpers);
|
|
47
|
-
if (
|
|
71
|
+
if (!isRuleHostResult(result)) {
|
|
48
72
|
throw new Error(`[${sourceLabel}]: evaluate returned invalid RuleHostResult (got ${typeof result === 'object' && result !== null ? safeStringifyPreview(result) : String(result)})`);
|
|
49
73
|
}
|
|
50
74
|
return result;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"demo-rule-compiler.js","sourceRoot":"","sources":["../../src/services/demo-rule-compiler.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,MAAM,SAAS,CAAC;AAI9B,OAAO,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;
|
|
1
|
+
{"version":3,"file":"demo-rule-compiler.js","sourceRoot":"","sources":["../../src/services/demo-rule-compiler.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -18,7 +18,7 @@ import * as vm from 'node:vm';
|
|
|
18
18
|
import type { RuleHostInput, RuleHostResult } from '@principles/core/runtime-v2';
|
|
19
19
|
import type { RuleHostHelpers } from '@principles/core/runtime-v2';
|
|
20
20
|
import type { ReplayEvaluateFn } from '@principles/core/runtime-v2';
|
|
21
|
-
import { safeStringifyPreview } from '@principles/core/runtime-v2';
|
|
21
|
+
import { safeStringifyPreview, validateCorrectionProposal } from '@principles/core/runtime-v2';
|
|
22
22
|
|
|
23
23
|
function normalizeSource(sourceCode: string): string {
|
|
24
24
|
const withoutExports = sourceCode
|
|
@@ -32,6 +32,29 @@ globalThis.__pdRuleModule = {
|
|
|
32
32
|
};`;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
36
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRuleEvaluator(value: unknown): value is (input: RuleHostInput, helpers: RuleHostHelpers) => unknown {
|
|
40
|
+
return typeof value === 'function';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isRuleHostResult(value: unknown): value is RuleHostResult {
|
|
44
|
+
if (!isRecord(value)) return false;
|
|
45
|
+
const decision = Object.hasOwn(value, 'decision') ? value.decision : undefined;
|
|
46
|
+
const matched = Object.hasOwn(value, 'matched') ? value.matched : undefined;
|
|
47
|
+
const reason = Object.hasOwn(value, 'reason') ? value.reason : undefined;
|
|
48
|
+
if (decision !== 'allow' && decision !== 'block' && decision !== 'requireApproval' && decision !== 'auto_correct') {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (typeof matched !== 'boolean' || typeof reason !== 'string') return false;
|
|
52
|
+
if (decision === 'auto_correct') {
|
|
53
|
+
const proposal = Object.hasOwn(value, 'correctionProposal') ? value.correctionProposal : undefined;
|
|
54
|
+
return validateCorrectionProposal(proposal).valid;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
35
58
|
/**
|
|
36
59
|
* Compile rule implementation code and return a typed evaluate function.
|
|
37
60
|
* Mirrors `createReplayEvaluateFromCode` in openclaw-plugin.
|
|
@@ -40,30 +63,27 @@ globalThis.__pdRuleModule = {
|
|
|
40
63
|
*/
|
|
41
64
|
export function compileDemoRule(code: string, sourceLabel: string): ReplayEvaluateFn {
|
|
42
65
|
const context = vm.createContext(Object.create(null));
|
|
43
|
-
const script = new vm.Script(normalizeSource(code), {
|
|
66
|
+
const script = new vm.Script(normalizeSource(code), {
|
|
67
|
+
filename: sourceLabel,
|
|
68
|
+
});
|
|
44
69
|
|
|
45
70
|
script.runInContext(context, { timeout: 1000, displayErrors: true });
|
|
46
71
|
|
|
47
|
-
const moduleExports = (context as { __pdRuleModule?: { meta?: unknown; evaluate?: unknown } })
|
|
48
|
-
.__pdRuleModule;
|
|
72
|
+
const moduleExports = (context as { __pdRuleModule?: { meta?: unknown; evaluate?: unknown } }).__pdRuleModule;
|
|
49
73
|
delete (context as { __pdRuleModule?: unknown }).__pdRuleModule;
|
|
50
74
|
|
|
51
|
-
if (!moduleExports ||
|
|
52
|
-
throw new Error(
|
|
53
|
-
`[compileDemoRule] ${sourceLabel}: compiled module has no evaluate function`,
|
|
54
|
-
);
|
|
75
|
+
if (!moduleExports || !isRuleEvaluator(moduleExports.evaluate)) {
|
|
76
|
+
throw new Error(`[compileDemoRule] ${sourceLabel}: compiled module has no evaluate function`);
|
|
55
77
|
}
|
|
56
78
|
|
|
57
|
-
const evaluateFn = moduleExports.evaluate
|
|
58
|
-
input: RuleHostInput,
|
|
59
|
-
helpers: RuleHostHelpers,
|
|
60
|
-
) => RuleHostResult;
|
|
61
|
-
|
|
79
|
+
const evaluateFn = moduleExports.evaluate;
|
|
62
80
|
return (input: RuleHostInput, helpers: RuleHostHelpers): RuleHostResult => {
|
|
63
81
|
const result = evaluateFn(input, helpers);
|
|
64
|
-
if (
|
|
82
|
+
if (!isRuleHostResult(result)) {
|
|
65
83
|
throw new Error(
|
|
66
|
-
`[${sourceLabel}]: evaluate returned invalid RuleHostResult (got ${
|
|
84
|
+
`[${sourceLabel}]: evaluate returned invalid RuleHostResult (got ${
|
|
85
|
+
typeof result === 'object' && result !== null ? safeStringifyPreview(result) : String(result)
|
|
86
|
+
})`,
|
|
67
87
|
);
|
|
68
88
|
}
|
|
69
89
|
return result;
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handleRunRuleHost behaviour tests (PRI-429) — input validation gates,
|
|
3
|
+
* dry-run output, and capability branches.
|
|
4
|
+
*
|
|
5
|
+
* The existing flag-wiring test only proves Commander parses the flags.
|
|
6
|
+
* This file verifies the handler's behavioural gates:
|
|
7
|
+
* - mutual exclusivity of --dry-run and --confirm
|
|
8
|
+
* - required pain-id (and whitespace-only forms)
|
|
9
|
+
* - channel allowlist enforcement
|
|
10
|
+
* - --max-rounds / --timeout-ms positive-integer validation
|
|
11
|
+
* - default dry-run output (both plain-text and JSON)
|
|
12
|
+
* - --json output parses as a single JSON object
|
|
13
|
+
*
|
|
14
|
+
* These gates are the CLI-gate contract for run-rulehost. If any of them
|
|
15
|
+
* regress, operators silently get undefined behaviour instead of a clear
|
|
16
|
+
* error + next-action recommendation.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
import * as os from 'node:os';
|
|
22
|
+
import * as yaml from 'js-yaml';
|
|
23
|
+
import { handleRunRuleHost } from '../../src/commands/runtime-internalization-run-rulehost.js';
|
|
24
|
+
|
|
25
|
+
// ── workspace helpers ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function mkTmpDir(): string {
|
|
28
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'pd-rulehost-handler-'));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeMinimalValidConfig(workspaceDir: string): void {
|
|
32
|
+
const configDir = path.join(workspaceDir, '.pd');
|
|
33
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
34
|
+
const cfg = {
|
|
35
|
+
version: 1,
|
|
36
|
+
features: {
|
|
37
|
+
prompt: { category: 'core', enabled: true },
|
|
38
|
+
code_tool_hook: { category: 'core', enabled: true },
|
|
39
|
+
defer_archive: { category: 'core', enabled: true },
|
|
40
|
+
},
|
|
41
|
+
workspace: { default: workspaceDir },
|
|
42
|
+
runtimeProfiles: {
|
|
43
|
+
'pi-ai.default': { type: 'pi-ai', provider: 'anthropic', model: 'claude-sonnet', apiKeyEnv: 'ANTHROPIC_API_KEY' },
|
|
44
|
+
},
|
|
45
|
+
internalAgents: {
|
|
46
|
+
defaultRuntime: 'pi-ai.default',
|
|
47
|
+
agents: {
|
|
48
|
+
dreamer: { enabled: true, runtimeProfile: 'pi-ai.default' },
|
|
49
|
+
philosopher: { enabled: true, runtimeProfile: 'pi-ai.default' },
|
|
50
|
+
scribe: { enabled: true, runtimeProfile: 'pi-ai.default' },
|
|
51
|
+
evaluator: { enabled: true, runtimeProfile: 'pi-ai.default' },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
fs.writeFileSync(path.join(configDir, 'config.yaml'), yaml.dump(cfg), 'utf8');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── stdout/stderr capture helpers ──────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
interface StdIoState {
|
|
61
|
+
stdout: string;
|
|
62
|
+
stderr: string;
|
|
63
|
+
exitCode: number | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function parseJsonObject(text: string): Record<string, unknown> {
|
|
67
|
+
const parsed: unknown = JSON.parse(text);
|
|
68
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
69
|
+
throw new Error('stdout is not a JSON object');
|
|
70
|
+
}
|
|
71
|
+
return parsed;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function captureStdio(fn: () => Promise<void>): Promise<StdIoState> {
|
|
75
|
+
return new Promise((resolve, reject) => {
|
|
76
|
+
const origExitCode = process.exitCode;
|
|
77
|
+
process.exitCode = undefined;
|
|
78
|
+
let stdout = '';
|
|
79
|
+
let stderr = '';
|
|
80
|
+
const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation((chunk: string | Uint8Array) => {
|
|
81
|
+
stdout += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
82
|
+
return true;
|
|
83
|
+
});
|
|
84
|
+
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: string | Uint8Array) => {
|
|
85
|
+
stderr += typeof chunk === 'string' ? chunk : chunk.toString();
|
|
86
|
+
return true;
|
|
87
|
+
});
|
|
88
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
|
|
89
|
+
stderr += args.map((a) => (typeof a === 'string' ? a : String(a))).join(' ') + '\n';
|
|
90
|
+
});
|
|
91
|
+
fn()
|
|
92
|
+
.then(() => {
|
|
93
|
+
const captured = { stdout, stderr, exitCode: process.exitCode };
|
|
94
|
+
stdoutSpy.mockRestore();
|
|
95
|
+
stderrSpy.mockRestore();
|
|
96
|
+
consoleErrorSpy.mockRestore();
|
|
97
|
+
process.exitCode = origExitCode;
|
|
98
|
+
resolve(captured);
|
|
99
|
+
})
|
|
100
|
+
.catch((err) => {
|
|
101
|
+
stdoutSpy.mockRestore();
|
|
102
|
+
stderrSpy.mockRestore();
|
|
103
|
+
consoleErrorSpy.mockRestore();
|
|
104
|
+
process.exitCode = origExitCode;
|
|
105
|
+
reject(err);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('handleRunRuleHost — input validation gates', () => {
|
|
111
|
+
it('sets exitCode=1 and emits an error when both --dry-run and --confirm are passed (plain text)', async () => {
|
|
112
|
+
const { stdout, stderr, exitCode } = await captureStdio(() =>
|
|
113
|
+
handleRunRuleHost({ painId: 'pain-1', dryRun: true, confirm: true }),
|
|
114
|
+
);
|
|
115
|
+
expect(exitCode).toBe(1);
|
|
116
|
+
// plain-text mode: error on stderr includes 'mutually exclusive'
|
|
117
|
+
expect(stderr).toMatch(/mutually exclusive/i);
|
|
118
|
+
// stdout must not contain JSON object (handler writes to stderr).
|
|
119
|
+
expect(stdout.trim()).toBe('');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('sets exitCode=1 and emits a JSON object when both --dry-run and --confirm are passed with --json', async () => {
|
|
123
|
+
const { stdout, exitCode } = await captureStdio(() =>
|
|
124
|
+
handleRunRuleHost({ painId: 'pain-1', dryRun: true, confirm: true, json: true }),
|
|
125
|
+
);
|
|
126
|
+
expect(exitCode).toBe(1);
|
|
127
|
+
const payload = parseJsonObject(stdout.trim());
|
|
128
|
+
expect(payload.status).toBe('failed');
|
|
129
|
+
expect(payload.nextAction).toBeDefined();
|
|
130
|
+
expect(typeof payload.reason).toBe('string');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('sets exitCode=1 when --pain-id is missing (falsy string)', async () => {
|
|
134
|
+
const { exitCode } = await captureStdio(() => handleRunRuleHost({ painId: '' }));
|
|
135
|
+
expect(exitCode).toBe(1);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('sets exitCode=1 when --pain-id is whitespace-only', async () => {
|
|
139
|
+
const { exitCode } = await captureStdio(() => handleRunRuleHost({ painId: ' \t ' }));
|
|
140
|
+
expect(exitCode).toBe(1);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('sets exitCode=1 for unsupported channels (plain-text mode)', async () => {
|
|
144
|
+
const { exitCode, stderr } = await captureStdio(() =>
|
|
145
|
+
handleRunRuleHost({ painId: 'pain-1', channel: 'wizard' }),
|
|
146
|
+
);
|
|
147
|
+
expect(exitCode).toBe(1);
|
|
148
|
+
expect(stderr).toMatch(/wizard/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('sets exitCode=1 for unsupported channels (JSON mode)', async () => {
|
|
152
|
+
const { stdout, exitCode } = await captureStdio(() =>
|
|
153
|
+
handleRunRuleHost({ painId: 'pain-1', channel: 'wizard', json: true }),
|
|
154
|
+
);
|
|
155
|
+
expect(exitCode).toBe(1);
|
|
156
|
+
const payload = parseJsonObject(stdout.trim());
|
|
157
|
+
expect(payload.status).toBe('failed');
|
|
158
|
+
expect(payload.reason).toMatch(/wizard/);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('sets exitCode=1 when --max-rounds is zero', async () => {
|
|
162
|
+
const { exitCode } = await captureStdio(() =>
|
|
163
|
+
handleRunRuleHost({ painId: 'pain-1', maxRounds: 0, dryRun: true }));
|
|
164
|
+
expect(exitCode).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('sets exitCode=1 when --max-rounds is negative', async () => {
|
|
168
|
+
const { exitCode } = await captureStdio(() =>
|
|
169
|
+
handleRunRuleHost({ painId: 'pain-1', maxRounds: -5, dryRun: true }));
|
|
170
|
+
expect(exitCode).toBe(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('sets exitCode=1 when --timeout-ms is zero', async () => {
|
|
174
|
+
const { exitCode } = await captureStdio(() =>
|
|
175
|
+
handleRunRuleHost({ painId: 'pain-1', timeoutMs: 0, dryRun: true }));
|
|
176
|
+
expect(exitCode).toBe(1);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('sets exitCode=1 when --timeout-ms is negative', async () => {
|
|
180
|
+
const { exitCode } = await captureStdio(() =>
|
|
181
|
+
handleRunRuleHost({ painId: 'pain-1', timeoutMs: -100, dryRun: true }));
|
|
182
|
+
expect(exitCode).toBe(1);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it.each([
|
|
186
|
+
['NaN maxRounds', { maxRounds: Number.NaN }],
|
|
187
|
+
['fractional maxRounds', { maxRounds: 1.5 }],
|
|
188
|
+
['NaN timeoutMs', { timeoutMs: Number.NaN }],
|
|
189
|
+
['fractional timeoutMs', { timeoutMs: 1.5 }],
|
|
190
|
+
])('sets exitCode=1 for %s', async (_label, invalidOption) => {
|
|
191
|
+
const { exitCode } = await captureStdio(() =>
|
|
192
|
+
handleRunRuleHost({ painId: 'pain-1', dryRun: true, ...invalidOption }),
|
|
193
|
+
);
|
|
194
|
+
expect(exitCode).toBe(1);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe('handleRunRuleHost — dry-run mode output shape (with minimal pd-config.yaml', () => {
|
|
199
|
+
let workspaceDir: string;
|
|
200
|
+
let savedEnv: NodeJS.ProcessEnv;
|
|
201
|
+
|
|
202
|
+
beforeEach(() => {
|
|
203
|
+
workspaceDir = mkTmpDir();
|
|
204
|
+
writeMinimalValidConfig(workspaceDir);
|
|
205
|
+
savedEnv = { ...process.env };
|
|
206
|
+
process.env.ANTHROPIC_API_KEY = 'sk-ant-test-key';
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
afterEach(() => {
|
|
210
|
+
process.env = savedEnv;
|
|
211
|
+
try { fs.rmSync(workspaceDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('emits a single JSON object in --json dry-run mode', async () => {
|
|
215
|
+
const { stdout, exitCode } = await captureStdio(() =>
|
|
216
|
+
handleRunRuleHost({ painId: 'pain-1', dryRun: true, json: true, workspace: workspaceDir }),
|
|
217
|
+
);
|
|
218
|
+
const payload = parseJsonObject(stdout.trim());
|
|
219
|
+
expect(payload.status).toBe('dry_run');
|
|
220
|
+
expect(typeof payload.capabilityStatus).toBe('string');
|
|
221
|
+
expect(typeof payload.nextAction).toBe('string');
|
|
222
|
+
expect(payload.painId).toBe('pain-1');
|
|
223
|
+
// Must not be error-exit for a valid dry-run.
|
|
224
|
+
expect(exitCode).toBeUndefined();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does NOT emit JSON on stdout in plain-text dry-run mode', async () => {
|
|
228
|
+
const { stdout } = await captureStdio(() =>
|
|
229
|
+
handleRunRuleHost({ painId: 'pain-1', dryRun: true, workspace: workspaceDir }),
|
|
230
|
+
);
|
|
231
|
+
expect(stdout).toMatch(/RuleHost Pipeline/);
|
|
232
|
+
// Plain text output should not start with '{'
|
|
233
|
+
expect(() => JSON.parse(stdout.trim())).toThrow();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('defaults to dry-run behaviour when neither --dry-run nor --confirm is passed', async () => {
|
|
237
|
+
const { stdout } = await captureStdio(() =>
|
|
238
|
+
handleRunRuleHost({ painId: 'pain-1', json: true, workspace: workspaceDir }),
|
|
239
|
+
);
|
|
240
|
+
const payload = parseJsonObject(stdout.trim());
|
|
241
|
+
expect(payload.status).toBe('dry_run');
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('accepts the three supported channels and passes config', async () => {
|
|
245
|
+
for (const channel of ['prompt', 'code_tool_hook', 'defer_archive'] as const) {
|
|
246
|
+
const { exitCode } = await captureStdio(() =>
|
|
247
|
+
handleRunRuleHost({ painId: 'pain-1', channel, dryRun: true, workspace: workspaceDir }),
|
|
248
|
+
);
|
|
249
|
+
// Channel gate passed — exitCode should not be 1 for supported channels.
|
|
250
|
+
expect(exitCode).toBeUndefined();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
});
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compileDemoRule unit tests (PRI-429).
|
|
3
|
+
*
|
|
4
|
+
* The demo rule compiler is the sandbox adapter for run-rulehost's
|
|
5
|
+
* adversarial loop. It parses TypeScript rule implementations, extracts
|
|
6
|
+
* evaluate(), and validates the returned rule host result shape.
|
|
7
|
+
*
|
|
8
|
+
* Missing coverage would allow silent regression in the
|
|
9
|
+
* RefinerSandbox contract (evaluate output shape, invalid rule bodies,
|
|
10
|
+
* meta export shapes, polluted globals, etc.).
|
|
11
|
+
*
|
|
12
|
+
* ERR refs:
|
|
13
|
+
* - ERR-021: vm.Script runInContext must not leak globals
|
|
14
|
+
* - ERR-025: Object.hasOwn for untrusted output shape validation
|
|
15
|
+
* - ERR-037: non-object evaluate() return must throw loudly
|
|
16
|
+
*/
|
|
17
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
18
|
+
import { compileDemoRule } from '../../src/services/demo-rule-compiler.js';
|
|
19
|
+
import { createRuleHostHelpers } from '@principles/core/runtime-v2';
|
|
20
|
+
import type { ReplayEvaluateFn, RuleHostInput, RuleHostResult } from '@principles/core/runtime-v2';
|
|
21
|
+
|
|
22
|
+
const VALID_RULE = `
|
|
23
|
+
export const meta = {
|
|
24
|
+
id: 'r1',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
purpose: 'unit test',
|
|
27
|
+
};
|
|
28
|
+
export function evaluate(input, helpers) {
|
|
29
|
+
return { decision: 'allow', matched: false, reason: 'ok' };
|
|
30
|
+
}
|
|
31
|
+
`;
|
|
32
|
+
|
|
33
|
+
const NO_EVALUATE = `
|
|
34
|
+
export const meta = { id: 'r1' };
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const THROWING_EVALUATE = `
|
|
38
|
+
export function evaluate(input, helpers) {
|
|
39
|
+
throw new Error('boom');
|
|
40
|
+
}
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
const INVALID_RETURN_WRONG_DECISION = `
|
|
44
|
+
export function evaluate() {
|
|
45
|
+
return { decision: 'accepted', matched: false, reason: 'wrong decision enum' };
|
|
46
|
+
}
|
|
47
|
+
`;
|
|
48
|
+
|
|
49
|
+
const INVALID_RETURN_NO_MATCHED = `
|
|
50
|
+
export function evaluate() {
|
|
51
|
+
return { decision: 'allow', reason: 'missing matched' };
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const INVALID_RETURN_NO_DECISION = `
|
|
56
|
+
export function evaluate(input, helpers) {
|
|
57
|
+
return { reason: 'no-decision-field' };
|
|
58
|
+
}
|
|
59
|
+
`;
|
|
60
|
+
|
|
61
|
+
const INVALID_RETURN_PRIMITIVE = `
|
|
62
|
+
export function evaluate(input, helpers) {
|
|
63
|
+
return 42;
|
|
64
|
+
}
|
|
65
|
+
`;
|
|
66
|
+
|
|
67
|
+
const INVALID_RETURN_NULL = `
|
|
68
|
+
export function evaluate(input, helpers) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
const INVALID_RETURN_UNDEF = `
|
|
74
|
+
export function evaluate(input, helpers) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
`;
|
|
78
|
+
|
|
79
|
+
const INVALID_RETURN_STRING = `
|
|
80
|
+
export function evaluate(input, helpers) {
|
|
81
|
+
return 'accepted';
|
|
82
|
+
}
|
|
83
|
+
`;
|
|
84
|
+
|
|
85
|
+
const RULE_WITH_EVIDENCE = `
|
|
86
|
+
export const meta = { id: 'r-evidence' };
|
|
87
|
+
export function evaluate(input, helpers) {
|
|
88
|
+
const count = input.derived.estimatedLineChanges;
|
|
89
|
+
const matched = count > 0;
|
|
90
|
+
return { decision: matched ? 'block' : 'allow', matched, reason: 'based on changes', diagnostics: { count } };
|
|
91
|
+
}
|
|
92
|
+
`;
|
|
93
|
+
|
|
94
|
+
const RULE_WITH_HASOWN_POISON_PAYLOAD = `
|
|
95
|
+
export function evaluate(input, helpers) {
|
|
96
|
+
const poisoned = Object.create(null);
|
|
97
|
+
poisoned.decision = 'allow';
|
|
98
|
+
poisoned.matched = false;
|
|
99
|
+
poisoned.reason = 'ok';
|
|
100
|
+
return poisoned;
|
|
101
|
+
}
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
function makeRuleHostInput(estimatedLineChanges = 0): RuleHostInput {
|
|
105
|
+
return {
|
|
106
|
+
action: {
|
|
107
|
+
toolName: 'write_file',
|
|
108
|
+
normalizedPath: '/workspace/a.ts',
|
|
109
|
+
paramsSummary: {},
|
|
110
|
+
},
|
|
111
|
+
workspace: { isRiskPath: false, planStatus: 'READY', hasPlanFile: true },
|
|
112
|
+
session: { currentGfi: 0, recentThinking: true },
|
|
113
|
+
evolution: { epTier: 0 },
|
|
114
|
+
derived: { estimatedLineChanges, bashRisk: 'safe' },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function evaluateRule(evaluate: ReplayEvaluateFn, input: RuleHostInput = makeRuleHostInput()): RuleHostResult {
|
|
119
|
+
return evaluate(input, createRuleHostHelpers(input));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
describe('compileDemoRule', () => {
|
|
123
|
+
describe('source normalization', () => {
|
|
124
|
+
it('compiles a syntactically valid rule module', () => {
|
|
125
|
+
const fn = compileDemoRule(VALID_RULE, 'valid-rule.ts');
|
|
126
|
+
expect(typeof fn).toBe('function');
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('throws on code missing evaluate function', () => {
|
|
130
|
+
expect(() => compileDemoRule(NO_EVALUATE, 'no-evaluate.ts')).toThrow(/evaluate/);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('propagates syntax errors from vm.Script', () => {
|
|
134
|
+
expect(() => compileDemoRule('this is not valid {{{', 'bad.ts')).toThrow();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe('evaluate output shape validation', () => {
|
|
139
|
+
it('returns a fully validated RuleHostResult', () => {
|
|
140
|
+
const fn = compileDemoRule(VALID_RULE, 'valid-rule.ts');
|
|
141
|
+
const result = evaluateRule(fn);
|
|
142
|
+
expect(result).toEqual({
|
|
143
|
+
decision: 'allow',
|
|
144
|
+
matched: false,
|
|
145
|
+
reason: 'ok',
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('throws when evaluate returns an object without a decision field (ERR-037)', () => {
|
|
150
|
+
const fn = compileDemoRule(INVALID_RETURN_NO_DECISION, 'no-decision.ts');
|
|
151
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('rejects objects using a non-RuleHost decision enum', () => {
|
|
155
|
+
const fn = compileDemoRule(INVALID_RETURN_WRONG_DECISION, 'wrong-decision.ts');
|
|
156
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('rejects objects missing the required matched flag', () => {
|
|
160
|
+
const fn = compileDemoRule(INVALID_RETURN_NO_MATCHED, 'missing-matched.ts');
|
|
161
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('throws when evaluate returns a number (non-object)', () => {
|
|
165
|
+
const fn = compileDemoRule(INVALID_RETURN_PRIMITIVE, 'primitive.ts');
|
|
166
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('throws when evaluate returns null', () => {
|
|
170
|
+
const fn = compileDemoRule(INVALID_RETURN_NULL, 'null-return.ts');
|
|
171
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('throws when evaluate returns undefined', () => {
|
|
175
|
+
const fn = compileDemoRule(INVALID_RETURN_UNDEF, 'undef-return.ts');
|
|
176
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('throws when evaluate returns a string', () => {
|
|
180
|
+
const fn = compileDemoRule(INVALID_RETURN_STRING, 'string-return.ts');
|
|
181
|
+
expect(() => evaluateRule(fn)).toThrow(/invalid RuleHostResult/);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it('handles Object.create(null) output (no prototype) via Object.hasOwn (ERR-025)', () => {
|
|
185
|
+
const fn = compileDemoRule(RULE_WITH_HASOWN_POISON_PAYLOAD, 'hasown-poison.ts');
|
|
186
|
+
const result = evaluateRule(fn);
|
|
187
|
+
expect(result.decision).toBe('allow');
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe('evaluate behaviour', () => {
|
|
192
|
+
it('propagates evaluate() exceptions to the caller (fail loud)', () => {
|
|
193
|
+
const fn = compileDemoRule(THROWING_EVALUATE, 'throwing.ts');
|
|
194
|
+
expect(() => evaluateRule(fn)).toThrow(/boom/);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('reads real RuleHostInput fields and returns different decisions', () => {
|
|
198
|
+
const fn = compileDemoRule(RULE_WITH_EVIDENCE, 'evidence-rule.ts');
|
|
199
|
+
const withChanges = evaluateRule(fn, makeRuleHostInput(3));
|
|
200
|
+
const withoutChanges = evaluateRule(fn);
|
|
201
|
+
expect(withChanges.decision).toBe('block');
|
|
202
|
+
expect(withoutChanges.decision).toBe('allow');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('vm sandbox isolation', () => {
|
|
207
|
+
it('does not pollute Node.js globalThis between invocations (ERR-021)', () => {
|
|
208
|
+
const polluter = `
|
|
209
|
+
export function evaluate() {
|
|
210
|
+
globalThis.__pd_leaked_test = 1;
|
|
211
|
+
return { decision: 'block', matched: true, reason: 'polluting' };
|
|
212
|
+
}
|
|
213
|
+
`;
|
|
214
|
+
expect(Reflect.get(globalThis, '__pd_leaked_test')).toBeUndefined();
|
|
215
|
+
const fn = compileDemoRule(polluter, 'polluter.ts');
|
|
216
|
+
evaluateRule(fn);
|
|
217
|
+
const leakedValue = Reflect.get(globalThis, '__pd_leaked_test');
|
|
218
|
+
// The sandboxed __pdRuleModule temporary assignment must not leak
|
|
219
|
+
// arbitrary user-defined globals.
|
|
220
|
+
expect(leakedValue).toBeUndefined();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('removes the __pdRuleModule helper from the sandbox after compilation', () => {
|
|
224
|
+
// This indirectly asserts the cleanup path — a second compilation
|
|
225
|
+
// that does not export evaluate still throws rather than returning
|
|
226
|
+
// a stale value from the first run.
|
|
227
|
+
compileDemoRule(VALID_RULE, 'first.ts');
|
|
228
|
+
expect(() => compileDemoRule(NO_EVALUATE, 'second.ts')).toThrow(/evaluate/);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
describe('sourceLabel is threaded into error messages', () => {
|
|
233
|
+
it('includes sourceLabel when evaluate() returns invalid output', () => {
|
|
234
|
+
const fn = compileDemoRule(INVALID_RETURN_PRIMITIVE, 'labeled-42.ts');
|
|
235
|
+
expect(() => evaluateRule(fn)).toThrow(/labeled-42\.ts/);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it('includes sourceLabel when evaluate export is missing', () => {
|
|
239
|
+
expect(() => compileDemoRule(NO_EVALUATE, 'no-eval-source-label.ts')).toThrow(/no-eval-source-label\.ts/);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
});
|