principles-disciple 1.62.0 → 1.63.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/hooks/gate-block-helper.ts +1 -1
- package/src/hooks/gate.ts +27 -205
- package/tests/hooks/gate-rule-host-pipeline.test.ts +159 -334
- package/tests/service/evolution-worker.compilation-backfill.test.ts +5 -1
- package/src/hooks/bash-risk.ts +0 -175
- package/src/hooks/edit-verification.ts +0 -302
- package/src/hooks/gfi-gate.ts +0 -186
- package/src/hooks/progressive-trust-gate.ts +0 -183
- package/src/hooks/thinking-checkpoint.ts +0 -76
- package/tests/hooks/bash-risk-integration.test.ts +0 -137
- package/tests/hooks/bash-risk.test.ts +0 -81
- package/tests/hooks/edit-verification.test.ts +0 -678
- package/tests/hooks/gate-edit-verification-p1.test.ts +0 -632
- package/tests/hooks/gate-pipeline-integration.test.ts +0 -404
- package/tests/hooks/gate.test.ts +0 -271
- package/tests/hooks/gfi-gate-unit.test.ts +0 -422
- package/tests/hooks/gfi-gate.test.ts +0 -669
- package/tests/hooks/thinking-gate.test.ts +0 -313
|
@@ -1,404 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Gate Pipeline Integration Tests
|
|
3
|
-
*
|
|
4
|
-
* PURPOSE: Prove that gate.ts has ONE authoritative orchestration path.
|
|
5
|
-
*
|
|
6
|
-
* These tests verify:
|
|
7
|
-
* 1. Progressive gate enabled + edit verification required => verification still runs
|
|
8
|
-
* 2. Progressive gate enabled + GFI block => block still uses the same persistence path
|
|
9
|
-
* 3. Progressive gate enabled + thinking checkpoint => checkpoint still participates in default flow
|
|
10
|
-
* 4. Gate block has ONE authoritative persistence implementation
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
14
|
-
import { handleBeforeToolCall } from '../../src/hooks/gate.js';
|
|
15
|
-
import * as fs from 'fs';
|
|
16
|
-
import * as path from 'path';
|
|
17
|
-
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
18
|
-
import * as sessionTracker from '../../src/core/session-tracker.js';
|
|
19
|
-
import * as evolutionEngine from '../../src/core/evolution-engine.js';
|
|
20
|
-
|
|
21
|
-
vi.mock('fs');
|
|
22
|
-
vi.mock('../../src/core/workspace-context.js');
|
|
23
|
-
vi.mock('../../src/core/session-tracker.js', () => ({
|
|
24
|
-
getSession: vi.fn(() => ({ currentGfi: 0 })),
|
|
25
|
-
trackBlock: vi.fn(),
|
|
26
|
-
hasRecentThinking: vi.fn(() => false),
|
|
27
|
-
}));
|
|
28
|
-
vi.mock('../../src/core/evolution-engine.js', async () => {
|
|
29
|
-
const actual = await vi.importActual('../../src/core/evolution-engine.js');
|
|
30
|
-
return {
|
|
31
|
-
...actual,
|
|
32
|
-
checkEvolutionGate: vi.fn(() => ({ allowed: true, currentTier: 'SEED' })),
|
|
33
|
-
getEvolutionEngine: vi.fn(),
|
|
34
|
-
};
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const mockEvolution = {
|
|
38
|
-
getTier: vi.fn().mockReturnValue(3),
|
|
39
|
-
getPoints: vi.fn().mockReturnValue(200),
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
const mockedHasRecentThinking = vi.mocked(sessionTracker.hasRecentThinking);
|
|
43
|
-
|
|
44
|
-
describe('Gate Pipeline Integration - Single Authoritative Path', () => {
|
|
45
|
-
const workspaceDir = '/mock/workspace';
|
|
46
|
-
const sessionId = 'test-session-123';
|
|
47
|
-
|
|
48
|
-
const mockConfig = {
|
|
49
|
-
get: vi.fn().mockImplementation((key) => {
|
|
50
|
-
if (key === 'trust') return {
|
|
51
|
-
limits: { stage_2_max_lines: 50, stage_3_max_lines: 300 }
|
|
52
|
-
};
|
|
53
|
-
if (key === 'gfi_gate') return {
|
|
54
|
-
enabled: true,
|
|
55
|
-
thresholds: { low_risk_block: 70, high_risk_block: 40 },
|
|
56
|
-
bash_safe_patterns: ['^(ls|dir|pwd)$'],
|
|
57
|
-
bash_dangerous_patterns: ['rm\s+-rf'],
|
|
58
|
-
};
|
|
59
|
-
return undefined;
|
|
60
|
-
})
|
|
61
|
-
};
|
|
62
|
-
|
|
63
|
-
const mockEventLog = {
|
|
64
|
-
recordGateBlock: vi.fn(),
|
|
65
|
-
recordPlanApproval: vi.fn(),
|
|
66
|
-
recordGateBypass: vi.fn(),
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
const mockTrajectory = {
|
|
70
|
-
recordGateBlock: vi.fn(),
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const mockWctx = {
|
|
74
|
-
workspaceDir,
|
|
75
|
-
stateDir: '/mock/state',
|
|
76
|
-
config: mockConfig,
|
|
77
|
-
eventLog: mockEventLog,
|
|
78
|
-
trajectory: mockTrajectory,
|
|
79
|
-
evolution: mockEvolution,
|
|
80
|
-
resolve: vi.fn().mockImplementation((key) => {
|
|
81
|
-
if (key === 'PROFILE') return path.join(workspaceDir, '.principles', 'PROFILE.json');
|
|
82
|
-
if (key === 'PLAN') return path.join(workspaceDir, 'PLAN.md');
|
|
83
|
-
if (key === 'STATE_DIR') return path.join(workspaceDir, '.state');
|
|
84
|
-
if (typeof key === 'string' && !key.includes(':')) {
|
|
85
|
-
return path.join(workspaceDir, key);
|
|
86
|
-
}
|
|
87
|
-
return key;
|
|
88
|
-
}),
|
|
89
|
-
};
|
|
90
|
-
|
|
91
|
-
beforeEach(() => {
|
|
92
|
-
vi.clearAllMocks();
|
|
93
|
-
vi.useFakeTimers();
|
|
94
|
-
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
|
|
95
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 0 } as any);
|
|
96
|
-
vi.mocked(sessionTracker.trackBlock).mockImplementation(() => {});
|
|
97
|
-
vi.mocked(evolutionEngine.getEvolutionEngine).mockReturnValue(mockEvolution);
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
afterEach(() => {
|
|
101
|
-
vi.useRealTimers();
|
|
102
|
-
});
|
|
103
|
-
|
|
104
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
105
|
-
// TEST 1: Progressive gate enabled + edit verification => verification runs
|
|
106
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
107
|
-
describe('Edit Verification Integration', () => {
|
|
108
|
-
it('should run edit verification when progressive gate is enabled and operation is allowed', () => {
|
|
109
|
-
const fileContent = 'const x = 1;\n';
|
|
110
|
-
const editEvent = {
|
|
111
|
-
toolName: 'edit',
|
|
112
|
-
params: {
|
|
113
|
-
file_path: 'src/example.ts',
|
|
114
|
-
oldText: 'wrong text that does not exist', // This should trigger edit verification
|
|
115
|
-
newText: 'const x = 2;',
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
// Progressive gate enabled
|
|
120
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
121
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
122
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
123
|
-
return JSON.stringify({
|
|
124
|
-
risk_paths: [],
|
|
125
|
-
progressive_gate: { enabled: true }, // ENABLED
|
|
126
|
-
edit_verification: { enabled: true },
|
|
127
|
-
});
|
|
128
|
-
}
|
|
129
|
-
if (typeof p === 'string' && p.includes('example.ts')) {
|
|
130
|
-
return fileContent;
|
|
131
|
-
}
|
|
132
|
-
return '';
|
|
133
|
-
});
|
|
134
|
-
vi.mocked(fs.statSync).mockReturnValue({ size: 1000 } as any);
|
|
135
|
-
|
|
136
|
-
const result = handleBeforeToolCall(editEvent as any, { workspaceDir, sessionId } as any);
|
|
137
|
-
|
|
138
|
-
// EXPECTED: Edit verification should run and block because oldText doesn't match
|
|
139
|
-
// CURRENT BEHAVIOR (BUG): progressive gate returns early, edit verification never runs
|
|
140
|
-
expect(result).toBeDefined();
|
|
141
|
-
expect(result?.block).toBe(true);
|
|
142
|
-
expect(result?.blockReason).toContain('P-03'); // Matches [P-03 Violation] or [P-03 Error]
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
it('should allow edit when oldText matches (progressive gate enabled)', () => {
|
|
146
|
-
const fileContent = 'const x = 1;\n';
|
|
147
|
-
const editEvent = {
|
|
148
|
-
toolName: 'edit',
|
|
149
|
-
params: {
|
|
150
|
-
file_path: 'src/example.ts',
|
|
151
|
-
oldText: 'const x = 1;', // Exact match (file has \n at end but we match without)
|
|
152
|
-
newText: 'const x = 2;',
|
|
153
|
-
},
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
157
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
158
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
159
|
-
return JSON.stringify({
|
|
160
|
-
risk_paths: [],
|
|
161
|
-
progressive_gate: { enabled: true },
|
|
162
|
-
edit_verification: { enabled: true },
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
if (typeof p === 'string' && p.includes('example.ts')) {
|
|
166
|
-
return fileContent;
|
|
167
|
-
}
|
|
168
|
-
return '';
|
|
169
|
-
});
|
|
170
|
-
vi.mocked(fs.statSync).mockReturnValue({ size: 1000 } as any);
|
|
171
|
-
|
|
172
|
-
const result = handleBeforeToolCall(editEvent as any, { workspaceDir, sessionId } as any);
|
|
173
|
-
|
|
174
|
-
// Should pass edit verification - fuzzy match should find the content
|
|
175
|
-
// If exact match fails, fuzzy match (0.8 threshold) should pass
|
|
176
|
-
expect(result).toBeUndefined();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
181
|
-
// TEST 2: GFI block uses single persistence path
|
|
182
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
183
|
-
describe('GFI Gate Block Persistence', () => {
|
|
184
|
-
it('should use single authoritative block path when GFI gate blocks', () => {
|
|
185
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 85 } as any);
|
|
186
|
-
|
|
187
|
-
const writeEvent = {
|
|
188
|
-
toolName: 'write',
|
|
189
|
-
params: {
|
|
190
|
-
file_path: 'src/test.ts',
|
|
191
|
-
content: 'new content',
|
|
192
|
-
},
|
|
193
|
-
};
|
|
194
|
-
|
|
195
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
196
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
197
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
198
|
-
return JSON.stringify({
|
|
199
|
-
risk_paths: [],
|
|
200
|
-
progressive_gate: { enabled: true },
|
|
201
|
-
});
|
|
202
|
-
}
|
|
203
|
-
return '';
|
|
204
|
-
});
|
|
205
|
-
|
|
206
|
-
const result = handleBeforeToolCall(writeEvent as any, { workspaceDir, sessionId } as any);
|
|
207
|
-
|
|
208
|
-
// EXPECTED: Block through GFI gate, with single persistence path
|
|
209
|
-
expect(result).toBeDefined();
|
|
210
|
-
expect(result?.block).toBe(true);
|
|
211
|
-
expect(result?.blockReason).toContain('GFI');
|
|
212
|
-
|
|
213
|
-
// CRITICAL: trackBlock should be called exactly once (single persistence)
|
|
214
|
-
expect(sessionTracker.trackBlock).toHaveBeenCalledTimes(1);
|
|
215
|
-
expect(sessionTracker.trackBlock).toHaveBeenCalledWith(sessionId);
|
|
216
|
-
|
|
217
|
-
// EventLog should record the block
|
|
218
|
-
expect(mockEventLog.recordGateBlock).toHaveBeenCalled();
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
it('should persist trajectory gate block with retry on failure', async () => {
|
|
222
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 85 } as any);
|
|
223
|
-
|
|
224
|
-
const writeEvent = {
|
|
225
|
-
toolName: 'write',
|
|
226
|
-
params: {
|
|
227
|
-
file_path: 'src/test.ts',
|
|
228
|
-
content: 'new content',
|
|
229
|
-
},
|
|
230
|
-
};
|
|
231
|
-
|
|
232
|
-
// Make trajectory.recordGateBlock fail initially
|
|
233
|
-
mockTrajectory.recordGateBlock.mockImplementation(() => {
|
|
234
|
-
throw new Error('DB locked');
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
238
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
239
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
240
|
-
return JSON.stringify({
|
|
241
|
-
risk_paths: [],
|
|
242
|
-
progressive_gate: { enabled: true },
|
|
243
|
-
});
|
|
244
|
-
}
|
|
245
|
-
return '';
|
|
246
|
-
});
|
|
247
|
-
|
|
248
|
-
const result = handleBeforeToolCall(writeEvent as any, { workspaceDir, sessionId } as any);
|
|
249
|
-
|
|
250
|
-
expect(result?.block).toBe(true);
|
|
251
|
-
|
|
252
|
-
// Advance timers to trigger retry
|
|
253
|
-
await vi.advanceTimersByTimeAsync(100);
|
|
254
|
-
|
|
255
|
-
// Should have attempted trajectory persistence (with retry)
|
|
256
|
-
expect(mockTrajectory.recordGateBlock).toHaveBeenCalled();
|
|
257
|
-
});
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
261
|
-
// TEST 3: Thinking checkpoint participates in default flow
|
|
262
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
263
|
-
describe('Thinking Checkpoint Integration', () => {
|
|
264
|
-
it('should run thinking checkpoint before progressive gate (when enabled)', () => {
|
|
265
|
-
// Setup: Enable thinking checkpoint
|
|
266
|
-
const bashEvent = {
|
|
267
|
-
toolName: 'run_shell_command',
|
|
268
|
-
params: {
|
|
269
|
-
command: 'rm -rf node_modules',
|
|
270
|
-
},
|
|
271
|
-
};
|
|
272
|
-
|
|
273
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
274
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
275
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
276
|
-
return JSON.stringify({
|
|
277
|
-
risk_paths: [],
|
|
278
|
-
progressive_gate: { enabled: true },
|
|
279
|
-
thinking_checkpoint: {
|
|
280
|
-
enabled: true,
|
|
281
|
-
window_ms: 300000,
|
|
282
|
-
high_risk_tools: ['run_shell_command', 'delete_file'],
|
|
283
|
-
},
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
return '';
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
// Mock session without recent thinking
|
|
290
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({
|
|
291
|
-
currentGfi: 0,
|
|
292
|
-
lastThinkingAt: Date.now() - 600000, // 10 min ago
|
|
293
|
-
} as any);
|
|
294
|
-
mockedHasRecentThinking.mockReturnValue(false);
|
|
295
|
-
|
|
296
|
-
const result = handleBeforeToolCall(bashEvent as any, { workspaceDir, sessionId } as any);
|
|
297
|
-
|
|
298
|
-
// EXPECTED: Thinking checkpoint should block before progressive gate
|
|
299
|
-
expect(result).toBeDefined();
|
|
300
|
-
expect(result?.block).toBe(true);
|
|
301
|
-
// Check for Chinese message (thinking-checkpoint.ts uses Chinese)
|
|
302
|
-
expect(result?.blockReason).toContain('深度思考');
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
it('should allow operation after thinking checkpoint passes', () => {
|
|
306
|
-
const bashEvent = {
|
|
307
|
-
toolName: 'run_shell_command',
|
|
308
|
-
params: {
|
|
309
|
-
command: 'ls -la',
|
|
310
|
-
},
|
|
311
|
-
};
|
|
312
|
-
|
|
313
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
314
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
315
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
316
|
-
return JSON.stringify({
|
|
317
|
-
risk_paths: [],
|
|
318
|
-
progressive_gate: { enabled: true },
|
|
319
|
-
thinking_checkpoint: {
|
|
320
|
-
enabled: true,
|
|
321
|
-
window_ms: 300000,
|
|
322
|
-
high_risk_tools: ['run_shell_command', 'delete_file'],
|
|
323
|
-
},
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
return '';
|
|
327
|
-
});
|
|
328
|
-
|
|
329
|
-
// Mock session WITH recent thinking (checkpoint passes)
|
|
330
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({
|
|
331
|
-
currentGfi: 0,
|
|
332
|
-
lastThinkingAt: Date.now() - 60000, // 1 min ago (within window)
|
|
333
|
-
} as any);
|
|
334
|
-
mockedHasRecentThinking.mockReturnValue(true);
|
|
335
|
-
|
|
336
|
-
const result = handleBeforeToolCall(bashEvent as any, { workspaceDir, sessionId } as any);
|
|
337
|
-
|
|
338
|
-
// Thinking checkpoint passes, no GFI concern, Stage 4 allows
|
|
339
|
-
expect(result).toBeUndefined();
|
|
340
|
-
});
|
|
341
|
-
});
|
|
342
|
-
|
|
343
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
344
|
-
// TEST 4: Single block persistence implementation
|
|
345
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
346
|
-
describe('Single Block Persistence Implementation', () => {
|
|
347
|
-
it('should have only ONE trackBlock call per gate block (no duplicate tracking)', () => {
|
|
348
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 85 } as any);
|
|
349
|
-
|
|
350
|
-
const writeEvent = {
|
|
351
|
-
toolName: 'write',
|
|
352
|
-
params: {
|
|
353
|
-
file_path: 'src/test.ts',
|
|
354
|
-
content: 'x'.repeat(100),
|
|
355
|
-
},
|
|
356
|
-
};
|
|
357
|
-
|
|
358
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
359
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
360
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
361
|
-
return JSON.stringify({
|
|
362
|
-
risk_paths: [],
|
|
363
|
-
progressive_gate: { enabled: true },
|
|
364
|
-
});
|
|
365
|
-
}
|
|
366
|
-
return '';
|
|
367
|
-
});
|
|
368
|
-
|
|
369
|
-
handleBeforeToolCall(writeEvent as any, { workspaceDir, sessionId } as any);
|
|
370
|
-
|
|
371
|
-
// CRITICAL: trackBlock should be called exactly ONCE
|
|
372
|
-
// If called multiple times, there are multiple block implementations
|
|
373
|
-
expect(sessionTracker.trackBlock).toHaveBeenCalledTimes(1);
|
|
374
|
-
});
|
|
375
|
-
|
|
376
|
-
it('should have only ONE eventLog.recordGateBlock call per gate block', () => {
|
|
377
|
-
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 85 } as any);
|
|
378
|
-
|
|
379
|
-
const writeEvent = {
|
|
380
|
-
toolName: 'write',
|
|
381
|
-
params: {
|
|
382
|
-
file_path: 'src/test.ts',
|
|
383
|
-
content: 'x'.repeat(100),
|
|
384
|
-
},
|
|
385
|
-
};
|
|
386
|
-
|
|
387
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
388
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
389
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
390
|
-
return JSON.stringify({
|
|
391
|
-
risk_paths: [],
|
|
392
|
-
progressive_gate: { enabled: true },
|
|
393
|
-
});
|
|
394
|
-
}
|
|
395
|
-
return '';
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
handleBeforeToolCall(writeEvent as any, { workspaceDir, sessionId } as any);
|
|
399
|
-
|
|
400
|
-
// Event log should record exactly once
|
|
401
|
-
expect(mockEventLog.recordGateBlock).toHaveBeenCalledTimes(1);
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
});
|
package/tests/hooks/gate.test.ts
DELETED
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
-
import { handleBeforeToolCall } from '../../src/hooks/gate.js';
|
|
3
|
-
import { normalizeProfile, PROFILE_DEFAULTS } from '../../src/core/profile.js';
|
|
4
|
-
import * as fs from 'fs';
|
|
5
|
-
import * as path from 'path';
|
|
6
|
-
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
7
|
-
import * as riskCalculator from '../../src/core/risk-calculator.js';
|
|
8
|
-
import * as evolutionEngine from '../../src/core/evolution-engine.js';
|
|
9
|
-
|
|
10
|
-
vi.mock('fs');
|
|
11
|
-
vi.mock('../../src/core/workspace-context.js');
|
|
12
|
-
vi.mock('../../src/core/risk-calculator.js');
|
|
13
|
-
vi.mock('../../src/core/evolution-engine.js');
|
|
14
|
-
|
|
15
|
-
describe('Progressive Gate Hook', () => {
|
|
16
|
-
const workspaceDir = '/mock/workspace';
|
|
17
|
-
|
|
18
|
-
const mockTrust = {
|
|
19
|
-
getScorecard: vi.fn(),
|
|
20
|
-
getScore: vi.fn(),
|
|
21
|
-
getStage: vi.fn(),
|
|
22
|
-
};
|
|
23
|
-
|
|
24
|
-
const mockConfig = {
|
|
25
|
-
get: vi.fn().mockImplementation((key) => {
|
|
26
|
-
if (key === 'trust') return {
|
|
27
|
-
limits: { stage_2_max_lines: 10, stage_3_max_lines: 100 }
|
|
28
|
-
};
|
|
29
|
-
return undefined;
|
|
30
|
-
})
|
|
31
|
-
};
|
|
32
|
-
|
|
33
|
-
const mockEventLog = {
|
|
34
|
-
recordGateBlock: vi.fn(),
|
|
35
|
-
recordPlanApproval: vi.fn(),
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
const mockWctx = {
|
|
39
|
-
workspaceDir,
|
|
40
|
-
stateDir: '/mock/state',
|
|
41
|
-
trust: mockTrust,
|
|
42
|
-
config: mockConfig,
|
|
43
|
-
eventLog: mockEventLog,
|
|
44
|
-
trajectory: {
|
|
45
|
-
recordGateBlock: vi.fn(),
|
|
46
|
-
},
|
|
47
|
-
resolve: vi.fn().mockImplementation((key) => {
|
|
48
|
-
if (key === 'PROFILE') return path.join(workspaceDir, '.principles', 'PROFILE.json');
|
|
49
|
-
if (key === 'PLAN') return path.join(workspaceDir, 'PLAN.md');
|
|
50
|
-
return '';
|
|
51
|
-
}),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
beforeEach(() => {
|
|
55
|
-
vi.clearAllMocks();
|
|
56
|
-
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
|
|
57
|
-
vi.mocked(riskCalculator.assessRiskLevel).mockReturnValue('LOW');
|
|
58
|
-
vi.mocked(riskCalculator.estimateLineChanges).mockReturnValue(1);
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('should block risk path writes at Seed tier (EP system)', () => {
|
|
62
|
-
const mockCtx = { workspaceDir };
|
|
63
|
-
const mockEvent = {
|
|
64
|
-
toolName: 'write',
|
|
65
|
-
params: { file_path: 'src/main.ts' }
|
|
66
|
-
};
|
|
67
|
-
|
|
68
|
-
mockTrust.getScore.mockReturnValue(25);
|
|
69
|
-
mockTrust.getStage.mockReturnValue(1);
|
|
70
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
71
|
-
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
72
|
-
return JSON.stringify({ risk_paths: ['src/'], progressive_gate: { enabled: true } });
|
|
73
|
-
});
|
|
74
|
-
// EP system blocks risk path at Seed tier
|
|
75
|
-
vi.mocked(evolutionEngine.checkEvolutionGate).mockReturnValue({
|
|
76
|
-
allowed: false,
|
|
77
|
-
currentTier: 1,
|
|
78
|
-
reason: 'Seed tier cannot modify risk paths'
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
82
|
-
|
|
83
|
-
expect(result).toBeDefined();
|
|
84
|
-
expect(result?.block).toBe(true);
|
|
85
|
-
expect(result?.blockReason).toContain('risk path');
|
|
86
|
-
});
|
|
87
|
-
|
|
88
|
-
it('should block large writes at Seed tier (EP system)', () => {
|
|
89
|
-
const mockCtx = { workspaceDir };
|
|
90
|
-
const mockEvent = {
|
|
91
|
-
toolName: 'write',
|
|
92
|
-
params: { file_path: 'docs/readme.md' }
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
mockTrust.getScore.mockReturnValue(50);
|
|
96
|
-
mockTrust.getStage.mockReturnValue(2);
|
|
97
|
-
vi.mocked(riskCalculator.estimateLineChanges).mockReturnValue(200);
|
|
98
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
99
|
-
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
100
|
-
return JSON.stringify({ risk_paths: ['src/'], progressive_gate: { enabled: true } });
|
|
101
|
-
});
|
|
102
|
-
// EP system blocks 200 lines at Seed tier (limit is 150)
|
|
103
|
-
vi.mocked(evolutionEngine.checkEvolutionGate).mockReturnValue({
|
|
104
|
-
allowed: false,
|
|
105
|
-
currentTier: 1,
|
|
106
|
-
reason: 'Seed tier limit is 150 lines per write, requested 200'
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
110
|
-
|
|
111
|
-
expect(result).toBeDefined();
|
|
112
|
-
expect(result?.block).toBe(true);
|
|
113
|
-
expect(result?.blockReason).toContain('150');
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it('should allow risk paths at Sapling tier (EP system)', () => {
|
|
117
|
-
const mockCtx = { workspaceDir };
|
|
118
|
-
const mockEvent = {
|
|
119
|
-
toolName: 'write',
|
|
120
|
-
params: { file_path: 'src/core.ts' }
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
mockTrust.getScore.mockReturnValue(70);
|
|
124
|
-
mockTrust.getStage.mockReturnValue(3);
|
|
125
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
126
|
-
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
127
|
-
return JSON.stringify({ risk_paths: ['src/'], progressive_gate: { enabled: true } });
|
|
128
|
-
});
|
|
129
|
-
// EP system allows at Sapling tier (risk path allowed)
|
|
130
|
-
vi.mocked(evolutionEngine.checkEvolutionGate).mockReturnValue({
|
|
131
|
-
allowed: true,
|
|
132
|
-
currentTier: 3,
|
|
133
|
-
reason: ''
|
|
134
|
-
});
|
|
135
|
-
|
|
136
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
137
|
-
|
|
138
|
-
expect(result).toBeUndefined(); // Allowed
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
it('should allow everything for Forest tier (EP system)', () => {
|
|
142
|
-
const mockCtx = { workspaceDir };
|
|
143
|
-
const mockEvent = {
|
|
144
|
-
toolName: 'write',
|
|
145
|
-
params: { file_path: 'src/core.ts' }
|
|
146
|
-
};
|
|
147
|
-
|
|
148
|
-
mockTrust.getScore.mockReturnValue(90);
|
|
149
|
-
mockTrust.getStage.mockReturnValue(4);
|
|
150
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
151
|
-
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
152
|
-
return JSON.stringify({ risk_paths: ['src/'], progressive_gate: { enabled: true } });
|
|
153
|
-
});
|
|
154
|
-
// EP system allows everything at Forest tier
|
|
155
|
-
vi.mocked(evolutionEngine.checkEvolutionGate).mockReturnValue({
|
|
156
|
-
allowed: true,
|
|
157
|
-
currentTier: 5,
|
|
158
|
-
reason: ''
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
162
|
-
|
|
163
|
-
expect(result).toBeUndefined(); // Allowed
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it('should fall back to original logic if progressive gate is disabled', () => {
|
|
167
|
-
const mockCtx = { workspaceDir };
|
|
168
|
-
const mockEvent = {
|
|
169
|
-
toolName: 'write',
|
|
170
|
-
params: { file_path: 'src/db/user.ts' }
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
vi.mocked(fs.existsSync).mockReturnValue(true);
|
|
174
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
175
|
-
if (p.includes('PROFILE.json')) {
|
|
176
|
-
return JSON.stringify({ risk_paths: ['src/db/'], gate: { require_plan_for_risk_paths: true }, progressive_gate: { enabled: false } });
|
|
177
|
-
}
|
|
178
|
-
if (p.includes('PLAN.md')) return 'STATUS: DRAFT\n';
|
|
179
|
-
return '';
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
183
|
-
|
|
184
|
-
expect(result).toBeDefined();
|
|
185
|
-
expect(result?.block).toBe(true);
|
|
186
|
-
expect(result?.blockReason).toContain('No READY plan found');
|
|
187
|
-
});
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
191
|
-
// Task 4: Default Values Consistency Tests
|
|
192
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
193
|
-
describe('Gate Default Values Consistency', () => {
|
|
194
|
-
/**
|
|
195
|
-
* PURPOSE: Prove that gate.ts inline defaults match PROFILE_DEFAULTS.
|
|
196
|
-
* If gate.ts has inline defaults that differ from normalizeProfile(),
|
|
197
|
-
* this is a bug - the defaults should come from a single source of truth.
|
|
198
|
-
*
|
|
199
|
-
* IMPORTANT: gate.ts should use normalizeProfile({}) for defaults,
|
|
200
|
-
* not maintain a separate inline object.
|
|
201
|
-
*/
|
|
202
|
-
|
|
203
|
-
it('gate.ts uses PROFILE_DEFAULTS when PROFILE.json does not exist', () => {
|
|
204
|
-
// When PROFILE.json doesn't exist, gate.ts should use defaults from normalizeProfile
|
|
205
|
-
const expectedDefaults = normalizeProfile({});
|
|
206
|
-
|
|
207
|
-
// Verify key defaults are correct
|
|
208
|
-
expect(expectedDefaults.progressive_gate.enabled).toBe(true);
|
|
209
|
-
expect(expectedDefaults.edit_verification.enabled).toBe(true);
|
|
210
|
-
expect(expectedDefaults.thinking_checkpoint.enabled).toBe(false);
|
|
211
|
-
expect(expectedDefaults.risk_paths).toEqual([]);
|
|
212
|
-
expect(expectedDefaults.gate.require_plan_for_risk_paths).toBe(true);
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
it('gate.ts default behavior matches normalizeProfile({})', () => {
|
|
216
|
-
const defaults = normalizeProfile({});
|
|
217
|
-
|
|
218
|
-
// Test gate behavior with no PROFILE.json
|
|
219
|
-
const mockCtx = { workspaceDir: '/test/workspace', sessionId: 'test-session' };
|
|
220
|
-
const mockEvent = {
|
|
221
|
-
toolName: 'write',
|
|
222
|
-
params: { file_path: 'src/test.ts', content: 'test' }
|
|
223
|
-
};
|
|
224
|
-
|
|
225
|
-
vi.mocked(fs.existsSync).mockReturnValue(false); // No PROFILE.json
|
|
226
|
-
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue({
|
|
227
|
-
workspaceDir: '/test/workspace',
|
|
228
|
-
trust: { getScore: () => 80, getStage: () => 4 },
|
|
229
|
-
config: { get: () => undefined },
|
|
230
|
-
eventLog: { recordGateBlock: () => {} },
|
|
231
|
-
trajectory: { recordGateBlock: () => {} },
|
|
232
|
-
resolve: (key: string) => `/test/workspace/.state/${key}.json`,
|
|
233
|
-
} as any);
|
|
234
|
-
|
|
235
|
-
// Should not throw, should use defaults
|
|
236
|
-
const result = handleBeforeToolCall(mockEvent as any, mockCtx as any);
|
|
237
|
-
|
|
238
|
-
// With defaults: progressive_gate.enabled=true, trust=Stage4, should allow
|
|
239
|
-
// (Stage 4 bypasses progressive gate)
|
|
240
|
-
expect(result).toBeUndefined();
|
|
241
|
-
});
|
|
242
|
-
|
|
243
|
-
it('gate.ts thinking_checkpoint default is OFF (false)', () => {
|
|
244
|
-
const defaults = normalizeProfile({});
|
|
245
|
-
|
|
246
|
-
// CRITICAL: thinking_checkpoint must default to false
|
|
247
|
-
// Otherwise new users will be blocked by deep reflection requirement
|
|
248
|
-
expect(defaults.thinking_checkpoint.enabled).toBe(false);
|
|
249
|
-
|
|
250
|
-
// Verify gate.ts respects this default
|
|
251
|
-
const mockCtx = { workspaceDir: '/test/workspace', sessionId: 'test-session' };
|
|
252
|
-
const bashEvent = {
|
|
253
|
-
toolName: 'run_shell_command',
|
|
254
|
-
params: { command: 'ls -la' }
|
|
255
|
-
};
|
|
256
|
-
|
|
257
|
-
vi.mocked(fs.existsSync).mockReturnValue(false); // No PROFILE.json
|
|
258
|
-
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue({
|
|
259
|
-
workspaceDir: '/test/workspace',
|
|
260
|
-
trust: { getScore: () => 80, getStage: () => 4 },
|
|
261
|
-
config: { get: () => undefined },
|
|
262
|
-
eventLog: { recordGateBlock: () => {} },
|
|
263
|
-
trajectory: { recordGateBlock: () => {} },
|
|
264
|
-
resolve: (key: string) => `/test/workspace/.state/${key}.json`,
|
|
265
|
-
} as any);
|
|
266
|
-
|
|
267
|
-
// Should NOT be blocked by thinking checkpoint (default OFF)
|
|
268
|
-
const result = handleBeforeToolCall(bashEvent as any, mockCtx as any);
|
|
269
|
-
expect(result).toBeUndefined();
|
|
270
|
-
});
|
|
271
|
-
});
|