principles-disciple 1.124.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.
@@ -0,0 +1,231 @@
1
+ /**
2
+ * PRI-437 Slice 3: Infinite loop and memory allocation terminate without taking down host
3
+ *
4
+ * PURPOSE: Verify that RuleCode with infinite loops or excessive memory allocation
5
+ * is terminated by the vm timeout boundary and degrades conservatively (undefined).
6
+ *
7
+ * ERR risk mitigation:
8
+ * - ERR-002: timeout/degradation must include a reason (not silent)
9
+ * - Resource boundary: RuleCode must have time AND memory limits
10
+ *
11
+ * Test approach:
12
+ * - Real SQLite activation with malicious RuleCode
13
+ * - Real RuleHost.evaluate() (public interface)
14
+ * - Verify termination within reasonable time (not hang forever)
15
+ * - Verify conservative degradation (undefined result + warn evidence)
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
22
+ import type { RuleHostInput } from '@principles/core/runtime-v2';
23
+ import { RuleHost } from '../../src/core/rule-host.js';
24
+
25
+ // ── Test helpers ───────────────────────────────────────────────────────────
26
+
27
+ const RULE_ID_INFINITE = 'R_TEST_INFINITE_003';
28
+ const ARTIFACT_ID_INFINITE = 'art-infinite-003';
29
+ const ACTIVATION_ID_INFINITE = `act_code_${RULE_ID_INFINITE}`;
30
+
31
+ const INFINITE_LOOP_CODE = `
32
+ function evaluate(input, helpers) {
33
+ // Infinite loop — must be terminated by vm timeout
34
+ while (true) {
35
+ // burn CPU forever
36
+ }
37
+ return { decision: 'block', matched: true, reason: 'never reached' };
38
+ }
39
+ var meta = { name: 'infinite-loop-rule', version: '1', ruleId: '${RULE_ID_INFINITE}', coversCondition: 'all' };
40
+ `;
41
+
42
+ const RULE_ID_MEMORY = 'R_TEST_MEMORY_003';
43
+ const ARTIFACT_ID_MEMORY = 'art-memory-003';
44
+ const ACTIVATION_ID_MEMORY = `act_code_${RULE_ID_MEMORY}`;
45
+
46
+ const MEMORY_BOMB_CODE = `
47
+ function evaluate(input, helpers) {
48
+ // Excessive memory allocation — must be bounded or caught
49
+ var arr = [];
50
+ for (var i = 0; i < 100000000; i++) {
51
+ arr.push(new Array(10000));
52
+ }
53
+ return { decision: 'block', matched: true, reason: 'memory bomb' };
54
+ }
55
+ var meta = { name: 'memory-bomb-rule', version: '1', ruleId: '${RULE_ID_MEMORY}', coversCondition: 'all' };
56
+ `;
57
+
58
+ let tempWorkspaceDir: string;
59
+ let tempStateDir: string;
60
+ let sqliteConn: SqliteConnection;
61
+
62
+ function setupTempDirs(): void {
63
+ const baseTmp = os.tmpdir();
64
+ tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-bounds-'));
65
+ tempStateDir = path.join(tempWorkspaceDir, '.principles');
66
+ fs.mkdirSync(tempStateDir, { recursive: true });
67
+ }
68
+
69
+ function insertRuleArtifact(
70
+ artifactId: string,
71
+ ruleId: string,
72
+ sourceTaskId: string,
73
+ code: string,
74
+ ): void {
75
+ const db = sqliteConn.getDb();
76
+ const now = new Date().toISOString();
77
+ const contentJson = JSON.stringify({
78
+ principleId: `P_${ruleId}`,
79
+ ruleId,
80
+ implementationCode: code,
81
+ goldenTrace: { traceId: `trace-${ruleId}`, cases: [], createdAt: now, version: 1 },
82
+ ruleHostGateDecision: 'accepted_shadow',
83
+ affectedTools: ['write_file'],
84
+ painReasonSummary: 'Test: resource boundary',
85
+ });
86
+
87
+ db.prepare(`
88
+ 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)
89
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
90
+ `).run(
91
+ artifactId,
92
+ 'rule',
93
+ sourceTaskId,
94
+ `P_${ruleId}`,
95
+ ruleId,
96
+ '[]',
97
+ 'validated',
98
+ contentJson,
99
+ now,
100
+ now,
101
+ );
102
+ }
103
+
104
+ async function insertActivation(
105
+ activationId: string,
106
+ artifactId: string,
107
+ ruleId: string,
108
+ ): Promise<void> {
109
+ const store = new SqliteActivationStateStore(sqliteConn);
110
+ const now = new Date().toISOString();
111
+ await store.recordActivation({
112
+ activationId,
113
+ idempotencyKey: `${artifactId}::code_tool_hook`,
114
+ artifactId,
115
+ channel: 'code_tool_hook',
116
+ action: 'code_tool_hook_shadow_activate',
117
+ targetRef: `impl://${ruleId}`,
118
+ activatedAt: now,
119
+ deactivatedAt: null,
120
+ });
121
+ }
122
+
123
+ function makeInput(): RuleHostInput {
124
+ return {
125
+ action: {
126
+ toolName: 'write_file',
127
+ normalizedPath: '/etc/passwd',
128
+ paramsSummary: { path: '/etc/passwd' },
129
+ },
130
+ workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
131
+ session: { sessionId: 'test-session-bounds', currentGfi: 0, recentThinking: false },
132
+ evolution: { epTier: 1 },
133
+ derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
134
+ };
135
+ }
136
+
137
+ // ── Setup / Teardown ───────────────────────────────────────────────────────
138
+
139
+ beforeEach(() => {
140
+ setupTempDirs();
141
+ sqliteConn = new SqliteConnection(tempWorkspaceDir);
142
+ sqliteConn.getDb();
143
+ });
144
+
145
+ afterEach(() => {
146
+ try { sqliteConn?.close(); } catch { /* best-effort */ }
147
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
148
+ });
149
+
150
+ // ── Slice 3: Resource boundaries ───────────────────────────────────────────
151
+
152
+ describe('PRI-437 Slice 3: Infinite loop and memory allocation terminate without taking down host', () => {
153
+ it('infinite loop in evaluate() is terminated by vm timeout → undefined + warn evidence', async () => {
154
+ insertRuleArtifact(ARTIFACT_ID_INFINITE, RULE_ID_INFINITE, 'task-infinite-003', INFINITE_LOOP_CODE);
155
+ await insertActivation(ACTIVATION_ID_INFINITE, ARTIFACT_ID_INFINITE, RULE_ID_INFINITE);
156
+
157
+ const warnCalls: string[] = [];
158
+ const spyLogger = {
159
+ warn: (message: string) => { warnCalls.push(message); },
160
+ error: () => {},
161
+ info: () => {},
162
+ };
163
+
164
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
165
+
166
+ // Must terminate within 10 seconds (vm timeout is 1000ms, plus overhead)
167
+ const startTime = Date.now();
168
+ const result = ruleHost.evaluate(makeInput());
169
+ const elapsed = Date.now() - startTime;
170
+
171
+ // Conservative degradation: undefined (no opinion)
172
+ expect(result).toBeUndefined();
173
+
174
+ // Must not hang — should complete well under 10 seconds
175
+ expect(elapsed).toBeLessThan(10000);
176
+
177
+ // Must emit structured warn evidence (ERR-002: degradation includes reason)
178
+ expect(warnCalls.length).toBeGreaterThan(0);
179
+ const timeoutWarn = warnCalls.find(m =>
180
+ m.toLowerCase().includes('timeout') ||
181
+ m.toLowerCase().includes('timed out') ||
182
+ m.toLowerCase().includes('terminated')
183
+ );
184
+ expect(timeoutWarn).toBeDefined();
185
+ }, 15000); // 15 second test timeout (fail safe)
186
+
187
+ it('memory bomb in evaluate() is bounded or caught → undefined + warn evidence', async () => {
188
+ insertRuleArtifact(ARTIFACT_ID_MEMORY, RULE_ID_MEMORY, 'task-memory-003', MEMORY_BOMB_CODE);
189
+ await insertActivation(ACTIVATION_ID_MEMORY, ARTIFACT_ID_MEMORY, RULE_ID_MEMORY);
190
+
191
+ const warnCalls: string[] = [];
192
+ const spyLogger = {
193
+ warn: (message: string) => { warnCalls.push(message); },
194
+ error: () => {},
195
+ info: () => {},
196
+ };
197
+
198
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
199
+
200
+ // Must terminate without crashing the host process
201
+ const startTime = Date.now();
202
+ let thrown: unknown;
203
+ let result: unknown;
204
+ try {
205
+ result = ruleHost.evaluate(makeInput());
206
+ } catch (err) {
207
+ thrown = err;
208
+ }
209
+ const elapsed = Date.now() - startTime;
210
+
211
+ // PRI-437 fail-closed: must NOT throw (vm timeout should catch it),
212
+ // and must return undefined (conservative degradation)
213
+ expect(thrown).toBeUndefined();
214
+ expect(result).toBeUndefined();
215
+
216
+ // Must not hang — should complete well under 30 seconds
217
+ expect(elapsed).toBeLessThan(30000);
218
+
219
+ // Must emit structured warn evidence with reason (ERR-002)
220
+ expect(warnCalls.length).toBeGreaterThan(0);
221
+ const reasonWarn = warnCalls.find(m =>
222
+ m.toLowerCase().includes('timeout') ||
223
+ m.toLowerCase().includes('timed out') ||
224
+ m.toLowerCase().includes('terminated') ||
225
+ m.toLowerCase().includes('memory') ||
226
+ m.toLowerCase().includes('heap') ||
227
+ m.toLowerCase().includes('range')
228
+ );
229
+ expect(reasonWarn).toBeDefined();
230
+ }, 60000); // 60 second test timeout (fail safe for memory pressure)
231
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * PRI-437 Slice 4: Approved compile failure is visible in EventLog, CLI and Console
3
+ *
4
+ * PURPOSE: Verify that when an approved rule fails to compile/load, the failure
5
+ * is recorded in EventLog as an unhealthy event with reason and nextAction.
6
+ * NOT just a logger.warn that's silently skipped.
7
+ *
8
+ * ERR risk mitigation:
9
+ * - ERR-002: degradation must include a reason (not silent logger.warn)
10
+ * - Observability: unhealthy state must be persisted and queryable
11
+ *
12
+ * Test approach:
13
+ * - Real SQLite activation with broken code (syntax error)
14
+ * - Real RuleHost.evaluate() (public interface)
15
+ * - Read real EventLog JSONL file to verify unhealthy record
16
+ */
17
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { SqliteConnection, SqliteActivationStateStore } from '@principles/core/runtime-v2';
22
+ import type { RuleHostInput } from '@principles/core/runtime-v2';
23
+ import { RuleHost } from '../../src/core/rule-host.js';
24
+ import { EventLogService } from '../../src/core/event-log.js';
25
+
26
+ // ── Test helpers ───────────────────────────────────────────────────────────
27
+
28
+ const RULE_ID_BROKEN = 'R_TEST_BROKEN_004';
29
+ const ARTIFACT_ID_BROKEN = 'art-broken-004';
30
+ const ACTIVATION_ID_BROKEN = `act_code_${RULE_ID_BROKEN}`;
31
+
32
+ // Code with a syntax error that will cause compilation failure
33
+ const BROKEN_CODE = `
34
+ function evaluate(input, helpers) {
35
+ // Syntax error: unclosed brace
36
+ return { decision: 'block', matched: true, reason: 'broken'
37
+ // Missing closing brace for function and return object
38
+ var meta = { name: 'broken-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
39
+ `;
40
+
41
+ let tempWorkspaceDir: string;
42
+ let tempStateDir: string;
43
+ let sqliteConn: SqliteConnection;
44
+
45
+ function setupTempDirs(): void {
46
+ const baseTmp = os.tmpdir();
47
+ tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-unhealthy-'));
48
+ tempStateDir = path.join(tempWorkspaceDir, '.principles');
49
+ fs.mkdirSync(tempStateDir, { recursive: true });
50
+ }
51
+
52
+ function insertRuleArtifact(): void {
53
+ const db = sqliteConn.getDb();
54
+ const now = new Date().toISOString();
55
+ const contentJson = JSON.stringify({
56
+ principleId: 'P_TEST_BROKEN_004',
57
+ ruleId: RULE_ID_BROKEN,
58
+ implementationCode: BROKEN_CODE,
59
+ goldenTrace: { traceId: 'trace-broken-004', cases: [], createdAt: now, version: 1 },
60
+ ruleHostGateDecision: 'accepted_shadow',
61
+ affectedTools: ['write_file'],
62
+ painReasonSummary: 'Test: broken code compilation',
63
+ });
64
+
65
+ db.prepare(`
66
+ 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)
67
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
68
+ `).run(
69
+ ARTIFACT_ID_BROKEN,
70
+ 'rule',
71
+ 'task-broken-004',
72
+ 'P_TEST_BROKEN_004',
73
+ RULE_ID_BROKEN,
74
+ '[]',
75
+ 'validated',
76
+ contentJson,
77
+ now,
78
+ now,
79
+ );
80
+ }
81
+
82
+ async function insertActivation(): Promise<void> {
83
+ const store = new SqliteActivationStateStore(sqliteConn);
84
+ const now = new Date().toISOString();
85
+ await store.recordActivation({
86
+ activationId: ACTIVATION_ID_BROKEN,
87
+ idempotencyKey: `${ARTIFACT_ID_BROKEN}::code_tool_hook`,
88
+ artifactId: ARTIFACT_ID_BROKEN,
89
+ channel: 'code_tool_hook',
90
+ action: 'code_tool_hook_shadow_activate',
91
+ targetRef: `impl://${RULE_ID_BROKEN}`,
92
+ activatedAt: now,
93
+ deactivatedAt: null,
94
+ });
95
+ }
96
+
97
+ function makeInput(): RuleHostInput {
98
+ return {
99
+ action: {
100
+ toolName: 'write_file',
101
+ normalizedPath: '/etc/passwd',
102
+ paramsSummary: { path: '/etc/passwd' },
103
+ },
104
+ workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
105
+ session: { sessionId: 'test-session-unhealthy', currentGfi: 0, recentThinking: false },
106
+ evolution: { epTier: 1 },
107
+ derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
108
+ };
109
+ }
110
+
111
+ function readTodayEvents(stateDir: string): string {
112
+ const today = new Date().toISOString().slice(0, 10);
113
+ const eventsFile = path.join(stateDir, 'logs', `events_${today}.jsonl`);
114
+ if (!fs.existsSync(eventsFile)) {
115
+ return '';
116
+ }
117
+ return fs.readFileSync(eventsFile, 'utf-8');
118
+ }
119
+
120
+ // ── Setup / Teardown ───────────────────────────────────────────────────────
121
+
122
+ beforeEach(() => {
123
+ setupTempDirs();
124
+ EventLogService.disposeAll();
125
+ sqliteConn = new SqliteConnection(tempWorkspaceDir);
126
+ sqliteConn.getDb();
127
+ });
128
+
129
+ afterEach(() => {
130
+ EventLogService.disposeAll();
131
+ try { sqliteConn?.close(); } catch { /* best-effort */ }
132
+ try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
133
+ });
134
+
135
+ // ── Slice 4: Compile failure visible in EventLog ───────────────────────────
136
+
137
+ describe('PRI-437 Slice 4: Approved compile failure is visible in EventLog', () => {
138
+ it('broken code compilation → EventLog records rulehost_unhealthy with reason and nextAction', async () => {
139
+ insertRuleArtifact();
140
+ await insertActivation();
141
+
142
+ const warnCalls: string[] = [];
143
+ const spyLogger = {
144
+ warn: (message: string) => { warnCalls.push(message); },
145
+ error: () => {},
146
+ info: () => {},
147
+ };
148
+
149
+ const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
150
+
151
+ // Evaluate — should attempt to compile the broken code and fail
152
+ const result = ruleHost.evaluate(makeInput());
153
+
154
+ // Conservative degradation: undefined (no opinion)
155
+ expect(result).toBeUndefined();
156
+
157
+ // Flush EventLog to ensure events are written to disk
158
+ const eventLog = EventLogService.get(tempStateDir);
159
+ eventLog.flush();
160
+
161
+ // Read the EventLog JSONL file and verify unhealthy record exists
162
+ const eventsContent = readTodayEvents(tempStateDir);
163
+
164
+ // Must contain a rulehost_unhealthy event (NOT just logger.warn)
165
+ expect(eventsContent).toContain('rulehost_unhealthy');
166
+
167
+ // Structured validation: parse JSONL lines and find the unhealthy event
168
+ const lines = eventsContent.trim().split('\n').filter(l => l.trim());
169
+ const unhealthyEvents = lines
170
+ .map(line => { try { return JSON.parse(line); } catch { return null; } })
171
+ .filter(evt => evt?.type === 'rulehost_unhealthy');
172
+
173
+ expect(unhealthyEvents.length).toBeGreaterThanOrEqual(1);
174
+
175
+ // Verify the unhealthy event has all required fields in the same record
176
+ const unhealthy = unhealthyEvents[0];
177
+ expect(unhealthy.data.activationId).toBe(ACTIVATION_ID_BROKEN);
178
+ expect(unhealthy.data.ruleId).toBe(RULE_ID_BROKEN);
179
+ expect(typeof unhealthy.data.reason).toBe('string');
180
+ expect(unhealthy.data.reason.length).toBeGreaterThan(0);
181
+ expect(typeof unhealthy.data.nextAction).toBe('string');
182
+ expect(unhealthy.data.nextAction.length).toBeGreaterThan(0);
183
+ });
184
+
185
+ it('valid rule does NOT produce rulehost_unhealthy event', async () => {
186
+ // Insert a VALID rule (not broken)
187
+ const VALID_CODE = `
188
+ function evaluate(input, helpers) {
189
+ var p = input.action.normalizedPath || '';
190
+ if (p.indexOf('/etc/') === 0) {
191
+ return { decision: 'block', matched: true, reason: 'valid block' };
192
+ }
193
+ return { decision: 'allow', matched: false, reason: 'not matched' };
194
+ }
195
+ var meta = { name: 'valid-rule', version: '1', ruleId: '${RULE_ID_BROKEN}', coversCondition: 'all' };
196
+ `;
197
+
198
+ const db = sqliteConn.getDb();
199
+ const now = new Date().toISOString();
200
+ const contentJson = JSON.stringify({
201
+ principleId: 'P_TEST_VALID_004',
202
+ ruleId: RULE_ID_BROKEN,
203
+ implementationCode: VALID_CODE,
204
+ goldenTrace: { traceId: 'trace-valid-004', cases: [], createdAt: now, version: 1 },
205
+ ruleHostGateDecision: 'accepted_shadow',
206
+ affectedTools: ['write_file'],
207
+ painReasonSummary: 'Test: valid code',
208
+ });
209
+
210
+ db.prepare(`
211
+ 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)
212
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
213
+ `).run(
214
+ ARTIFACT_ID_BROKEN, 'rule', 'task-valid-004', 'P_TEST_VALID_004', RULE_ID_BROKEN,
215
+ '[]', 'validated', contentJson, now, now,
216
+ );
217
+
218
+ await insertActivation();
219
+
220
+ const ruleHost = new RuleHost(tempStateDir, { warn: () => {}, error: () => {}, info: () => {} }, { workspaceDir: tempWorkspaceDir });
221
+ const result = ruleHost.evaluate(makeInput());
222
+
223
+ // Valid rule should block (not undefined)
224
+ expect(result).toBeDefined();
225
+ expect(result?.decision).toBe('block');
226
+
227
+ // Flush and verify NO unhealthy event
228
+ const eventLog = EventLogService.get(tempStateDir);
229
+ eventLog.flush();
230
+
231
+ const eventsContent = readTodayEvents(tempStateDir);
232
+ expect(eventsContent).not.toContain('rulehost_unhealthy');
233
+ });
234
+ });