principles-disciple 1.124.0 → 1.126.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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/core/event-log.ts +13 -0
- package/src/core/rule-host.ts +99 -9
- package/src/core/rule-implementation-runtime.ts +110 -3
- package/src/types/event-types.ts +1 -0
- package/src/utils/io.ts +23 -5
- package/tests/core/rule-host-adversarial-output.test.ts +242 -0
- package/tests/core/rule-host-autocorrect-vm.test.ts +163 -0
- package/tests/core/rule-host-resource-bounds.test.ts +231 -0
- package/tests/core/rule-host-unhealthy-visibility.test.ts +261 -0
- package/tests/core/rule-host-validation.test.ts +315 -0
- package/tests/core/rule-implementation-runtime.test.ts +12 -0
- package/tests/hooks/gate-rule-host-real-pipeline.test.ts +190 -0
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.126.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
package/src/core/event-log.ts
CHANGED
|
@@ -28,6 +28,7 @@ import type {
|
|
|
28
28
|
RuleHostAutoCorrectProposedEventData,
|
|
29
29
|
RuleHostAutoCorrectAppliedEventData,
|
|
30
30
|
RuntimeV2PromptActivationsInjectedEventData,
|
|
31
|
+
RuleHostUnhealthyEventData,
|
|
31
32
|
} from '../types/event-types.js';
|
|
32
33
|
import { createEmptyDailyStats } from '../types/event-types.js';
|
|
33
34
|
import { atomicWriteFileSync } from '../utils/io.js';
|
|
@@ -210,6 +211,18 @@ export class EventLog {
|
|
|
210
211
|
this.record('runtime_v2_prompt_activations_injected', 'injected', data.sessionId, data);
|
|
211
212
|
}
|
|
212
213
|
|
|
214
|
+
/**
|
|
215
|
+
* PRI-437: Record that an approved rule failed to compile or load.
|
|
216
|
+
*
|
|
217
|
+
* This is NOT just a logger.warn — the unhealthy state is persisted to EventLog
|
|
218
|
+
* so it's visible to CLI (pd runtime health) and Console API.
|
|
219
|
+
*
|
|
220
|
+
* ERR-002: degradation includes a reason and nextAction (not silent).
|
|
221
|
+
*/
|
|
222
|
+
recordRuleHostUnhealthy(data: RuleHostUnhealthyEventData): void {
|
|
223
|
+
this.record('rulehost_unhealthy', 'failure', undefined, data);
|
|
224
|
+
}
|
|
225
|
+
|
|
213
226
|
/**
|
|
214
227
|
* Redact telemetry-sensitive string values in event data before persistence.
|
|
215
228
|
* Applies redactTelemetryString to known high-risk fields (filePath, command,
|
package/src/core/rule-host.ts
CHANGED
|
@@ -26,8 +26,10 @@
|
|
|
26
26
|
|
|
27
27
|
import { createRuleHostHelpers } from '@principles/core/runtime-v2';
|
|
28
28
|
import { mergeDecisions } from '@principles/core/runtime-v2';
|
|
29
|
+
import { validateRuleHostResult } from '@principles/core/runtime-v2';
|
|
29
30
|
import { SqliteConnection } from '@principles/core/runtime-v2';
|
|
30
31
|
import { loadRuleImplementationModule } from './rule-implementation-runtime.js';
|
|
32
|
+
import { EventLogService } from './event-log.js';
|
|
31
33
|
import type {
|
|
32
34
|
RuleHostInput,
|
|
33
35
|
RuleHostResult,
|
|
@@ -64,6 +66,7 @@ export class RuleHost {
|
|
|
64
66
|
private readonly stateDir: string;
|
|
65
67
|
private readonly logger: RuleHostLogger;
|
|
66
68
|
private readonly workspaceDir: string | null;
|
|
69
|
+
private readonly implementationSources = new Map<string, { activationId: string; artifactId: string; ruleId: string }>();
|
|
67
70
|
|
|
68
71
|
constructor(stateDir: string, logger: RuleHostLogger = console, options?: RuleHostOptions) {
|
|
69
72
|
this.stateDir = stateDir;
|
|
@@ -83,7 +86,23 @@ export class RuleHost {
|
|
|
83
86
|
evaluate(input: RuleHostInput): RuleHostResult | undefined {
|
|
84
87
|
try {
|
|
85
88
|
const activeImpls = this._loadActiveCodeImplementations();
|
|
86
|
-
return mergeDecisions(activeImpls, input,
|
|
89
|
+
return mergeDecisions(activeImpls, input, {
|
|
90
|
+
warn: this.logger.warn,
|
|
91
|
+
onImplementationUnhealthy: (impl, reason) => {
|
|
92
|
+
const source = this.implementationSources.get(impl.implId);
|
|
93
|
+
if (!source) {
|
|
94
|
+
this.logger.warn?.(`[RuleHost] No source mapping for implId=${impl.implId}, cannot record unhealthy event`);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this._recordUnhealthy(
|
|
98
|
+
source.activationId,
|
|
99
|
+
source.artifactId,
|
|
100
|
+
source.ruleId,
|
|
101
|
+
reason,
|
|
102
|
+
'Fix the RuleCode runtime error or return shape, then re-activate the rule',
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
});
|
|
87
106
|
} catch (hostError: unknown) {
|
|
88
107
|
// Conservative degradation: log and return undefined (D-08)
|
|
89
108
|
this.logger.warn?.(
|
|
@@ -134,6 +153,7 @@ export class RuleHost {
|
|
|
134
153
|
* All data from SQLite is treated as unknown and validated before use.
|
|
135
154
|
*/
|
|
136
155
|
private _loadFromActivationsTable(workspaceDir: string): LoadedImplementation[] {
|
|
156
|
+
this.implementationSources.clear();
|
|
137
157
|
const sqliteConn = new SqliteConnection(workspaceDir);
|
|
138
158
|
try {
|
|
139
159
|
const db = sqliteConn.getDb();
|
|
@@ -197,6 +217,19 @@ export class RuleHost {
|
|
|
197
217
|
`reason=at most one active activation per rule is allowed ` +
|
|
198
218
|
`nextAction=deactivate all but one activation for this target_ref`
|
|
199
219
|
);
|
|
220
|
+
for (const r of group) {
|
|
221
|
+
const activationId = typeof r['activation_id'] === 'string' ? r['activation_id'] : '';
|
|
222
|
+
const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
|
|
223
|
+
if (activationId && artifactId) {
|
|
224
|
+
this._recordUnhealthy(
|
|
225
|
+
activationId,
|
|
226
|
+
artifactId,
|
|
227
|
+
targetRef,
|
|
228
|
+
`duplicate active activation for target_ref ${targetRef}`,
|
|
229
|
+
'Deactivate all but one activation for this target_ref',
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
200
233
|
} else {
|
|
201
234
|
validRows.push(group[0]);
|
|
202
235
|
}
|
|
@@ -241,10 +274,13 @@ export class RuleHost {
|
|
|
241
274
|
const implId = `act-impl-${activationId}`;
|
|
242
275
|
const moduleExports = loadRuleImplementationModule(implementationCode, implId);
|
|
243
276
|
|
|
244
|
-
if (!moduleExports || typeof moduleExports.
|
|
277
|
+
if (!moduleExports || typeof moduleExports.callEvaluate !== 'function') {
|
|
278
|
+
const reason = 'compiled module has no evaluate function';
|
|
245
279
|
this.logger.warn?.(
|
|
246
|
-
`[RuleHost] Activation ${activationId}:
|
|
280
|
+
`[RuleHost] Activation ${activationId}: ${reason}, skipping`
|
|
247
281
|
);
|
|
282
|
+
this._recordUnhealthy(activationId, artifactId, ruleId, reason,
|
|
283
|
+
'Fix the RuleCode to export an evaluate(input, helpers) function, then re-activate');
|
|
248
284
|
continue;
|
|
249
285
|
}
|
|
250
286
|
|
|
@@ -258,10 +294,11 @@ export class RuleHost {
|
|
|
258
294
|
? moduleExports.meta
|
|
259
295
|
: fallbackMeta;
|
|
260
296
|
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
297
|
+
// PRI-437: Use callEvaluate (vm-context-bounded) instead of raw evaluate.
|
|
298
|
+
// callEvaluate runs the invocation INSIDE the vm context with a time
|
|
299
|
+
// boundary, terminating infinite loops and excessive computation.
|
|
300
|
+
const boundedCallEvaluate = moduleExports.callEvaluate;
|
|
301
|
+
this.implementationSources.set(implId, { activationId, artifactId, ruleId });
|
|
265
302
|
|
|
266
303
|
loaded.push({
|
|
267
304
|
implId,
|
|
@@ -269,7 +306,15 @@ export class RuleHost {
|
|
|
269
306
|
meta,
|
|
270
307
|
evaluate: (input: RuleHostInput): RuleHostResult => {
|
|
271
308
|
const frozenHelpers = createRuleHostHelpers(input);
|
|
272
|
-
|
|
309
|
+
// PRI-437: Execute inside vm context with timeout boundary.
|
|
310
|
+
const rawResult = boundedCallEvaluate(input, frozenHelpers);
|
|
311
|
+
const validation = validateRuleHostResult(rawResult);
|
|
312
|
+
if (!validation.valid) {
|
|
313
|
+
throw new Error(
|
|
314
|
+
`[RuleHost] Activation ${activationId} returned invalid RuleHostResult: ${validation.errors.join('; ')}`
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
const result = rawResult as RuleHostResult;
|
|
273
318
|
if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
|
|
274
319
|
result.ruleId = ruleId;
|
|
275
320
|
result.principleId = meta.ruleId ?? ruleId;
|
|
@@ -278,9 +323,16 @@ export class RuleHost {
|
|
|
278
323
|
},
|
|
279
324
|
});
|
|
280
325
|
} catch (loadError: unknown) {
|
|
326
|
+
const reason = `compilation failed: ${String(loadError)}`;
|
|
281
327
|
this.logger.warn?.(
|
|
282
|
-
`[RuleHost] Failed to load activation ${activationId}: ${
|
|
328
|
+
`[RuleHost] Failed to load activation ${activationId}: ${reason}`
|
|
283
329
|
);
|
|
330
|
+
// ruleId is declared inside the try block and may not be assigned yet;
|
|
331
|
+
// fall back to sourceRuleId or artifactId (both available in scope)
|
|
332
|
+
this._recordUnhealthy(activationId, artifactId,
|
|
333
|
+
sourceRuleId ?? artifactId,
|
|
334
|
+
reason,
|
|
335
|
+
'Fix the RuleCode syntax/compilation error, then re-activate the rule');
|
|
284
336
|
}
|
|
285
337
|
}
|
|
286
338
|
|
|
@@ -293,4 +345,42 @@ export class RuleHost {
|
|
|
293
345
|
}
|
|
294
346
|
}
|
|
295
347
|
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* PRI-437: Record an unhealthy activation state to EventLog.
|
|
351
|
+
*
|
|
352
|
+
* This makes compile/load failures visible to CLI (pd runtime health) and
|
|
353
|
+
* Console API — NOT just a logger.warn that's silently skipped.
|
|
354
|
+
*
|
|
355
|
+
* ERR-002: degradation includes a reason and nextAction (not silent).
|
|
356
|
+
* Failures in EventLog recording are caught and logged (never throw).
|
|
357
|
+
*/
|
|
358
|
+
private _recordUnhealthy(
|
|
359
|
+
activationId: string,
|
|
360
|
+
artifactId: string,
|
|
361
|
+
ruleId: string,
|
|
362
|
+
reason: string,
|
|
363
|
+
nextAction: string,
|
|
364
|
+
): void {
|
|
365
|
+
try {
|
|
366
|
+
// Pass undefined as logger: RuleHostLogger only has warn(), but EventLog
|
|
367
|
+
// calls this.logger.error() without optional chaining. Passing the
|
|
368
|
+
// RuleHostLogger directly would cause TypeError if EventLog tried to
|
|
369
|
+
// log an internal error. EventLog's logger is optional; RuleHost already
|
|
370
|
+
// logs its own warnings for the unhealthy event.
|
|
371
|
+
const eventLog = EventLogService.get(this.stateDir);
|
|
372
|
+
eventLog.recordRuleHostUnhealthy({
|
|
373
|
+
activationId,
|
|
374
|
+
artifactId,
|
|
375
|
+
ruleId,
|
|
376
|
+
reason,
|
|
377
|
+
nextAction,
|
|
378
|
+
});
|
|
379
|
+
} catch (recordError: unknown) {
|
|
380
|
+
// EventLog recording must never break RuleHost evaluation
|
|
381
|
+
this.logger.warn?.(
|
|
382
|
+
`[RuleHost] Failed to record unhealthy event for activation ${activationId}: ${String(recordError)}`
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
296
386
|
}
|
|
@@ -1,10 +1,58 @@
|
|
|
1
1
|
import { nodeVm } from '../utils/node-vm-polyfill.js';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
|
|
3
4
|
export interface RuleImplementationModuleExports {
|
|
4
5
|
meta?: unknown;
|
|
5
6
|
evaluate?: unknown;
|
|
7
|
+
/**
|
|
8
|
+
* Call evaluate(input, helpers) INSIDE the vm context with a time boundary.
|
|
9
|
+
*
|
|
10
|
+
* PRI-437: The evaluate() function extracted from a vm context executes in
|
|
11
|
+
* the vm realm, but a direct host-realm call has NO timeout protection —
|
|
12
|
+
* an infinite loop in RuleCode would hang the host process forever.
|
|
13
|
+
*
|
|
14
|
+
* callEvaluate runs the invocation inside the vm context via
|
|
15
|
+
* Script.runInContext({ timeout }), which terminates infinite loops.
|
|
16
|
+
*
|
|
17
|
+
* Throws on timeout, compilation error, or if evaluate is missing.
|
|
18
|
+
*/
|
|
19
|
+
callEvaluate?: (input: unknown, helpers: unknown) => unknown;
|
|
6
20
|
}
|
|
7
21
|
|
|
22
|
+
/** Timeout (ms) for compiling RuleCode (defining evaluate + meta). */
|
|
23
|
+
const COMPILE_TIMEOUT_MS = 1000;
|
|
24
|
+
|
|
25
|
+
/** Timeout (ms) for executing evaluate(input, helpers) inside the vm. */
|
|
26
|
+
const EVALUATE_PROCESS_TIMEOUT_MS = 3000;
|
|
27
|
+
const EVALUATE_PROCESS_OUTPUT_BYTES = 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
const EVALUATION_PROCESS_SOURCE = String.raw`
|
|
30
|
+
const vm = require('node:vm');
|
|
31
|
+
try {
|
|
32
|
+
const workerData = JSON.parse(require('node:fs').readFileSync(0, 'utf8'));
|
|
33
|
+
const context = vm.createContext(Object.create(null));
|
|
34
|
+
new vm.Script(workerData.source, { filename: workerData.filename }).runInContext(context, { timeout: 1000 });
|
|
35
|
+
const input = workerData.input;
|
|
36
|
+
const helpers = Object.freeze({
|
|
37
|
+
isRiskPath: () => input.workspace.isRiskPath,
|
|
38
|
+
getToolName: () => input.action.toolName,
|
|
39
|
+
getEstimatedLineChanges: () => input.derived.estimatedLineChanges,
|
|
40
|
+
getBashRisk: () => input.derived.bashRisk,
|
|
41
|
+
hasPlanFile: () => input.workspace.hasPlanFile,
|
|
42
|
+
getPlanStatus: () => input.workspace.planStatus,
|
|
43
|
+
getEpTier: () => input.evolution.epTier,
|
|
44
|
+
});
|
|
45
|
+
context.__pdCallInput = input;
|
|
46
|
+
context.__pdCallHelpers = helpers;
|
|
47
|
+
const result = new vm.Script('__pdRuleModule.evaluate(__pdCallInput, __pdCallHelpers)', { filename: workerData.filename + '.call' })
|
|
48
|
+
.runInContext(context, { timeout: 1000 });
|
|
49
|
+
process.stdout.write(JSON.stringify({ ok: true, result }));
|
|
50
|
+
} catch (error) {
|
|
51
|
+
process.stdout.write(JSON.stringify({ ok: false, error: String(error) }));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
}
|
|
54
|
+
`;
|
|
55
|
+
|
|
8
56
|
function normalizeImplementationSource(sourceCode: string): string {
|
|
9
57
|
const withoutExports = sourceCode
|
|
10
58
|
.replace(/export\s+const\s+meta\s*=/, 'const meta =')
|
|
@@ -17,6 +65,10 @@ globalThis.__pdRuleModule = {
|
|
|
17
65
|
};`;
|
|
18
66
|
}
|
|
19
67
|
|
|
68
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
69
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
70
|
+
}
|
|
71
|
+
|
|
20
72
|
export function loadRuleImplementationModule(
|
|
21
73
|
sourceCode: string,
|
|
22
74
|
filename: string,
|
|
@@ -26,13 +78,68 @@ export function loadRuleImplementationModule(
|
|
|
26
78
|
filename,
|
|
27
79
|
});
|
|
28
80
|
|
|
81
|
+
// Compile phase: define evaluate + meta (timeout-bounded)
|
|
29
82
|
script.runInContext(context, {
|
|
30
|
-
timeout:
|
|
83
|
+
timeout: COMPILE_TIMEOUT_MS,
|
|
31
84
|
displayErrors: true,
|
|
32
85
|
});
|
|
33
86
|
|
|
34
87
|
const moduleExports = (context as { __pdRuleModule?: RuleImplementationModuleExports }).__pdRuleModule;
|
|
35
|
-
|
|
88
|
+
// Note: keep __pdRuleModule on the context so callEvaluate can reference it.
|
|
89
|
+
// We do NOT delete it here — it's needed for subsequent evaluate calls.
|
|
90
|
+
|
|
91
|
+
const hasEvaluate = typeof moduleExports?.evaluate === 'function';
|
|
92
|
+
const meta = moduleExports?.meta;
|
|
93
|
+
|
|
94
|
+
if (!hasEvaluate) {
|
|
95
|
+
// No evaluate function — return early with no callEvaluate
|
|
96
|
+
return { meta, evaluate: moduleExports?.evaluate };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// PRI-437: Create a context-aware caller that runs evaluate INSIDE the vm
|
|
100
|
+
// context with a time boundary. This terminates infinite loops and
|
|
101
|
+
// excessive computation that would otherwise hang the host process.
|
|
102
|
+
//
|
|
103
|
+
// The callEvaluate function:
|
|
104
|
+
// 1. Sets input and helpers as context globals (sandboxed)
|
|
105
|
+
// 2. Compiles a tiny call script
|
|
106
|
+
// 3. Runs it in the context with EVALUATE_TIMEOUT_MS
|
|
107
|
+
// 4. Cleans up globals
|
|
108
|
+
// 5. Returns the raw result (validation happens in the caller)
|
|
109
|
+
const normalizedSource = normalizeImplementationSource(sourceCode);
|
|
110
|
+
const callEvaluate = (input: unknown, _helpers: unknown): unknown => {
|
|
111
|
+
const child = spawnSync(process.execPath, [
|
|
112
|
+
'--max-old-space-size=32',
|
|
113
|
+
'-e',
|
|
114
|
+
EVALUATION_PROCESS_SOURCE,
|
|
115
|
+
], {
|
|
116
|
+
input: JSON.stringify({ source: normalizedSource, filename, input }),
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: EVALUATE_PROCESS_TIMEOUT_MS,
|
|
119
|
+
maxBuffer: EVALUATE_PROCESS_OUTPUT_BYTES,
|
|
120
|
+
windowsHide: true,
|
|
121
|
+
});
|
|
122
|
+
if (child.error) {
|
|
123
|
+
throw new Error(`RuleCode process failed: ${child.error.message}`);
|
|
124
|
+
}
|
|
125
|
+
let parsed: unknown;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(child.stdout);
|
|
128
|
+
} catch {
|
|
129
|
+
const stderr = child.stderr.trim().slice(0, 500);
|
|
130
|
+
throw new Error(`RuleCode process exited without a valid result${stderr ? `: ${stderr}` : ''}`);
|
|
131
|
+
}
|
|
132
|
+
if (!isRecord(parsed) || !Object.hasOwn(parsed, 'ok') || parsed['ok'] !== true || !Object.hasOwn(parsed, 'result')) {
|
|
133
|
+
const reason = isRecord(parsed) && Object.hasOwn(parsed, 'error') && typeof parsed['error'] === 'string'
|
|
134
|
+
? parsed['error'] : 'RuleCode process failed';
|
|
135
|
+
throw new Error(reason);
|
|
136
|
+
}
|
|
137
|
+
return parsed['result'];
|
|
138
|
+
};
|
|
36
139
|
|
|
37
|
-
return
|
|
140
|
+
return {
|
|
141
|
+
meta,
|
|
142
|
+
evaluate: moduleExports?.evaluate,
|
|
143
|
+
callEvaluate,
|
|
144
|
+
};
|
|
38
145
|
}
|
package/src/types/event-types.ts
CHANGED
package/src/utils/io.ts
CHANGED
|
@@ -66,16 +66,34 @@ export function normalizePath(filePath: string, projectDir: string): string {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
// POSIX absolute paths (e.g., /etc/passwd) are NOT recognized as absolute
|
|
70
|
+
// by path.isAbsolute() on Windows. When both the project dir and the file
|
|
71
|
+
// path are POSIX paths, use path.posix for relative computation.
|
|
72
|
+
const isPosixAbsolute = normalizedFilePath.startsWith('/') && !fileIsWin;
|
|
73
|
+
const projectIsPosix = !projectIsWin;
|
|
74
|
+
|
|
71
75
|
let rel: string;
|
|
72
76
|
if (projectIsWin) {
|
|
73
77
|
const projectAbs = path.resolve(projectDir);
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
if (isPosixAbsolute) {
|
|
79
|
+
// POSIX absolute path on Windows project: cannot resolve relative to project,
|
|
80
|
+
// return as-is (path is outside the project directory).
|
|
81
|
+
rel = normalizedFilePath;
|
|
82
|
+
} else {
|
|
83
|
+
const fileAbs = path.isAbsolute(normalizedFilePath)
|
|
84
|
+
? normalizedFilePath
|
|
85
|
+
: path.join(projectAbs, normalizedFilePath);
|
|
86
|
+
rel = path.relative(projectAbs, fileAbs);
|
|
87
|
+
}
|
|
88
|
+
} else if (projectIsPosix && isPosixAbsolute) {
|
|
89
|
+
// Both project and file are POSIX → use path.posix
|
|
90
|
+
const projectPosix = projectDir.replace(/\\/g, '/');
|
|
91
|
+
rel = path.posix.relative(projectPosix, normalizedFilePath);
|
|
76
92
|
} else {
|
|
77
93
|
const projectPosix = projectDir.replace(/\\/g, '/');
|
|
78
|
-
const filePosix = path.isAbsolute(normalizedFilePath)
|
|
94
|
+
const filePosix = path.isAbsolute(normalizedFilePath)
|
|
95
|
+
? normalizedFilePath
|
|
96
|
+
: path.posix.join(projectPosix, normalizedFilePath.replace(/\\/g, '/'));
|
|
79
97
|
rel = path.posix.relative(projectPosix, filePosix);
|
|
80
98
|
}
|
|
81
99
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-437 Slice 5: Invalid tier/adversarial diagnostics cannot corrupt output
|
|
3
|
+
*
|
|
4
|
+
* PURPOSE: Verify that adversarial RuleCode cannot corrupt the RuleHost output
|
|
5
|
+
* or merge logic through:
|
|
6
|
+
* 1. Prototype pollution in diagnostics field
|
|
7
|
+
* 2. Invalid tier in input (non-number epTier)
|
|
8
|
+
* 3. Adversarial correctionProposal with prototype pollution
|
|
9
|
+
*
|
|
10
|
+
* ERR risk mitigation:
|
|
11
|
+
* - ERR-001: no `as` bypass on untrusted VM output
|
|
12
|
+
* - ERR-013: Object.hasOwn for untrusted keys
|
|
13
|
+
* - ERR-005: validate array element types
|
|
14
|
+
*
|
|
15
|
+
* Test approach:
|
|
16
|
+
* - Real SQLite activation with adversarial RuleCode
|
|
17
|
+
* - Real RuleHost.evaluate() (public interface)
|
|
18
|
+
* - Verify output is either rejected (undefined) or safely contained
|
|
19
|
+
*/
|
|
20
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as os from 'os';
|
|
23
|
+
import * as path from 'path';
|
|
24
|
+
import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
|
|
25
|
+
import type { RuleHostInput } from '@principles/core/runtime-v2';
|
|
26
|
+
import { RuleHost } from '../../src/core/rule-host.js';
|
|
27
|
+
|
|
28
|
+
// ── Test helpers ───────────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
let tempWorkspaceDir: string;
|
|
31
|
+
let tempStateDir: string;
|
|
32
|
+
let sqliteConn: SqliteConnection;
|
|
33
|
+
|
|
34
|
+
function setupTempDirs(): void {
|
|
35
|
+
const baseTmp = os.tmpdir();
|
|
36
|
+
tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-adversarial-'));
|
|
37
|
+
tempStateDir = path.join(tempWorkspaceDir, '.principles');
|
|
38
|
+
fs.mkdirSync(tempStateDir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function insertRuleArtifact(
|
|
42
|
+
artifactId: string,
|
|
43
|
+
ruleId: string,
|
|
44
|
+
sourceTaskId: string,
|
|
45
|
+
code: string,
|
|
46
|
+
): void {
|
|
47
|
+
const db = sqliteConn.getDb();
|
|
48
|
+
const now = new Date().toISOString();
|
|
49
|
+
const contentJson = JSON.stringify({
|
|
50
|
+
principleId: `P_${ruleId}`,
|
|
51
|
+
ruleId,
|
|
52
|
+
implementationCode: code,
|
|
53
|
+
goldenTrace: { traceId: `trace-${ruleId}`, cases: [], createdAt: now, version: 1 },
|
|
54
|
+
ruleHostGateDecision: 'accepted_shadow',
|
|
55
|
+
affectedTools: ['write_file'],
|
|
56
|
+
painReasonSummary: 'Test: adversarial',
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
db.prepare(`
|
|
60
|
+
INSERT INTO pi_artifacts (artifact_id, artifact_kind, source_task_id, source_principle_id, source_rule_id, lineage_artifact_ids, validation_status, content_json, created_at, updated_at)
|
|
61
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
62
|
+
`).run(
|
|
63
|
+
artifactId, 'rule', sourceTaskId, `P_${ruleId}`, ruleId,
|
|
64
|
+
'[]', 'validated', contentJson, now, now,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function insertActivation(
|
|
69
|
+
activationId: string,
|
|
70
|
+
artifactId: string,
|
|
71
|
+
ruleId: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const store = new SqliteActivationStateStore(sqliteConn);
|
|
74
|
+
const now = new Date().toISOString();
|
|
75
|
+
await store.recordActivation({
|
|
76
|
+
activationId,
|
|
77
|
+
idempotencyKey: `${artifactId}::code_tool_hook`,
|
|
78
|
+
artifactId,
|
|
79
|
+
channel: 'code_tool_hook',
|
|
80
|
+
action: 'code_tool_hook_shadow_activate',
|
|
81
|
+
targetRef: `impl://${ruleId}`,
|
|
82
|
+
activatedAt: now,
|
|
83
|
+
deactivatedAt: null,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function makeInput(normalizedPath: string, epTier?: unknown): RuleHostInput {
|
|
88
|
+
return {
|
|
89
|
+
action: {
|
|
90
|
+
toolName: 'write_file',
|
|
91
|
+
normalizedPath,
|
|
92
|
+
paramsSummary: { path: normalizedPath },
|
|
93
|
+
},
|
|
94
|
+
workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
|
|
95
|
+
session: { sessionId: 'test-session-adversarial', currentGfi: 0, recentThinking: false },
|
|
96
|
+
evolution: { epTier: epTier as number },
|
|
97
|
+
derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Setup / Teardown ───────────────────────────────────────────────────────
|
|
102
|
+
|
|
103
|
+
beforeEach(() => {
|
|
104
|
+
setupTempDirs();
|
|
105
|
+
sqliteConn = new SqliteConnection(tempWorkspaceDir);
|
|
106
|
+
sqliteConn.getDb();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
try { sqliteConn?.close(); } catch { /* best-effort */ }
|
|
111
|
+
try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── Slice 5: Adversarial diagnostics cannot corrupt output ─────────────────
|
|
115
|
+
|
|
116
|
+
describe('PRI-437 Slice 5: Invalid tier/adversarial diagnostics cannot corrupt output', () => {
|
|
117
|
+
it('prototype pollution in diagnostics field is rejected by validator', async () => {
|
|
118
|
+
const RULE_ID = 'R_TEST_PROTO_005';
|
|
119
|
+
const ARTIFACT_ID = 'art-proto-005';
|
|
120
|
+
const ACTIVATION_ID = `act_code_${RULE_ID}`;
|
|
121
|
+
|
|
122
|
+
// RuleCode that tries to inject __proto__ as an own property in diagnostics
|
|
123
|
+
const ADVERSARIAL_CODE = `
|
|
124
|
+
function evaluate(input, helpers) {
|
|
125
|
+
var diag = {};
|
|
126
|
+
Object.defineProperty(diag, '__proto__', { value: { polluted: true }, enumerable: true, configurable: true });
|
|
127
|
+
Object.defineProperty(diag, 'constructor', { value: { polluted: true }, enumerable: true, configurable: true });
|
|
128
|
+
return { decision: 'block', matched: true, reason: 'adversarial', diagnostics: diag };
|
|
129
|
+
}
|
|
130
|
+
var meta = { name: 'proto-rule', version: '1', ruleId: '${RULE_ID}', coversCondition: 'all' };
|
|
131
|
+
`;
|
|
132
|
+
|
|
133
|
+
insertRuleArtifact(ARTIFACT_ID, RULE_ID, 'task-proto-005', ADVERSARIAL_CODE);
|
|
134
|
+
await insertActivation(ACTIVATION_ID, ARTIFACT_ID, RULE_ID);
|
|
135
|
+
|
|
136
|
+
const warnCalls: string[] = [];
|
|
137
|
+
const spyLogger = {
|
|
138
|
+
warn: (message: string) => { warnCalls.push(message); },
|
|
139
|
+
error: () => {},
|
|
140
|
+
info: () => {},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
|
|
144
|
+
const result = ruleHost.evaluate(makeInput('/etc/passwd'));
|
|
145
|
+
|
|
146
|
+
// PRI-437 fail-closed contract: adversarial results must be rejected entirely,
|
|
147
|
+
// resulting in conservative degradation (undefined). Not "accepted but sanitized".
|
|
148
|
+
expect(result).toBeUndefined();
|
|
149
|
+
|
|
150
|
+
// Must emit warn evidence about the invalid result
|
|
151
|
+
expect(warnCalls.length).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('invalid tier (string) in input does not corrupt output validation', async () => {
|
|
155
|
+
const RULE_ID = 'R_TEST_TIER_005';
|
|
156
|
+
const ARTIFACT_ID = 'art-tier-005';
|
|
157
|
+
const ACTIVATION_ID = `act_code_${RULE_ID}`;
|
|
158
|
+
|
|
159
|
+
// RuleCode that tries to use epTier as a string and produces invalid output
|
|
160
|
+
const ADVERSARIAL_CODE = `
|
|
161
|
+
function evaluate(input, helpers) {
|
|
162
|
+
var tier = input.evolution.epTier;
|
|
163
|
+
// If tier is a string, try to use it to bypass validation
|
|
164
|
+
if (typeof tier === 'string') {
|
|
165
|
+
return { decision: 'BLOCK', matched: 'yes', reason: 123 };
|
|
166
|
+
}
|
|
167
|
+
return { decision: 'block', matched: true, reason: 'valid block' };
|
|
168
|
+
}
|
|
169
|
+
var meta = { name: 'tier-rule', version: '1', ruleId: '${RULE_ID}', coversCondition: 'all' };
|
|
170
|
+
`;
|
|
171
|
+
|
|
172
|
+
insertRuleArtifact(ARTIFACT_ID, RULE_ID, 'task-tier-005', ADVERSARIAL_CODE);
|
|
173
|
+
await insertActivation(ACTIVATION_ID, ARTIFACT_ID, RULE_ID);
|
|
174
|
+
|
|
175
|
+
const warnCalls: string[] = [];
|
|
176
|
+
const spyLogger = {
|
|
177
|
+
warn: (message: string) => { warnCalls.push(message); },
|
|
178
|
+
error: () => {},
|
|
179
|
+
info: () => {},
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
|
|
183
|
+
|
|
184
|
+
// Pass invalid tier (string instead of number)
|
|
185
|
+
const result = ruleHost.evaluate(makeInput('/etc/passwd', 'invalid_tier_string'));
|
|
186
|
+
|
|
187
|
+
// The invalid output (decision='BLOCK' instead of 'block', matched='yes' instead of boolean)
|
|
188
|
+
// must be rejected by the validator → undefined (conservative degradation)
|
|
189
|
+
expect(result).toBeUndefined();
|
|
190
|
+
|
|
191
|
+
// Must emit warn evidence about the invalid result
|
|
192
|
+
expect(warnCalls.length).toBeGreaterThan(0);
|
|
193
|
+
const invalidWarn = warnCalls.find(m =>
|
|
194
|
+
m.toLowerCase().includes('invalid') ||
|
|
195
|
+
m.toLowerCase().includes('evaluation failed')
|
|
196
|
+
);
|
|
197
|
+
expect(invalidWarn).toBeDefined();
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('adversarial correctionProposal with prototype pollution is rejected', async () => {
|
|
201
|
+
const RULE_ID = 'R_TEST_CORR_005';
|
|
202
|
+
const ARTIFACT_ID = 'art-corr-005';
|
|
203
|
+
const ACTIVATION_ID = `act_code_${RULE_ID}`;
|
|
204
|
+
|
|
205
|
+
// RuleCode that returns auto_correct with adversarial correctionProposal
|
|
206
|
+
const ADVERSARIAL_CODE = `
|
|
207
|
+
function evaluate(input, helpers) {
|
|
208
|
+
var proposal = {
|
|
209
|
+
ruleId: '${RULE_ID}',
|
|
210
|
+
correctedFields: [{ field: 'file_path', reason: 'x' }],
|
|
211
|
+
proposedParams: { file_path: '/safe/path' },
|
|
212
|
+
applicationMode: 'live',
|
|
213
|
+
confidence: 0.9,
|
|
214
|
+
};
|
|
215
|
+
// Try to inject __proto__ into the proposal
|
|
216
|
+
Object.defineProperty(proposal, '__proto__', { value: { polluted: true }, enumerable: true });
|
|
217
|
+
return { decision: 'auto_correct', matched: true, reason: 'adversarial correction', correctionProposal: proposal };
|
|
218
|
+
}
|
|
219
|
+
var meta = { name: 'corr-rule', version: '1', ruleId: '${RULE_ID}', coversCondition: 'all' };
|
|
220
|
+
`;
|
|
221
|
+
|
|
222
|
+
insertRuleArtifact(ARTIFACT_ID, RULE_ID, 'task-corr-005', ADVERSARIAL_CODE);
|
|
223
|
+
await insertActivation(ACTIVATION_ID, ARTIFACT_ID, RULE_ID);
|
|
224
|
+
|
|
225
|
+
const warnCalls: string[] = [];
|
|
226
|
+
const spyLogger = {
|
|
227
|
+
warn: (message: string) => { warnCalls.push(message); },
|
|
228
|
+
error: () => {},
|
|
229
|
+
info: () => {},
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
|
|
233
|
+
const result = ruleHost.evaluate(makeInput('/etc/passwd'));
|
|
234
|
+
|
|
235
|
+
// PRI-437 fail-closed contract: adversarial correctionProposal must be rejected
|
|
236
|
+
// entirely, resulting in conservative degradation (undefined).
|
|
237
|
+
expect(result).toBeUndefined();
|
|
238
|
+
|
|
239
|
+
// Must emit warn evidence
|
|
240
|
+
expect(warnCalls.length).toBeGreaterThan(0);
|
|
241
|
+
});
|
|
242
|
+
});
|