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
|
@@ -5,9 +5,7 @@ import * as path from 'path';
|
|
|
5
5
|
import {
|
|
6
6
|
deleteImplementationAssetDir,
|
|
7
7
|
getImplementationAssetRoot,
|
|
8
|
-
loadManifest,
|
|
9
8
|
writeManifest,
|
|
10
|
-
loadEntrySource,
|
|
11
9
|
createImplementationAssetDir,
|
|
12
10
|
writeEntrySource,
|
|
13
11
|
type CodeImplementationManifest,
|
|
@@ -63,47 +61,6 @@ describe('CodeImplementationStorage', () => {
|
|
|
63
61
|
});
|
|
64
62
|
});
|
|
65
63
|
|
|
66
|
-
// -------------------------------------------------------------------------
|
|
67
|
-
// loadManifest
|
|
68
|
-
// -------------------------------------------------------------------------
|
|
69
|
-
|
|
70
|
-
describe('loadManifest', () => {
|
|
71
|
-
it('returns null when no manifest file exists', () => {
|
|
72
|
-
const result = loadManifest(stateDir, 'nonexistent');
|
|
73
|
-
expect(result).toBeNull();
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('returns parsed manifest when manifest file exists', () => {
|
|
77
|
-
const implId = 'IMPL_002';
|
|
78
|
-
const manifest: CodeImplementationManifest = {
|
|
79
|
-
version: '1.0.0',
|
|
80
|
-
entryFile: 'entry.js',
|
|
81
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
82
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
83
|
-
replaySampleRefs: [],
|
|
84
|
-
lastEvalReportRef: null,
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
// Write directly (bypass writeManifest to test load independently)
|
|
88
|
-
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
89
|
-
fs.mkdirSync(assetRoot, { recursive: true });
|
|
90
|
-
fs.writeFileSync(path.join(assetRoot, 'manifest.json'), JSON.stringify(manifest), 'utf-8');
|
|
91
|
-
|
|
92
|
-
const result = loadManifest(stateDir, implId);
|
|
93
|
-
expect(result).toEqual(manifest);
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
it('returns null for corrupted manifest JSON', () => {
|
|
97
|
-
const implId = 'IMPL_003';
|
|
98
|
-
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
99
|
-
fs.mkdirSync(assetRoot, { recursive: true });
|
|
100
|
-
fs.writeFileSync(path.join(assetRoot, 'manifest.json'), 'not-valid-json{{{', 'utf-8');
|
|
101
|
-
|
|
102
|
-
const result = loadManifest(stateDir, implId);
|
|
103
|
-
expect(result).toBeNull();
|
|
104
|
-
});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
64
|
// -------------------------------------------------------------------------
|
|
108
65
|
// writeManifest
|
|
109
66
|
// -------------------------------------------------------------------------
|
|
@@ -177,67 +134,6 @@ describe('CodeImplementationStorage', () => {
|
|
|
177
134
|
});
|
|
178
135
|
});
|
|
179
136
|
|
|
180
|
-
// -------------------------------------------------------------------------
|
|
181
|
-
// loadEntrySource
|
|
182
|
-
// -------------------------------------------------------------------------
|
|
183
|
-
|
|
184
|
-
describe('loadEntrySource', () => {
|
|
185
|
-
it('returns null when no manifest exists', () => {
|
|
186
|
-
const result = loadEntrySource(stateDir, 'nonexistent');
|
|
187
|
-
expect(result).toBeNull();
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it('returns null when manifest exists but entry file does not', () => {
|
|
191
|
-
const implId = 'IMPL_007';
|
|
192
|
-
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
193
|
-
fs.mkdirSync(assetRoot, { recursive: true });
|
|
194
|
-
|
|
195
|
-
// Write manifest pointing to a non-existent entry file
|
|
196
|
-
const manifest: CodeImplementationManifest = {
|
|
197
|
-
version: '1.0.0',
|
|
198
|
-
entryFile: 'nonexistent.js',
|
|
199
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
200
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
201
|
-
replaySampleRefs: [],
|
|
202
|
-
lastEvalReportRef: null,
|
|
203
|
-
};
|
|
204
|
-
fs.writeFileSync(
|
|
205
|
-
path.join(assetRoot, 'manifest.json'),
|
|
206
|
-
JSON.stringify(manifest),
|
|
207
|
-
'utf-8',
|
|
208
|
-
);
|
|
209
|
-
|
|
210
|
-
const result = loadEntrySource(stateDir, implId);
|
|
211
|
-
expect(result).toBeNull();
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
it('returns file content when entry file exists', () => {
|
|
215
|
-
const implId = 'IMPL_008';
|
|
216
|
-
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
217
|
-
fs.mkdirSync(assetRoot, { recursive: true });
|
|
218
|
-
|
|
219
|
-
const entryContent = 'export function evaluate() { return true; }';
|
|
220
|
-
fs.writeFileSync(path.join(assetRoot, 'entry.js'), entryContent, 'utf-8');
|
|
221
|
-
|
|
222
|
-
const manifest: CodeImplementationManifest = {
|
|
223
|
-
version: '1.0.0',
|
|
224
|
-
entryFile: 'entry.js',
|
|
225
|
-
createdAt: '2026-01-01T00:00:00.000Z',
|
|
226
|
-
updatedAt: '2026-01-01T00:00:00.000Z',
|
|
227
|
-
replaySampleRefs: [],
|
|
228
|
-
lastEvalReportRef: null,
|
|
229
|
-
};
|
|
230
|
-
fs.writeFileSync(
|
|
231
|
-
path.join(assetRoot, 'manifest.json'),
|
|
232
|
-
JSON.stringify(manifest),
|
|
233
|
-
'utf-8',
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
const result = loadEntrySource(stateDir, implId);
|
|
237
|
-
expect(result).toBe(entryContent);
|
|
238
|
-
});
|
|
239
|
-
});
|
|
240
|
-
|
|
241
137
|
// -------------------------------------------------------------------------
|
|
242
138
|
// createImplementationAssetDir
|
|
243
139
|
// -------------------------------------------------------------------------
|
|
@@ -300,7 +196,9 @@ describe('CodeImplementationStorage', () => {
|
|
|
300
196
|
artificerArtifactId: 'artifact-1',
|
|
301
197
|
});
|
|
302
198
|
|
|
303
|
-
|
|
199
|
+
// Verify persisted manifest contains lineage
|
|
200
|
+
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
201
|
+
const loaded = JSON.parse(fs.readFileSync(path.join(assetRoot, 'manifest.json'), 'utf-8'));
|
|
304
202
|
expect(loaded?.lineage).toMatchObject({
|
|
305
203
|
sourcePainIds: ['pain:gate:1'],
|
|
306
204
|
sourceGateBlockIds: ['gate:write:1'],
|
|
@@ -344,22 +242,23 @@ describe('CodeImplementationStorage', () => {
|
|
|
344
242
|
expect(fs.existsSync(reportPath)).toBe(true);
|
|
345
243
|
});
|
|
346
244
|
|
|
347
|
-
it('manifest is
|
|
245
|
+
it('manifest is persisted with correct version after creation', () => {
|
|
348
246
|
const implId = 'IMPL_014';
|
|
349
247
|
createImplementationAssetDir(stateDir, implId, '4.0.0');
|
|
350
248
|
|
|
351
|
-
const
|
|
249
|
+
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
250
|
+
const loaded = JSON.parse(fs.readFileSync(path.join(assetRoot, 'manifest.json'), 'utf-8'));
|
|
352
251
|
expect(loaded).not.toBeNull();
|
|
353
|
-
expect(loaded
|
|
354
|
-
expect(loaded
|
|
252
|
+
expect(loaded.version).toBe('4.0.0');
|
|
253
|
+
expect(loaded.entryFile).toBe('entry.js');
|
|
355
254
|
});
|
|
356
255
|
|
|
357
|
-
it('entry source is
|
|
256
|
+
it('entry source is persisted after creation', () => {
|
|
358
257
|
const implId = 'IMPL_015';
|
|
359
258
|
createImplementationAssetDir(stateDir, implId, '1.0.0');
|
|
360
259
|
|
|
361
|
-
const
|
|
362
|
-
|
|
260
|
+
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
261
|
+
const source = fs.readFileSync(path.join(assetRoot, 'entry.js'), 'utf-8');
|
|
363
262
|
expect(source).toContain('export const meta');
|
|
364
263
|
expect(source).toContain('export function evaluate');
|
|
365
264
|
});
|
|
@@ -370,7 +269,8 @@ describe('CodeImplementationStorage', () => {
|
|
|
370
269
|
entrySource: 'export const meta = { name: "x", version: "1", ruleId: "R", coversCondition: "c" }; export function evaluate() { return { decision: "allow", matched: false, reason: "ok" }; }',
|
|
371
270
|
});
|
|
372
271
|
|
|
373
|
-
const
|
|
272
|
+
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
273
|
+
const source = fs.readFileSync(path.join(assetRoot, 'entry.js'), 'utf-8');
|
|
374
274
|
expect(source).toContain('export const meta');
|
|
375
275
|
expect(source).not.toContain('placeholder');
|
|
376
276
|
});
|
|
@@ -383,7 +283,9 @@ describe('CodeImplementationStorage', () => {
|
|
|
383
283
|
|
|
384
284
|
writeEntrySource(stateDir, implId, 'export const meta = { name: "updated", version: "1", ruleId: "R", coversCondition: "c" }; export function evaluate() { return { decision: "allow", matched: false, reason: "updated" }; }');
|
|
385
285
|
|
|
386
|
-
|
|
286
|
+
const assetRoot = getImplementationAssetRoot(stateDir, implId);
|
|
287
|
+
const source = fs.readFileSync(path.join(assetRoot, 'entry.js'), 'utf-8');
|
|
288
|
+
expect(source).toContain('updated');
|
|
387
289
|
});
|
|
388
290
|
|
|
389
291
|
it('removes the asset directory through deleteImplementationAssetDir', () => {
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRI-437 Adversarial Self-Review: Valid auto_correct from VM must be accepted
|
|
3
|
+
*
|
|
4
|
+
* PURPOSE: Verify that a valid auto_correct proposal created INSIDE the vm
|
|
5
|
+
* context is accepted by the validator (not rejected due to prototype realm
|
|
6
|
+
* mismatch).
|
|
7
|
+
*
|
|
8
|
+
* CONTEXT: validateCorrectionProposal uses isPlainObject which checks
|
|
9
|
+
* Object.getPrototypeOf() === Object.prototype. VM-created objects have
|
|
10
|
+
* prototypes from the VM realm, not the host realm, so isPlainObject
|
|
11
|
+
* returns false and rejects all VM-created proposals.
|
|
12
|
+
*
|
|
13
|
+
* ERR risk mitigation:
|
|
14
|
+
* - ERR-001: no `as` bypass on untrusted VM output
|
|
15
|
+
* - ERR-002: fail-closed with reason (not silent rejection)
|
|
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
|
+
let tempWorkspaceDir: string;
|
|
26
|
+
let tempStateDir: string;
|
|
27
|
+
let sqliteConn: SqliteConnection;
|
|
28
|
+
|
|
29
|
+
function setupTempDirs(): void {
|
|
30
|
+
const baseTmp = os.tmpdir();
|
|
31
|
+
tempWorkspaceDir = fs.mkdtempSync(path.join(baseTmp, 'pd-rulehost-autocorrect-'));
|
|
32
|
+
tempStateDir = path.join(tempWorkspaceDir, '.principles');
|
|
33
|
+
fs.mkdirSync(tempStateDir, { recursive: true });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function insertRuleArtifact(
|
|
37
|
+
artifactId: string,
|
|
38
|
+
ruleId: string,
|
|
39
|
+
sourceTaskId: string,
|
|
40
|
+
code: string,
|
|
41
|
+
): void {
|
|
42
|
+
const db = sqliteConn.getDb();
|
|
43
|
+
const now = new Date().toISOString();
|
|
44
|
+
const contentJson = JSON.stringify({
|
|
45
|
+
principleId: `P_${ruleId}`,
|
|
46
|
+
ruleId,
|
|
47
|
+
implementationCode: code,
|
|
48
|
+
goldenTrace: { traceId: `trace-${ruleId}`, cases: [], createdAt: now, version: 1 },
|
|
49
|
+
ruleHostGateDecision: 'accepted_shadow',
|
|
50
|
+
affectedTools: ['write_file'],
|
|
51
|
+
painReasonSummary: 'Test: auto_correct',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
db.prepare(`
|
|
55
|
+
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)
|
|
56
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
57
|
+
`).run(
|
|
58
|
+
artifactId, 'rule', sourceTaskId, `P_${ruleId}`, ruleId,
|
|
59
|
+
'[]', 'validated', contentJson, now, now,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function insertActivation(
|
|
64
|
+
activationId: string,
|
|
65
|
+
artifactId: string,
|
|
66
|
+
ruleId: string,
|
|
67
|
+
): Promise<void> {
|
|
68
|
+
const store = new SqliteActivationStateStore(sqliteConn);
|
|
69
|
+
const now = new Date().toISOString();
|
|
70
|
+
await store.recordActivation({
|
|
71
|
+
activationId,
|
|
72
|
+
idempotencyKey: `${artifactId}::code_tool_hook`,
|
|
73
|
+
artifactId,
|
|
74
|
+
channel: 'code_tool_hook',
|
|
75
|
+
action: 'code_tool_hook_shadow_activate',
|
|
76
|
+
targetRef: `impl://${ruleId}`,
|
|
77
|
+
activatedAt: now,
|
|
78
|
+
deactivatedAt: null,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function makeInput(normalizedPath: string): RuleHostInput {
|
|
83
|
+
return {
|
|
84
|
+
action: {
|
|
85
|
+
toolName: 'write_file',
|
|
86
|
+
normalizedPath,
|
|
87
|
+
paramsSummary: { path: normalizedPath },
|
|
88
|
+
},
|
|
89
|
+
workspace: { isRiskPath: false, planStatus: 'NONE' as const, hasPlanFile: false },
|
|
90
|
+
session: { sessionId: 'test-session-autocorrect', currentGfi: 0, recentThinking: false },
|
|
91
|
+
evolution: { epTier: 0 },
|
|
92
|
+
derived: { estimatedLineChanges: 1, bashRisk: 'safe' as const },
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
beforeEach(() => {
|
|
97
|
+
setupTempDirs();
|
|
98
|
+
sqliteConn = new SqliteConnection(tempWorkspaceDir);
|
|
99
|
+
sqliteConn.getDb();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
try { sqliteConn?.close(); } catch { /* best-effort */ }
|
|
104
|
+
try { fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); } catch { /* Windows */ }
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe('PRI-437 Adversarial Self-Review: Valid auto_correct from VM must be accepted', () => {
|
|
108
|
+
it('valid auto_correct proposal created inside VM is accepted (not rejected by isPlainObject)', async () => {
|
|
109
|
+
const RULE_ID = 'R_TEST_AUTOCORRECT_VALID';
|
|
110
|
+
const ARTIFACT_ID = 'art-autocorrect-valid';
|
|
111
|
+
const ACTIVATION_ID = `act_code_${RULE_ID}`;
|
|
112
|
+
|
|
113
|
+
// RuleCode that returns a valid auto_correct proposal.
|
|
114
|
+
// The proposal object is created INSIDE the vm context, so its prototype
|
|
115
|
+
// is the VM realm's Object.prototype, not the host's.
|
|
116
|
+
const VALID_AUTOCORRECT_CODE = `
|
|
117
|
+
function evaluate(input, helpers) {
|
|
118
|
+
return {
|
|
119
|
+
decision: 'auto_correct',
|
|
120
|
+
matched: true,
|
|
121
|
+
reason: 'path should be within workspace',
|
|
122
|
+
correctionProposal: {
|
|
123
|
+
ruleId: '${RULE_ID}',
|
|
124
|
+
correctedFields: [{ field: 'file_path', original: input.action.normalizedPath, proposed: '/safe/path', reason: 'redirect to safe path' }],
|
|
125
|
+
proposedParams: { file_path: '/safe/path' },
|
|
126
|
+
applicationMode: 'shadow',
|
|
127
|
+
confidence: 0.9,
|
|
128
|
+
notifyAgent: true
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
var meta = { name: 'autocorrect-rule', version: '1', ruleId: '${RULE_ID}', coversCondition: 'all' };
|
|
133
|
+
`;
|
|
134
|
+
|
|
135
|
+
insertRuleArtifact(ARTIFACT_ID, RULE_ID, 'task-autocorrect-valid', VALID_AUTOCORRECT_CODE);
|
|
136
|
+
await insertActivation(ACTIVATION_ID, ARTIFACT_ID, RULE_ID);
|
|
137
|
+
|
|
138
|
+
const warnCalls: string[] = [];
|
|
139
|
+
const spyLogger = {
|
|
140
|
+
warn: (message: string) => { warnCalls.push(message); },
|
|
141
|
+
error: () => {},
|
|
142
|
+
info: () => {},
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const ruleHost = new RuleHost(tempStateDir, spyLogger, { workspaceDir: tempWorkspaceDir });
|
|
146
|
+
const result = ruleHost.evaluate(makeInput('/etc/passwd'));
|
|
147
|
+
|
|
148
|
+
// The valid auto_correct proposal MUST be accepted — not rejected due to
|
|
149
|
+
// VM prototype realm mismatch.
|
|
150
|
+
expect(result).toBeDefined();
|
|
151
|
+
expect(result?.decision).toBe('auto_correct');
|
|
152
|
+
expect(result?.matched).toBe(true);
|
|
153
|
+
expect(result?.correctionProposal).toBeDefined();
|
|
154
|
+
expect(result?.correctionProposal?.ruleId).toBe(RULE_ID);
|
|
155
|
+
|
|
156
|
+
// No warnings should be emitted about invalid results
|
|
157
|
+
const invalidWarn = warnCalls.find(m =>
|
|
158
|
+
m.toLowerCase().includes('invalid') ||
|
|
159
|
+
m.toLowerCase().includes('must be a plain object')
|
|
160
|
+
);
|
|
161
|
+
expect(invalidWarn).toBeUndefined();
|
|
162
|
+
});
|
|
163
|
+
});
|