principles-disciple 1.125.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.
@@ -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.125.0",
5
+ "version": "1.126.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.125.0",
3
+ "version": "1.126.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -66,6 +66,7 @@ export class RuleHost {
66
66
  private readonly stateDir: string;
67
67
  private readonly logger: RuleHostLogger;
68
68
  private readonly workspaceDir: string | null;
69
+ private readonly implementationSources = new Map<string, { activationId: string; artifactId: string; ruleId: string }>();
69
70
 
70
71
  constructor(stateDir: string, logger: RuleHostLogger = console, options?: RuleHostOptions) {
71
72
  this.stateDir = stateDir;
@@ -85,7 +86,23 @@ export class RuleHost {
85
86
  evaluate(input: RuleHostInput): RuleHostResult | undefined {
86
87
  try {
87
88
  const activeImpls = this._loadActiveCodeImplementations();
88
- return mergeDecisions(activeImpls, input, this.logger);
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
+ });
89
106
  } catch (hostError: unknown) {
90
107
  // Conservative degradation: log and return undefined (D-08)
91
108
  this.logger.warn?.(
@@ -136,6 +153,7 @@ export class RuleHost {
136
153
  * All data from SQLite is treated as unknown and validated before use.
137
154
  */
138
155
  private _loadFromActivationsTable(workspaceDir: string): LoadedImplementation[] {
156
+ this.implementationSources.clear();
139
157
  const sqliteConn = new SqliteConnection(workspaceDir);
140
158
  try {
141
159
  const db = sqliteConn.getDb();
@@ -199,6 +217,19 @@ export class RuleHost {
199
217
  `reason=at most one active activation per rule is allowed ` +
200
218
  `nextAction=deactivate all but one activation for this target_ref`
201
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
+ }
202
233
  } else {
203
234
  validRows.push(group[0]);
204
235
  }
@@ -267,6 +298,7 @@ export class RuleHost {
267
298
  // callEvaluate runs the invocation INSIDE the vm context with a time
268
299
  // boundary, terminating infinite loops and excessive computation.
269
300
  const boundedCallEvaluate = moduleExports.callEvaluate;
301
+ this.implementationSources.set(implId, { activationId, artifactId, ruleId });
270
302
 
271
303
  loaded.push({
272
304
  implId,
@@ -275,9 +307,6 @@ export class RuleHost {
275
307
  evaluate: (input: RuleHostInput): RuleHostResult => {
276
308
  const frozenHelpers = createRuleHostHelpers(input);
277
309
  // 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
310
  const rawResult = boundedCallEvaluate(input, frozenHelpers);
282
311
  const validation = validateRuleHostResult(rawResult);
283
312
  if (!validation.valid) {
@@ -1,4 +1,5 @@
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;
@@ -22,7 +23,35 @@ export interface RuleImplementationModuleExports {
22
23
  const COMPILE_TIMEOUT_MS = 1000;
23
24
 
24
25
  /** Timeout (ms) for executing evaluate(input, helpers) inside the vm. */
25
- const EVALUATE_TIMEOUT_MS = 1000;
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
+ `;
26
55
 
27
56
  function normalizeImplementationSource(sourceCode: string): string {
28
57
  const withoutExports = sourceCode
@@ -36,6 +65,10 @@ globalThis.__pdRuleModule = {
36
65
  };`;
37
66
  }
38
67
 
68
+ function isRecord(value: unknown): value is Record<string, unknown> {
69
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
70
+ }
71
+
39
72
  export function loadRuleImplementationModule(
40
73
  sourceCode: string,
41
74
  filename: string,
@@ -73,23 +106,35 @@ export function loadRuleImplementationModule(
73
106
  // 3. Runs it in the context with EVALUATE_TIMEOUT_MS
74
107
  // 4. Cleans up globals
75
108
  // 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;
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;
84
126
  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 */ }
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);
92
136
  }
137
+ return parsed['result'];
93
138
  };
94
139
 
95
140
  return {
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
- const fileAbs = path.isAbsolute(normalizedFilePath) ? normalizedFilePath : path.join(projectAbs, normalizedFilePath);
75
- rel = path.relative(projectAbs, fileAbs);
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) ? normalizedFilePath : path.posix.join(projectPosix, normalizedFilePath.replace(/\\/g, '/'));
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
 
@@ -231,4 +231,31 @@ var meta = { name: 'valid-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', cove
231
231
  const eventsContent = readTodayEvents(tempStateDir);
232
232
  expect(eventsContent).not.toContain('rulehost_unhealthy');
233
233
  });
234
+
235
+ it('runtime-invalid result is persisted as rulehost_unhealthy and never executes', async () => {
236
+ const invalidResultCode = `
237
+ function evaluate() {
238
+ return { decision: 'block', matched: 'yes', reason: 'invalid matched type' };
239
+ }
240
+ var meta = { name: 'invalid-result-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
241
+ `;
242
+ const now = new Date().toISOString();
243
+ sqliteConn.getDb().prepare(`
244
+ 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)
245
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
246
+ `).run(
247
+ ARTIFACT_ID_BROKEN, 'rule', 'task-invalid-result', 'P_TEST_INVALID_RESULT', RULE_ID_BROKEN,
248
+ '[]', 'validated', JSON.stringify({ ruleId: RULE_ID_BROKEN, implementationCode: invalidResultCode }), now, now,
249
+ );
250
+ await insertActivation();
251
+
252
+ const ruleHost = new RuleHost(tempStateDir, { warn: () => {} }, { workspaceDir: tempWorkspaceDir });
253
+ expect(ruleHost.evaluate(makeInput())).toBeUndefined();
254
+ EventLogService.get(tempStateDir).flush();
255
+
256
+ const events = readTodayEvents(tempStateDir);
257
+ expect(events).toContain('rulehost_unhealthy');
258
+ expect(events).toContain('invalid RuleHostResult');
259
+ expect(events).toContain(ACTIVATION_ID_BROKEN);
260
+ });
234
261
  });
@@ -34,4 +34,16 @@ describe('rule-implementation-runtime', () => {
34
34
 
35
35
  expect((globalThis as Record<string, unknown>).__pdRuleHostLeak).toBeUndefined();
36
36
  });
37
+
38
+ it('contains memory-exhausting evaluation in a resource-limited worker', () => {
39
+ const moduleExports = loadRuleImplementationModule(
40
+ `export function evaluate() {
41
+ const memoryBomb = new Array(100_000_000).fill('x');
42
+ return { decision: 'allow', matched: false, reason: String(memoryBomb.length) };
43
+ }`,
44
+ 'rule-memory-limit.js',
45
+ );
46
+
47
+ expect(() => moduleExports.callEvaluate?.({}, {})).toThrow(/worker|memory|heap|timed out|exited without a valid result/i);
48
+ });
37
49
  });