principles-disciple 1.123.0 → 1.125.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/code-implementation-storage.ts +0 -31
- package/src/core/event-log.ts +13 -0
- package/src/core/principle-tree-ledger.ts +0 -13
- package/src/core/rule-host.ts +136 -151
- package/src/core/rule-implementation-runtime.ts +65 -3
- package/src/types/event-types.ts +1 -0
- package/tests/core/code-implementation-storage.test.ts +16 -114
- 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-sqlite-source.test.ts +720 -0
- package/tests/core/rule-host-unhealthy-visibility.test.ts +234 -0
- package/tests/core/rule-host-validation.test.ts +315 -0
- package/tests/hooks/gate-auto-correct-shadow.test.ts +0 -1
- package/tests/hooks/gate-auto-correct.test.ts +0 -1
- package/tests/hooks/gate-no-path-write-tool.test.ts +0 -1
- package/tests/hooks/gate-rule-host-pipeline.test.ts +0 -1
- package/tests/hooks/gate-rule-host-real-pipeline.test.ts +190 -0
- package/tests/integration/pain-id-chain-e2e.test.ts +59 -5
- package/tests/integration/principle-compiler-e2e.test.ts +62 -13
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.125.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
|
@@ -104,20 +104,6 @@ export function getImplementationAssetRoot(stateDir: string, implId: string): st
|
|
|
104
104
|
return path.join(stateDir, 'principles', 'implementations', implId);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
/**
|
|
108
|
-
* Load manifest from disk. Returns null if not found (does not throw).
|
|
109
|
-
*/
|
|
110
|
-
export function loadManifest(stateDir: string, implId: string): CodeImplementationManifest | null {
|
|
111
|
-
validateImplId(implId);
|
|
112
|
-
const manifestPath = path.join(getImplementationAssetRoot(stateDir, implId), MANIFEST_FILENAME);
|
|
113
|
-
if (!fs.existsSync(manifestPath)) return null;
|
|
114
|
-
try {
|
|
115
|
-
return JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as CodeImplementationManifest;
|
|
116
|
-
} catch {
|
|
117
|
-
return null;
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
107
|
/**
|
|
122
108
|
* Write manifest atomically using withLock.
|
|
123
109
|
*/
|
|
@@ -163,23 +149,6 @@ export function deleteImplementationAssetDir(stateDir: string, implId: string):
|
|
|
163
149
|
});
|
|
164
150
|
}
|
|
165
151
|
|
|
166
|
-
/**
|
|
167
|
-
* Load the entry source code from disk.
|
|
168
|
-
* Returns null if manifest doesn't exist or entry file is missing.
|
|
169
|
-
*/
|
|
170
|
-
export function loadEntrySource(stateDir: string, implId: string): string | null {
|
|
171
|
-
validateImplId(implId);
|
|
172
|
-
const manifest = loadManifest(stateDir, implId);
|
|
173
|
-
if (!manifest) return null;
|
|
174
|
-
const entryPath = path.join(getImplementationAssetRoot(stateDir, implId), manifest.entryFile);
|
|
175
|
-
if (!fs.existsSync(entryPath)) return null;
|
|
176
|
-
try {
|
|
177
|
-
return fs.readFileSync(entryPath, 'utf-8');
|
|
178
|
-
} catch {
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
152
|
/**
|
|
184
153
|
* Create the full asset directory structure for a new implementation.
|
|
185
154
|
*
|
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,
|
|
@@ -701,19 +701,6 @@ export function transitionImplementationState(
|
|
|
701
701
|
});
|
|
702
702
|
}
|
|
703
703
|
|
|
704
|
-
/**
|
|
705
|
-
* Get all implementations for a specific lifecycle state across all rules.
|
|
706
|
-
*/
|
|
707
|
-
export function listImplementationsByLifecycleState(
|
|
708
|
-
stateDir: string,
|
|
709
|
-
state: ImplementationLifecycleState
|
|
710
|
-
): Implementation[] {
|
|
711
|
-
const ledger = loadLedger(stateDir);
|
|
712
|
-
return Object.values(ledger.tree.implementations).filter(
|
|
713
|
-
(impl) => impl.lifecycleState === state
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
|
|
717
704
|
/**
|
|
718
705
|
* Get implementations in a specific lifecycle state for a given rule.
|
|
719
706
|
*/
|
package/src/core/rule-host.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Rule Host — Constrained execution layer for active code implementations
|
|
3
3
|
*
|
|
4
|
-
* PURPOSE: Load active code implementations from the
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* PURPOSE: Load active code implementations from the SQLite activations table
|
|
5
|
+
* (code_tool_hook channel), execute them in a constrained node:vm context,
|
|
6
|
+
* and merge their decisions.
|
|
7
7
|
*
|
|
8
|
-
* ARCHITECTURE:
|
|
9
|
-
* -
|
|
10
|
-
* -
|
|
8
|
+
* ARCHITECTURE (PRI-436):
|
|
9
|
+
* - SQLite is the SOLE production source of active RuleCode
|
|
10
|
+
* - No filesystem ledger or implementation asset reads occur during evaluation
|
|
11
|
+
* - Constructor takes stateDir for API compatibility (no longer used for impl loading)
|
|
12
|
+
* - workspaceDir enables reading code_tool_hook activations from SQLite
|
|
11
13
|
* - evaluate(input) loads active code implementations and runs them
|
|
12
14
|
* - Each implementation executes in an isolated vm context with minimal helpers
|
|
13
15
|
* - Decision merge: block short-circuits, requireApproval collects, allow is implicit
|
|
@@ -22,28 +24,24 @@
|
|
|
22
24
|
* - Never throw, never bypass downstream gates (Progressive Gate, Edit Verification)
|
|
23
25
|
*/
|
|
24
26
|
|
|
25
|
-
import * as fs from 'fs';
|
|
26
|
-
import {
|
|
27
|
-
listImplementationsByLifecycleState,
|
|
28
|
-
} from './principle-tree-ledger.js';
|
|
29
|
-
import { loadEntrySource } from './code-implementation-storage.js';
|
|
30
27
|
import { createRuleHostHelpers } from '@principles/core/runtime-v2';
|
|
31
28
|
import { mergeDecisions } from '@principles/core/runtime-v2';
|
|
29
|
+
import { validateRuleHostResult } from '@principles/core/runtime-v2';
|
|
32
30
|
import { SqliteConnection } from '@principles/core/runtime-v2';
|
|
33
31
|
import { loadRuleImplementationModule } from './rule-implementation-runtime.js';
|
|
32
|
+
import { EventLogService } from './event-log.js';
|
|
34
33
|
import type {
|
|
35
34
|
RuleHostInput,
|
|
36
35
|
RuleHostResult,
|
|
37
36
|
RuleHostMeta,
|
|
38
37
|
LoadedImplementation,
|
|
39
38
|
} from '@principles/core/runtime-v2';
|
|
40
|
-
import type { Implementation } from '../types/principle-tree-schema.js';
|
|
41
39
|
|
|
42
40
|
import type { RuleHostLogger } from '@principles/core/runtime-v2';
|
|
43
41
|
export type { RuleHostLogger } from '@principles/core/runtime-v2';
|
|
44
42
|
|
|
45
43
|
export interface RuleHostOptions {
|
|
46
|
-
/** Workspace directory for SQLite access.
|
|
44
|
+
/** Workspace directory for SQLite access. Required for RuleHost to load active code_tool_hook activations. */
|
|
47
45
|
workspaceDir?: string;
|
|
48
46
|
}
|
|
49
47
|
|
|
@@ -84,7 +82,6 @@ export class RuleHost {
|
|
|
84
82
|
* - { decision: 'block', ... } when any implementation returns block (short-circuits)
|
|
85
83
|
* - { decision: 'requireApproval', ... } when any implementation returns requireApproval
|
|
86
84
|
*/
|
|
87
|
-
|
|
88
85
|
evaluate(input: RuleHostInput): RuleHostResult | undefined {
|
|
89
86
|
try {
|
|
90
87
|
const activeImpls = this._loadActiveCodeImplementations();
|
|
@@ -99,70 +96,28 @@ export class RuleHost {
|
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
/**
|
|
102
|
-
* Load active code implementations from the
|
|
99
|
+
* Load active code implementations from the SQLite activations table.
|
|
103
100
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
101
|
+
* PRI-436: SQLite is the SOLE production source. The filesystem ledger
|
|
102
|
+
* (principle-tree-ledger) and implementation asset paths have been deleted.
|
|
103
|
+
* No fallback, no dual-source, no deprecated adapter.
|
|
107
104
|
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
* See BUG-001 / ERR-011 / ERR-035.
|
|
105
|
+
* Source: activations table (code_tool_hook channel, deactivated_at IS NULL)
|
|
106
|
+
* → JOIN pi_artifacts for content_json → extract implementationCode → compile
|
|
111
107
|
*/
|
|
112
108
|
private _loadActiveCodeImplementations(): LoadedImplementation[] {
|
|
113
|
-
|
|
114
|
-
|
|
109
|
+
if (!this.workspaceDir) {
|
|
110
|
+
return [];
|
|
111
|
+
}
|
|
115
112
|
|
|
116
|
-
// Source 1: principle-tree-ledger.json (existing path)
|
|
117
113
|
try {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
'active'
|
|
121
|
-
);
|
|
122
|
-
|
|
123
|
-
// Filter to code-type implementations only
|
|
124
|
-
const codeImpls = activeAllTypes.filter((impl) => impl.type === 'code');
|
|
125
|
-
|
|
126
|
-
for (const impl of codeImpls) {
|
|
127
|
-
try {
|
|
128
|
-
const loadedImpl = this._loadSingleImplementation(impl);
|
|
129
|
-
if (loadedImpl && !seenImplIds.has(loadedImpl.implId)) {
|
|
130
|
-
loaded.push(loadedImpl);
|
|
131
|
-
seenImplIds.add(loadedImpl.implId);
|
|
132
|
-
}
|
|
133
|
-
} catch (loadError: unknown) {
|
|
134
|
-
// Individual load failure: log and skip
|
|
135
|
-
this.logger.warn?.(
|
|
136
|
-
`[RuleHost] Failed to load implementation ${impl.id}: ${String(loadError)}`
|
|
137
|
-
);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
} catch (ledgerError: unknown) {
|
|
141
|
-
// Ledger access failure: log and continue to activations table
|
|
114
|
+
return this._loadFromActivationsTable(this.workspaceDir);
|
|
115
|
+
} catch (activationError: unknown) {
|
|
142
116
|
this.logger.warn?.(
|
|
143
|
-
`[RuleHost] Failed to
|
|
117
|
+
`[RuleHost] Failed to load code_tool_hook activations: ${String(activationError)}`
|
|
144
118
|
);
|
|
119
|
+
return [];
|
|
145
120
|
}
|
|
146
|
-
|
|
147
|
-
// Source 2: activations table (code_tool_hook channel) — bridges BUG-001
|
|
148
|
-
if (this.workspaceDir) {
|
|
149
|
-
try {
|
|
150
|
-
const activationImpls = this._loadFromActivationsTable(this.workspaceDir);
|
|
151
|
-
for (const impl of activationImpls) {
|
|
152
|
-
if (!seenImplIds.has(impl.implId)) {
|
|
153
|
-
loaded.push(impl);
|
|
154
|
-
seenImplIds.add(impl.implId);
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
} catch (activationError: unknown) {
|
|
158
|
-
// Activations table access failure: log and continue with ledger-only results
|
|
159
|
-
this.logger.warn?.(
|
|
160
|
-
`[RuleHost] Failed to load code_tool_hook activations: ${String(activationError)}`
|
|
161
|
-
);
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
return loaded;
|
|
166
121
|
}
|
|
167
122
|
|
|
168
123
|
/**
|
|
@@ -170,10 +125,15 @@ export class RuleHost {
|
|
|
170
125
|
*
|
|
171
126
|
* For each activation record:
|
|
172
127
|
* 1. Query the pi_artifacts table for the artifact content
|
|
173
|
-
* 2. Parse content_json to extract implementationCode
|
|
174
|
-
* 3. Compile via loadRuleImplementationModule (
|
|
128
|
+
* 2. Parse content_json to extract implementationCode (treated as unknown, EP-01)
|
|
129
|
+
* 3. Compile via loadRuleImplementationModule (isolated vm context)
|
|
130
|
+
*
|
|
131
|
+
* PRI-436 invariant: at most one active activation per rule (target_ref).
|
|
132
|
+
* Duplicate active activations for the same target_ref are ALL skipped
|
|
133
|
+
* (zero executions) and emit structured unhealthy evidence via logger.warn
|
|
134
|
+
* (Runtime Contract Rule 9: graceful degradation includes a reason).
|
|
175
135
|
*
|
|
176
|
-
*
|
|
136
|
+
* All data from SQLite is treated as unknown and validated before use.
|
|
177
137
|
*/
|
|
178
138
|
private _loadFromActivationsTable(workspaceDir: string): LoadedImplementation[] {
|
|
179
139
|
const sqliteConn = new SqliteConnection(workspaceDir);
|
|
@@ -195,13 +155,58 @@ export class RuleHost {
|
|
|
195
155
|
return [];
|
|
196
156
|
}
|
|
197
157
|
|
|
198
|
-
|
|
199
|
-
|
|
158
|
+
// PRI-436: Group active rows by target_ref to detect duplicates.
|
|
159
|
+
// At most one active activation per rule (target_ref) is allowed.
|
|
160
|
+
// Duplicate groups are skipped entirely (zero executions) and emit
|
|
161
|
+
// structured unhealthy evidence. Non-duplicate rows proceed to compilation.
|
|
162
|
+
const rowsByTargetRef = new Map<string, Record<string, unknown>[]>();
|
|
200
163
|
for (const row of rows) {
|
|
201
164
|
if (!row || typeof row !== 'object') {
|
|
202
165
|
continue;
|
|
203
166
|
}
|
|
204
167
|
const r = row as Record<string, unknown>;
|
|
168
|
+
const targetRef = typeof r['target_ref'] === 'string' ? r['target_ref'] : '';
|
|
169
|
+
const activationId = typeof r['activation_id'] === 'string' ? r['activation_id'] : '';
|
|
170
|
+
if (!targetRef) {
|
|
171
|
+
this.logger.warn?.(
|
|
172
|
+
`[RuleHost] Activation ${activationId}: missing target_ref, skipping`
|
|
173
|
+
);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const group = rowsByTargetRef.get(targetRef);
|
|
177
|
+
if (group) {
|
|
178
|
+
group.push(r);
|
|
179
|
+
} else {
|
|
180
|
+
rowsByTargetRef.set(targetRef, [r]);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Emit structured unhealthy evidence for duplicate groups; collect valid (non-duplicate) rows.
|
|
185
|
+
const validRows: Record<string, unknown>[] = [];
|
|
186
|
+
for (const [targetRef, group] of rowsByTargetRef) {
|
|
187
|
+
if (group.length > 1) {
|
|
188
|
+
const activationIds: string[] = [];
|
|
189
|
+
const artifactIds: string[] = [];
|
|
190
|
+
for (const r of group) {
|
|
191
|
+
if (typeof r['activation_id'] === 'string') activationIds.push(r['activation_id']);
|
|
192
|
+
if (typeof r['artifact_id'] === 'string') artifactIds.push(r['artifact_id']);
|
|
193
|
+
}
|
|
194
|
+
this.logger.warn?.(
|
|
195
|
+
`[RuleHost] Duplicate active activations detected — skipping all executions for this rule. ` +
|
|
196
|
+
`targetRef=${targetRef} count=${group.length} ` +
|
|
197
|
+
`activationIds=[${activationIds.join(', ')}] ` +
|
|
198
|
+
`artifactIds=[${artifactIds.join(', ')}] ` +
|
|
199
|
+
`reason=at most one active activation per rule is allowed ` +
|
|
200
|
+
`nextAction=deactivate all but one activation for this target_ref`
|
|
201
|
+
);
|
|
202
|
+
} else {
|
|
203
|
+
validRows.push(group[0]);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const loaded: LoadedImplementation[] = [];
|
|
208
|
+
|
|
209
|
+
for (const r of validRows) {
|
|
205
210
|
const activationId = typeof r['activation_id'] === 'string' ? r['activation_id'] : '';
|
|
206
211
|
const artifactId = typeof r['artifact_id'] === 'string' ? r['artifact_id'] : '';
|
|
207
212
|
const contentJson = typeof r['content_json'] === 'string' ? r['content_json'] : '';
|
|
@@ -238,10 +243,13 @@ export class RuleHost {
|
|
|
238
243
|
const implId = `act-impl-${activationId}`;
|
|
239
244
|
const moduleExports = loadRuleImplementationModule(implementationCode, implId);
|
|
240
245
|
|
|
241
|
-
if (!moduleExports || typeof moduleExports.
|
|
246
|
+
if (!moduleExports || typeof moduleExports.callEvaluate !== 'function') {
|
|
247
|
+
const reason = 'compiled module has no evaluate function';
|
|
242
248
|
this.logger.warn?.(
|
|
243
|
-
`[RuleHost] Activation ${activationId}:
|
|
249
|
+
`[RuleHost] Activation ${activationId}: ${reason}, skipping`
|
|
244
250
|
);
|
|
251
|
+
this._recordUnhealthy(activationId, artifactId, ruleId, reason,
|
|
252
|
+
'Fix the RuleCode to export an evaluate(input, helpers) function, then re-activate');
|
|
245
253
|
continue;
|
|
246
254
|
}
|
|
247
255
|
|
|
@@ -255,10 +263,10 @@ export class RuleHost {
|
|
|
255
263
|
? moduleExports.meta
|
|
256
264
|
: fallbackMeta;
|
|
257
265
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
266
|
+
// PRI-437: Use callEvaluate (vm-context-bounded) instead of raw evaluate.
|
|
267
|
+
// callEvaluate runs the invocation INSIDE the vm context with a time
|
|
268
|
+
// boundary, terminating infinite loops and excessive computation.
|
|
269
|
+
const boundedCallEvaluate = moduleExports.callEvaluate;
|
|
262
270
|
|
|
263
271
|
loaded.push({
|
|
264
272
|
implId,
|
|
@@ -266,7 +274,18 @@ export class RuleHost {
|
|
|
266
274
|
meta,
|
|
267
275
|
evaluate: (input: RuleHostInput): RuleHostResult => {
|
|
268
276
|
const frozenHelpers = createRuleHostHelpers(input);
|
|
269
|
-
|
|
277
|
+
// PRI-437: Execute inside vm context with timeout boundary.
|
|
278
|
+
// If the RuleCode infinite-loops or exceeds the time budget,
|
|
279
|
+
// vm throws an error that is caught by the caller (mergeDecisions
|
|
280
|
+
// try/catch), resulting in conservative degradation (undefined).
|
|
281
|
+
const rawResult = boundedCallEvaluate(input, frozenHelpers);
|
|
282
|
+
const validation = validateRuleHostResult(rawResult);
|
|
283
|
+
if (!validation.valid) {
|
|
284
|
+
throw new Error(
|
|
285
|
+
`[RuleHost] Activation ${activationId} returned invalid RuleHostResult: ${validation.errors.join('; ')}`
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
const result = rawResult as RuleHostResult;
|
|
270
289
|
if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
|
|
271
290
|
result.ruleId = ruleId;
|
|
272
291
|
result.principleId = meta.ruleId ?? ruleId;
|
|
@@ -275,9 +294,16 @@ export class RuleHost {
|
|
|
275
294
|
},
|
|
276
295
|
});
|
|
277
296
|
} catch (loadError: unknown) {
|
|
297
|
+
const reason = `compilation failed: ${String(loadError)}`;
|
|
278
298
|
this.logger.warn?.(
|
|
279
|
-
`[RuleHost] Failed to load activation ${activationId}: ${
|
|
299
|
+
`[RuleHost] Failed to load activation ${activationId}: ${reason}`
|
|
280
300
|
);
|
|
301
|
+
// ruleId is declared inside the try block and may not be assigned yet;
|
|
302
|
+
// fall back to sourceRuleId or artifactId (both available in scope)
|
|
303
|
+
this._recordUnhealthy(activationId, artifactId,
|
|
304
|
+
sourceRuleId ?? artifactId,
|
|
305
|
+
reason,
|
|
306
|
+
'Fix the RuleCode syntax/compilation error, then re-activate the rule');
|
|
281
307
|
}
|
|
282
308
|
}
|
|
283
309
|
|
|
@@ -292,81 +318,40 @@ export class RuleHost {
|
|
|
292
318
|
}
|
|
293
319
|
|
|
294
320
|
/**
|
|
295
|
-
*
|
|
321
|
+
* PRI-437: Record an unhealthy activation state to EventLog.
|
|
296
322
|
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
* - evaluate(input: RuleHostInput): RuleHostResult
|
|
323
|
+
* This makes compile/load failures visible to CLI (pd runtime health) and
|
|
324
|
+
* Console API — NOT just a logger.warn that's silently skipped.
|
|
300
325
|
*
|
|
301
|
-
*
|
|
302
|
-
* in
|
|
326
|
+
* ERR-002: degradation includes a reason and nextAction (not silent).
|
|
327
|
+
* Failures in EventLog recording are caught and logged (never throw).
|
|
303
328
|
*/
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (!assetPath || !fs.existsSync(assetPath)) {
|
|
312
|
-
return null;
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
sourceCode = fs.readFileSync(assetPath, 'utf-8');
|
|
317
|
-
} catch {
|
|
318
|
-
return null;
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
|
|
329
|
+
private _recordUnhealthy(
|
|
330
|
+
activationId: string,
|
|
331
|
+
artifactId: string,
|
|
332
|
+
ruleId: string,
|
|
333
|
+
reason: string,
|
|
334
|
+
nextAction: string,
|
|
335
|
+
): void {
|
|
322
336
|
try {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
ruleId
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
: fallbackMeta;
|
|
339
|
-
|
|
340
|
-
// Return a loaded implementation that wraps the compiled evaluate
|
|
341
|
-
// with the actual helpers from the input at evaluation time
|
|
342
|
-
|
|
343
|
-
const rawEvaluate = moduleExports.evaluate as (
|
|
344
|
-
_input: RuleHostInput,
|
|
345
|
-
_helpers: ReturnType<typeof createRuleHostHelpers>
|
|
346
|
-
) => RuleHostResult;
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
return {
|
|
350
|
-
implId: impl.id,
|
|
351
|
-
ruleId: impl.ruleId,
|
|
352
|
-
meta,
|
|
353
|
-
evaluate: (input: RuleHostInput): RuleHostResult => {
|
|
354
|
-
const frozenHelpers = createRuleHostHelpers(input);
|
|
355
|
-
const result = rawEvaluate(input, frozenHelpers);
|
|
356
|
-
// C: Enrich result with rule/principle IDs for observability
|
|
357
|
-
if (result.matched && (result.decision === 'block' || result.decision === 'requireApproval')) {
|
|
358
|
-
result.ruleId = impl.ruleId;
|
|
359
|
-
result.principleId = meta.ruleId ?? impl.ruleId;
|
|
360
|
-
}
|
|
361
|
-
return result;
|
|
362
|
-
},
|
|
363
|
-
};
|
|
364
|
-
} catch (compileError: unknown) {
|
|
365
|
-
// Compilation failure: log and skip
|
|
337
|
+
// Pass undefined as logger: RuleHostLogger only has warn(), but EventLog
|
|
338
|
+
// calls this.logger.error() without optional chaining. Passing the
|
|
339
|
+
// RuleHostLogger directly would cause TypeError if EventLog tried to
|
|
340
|
+
// log an internal error. EventLog's logger is optional; RuleHost already
|
|
341
|
+
// logs its own warnings for the unhealthy event.
|
|
342
|
+
const eventLog = EventLogService.get(this.stateDir);
|
|
343
|
+
eventLog.recordRuleHostUnhealthy({
|
|
344
|
+
activationId,
|
|
345
|
+
artifactId,
|
|
346
|
+
ruleId,
|
|
347
|
+
reason,
|
|
348
|
+
nextAction,
|
|
349
|
+
});
|
|
350
|
+
} catch (recordError: unknown) {
|
|
351
|
+
// EventLog recording must never break RuleHost evaluation
|
|
366
352
|
this.logger.warn?.(
|
|
367
|
-
`[RuleHost] Failed to
|
|
353
|
+
`[RuleHost] Failed to record unhealthy event for activation ${activationId}: ${String(recordError)}`
|
|
368
354
|
);
|
|
369
|
-
return null;
|
|
370
355
|
}
|
|
371
356
|
}
|
|
372
357
|
}
|
|
@@ -3,8 +3,27 @@ import { nodeVm } from '../utils/node-vm-polyfill.js';
|
|
|
3
3
|
export interface RuleImplementationModuleExports {
|
|
4
4
|
meta?: unknown;
|
|
5
5
|
evaluate?: unknown;
|
|
6
|
+
/**
|
|
7
|
+
* Call evaluate(input, helpers) INSIDE the vm context with a time boundary.
|
|
8
|
+
*
|
|
9
|
+
* PRI-437: The evaluate() function extracted from a vm context executes in
|
|
10
|
+
* the vm realm, but a direct host-realm call has NO timeout protection —
|
|
11
|
+
* an infinite loop in RuleCode would hang the host process forever.
|
|
12
|
+
*
|
|
13
|
+
* callEvaluate runs the invocation inside the vm context via
|
|
14
|
+
* Script.runInContext({ timeout }), which terminates infinite loops.
|
|
15
|
+
*
|
|
16
|
+
* Throws on timeout, compilation error, or if evaluate is missing.
|
|
17
|
+
*/
|
|
18
|
+
callEvaluate?: (input: unknown, helpers: unknown) => unknown;
|
|
6
19
|
}
|
|
7
20
|
|
|
21
|
+
/** Timeout (ms) for compiling RuleCode (defining evaluate + meta). */
|
|
22
|
+
const COMPILE_TIMEOUT_MS = 1000;
|
|
23
|
+
|
|
24
|
+
/** Timeout (ms) for executing evaluate(input, helpers) inside the vm. */
|
|
25
|
+
const EVALUATE_TIMEOUT_MS = 1000;
|
|
26
|
+
|
|
8
27
|
function normalizeImplementationSource(sourceCode: string): string {
|
|
9
28
|
const withoutExports = sourceCode
|
|
10
29
|
.replace(/export\s+const\s+meta\s*=/, 'const meta =')
|
|
@@ -26,13 +45,56 @@ export function loadRuleImplementationModule(
|
|
|
26
45
|
filename,
|
|
27
46
|
});
|
|
28
47
|
|
|
48
|
+
// Compile phase: define evaluate + meta (timeout-bounded)
|
|
29
49
|
script.runInContext(context, {
|
|
30
|
-
timeout:
|
|
50
|
+
timeout: COMPILE_TIMEOUT_MS,
|
|
31
51
|
displayErrors: true,
|
|
32
52
|
});
|
|
33
53
|
|
|
34
54
|
const moduleExports = (context as { __pdRuleModule?: RuleImplementationModuleExports }).__pdRuleModule;
|
|
35
|
-
|
|
55
|
+
// Note: keep __pdRuleModule on the context so callEvaluate can reference it.
|
|
56
|
+
// We do NOT delete it here — it's needed for subsequent evaluate calls.
|
|
57
|
+
|
|
58
|
+
const hasEvaluate = typeof moduleExports?.evaluate === 'function';
|
|
59
|
+
const meta = moduleExports?.meta;
|
|
60
|
+
|
|
61
|
+
if (!hasEvaluate) {
|
|
62
|
+
// No evaluate function — return early with no callEvaluate
|
|
63
|
+
return { meta, evaluate: moduleExports?.evaluate };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// PRI-437: Create a context-aware caller that runs evaluate INSIDE the vm
|
|
67
|
+
// context with a time boundary. This terminates infinite loops and
|
|
68
|
+
// excessive computation that would otherwise hang the host process.
|
|
69
|
+
//
|
|
70
|
+
// The callEvaluate function:
|
|
71
|
+
// 1. Sets input and helpers as context globals (sandboxed)
|
|
72
|
+
// 2. Compiles a tiny call script
|
|
73
|
+
// 3. Runs it in the context with EVALUATE_TIMEOUT_MS
|
|
74
|
+
// 4. Cleans up globals
|
|
75
|
+
// 5. Returns the raw result (validation happens in the caller)
|
|
76
|
+
const callScript = new nodeVm.Script(
|
|
77
|
+
'__pdRuleModule.evaluate(__pdCallInput, __pdCallHelpers)',
|
|
78
|
+
{ filename: `${filename}.call` },
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const callEvaluate = (input: unknown, helpers: unknown): unknown => {
|
|
82
|
+
(context as { __pdCallInput?: unknown }).__pdCallInput = input;
|
|
83
|
+
(context as { __pdCallHelpers?: unknown }).__pdCallHelpers = helpers;
|
|
84
|
+
try {
|
|
85
|
+
return callScript.runInContext(context, {
|
|
86
|
+
timeout: EVALUATE_TIMEOUT_MS,
|
|
87
|
+
displayErrors: true,
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
try { delete (context as { __pdCallInput?: unknown }).__pdCallInput; } catch { /* noop */ }
|
|
91
|
+
try { delete (context as { __pdCallHelpers?: unknown }).__pdCallHelpers; } catch { /* noop */ }
|
|
92
|
+
}
|
|
93
|
+
};
|
|
36
94
|
|
|
37
|
-
return
|
|
95
|
+
return {
|
|
96
|
+
meta,
|
|
97
|
+
evaluate: moduleExports?.evaluate,
|
|
98
|
+
callEvaluate,
|
|
99
|
+
};
|
|
38
100
|
}
|