principles-disciple 1.61.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 +4 -4
- package/package.json +3 -1
- package/scripts/sync-plugin.mjs +28 -36
- package/src/core/event-log.ts +71 -5
- package/src/core/workflow-funnel-loader.ts +170 -0
- package/src/hooks/gate-block-helper.ts +1 -1
- package/src/hooks/gate.ts +62 -203
- package/src/service/evolution-worker.ts +10 -0
- package/src/service/nocturnal-service.ts +24 -3
- package/src/service/subagent-workflow/nocturnal-workflow-manager.ts +16 -0
- package/src/types/event-types.ts +103 -2
- package/tests/core/event-log.test.ts +56 -1
- package/tests/hooks/gate-rule-host-pipeline.test.ts +161 -316
- 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,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
2
|
import { EventLogService, EventLog } from '../../src/core/event-log.js';
|
|
3
|
-
import type { DailyStats, DeepReflectionEventData } from '../../src/types/event-types.js';
|
|
3
|
+
import type { DailyStats, DeepReflectionEventData, DiagnosticianReportEventData } from '../../src/types/event-types.js';
|
|
4
4
|
import * as fs from 'fs';
|
|
5
5
|
import * as path from 'path';
|
|
6
6
|
import * as os from 'os';
|
|
@@ -252,5 +252,60 @@ describe('EventLog', () => {
|
|
|
252
252
|
expect(stats.pain.avgScore).toBe(60); // (50+70+60)/3 = 60
|
|
253
253
|
expect(stats.pain.maxScore).toBe(70);
|
|
254
254
|
});
|
|
255
|
+
|
|
256
|
+
// PD-FUNNEL-1.2: Legacy backward compat — old events with { success: boolean } shape
|
|
257
|
+
// Stats are loaded from daily-stats.json (not re-read from JSONL), so we
|
|
258
|
+
// populate the stats cache directly by writing to daily-stats.json and
|
|
259
|
+
// creating a new EventLog instance that loads it via loadStats().
|
|
260
|
+
it('should count legacy success:true events in diagnosticianReportsWritten', () => {
|
|
261
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
262
|
+
// Build a legacy daily-stats.json entry: old format had no category on
|
|
263
|
+
// diagnostician_report, and success:true meant it counted as written.
|
|
264
|
+
// statsFile lives at {tempDir}/logs/daily-stats.json (see EventLog constructor).
|
|
265
|
+
const statsFile = path.join(tempDir, 'logs', 'daily-stats.json');
|
|
266
|
+
fs.mkdirSync(path.dirname(statsFile), { recursive: true });
|
|
267
|
+
const legacyDailyStats = JSON.stringify({
|
|
268
|
+
[today]: {
|
|
269
|
+
date: today,
|
|
270
|
+
createdAt: new Date().toISOString(),
|
|
271
|
+
updatedAt: new Date().toISOString(),
|
|
272
|
+
tools: { total: 0, success: 0, failure: 0 },
|
|
273
|
+
pain: { signalsDetected: 0, avgScore: 0, maxScore: 0, signalsBySource: {} },
|
|
274
|
+
empathy: { totalEvents: 0, dedupedCount: 0, dedupeHitRate: 0, rolledBackScore: 0, rollbackCount: 0, bySeverity: { mild: 0, moderate: 0, severe: 0 }, scoreBySeverity: { mild: 0, moderate: 0, severe: 0 }, byDetectionMode: { structured: 0, legacy_tag: 0 }, byOrigin: { assistant_self_report: 0, user_manual: 0, system_infer: 0 }, confidenceDistribution: { high: 0, medium: 0, low: 0 }, dailyTrend: [] },
|
|
275
|
+
hooks: { total: 0, success: 0, failure: 0, byType: {} },
|
|
276
|
+
evolution: {
|
|
277
|
+
diagnosisTasksWritten: 0, heartbeatsInjected: 0,
|
|
278
|
+
diagnosticianReportsWritten: 1, // legacy success:true counted here
|
|
279
|
+
reportsMissingJson: 0, reportsIncompleteFields: 0,
|
|
280
|
+
principleCandidatesCreated: 0, rulesEnforced: 0,
|
|
281
|
+
nocturnalDreamerCompleted: 0, nocturnalArtifactPersisted: 0,
|
|
282
|
+
nocturnalCodeCandidateCreated: 0, rulehostEvaluated: 0,
|
|
283
|
+
rulehostBlocked: 0, rulehostRequireApproval: 0,
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
}, null, 2);
|
|
287
|
+
fs.writeFileSync(statsFile, legacyDailyStats, 'utf8');
|
|
288
|
+
|
|
289
|
+
// Create new EventLog instance so it loads the legacy stats via loadStats()
|
|
290
|
+
const reloaded = new EventLog(tempDir);
|
|
291
|
+
const stats = reloaded.getDailyStats(today);
|
|
292
|
+
expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should count incomplete_fields in both diagnosticianReportsWritten and reportsIncompleteFields', () => {
|
|
296
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
297
|
+
eventLog.recordDiagnosticianReport({
|
|
298
|
+
taskId: 'task-incomplete',
|
|
299
|
+
reportPath: '/test/incomplete.json',
|
|
300
|
+
category: 'incomplete_fields',
|
|
301
|
+
});
|
|
302
|
+
eventLog.flush();
|
|
303
|
+
|
|
304
|
+
const stats = eventLog.getDailyStats(today);
|
|
305
|
+
expect(stats.evolution.diagnosticianReportsWritten).toBe(1);
|
|
306
|
+
expect(stats.evolution.reportsIncompleteFields).toBe(1);
|
|
307
|
+
// Other sub-counters should not be set
|
|
308
|
+
expect(stats.evolution.reportsMissingJson).toBe(0);
|
|
309
|
+
});
|
|
255
310
|
});
|
|
256
311
|
});
|
|
@@ -1,385 +1,230 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Gate Rule Host Pipeline Integration Tests
|
|
2
|
+
* Gate Rule Host Only - Pipeline Integration Tests
|
|
3
3
|
*
|
|
4
|
-
* PURPOSE: Verify
|
|
5
|
-
* between GFI and Progressive Gate, with correct ordering and behavior.
|
|
4
|
+
* PURPOSE: Verify gate.ts with Rule Host Only (no hardcoded gates).
|
|
6
5
|
*
|
|
7
6
|
* Tests:
|
|
8
|
-
* 1.
|
|
9
|
-
* 2.
|
|
10
|
-
* 3.
|
|
11
|
-
* 4.
|
|
12
|
-
* 5.
|
|
13
|
-
* 6. Existing gate flow still works when no active implementations exist
|
|
7
|
+
* 1. Rule Host blocks operation → block result with blockSource='rule-host'
|
|
8
|
+
* 2. Rule Host allow (no match) → operation passes
|
|
9
|
+
* 3. Rule Host throws → degrades conservatively, allows operation
|
|
10
|
+
* 4. Rule Host requireApproval → records event, does not block
|
|
11
|
+
* 5. Non-target tools (read) → pass through early
|
|
14
12
|
*/
|
|
15
13
|
|
|
16
|
-
import { describe, it, expect, vi, beforeEach
|
|
14
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
17
15
|
import { handleBeforeToolCall } from '../../src/hooks/gate.js';
|
|
18
|
-
import * as fs from 'fs';
|
|
19
|
-
import * as path from 'path';
|
|
20
|
-
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
21
16
|
import * as sessionTracker from '../../src/core/session-tracker.js';
|
|
22
17
|
import * as evolutionEngine from '../../src/core/evolution-engine.js';
|
|
23
18
|
|
|
24
|
-
|
|
25
|
-
|
|
19
|
+
const workspaceDir = '/mock/workspace';
|
|
20
|
+
const sessionId = 'test-session-rh';
|
|
26
21
|
|
|
27
|
-
|
|
28
|
-
vi.
|
|
22
|
+
const mockEvolution = {
|
|
23
|
+
getTier: vi.fn().mockReturnValue(3),
|
|
24
|
+
getPoints: vi.fn().mockReturnValue(200),
|
|
25
|
+
};
|
|
29
26
|
|
|
30
|
-
// Mock session tracker
|
|
31
27
|
vi.mock('../../src/core/session-tracker.js', () => ({
|
|
32
28
|
getSession: vi.fn(() => ({ currentGfi: 0 })),
|
|
33
29
|
trackBlock: vi.fn(),
|
|
34
30
|
hasRecentThinking: vi.fn(() => false),
|
|
35
31
|
}));
|
|
36
32
|
|
|
37
|
-
|
|
38
|
-
vi.
|
|
39
|
-
|
|
40
|
-
return {
|
|
41
|
-
...actual,
|
|
42
|
-
checkEvolutionGate: vi.fn(() => ({ allowed: true, currentTier: 'SEED' })),
|
|
43
|
-
getEvolutionEngine: vi.fn(),
|
|
44
|
-
};
|
|
45
|
-
});
|
|
33
|
+
vi.mock('../../src/core/evolution-engine.js', () => ({
|
|
34
|
+
getEvolutionEngine: vi.fn(() => mockEvolution),
|
|
35
|
+
}));
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
37
|
+
const mockEventLogInstance = {
|
|
38
|
+
recordRuleHostEvaluated: vi.fn(),
|
|
39
|
+
recordRuleEnforced: vi.fn(),
|
|
40
|
+
recordRuleHostBlocked: vi.fn(),
|
|
41
|
+
recordRuleHostRequireApproval: vi.fn(),
|
|
42
|
+
};
|
|
43
|
+
vi.mock('../../src/core/event-log.js', () => ({
|
|
44
|
+
EventLogService: { get: vi.fn(() => mockEventLogInstance) },
|
|
45
|
+
}));
|
|
50
46
|
|
|
51
|
-
vi.
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
});
|
|
47
|
+
let _mockEvaluate = vi.fn().mockReturnValue(undefined);
|
|
48
|
+
vi.mock('../../src/core/rule-host.js', () => ({
|
|
49
|
+
RuleHost: vi.fn(function(this: any, _stateDir: string, _logger: any) {
|
|
50
|
+
this.evaluate = _mockEvaluate;
|
|
51
|
+
}),
|
|
52
|
+
}));
|
|
58
53
|
|
|
59
|
-
// Mock ledger to avoid file reads
|
|
60
54
|
vi.mock('../../src/core/principle-tree-ledger.js', () => ({
|
|
61
55
|
loadLedger: vi.fn(),
|
|
62
56
|
listImplementationsByLifecycleState: vi.fn(() => []),
|
|
63
57
|
}));
|
|
64
58
|
|
|
65
|
-
|
|
66
|
-
import * as sessionTrackerModule from '../../src/core/session-tracker.js';
|
|
67
|
-
import * as evolutionEngineModule from '../../src/core/evolution-engine.js';
|
|
68
|
-
|
|
69
|
-
const MockedRuleHost = vi.mocked(RuleHost);
|
|
70
|
-
|
|
71
|
-
const mockEvolution = {
|
|
72
|
-
getTier: vi.fn().mockReturnValue(3),
|
|
73
|
-
getPoints: vi.fn().mockReturnValue(200),
|
|
74
|
-
};
|
|
75
|
-
|
|
76
|
-
describe('Gate Rule Host Pipeline Integration', () => {
|
|
77
|
-
const workspaceDir = '/mock/workspace';
|
|
78
|
-
const sessionId = 'test-session-rh';
|
|
79
|
-
|
|
80
|
-
const mockConfig = {
|
|
81
|
-
get: vi.fn().mockImplementation((key: string) => {
|
|
82
|
-
if (key === 'trust') return {
|
|
83
|
-
limits: { stage_2_max_lines: 50, stage_3_max_lines: 300 }
|
|
84
|
-
};
|
|
85
|
-
if (key === 'gfi_gate') return {
|
|
86
|
-
enabled: true,
|
|
87
|
-
thresholds: { low_risk_block: 70, high_risk_block: 40 },
|
|
88
|
-
bash_safe_patterns: ['^(ls|dir|pwd)$'],
|
|
89
|
-
bash_dangerous_patterns: ['rm\\s+-rf'],
|
|
90
|
-
};
|
|
91
|
-
return undefined;
|
|
92
|
-
})
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
const mockEventLog = {
|
|
96
|
-
recordGateBlock: vi.fn(),
|
|
97
|
-
recordPlanApproval: vi.fn(),
|
|
98
|
-
recordGateBypass: vi.fn(),
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const mockTrajectory = {
|
|
102
|
-
recordGateBlock: vi.fn(),
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
const mockWctx = {
|
|
106
|
-
workspaceDir,
|
|
107
|
-
stateDir: '/mock/state',
|
|
108
|
-
config: mockConfig,
|
|
109
|
-
eventLog: mockEventLog,
|
|
110
|
-
trajectory: mockTrajectory,
|
|
111
|
-
evolution: mockEvolution,
|
|
112
|
-
resolve: vi.fn().mockImplementation((key: string) => {
|
|
113
|
-
if (key === 'PROFILE') return path.join(workspaceDir, '.principles', 'PROFILE.json');
|
|
114
|
-
if (key === 'PLAN') return path.join(workspaceDir, 'PLAN.md');
|
|
115
|
-
if (key === 'STATE_DIR') return path.join(workspaceDir, '.state');
|
|
116
|
-
if (typeof key === 'string' && !key.includes(':')) {
|
|
117
|
-
return path.join(workspaceDir, key);
|
|
118
|
-
}
|
|
119
|
-
return key;
|
|
120
|
-
}),
|
|
121
|
-
};
|
|
122
|
-
|
|
59
|
+
describe('Gate Rule Host Only Pipeline', () => {
|
|
123
60
|
beforeEach(() => {
|
|
124
61
|
vi.clearAllMocks();
|
|
125
|
-
vi.useFakeTimers();
|
|
126
|
-
// Reset the shared evaluate mock to default (returns undefined)
|
|
127
62
|
_mockEvaluate = vi.fn().mockReturnValue(undefined);
|
|
128
|
-
MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
|
|
129
|
-
this.evaluate = _mockEvaluate;
|
|
130
|
-
});
|
|
131
|
-
vi.mocked(WorkspaceContext.fromHookContext).mockReturnValue(mockWctx as any);
|
|
132
|
-
vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 0 } as any);
|
|
133
|
-
vi.mocked(sessionTrackerModule.trackBlock).mockImplementation(() => {});
|
|
134
|
-
vi.mocked(evolutionEngineModule.getEvolutionEngine).mockReturnValue(mockEvolution);
|
|
135
63
|
});
|
|
136
64
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
65
|
+
describe('Rule Host blocks', () => {
|
|
66
|
+
it('should block with blockSource=rule-host when Rule Host returns block', () => {
|
|
67
|
+
_mockEvaluate = vi.fn().mockReturnValue({
|
|
68
|
+
decision: 'block',
|
|
69
|
+
matched: true,
|
|
70
|
+
reason: 'Dangerous git force-push detected',
|
|
71
|
+
ruleId: 'R_001',
|
|
72
|
+
principleId: 'P_001',
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const event = {
|
|
76
|
+
toolName: 'bash',
|
|
77
|
+
params: { command: 'git push --force' },
|
|
78
|
+
};
|
|
140
79
|
|
|
141
|
-
|
|
142
|
-
* Helper: create a standard write event
|
|
143
|
-
*/
|
|
144
|
-
function makeWriteEvent(overrides?: Partial<any>) {
|
|
145
|
-
return {
|
|
146
|
-
toolName: 'write',
|
|
147
|
-
params: {
|
|
148
|
-
file_path: 'src/test.ts',
|
|
149
|
-
content: 'const x = 1;',
|
|
150
|
-
},
|
|
151
|
-
...overrides,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Helper: set up fs mocks for a profile with progressive gate enabled
|
|
157
|
-
*/
|
|
158
|
-
function setupProfileMock(profileOverrides?: Record<string, unknown>) {
|
|
159
|
-
vi.mocked(fs.existsSync).mockImplementation((p: any) => {
|
|
160
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
|
|
161
|
-
return false;
|
|
162
|
-
});
|
|
163
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
164
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
165
|
-
return JSON.stringify({
|
|
166
|
-
risk_paths: [],
|
|
167
|
-
progressive_gate: { enabled: true },
|
|
168
|
-
edit_verification: { enabled: true },
|
|
169
|
-
...profileOverrides,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
return '';
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
177
|
-
// TEST 1: When GFI blocks, Rule Host is never called
|
|
178
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
179
|
-
it('should not call Rule Host when GFI gate blocks', () => {
|
|
180
|
-
// Set high GFI to trigger GFI block
|
|
181
|
-
vi.mocked(sessionTrackerModule.getSession).mockReturnValue({ currentGfi: 85 } as any);
|
|
182
|
-
setupProfileMock();
|
|
183
|
-
|
|
184
|
-
const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
|
|
185
|
-
|
|
186
|
-
// GFI should block
|
|
187
|
-
expect(result).toBeDefined();
|
|
188
|
-
expect(result?.block).toBe(true);
|
|
189
|
-
expect(result?.blockReason).toContain('GFI');
|
|
190
|
-
|
|
191
|
-
// RuleHost constructor should NOT have been called
|
|
192
|
-
// (GFI returns before reaching Rule Host evaluation)
|
|
193
|
-
expect(MockedRuleHost).not.toHaveBeenCalled();
|
|
194
|
-
});
|
|
80
|
+
const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
195
81
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
it('should not reach Progressive Gate when Rule Host blocks', () => {
|
|
200
|
-
setupProfileMock();
|
|
201
|
-
|
|
202
|
-
// Mock RuleHost.evaluate to return a block
|
|
203
|
-
_mockEvaluate = vi.fn().mockReturnValue({
|
|
204
|
-
decision: 'block',
|
|
205
|
-
matched: true,
|
|
206
|
-
reason: 'Rule Host test block',
|
|
207
|
-
});
|
|
208
|
-
MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
|
|
209
|
-
this.evaluate = _mockEvaluate;
|
|
82
|
+
expect(result).toBeDefined();
|
|
83
|
+
expect(result?.block).toBe(true);
|
|
84
|
+
expect(result?.blockReason).toContain('Dangerous git force-push detected');
|
|
210
85
|
});
|
|
211
86
|
|
|
212
|
-
|
|
87
|
+
it('should record rulehost_blocked event when Rule Host blocks', () => {
|
|
88
|
+
_mockEvaluate = vi.fn().mockReturnValue({
|
|
89
|
+
decision: 'block',
|
|
90
|
+
matched: true,
|
|
91
|
+
reason: 'High-risk path',
|
|
92
|
+
ruleId: 'R_002',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const event = {
|
|
96
|
+
toolName: 'write',
|
|
97
|
+
params: { file_path: 'src/danger.ts', content: 'bad' },
|
|
98
|
+
};
|
|
213
99
|
|
|
214
|
-
|
|
215
|
-
expect(result).toBeDefined();
|
|
216
|
-
expect(result?.block).toBe(true);
|
|
100
|
+
handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
217
101
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
102
|
+
expect(mockEventLogInstance.recordRuleHostBlocked).toHaveBeenCalledWith(
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
toolName: 'write',
|
|
105
|
+
ruleId: 'R_002',
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
});
|
|
223
109
|
});
|
|
224
110
|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
it('should continue to Progressive Gate when Rule Host returns undefined', () => {
|
|
229
|
-
setupProfileMock();
|
|
111
|
+
describe('Rule Host allows', () => {
|
|
112
|
+
it('should allow when Rule Host returns undefined (no match)', () => {
|
|
113
|
+
_mockEvaluate = vi.fn().mockReturnValue(undefined);
|
|
230
114
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
});
|
|
115
|
+
const event = {
|
|
116
|
+
toolName: 'write',
|
|
117
|
+
params: { file_path: 'src/safe.ts', content: 'const x = 1' },
|
|
118
|
+
};
|
|
236
119
|
|
|
237
|
-
|
|
120
|
+
const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
238
121
|
|
|
239
|
-
|
|
240
|
-
|
|
122
|
+
expect(result).toBeUndefined();
|
|
123
|
+
});
|
|
241
124
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
});
|
|
125
|
+
it('should record rulehost_evaluated even when no match', () => {
|
|
126
|
+
_mockEvaluate = vi.fn().mockReturnValue(undefined);
|
|
245
127
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
setupProfileMock();
|
|
128
|
+
const event = {
|
|
129
|
+
toolName: 'edit',
|
|
130
|
+
params: { file_path: 'src/config.ts', oldText: 'x', newText: 'y' },
|
|
131
|
+
};
|
|
251
132
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
133
|
+
handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
134
|
+
|
|
135
|
+
expect(mockEventLogInstance.recordRuleHostEvaluated).toHaveBeenCalledWith(
|
|
136
|
+
expect.objectContaining({
|
|
137
|
+
toolName: 'edit',
|
|
138
|
+
matched: false,
|
|
139
|
+
decision: 'allow',
|
|
140
|
+
})
|
|
141
|
+
);
|
|
258
142
|
});
|
|
143
|
+
});
|
|
259
144
|
|
|
260
|
-
|
|
145
|
+
describe('Rule Host degradation', () => {
|
|
146
|
+
it('should allow operation when Rule Host throws (conservative degradation)', () => {
|
|
147
|
+
_mockEvaluate = vi.fn().mockImplementation(() => {
|
|
148
|
+
throw new Error('Host internal error');
|
|
149
|
+
});
|
|
261
150
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
151
|
+
const event = {
|
|
152
|
+
toolName: 'bash',
|
|
153
|
+
params: { command: 'ls -la' },
|
|
154
|
+
};
|
|
266
155
|
|
|
267
|
-
|
|
268
|
-
// TEST 5: Block result uses blockSource='rule-host'
|
|
269
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
270
|
-
it('should use blockSource=rule-host for Rule Host blocks', () => {
|
|
271
|
-
setupProfileMock();
|
|
156
|
+
const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
272
157
|
|
|
273
|
-
|
|
274
|
-
decision: 'block',
|
|
275
|
-
matched: true,
|
|
276
|
-
reason: 'Dangerous file modification',
|
|
277
|
-
});
|
|
278
|
-
MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
|
|
279
|
-
this.evaluate = _mockEvaluate;
|
|
158
|
+
expect(result).toBeUndefined();
|
|
280
159
|
});
|
|
160
|
+
});
|
|
281
161
|
|
|
282
|
-
|
|
162
|
+
describe('Rule Host requireApproval', () => {
|
|
163
|
+
it('should not block when Rule Host returns requireApproval', () => {
|
|
164
|
+
_mockEvaluate = vi.fn().mockReturnValue({
|
|
165
|
+
decision: 'requireApproval',
|
|
166
|
+
matched: true,
|
|
167
|
+
reason: 'High-risk operation needs approval',
|
|
168
|
+
ruleId: 'R_003',
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
const event = {
|
|
172
|
+
toolName: 'bash',
|
|
173
|
+
params: { command: 'rm -rf node_modules' },
|
|
174
|
+
};
|
|
283
175
|
|
|
284
|
-
|
|
176
|
+
const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
285
177
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
reason: 'Dangerous file modification',
|
|
292
|
-
})
|
|
293
|
-
);
|
|
178
|
+
expect(result).toBeUndefined();
|
|
179
|
+
expect(mockEventLogInstance.recordRuleEnforced).toHaveBeenCalledWith(
|
|
180
|
+
expect.objectContaining({ enforcement: 'requireApproval' })
|
|
181
|
+
);
|
|
182
|
+
});
|
|
294
183
|
});
|
|
295
184
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
_mockEvaluate = vi.fn().mockReturnValue({
|
|
303
|
-
decision: 'requireApproval',
|
|
304
|
-
matched: true,
|
|
305
|
-
reason: 'High-risk path requires approval',
|
|
306
|
-
});
|
|
307
|
-
MockedRuleHost.mockImplementation(function(this: any, _stateDir: string) {
|
|
308
|
-
this.evaluate = _mockEvaluate;
|
|
309
|
-
});
|
|
185
|
+
describe('Early return for non-target tools', () => {
|
|
186
|
+
it('should allow read tool without calling Rule Host', () => {
|
|
187
|
+
const event = {
|
|
188
|
+
toolName: 'read',
|
|
189
|
+
params: { file_path: 'src/readonly.ts' },
|
|
190
|
+
};
|
|
310
191
|
|
|
311
|
-
|
|
192
|
+
const result = handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
312
193
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
expect.objectContaining({
|
|
317
|
-
blockSource: 'rule-host',
|
|
318
|
-
reason: expect.stringContaining('[Rule Host] Approval required'),
|
|
319
|
-
})
|
|
320
|
-
);
|
|
321
|
-
});
|
|
194
|
+
expect(result).toBeUndefined();
|
|
195
|
+
expect(_mockEvaluate).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
322
197
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
198
|
+
it('should allow agent tool without calling Rule Host when no workspace', () => {
|
|
199
|
+
const event = {
|
|
200
|
+
toolName: 'agent',
|
|
201
|
+
params: { task: 'do something' },
|
|
202
|
+
};
|
|
328
203
|
|
|
329
|
-
|
|
330
|
-
const result = handleBeforeToolCall(makeWriteEvent() as any, { workspaceDir, sessionId } as any);
|
|
204
|
+
const result = handleBeforeToolCall(event as any, { sessionId } as any);
|
|
331
205
|
|
|
332
|
-
|
|
333
|
-
|
|
206
|
+
expect(result).toBeUndefined();
|
|
207
|
+
expect(_mockEvaluate).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
334
209
|
});
|
|
335
210
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
211
|
+
describe('Session GFI context', () => {
|
|
212
|
+
it('should pass current GFI to Rule Host', () => {
|
|
213
|
+
_mockEvaluate = vi.fn().mockReturnValue(undefined);
|
|
214
|
+
vi.mocked(sessionTracker.getSession).mockReturnValue({ currentGfi: 75 } as any);
|
|
340
215
|
|
|
341
|
-
|
|
342
|
-
|
|
216
|
+
const event = {
|
|
217
|
+
toolName: 'write',
|
|
218
|
+
params: { file_path: 'src/test.ts', content: 'x' },
|
|
219
|
+
};
|
|
343
220
|
|
|
344
|
-
|
|
345
|
-
expect(result?.blockReason).toContain('GFI');
|
|
346
|
-
});
|
|
221
|
+
handleBeforeToolCall(event as any, { workspaceDir, sessionId } as any);
|
|
347
222
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
file_path: 'src/example.ts',
|
|
354
|
-
oldText: 'const x = 1;',
|
|
355
|
-
newText: 'const x = 2;',
|
|
356
|
-
},
|
|
357
|
-
};
|
|
358
|
-
|
|
359
|
-
setupProfileMock();
|
|
360
|
-
|
|
361
|
-
vi.mocked(fs.readFileSync).mockImplementation((p: any) => {
|
|
362
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) {
|
|
363
|
-
return JSON.stringify({
|
|
364
|
-
risk_paths: [],
|
|
365
|
-
progressive_gate: { enabled: true },
|
|
366
|
-
edit_verification: { enabled: true },
|
|
367
|
-
});
|
|
368
|
-
}
|
|
369
|
-
if (typeof p === 'string' && p.includes('example.ts')) {
|
|
370
|
-
return fileContent;
|
|
371
|
-
}
|
|
372
|
-
return '';
|
|
223
|
+
expect(_mockEvaluate).toHaveBeenCalledWith(
|
|
224
|
+
expect.objectContaining({
|
|
225
|
+
session: expect.objectContaining({ currentGfi: 75 }),
|
|
226
|
+
})
|
|
227
|
+
);
|
|
373
228
|
});
|
|
374
|
-
vi.mocked(fs.statSync).mockReturnValue({ size: 1000 } as any);
|
|
375
|
-
vi.mocked(fs.existsSync).mockImplementation((p: any) => {
|
|
376
|
-
if (typeof p === 'string' && p.includes('PROFILE.json')) return true;
|
|
377
|
-
if (typeof p === 'string' && p.includes('example.ts')) return true;
|
|
378
|
-
return false;
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
const result = handleBeforeToolCall(editEvent as any, { workspaceDir, sessionId } as any);
|
|
382
|
-
|
|
383
|
-
expect(result).toBeUndefined();
|
|
384
229
|
});
|
|
385
230
|
});
|
|
@@ -70,7 +70,11 @@ const noopLogger: PluginLogger = {
|
|
|
70
70
|
afterEach(() => {
|
|
71
71
|
vi.restoreAllMocks();
|
|
72
72
|
for (const dir of tempDirs.splice(0)) {
|
|
73
|
-
|
|
73
|
+
try {
|
|
74
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
75
|
+
} catch {
|
|
76
|
+
// On Windows, temp dirs may be held open — ignore cleanup errors
|
|
77
|
+
}
|
|
74
78
|
}
|
|
75
79
|
});
|
|
76
80
|
|