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.
@@ -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.124.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.124.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",
@@ -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,
@@ -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, 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
+ });
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.evaluate !== 'function') {
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}: compiled module has no evaluate function, skipping`
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
- const rawEvaluate = moduleExports.evaluate as (
262
- _input: RuleHostInput,
263
- _helpers: ReturnType<typeof createRuleHostHelpers>
264
- ) => RuleHostResult;
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
- const result = rawEvaluate(input, frozenHelpers);
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}: ${String(loadError)}`
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: 1000,
83
+ timeout: COMPILE_TIMEOUT_MS,
31
84
  displayErrors: true,
32
85
  });
33
86
 
34
87
  const moduleExports = (context as { __pdRuleModule?: RuleImplementationModuleExports }).__pdRuleModule;
35
- delete (context as { __pdRuleModule?: RuleImplementationModuleExports }).__pdRuleModule;
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 moduleExports ?? {};
140
+ return {
141
+ meta,
142
+ evaluate: moduleExports?.evaluate,
143
+ callEvaluate,
144
+ };
38
145
  }
@@ -23,6 +23,7 @@ export type {
23
23
  RuleHostAutoCorrectProposedEventData,
24
24
  RuleHostAutoCorrectAppliedEventData,
25
25
  RuntimeV2PromptActivationsInjectedEventData,
26
+ RuleHostUnhealthyEventData,
26
27
  ToolCallStats,
27
28
  ErrorStats,
28
29
  PainStats,
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
 
@@ -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
+ });