principles-disciple 1.99.0 → 1.101.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/config-health.ts +58 -0
- package/src/hooks/trajectory-collector.ts +91 -133
- package/src/index.ts +32 -82
- package/tests/core/surface-guard.test.ts +7 -9
- package/tests/core-anti-growth.test.ts +1 -0
- package/tests/evolution-worker-slimming.test.ts +1 -4
- package/tests/hooks/trajectory-collector.test.ts +269 -0
- package/tests/integration/mvp-surface-registry-guard.test.ts +6 -8
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "principles-disciple",
|
|
3
3
|
"name": "Principles Disciple",
|
|
4
4
|
"description": "Evolutionary programming agent framework with strategic guardrails and reflection loops.",
|
|
5
|
-
"version": "1.
|
|
5
|
+
"version": "1.101.0",
|
|
6
6
|
"activation": {
|
|
7
7
|
"onCapabilities": [
|
|
8
8
|
"hook"
|
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conversation Access Health Check (PRI-343 / PRI-346)
|
|
3
|
+
*
|
|
4
|
+
* Pure function for checking whether OpenClaw plugin config has
|
|
5
|
+
* allowConversationAccess set to true. When missing, llm_output and
|
|
6
|
+
* trajectory hooks are silently blocked by OpenClaw, causing evidence
|
|
7
|
+
* to always be empty (PRI-338 root cause).
|
|
8
|
+
*
|
|
9
|
+
* Extracted from index.ts to avoid circular imports when trajectory-collector.ts
|
|
10
|
+
* needs to check conversation access state (PRI-346).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
|
|
14
|
+
const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess' as const;
|
|
15
|
+
|
|
16
|
+
export interface ConversationAccessCheckResult {
|
|
17
|
+
authorized: boolean;
|
|
18
|
+
reason?: string;
|
|
19
|
+
nextAction?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CONVERSATION_ACCESS_FIX_COMMAND =
|
|
23
|
+
'openclaw config set plugins.entries.principles-disciple.hooks.allowConversationAccess true --strict-json';
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* PRI-343: Pure function — checks if pluginConfig has hooks.allowConversationAccess === true.
|
|
27
|
+
* Returns a structured result with reason and nextAction when not authorized (ERR-002).
|
|
28
|
+
*/
|
|
29
|
+
export function checkConversationAccessConfig(pluginConfig: unknown): ConversationAccessCheckResult {
|
|
30
|
+
if (pluginConfig === null || pluginConfig === undefined || typeof pluginConfig !== 'object' || Array.isArray(pluginConfig)) {
|
|
31
|
+
return {
|
|
32
|
+
authorized: false,
|
|
33
|
+
reason: 'pluginConfig is missing or invalid — conversation hooks cannot be registered',
|
|
34
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const config = pluginConfig as Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
if (typeof config.hooks !== 'object' || config.hooks === null || Array.isArray(config.hooks)) {
|
|
41
|
+
return {
|
|
42
|
+
authorized: false,
|
|
43
|
+
reason: 'allowConversationAccess is not set to true',
|
|
44
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const hooks = config.hooks as Record<string, unknown>;
|
|
49
|
+
if (hooks[CONVERSATION_ACCESS_CONFIG_KEY] !== true) {
|
|
50
|
+
return {
|
|
51
|
+
authorized: false,
|
|
52
|
+
reason: 'allowConversationAccess is not set to true',
|
|
53
|
+
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { authorized: true };
|
|
58
|
+
}
|
|
@@ -1,39 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Trajectory Collector -
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* Trajectory Collector - message write trajectory recording
|
|
3
|
+
*
|
|
4
|
+
* Records message data to memory/trajectories/ JSONL files.
|
|
5
|
+
* PRI-347 removed tool_call and llm_output JSONL writers (no consumers).
|
|
6
|
+
* PRI-346 will repurpose handleBeforeMessageWrite for SQLite collection.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import * as fs from 'fs';
|
|
9
10
|
import * as path from 'path';
|
|
10
11
|
import type {
|
|
11
|
-
PluginHookAfterToolCallEvent,
|
|
12
|
-
PluginHookToolContext,
|
|
13
|
-
PluginHookLlmOutputEvent,
|
|
14
12
|
PluginHookAgentContext,
|
|
15
13
|
PluginHookBeforeMessageWriteEvent
|
|
16
14
|
} from '../openclaw-sdk.js';
|
|
17
15
|
import { MAX_STRING_LENGTH } from '../config/defaults/runtime.js';
|
|
16
|
+
import { WorkspaceContext } from '../core/workspace-context.js';
|
|
17
|
+
import { SystemLogger } from '../core/system-logger.js';
|
|
18
|
+
import { sanitizeForEvidence } from './message-sanitize.js';
|
|
19
|
+
import { checkConversationAccessConfig } from '../core/config-health.js';
|
|
18
20
|
|
|
19
21
|
const TRAJECTORY_DIR = 'memory/trajectories/';
|
|
20
22
|
|
|
21
23
|
// 敏感字段匹配正则
|
|
22
24
|
const SENSITIVE_KEY_PATTERN = /password|token|authorization|secret|api[_-]?key|credential|cookie|session/i;
|
|
23
25
|
|
|
24
|
-
// 最大结果长度(不同于 MAX_STRING_LENGTH)
|
|
25
|
-
const MAX_RESULT_LENGTH = 500;
|
|
26
|
-
|
|
27
26
|
/**
|
|
28
27
|
* 递归脱敏处理:遍历对象/数组,移除敏感字段值
|
|
29
28
|
*/
|
|
30
29
|
function scrubSensitive(obj: unknown, depth = 0): unknown {
|
|
31
30
|
// 防止无限递归
|
|
32
31
|
if (depth > 10) return '[MAX_DEPTH]';
|
|
33
|
-
|
|
32
|
+
|
|
34
33
|
// 处理 null/undefined
|
|
35
34
|
if (obj == null) return obj;
|
|
36
|
-
|
|
35
|
+
|
|
37
36
|
// 处理基本类型
|
|
38
37
|
if (typeof obj !== 'object') {
|
|
39
38
|
if (typeof obj === 'string' && obj.length > MAX_STRING_LENGTH) {
|
|
@@ -41,12 +40,12 @@ function scrubSensitive(obj: unknown, depth = 0): unknown {
|
|
|
41
40
|
}
|
|
42
41
|
return obj;
|
|
43
42
|
}
|
|
44
|
-
|
|
43
|
+
|
|
45
44
|
// 处理数组
|
|
46
45
|
if (Array.isArray(obj)) {
|
|
47
46
|
return obj.map(item => scrubSensitive(item, depth + 1));
|
|
48
47
|
}
|
|
49
|
-
|
|
48
|
+
|
|
50
49
|
// 处理对象
|
|
51
50
|
const result: Record<string, unknown> = {};
|
|
52
51
|
for (const [key, value] of Object.entries(obj as Record<string, unknown>)) {
|
|
@@ -65,20 +64,20 @@ function scrubSensitive(obj: unknown, depth = 0): unknown {
|
|
|
65
64
|
class AsyncWriteQueue {
|
|
66
65
|
private readonly queue: (() => Promise<void>)[] = [];
|
|
67
66
|
private processing = false;
|
|
68
|
-
|
|
67
|
+
|
|
69
68
|
async enqueue(task: () => Promise<void>): Promise<void> {
|
|
70
69
|
this.queue.push(task);
|
|
71
70
|
if (!this.processing) {
|
|
72
71
|
this.processNext();
|
|
73
72
|
}
|
|
74
73
|
}
|
|
75
|
-
|
|
74
|
+
|
|
76
75
|
private async processNext(): Promise<void> {
|
|
77
76
|
if (this.queue.length === 0) {
|
|
78
77
|
this.processing = false;
|
|
79
78
|
return;
|
|
80
79
|
}
|
|
81
|
-
|
|
80
|
+
|
|
82
81
|
this.processing = true;
|
|
83
82
|
const task = this.queue.shift();
|
|
84
83
|
|
|
@@ -92,7 +91,7 @@ class AsyncWriteQueue {
|
|
|
92
91
|
} catch {
|
|
93
92
|
// Silently fail - trajectory collection should not block main functionality
|
|
94
93
|
}
|
|
95
|
-
|
|
94
|
+
|
|
96
95
|
// 处理下一个任务
|
|
97
96
|
this.processNext();
|
|
98
97
|
}
|
|
@@ -109,11 +108,11 @@ const dirCache = new Map<string, boolean>();
|
|
|
109
108
|
*/
|
|
110
109
|
async function ensureTrajectoryDirAsync(workspaceDir: string): Promise<string> {
|
|
111
110
|
const dir = path.join(workspaceDir, TRAJECTORY_DIR);
|
|
112
|
-
|
|
111
|
+
|
|
113
112
|
if (dirCache.get(dir)) {
|
|
114
113
|
return dir;
|
|
115
114
|
}
|
|
116
|
-
|
|
115
|
+
|
|
117
116
|
try {
|
|
118
117
|
await fs.promises.mkdir(dir, { recursive: true });
|
|
119
118
|
dirCache.set(dir, true);
|
|
@@ -121,7 +120,7 @@ async function ensureTrajectoryDirAsync(workspaceDir: string): Promise<string> {
|
|
|
121
120
|
// 目录可能已存在,忽略错误
|
|
122
121
|
dirCache.set(dir, true);
|
|
123
122
|
}
|
|
124
|
-
|
|
123
|
+
|
|
125
124
|
return dir;
|
|
126
125
|
}
|
|
127
126
|
|
|
@@ -140,7 +139,7 @@ function getTodayFilename(): string {
|
|
|
140
139
|
*/
|
|
141
140
|
function writeTrajectoryRecord(workspaceDir: string, record: object): void {
|
|
142
141
|
const line = JSON.stringify(record) + '\n';
|
|
143
|
-
|
|
142
|
+
|
|
144
143
|
writeQueue.enqueue(async () => {
|
|
145
144
|
const dir = await ensureTrajectoryDirAsync(workspaceDir);
|
|
146
145
|
const filepath = path.join(dir, getTodayFilename());
|
|
@@ -149,89 +148,34 @@ function writeTrajectoryRecord(workspaceDir: string, record: object): void {
|
|
|
149
148
|
}
|
|
150
149
|
|
|
151
150
|
/**
|
|
152
|
-
*
|
|
153
|
-
*
|
|
151
|
+
* PRI-346: Message write hook with SQLite fallback trajectory recording.
|
|
152
|
+
*
|
|
153
|
+
* When allowConversationAccess is NOT set (unauthorized), llm_output is silently
|
|
154
|
+
* blocked by OpenClaw and trajectory.db has no data. This hook is NOT in
|
|
155
|
+
* CONVERSATION_HOOK_NAMES, so it always fires — making it the natural fallback.
|
|
156
|
+
*
|
|
157
|
+
* De-duplication: only writes to SQLite when llm_output is blocked (unauthorized).
|
|
158
|
+
* When llm_output is working (authorized), this hook degrades to JSONL-only.
|
|
159
|
+
*
|
|
160
|
+
* ERR-002: structured observability when fallback fires.
|
|
161
|
+
* ERR-001/005: content is sanitized before persisting.
|
|
154
162
|
*/
|
|
155
|
-
export function handleAfterToolCall(
|
|
156
|
-
event: PluginHookAfterToolCallEvent,
|
|
157
|
-
ctx: PluginHookToolContext & { workspaceDir?: string }
|
|
158
|
-
): void {
|
|
159
|
-
const {workspaceDir} = ctx;
|
|
160
|
-
if (!workspaceDir) return;
|
|
161
|
-
|
|
162
|
-
// 递归脱敏处理所有字段
|
|
163
|
-
const sanitizedParams = scrubSensitive(event.params);
|
|
164
|
-
const sanitizedResult = event.result == null
|
|
165
|
-
? null
|
|
166
|
-
: String(scrubSensitive(event.result)).slice(0, MAX_RESULT_LENGTH);
|
|
167
|
-
const sanitizedError = event.error == null
|
|
168
|
-
? null
|
|
169
|
-
: String(scrubSensitive(event.error));
|
|
170
|
-
|
|
171
|
-
writeTrajectoryRecord(workspaceDir, {
|
|
172
|
-
type: 'tool_call',
|
|
173
|
-
timestamp: new Date().toISOString(),
|
|
174
|
-
sessionId: ctx.sessionId || 'unknown',
|
|
175
|
-
toolName: event.toolName,
|
|
176
|
-
params: sanitizedParams,
|
|
177
|
-
result: sanitizedResult,
|
|
178
|
-
error: sanitizedError,
|
|
179
|
-
durationMs: event.durationMs,
|
|
180
|
-
success: !event.error,
|
|
181
|
-
runId: event.runId || null,
|
|
182
|
-
toolCallId: event.toolCallId || null
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* LLM 输出处理
|
|
188
|
-
* 记录:provider、model、输出长度、token 使用量
|
|
189
|
-
*/
|
|
190
|
-
export function handleLlmOutput(
|
|
191
|
-
event: PluginHookLlmOutputEvent,
|
|
192
|
-
ctx: PluginHookAgentContext & { workspaceDir?: string }
|
|
193
|
-
): void {
|
|
194
|
-
const {workspaceDir} = ctx;
|
|
195
|
-
if (!workspaceDir) return;
|
|
196
|
-
|
|
197
|
-
const totalTextLength = event.assistantTexts?.reduce((sum, text) => sum + (text?.length || 0), 0) || 0;
|
|
198
|
-
|
|
199
|
-
writeTrajectoryRecord(workspaceDir, {
|
|
200
|
-
type: 'llm_output',
|
|
201
|
-
timestamp: new Date().toISOString(),
|
|
202
|
-
sessionId: ctx.sessionId || 'unknown',
|
|
203
|
-
provider: event.provider,
|
|
204
|
-
model: event.model,
|
|
205
|
-
textLength: totalTextLength,
|
|
206
|
-
outputCount: event.assistantTexts?.length || 0,
|
|
207
|
-
usage: event.usage ? scrubSensitive(event.usage) : null
|
|
208
|
-
});
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* 消息写入前的处理
|
|
213
|
-
* 记录:用户/助手消息内容
|
|
214
|
-
*/
|
|
215
|
-
|
|
216
163
|
export function handleBeforeMessageWrite(
|
|
217
164
|
event: PluginHookBeforeMessageWriteEvent,
|
|
218
|
-
ctx: PluginHookAgentContext & { workspaceDir?: string }
|
|
165
|
+
ctx: PluginHookAgentContext & { workspaceDir?: string; pluginConfig?: unknown }
|
|
219
166
|
): void {
|
|
220
|
-
const {workspaceDir} = ctx;
|
|
167
|
+
const { workspaceDir } = ctx;
|
|
221
168
|
if (!workspaceDir) return;
|
|
222
169
|
|
|
223
170
|
const msg = event.message;
|
|
224
171
|
if (!msg || !msg.role) return;
|
|
225
172
|
|
|
226
|
-
//
|
|
173
|
+
// Only record user and assistant messages
|
|
227
174
|
if (msg.role !== 'user' && msg.role !== 'assistant') return;
|
|
228
175
|
|
|
229
|
-
//
|
|
176
|
+
// Extract text content (consistent with existing implementation)
|
|
230
177
|
let content = '';
|
|
231
178
|
if (typeof msg.content === 'string') {
|
|
232
|
-
|
|
233
|
-
// Reason: msg.content is string | ContentPart[]; destructuring would require renaming in the else branch
|
|
234
|
-
|
|
235
179
|
content = msg.content;
|
|
236
180
|
} else if (Array.isArray(msg.content)) {
|
|
237
181
|
content = msg.content
|
|
@@ -240,56 +184,70 @@ export function handleBeforeMessageWrite(
|
|
|
240
184
|
.join('\n');
|
|
241
185
|
}
|
|
242
186
|
|
|
243
|
-
//
|
|
187
|
+
// Sanitize content preview for JSONL
|
|
244
188
|
const sanitizedPreview = scrubSensitive(content.slice(0, 200));
|
|
245
189
|
|
|
190
|
+
// Existing JSONL write (always, for backward compatibility)
|
|
246
191
|
writeTrajectoryRecord(workspaceDir, {
|
|
247
192
|
type: 'message',
|
|
248
193
|
timestamp: new Date().toISOString(),
|
|
249
|
-
sessionId: event.sessionKey || 'unknown',
|
|
194
|
+
sessionId: event.sessionKey || event.sessionId || 'unknown',
|
|
250
195
|
role: msg.role,
|
|
251
196
|
contentLength: content.length,
|
|
252
197
|
contentPreview: typeof sanitizedPreview === 'string' ? sanitizedPreview : '[sanitized]',
|
|
253
|
-
agentId: event.agentId || null
|
|
198
|
+
agentId: event.agentId || null,
|
|
199
|
+
fallback: 'before_message_write',
|
|
254
200
|
});
|
|
255
|
-
}
|
|
256
201
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
202
|
+
// ── SQLite fallback (PRI-346): only when conversation hooks are blocked ──
|
|
203
|
+
const accessCheck = checkConversationAccessConfig(ctx.pluginConfig);
|
|
204
|
+
if (accessCheck.authorized) {
|
|
205
|
+
// llm_output is working — do NOT duplicate write to SQLite (de-dup, case D)
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Conversation hooks blocked — this hook is the fallback trajectory writer
|
|
210
|
+
if (msg.role === 'assistant') {
|
|
211
|
+
try {
|
|
212
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, logger: ctx.logger });
|
|
213
|
+
const sanitized = sanitizeForEvidence(content.slice(0, MAX_STRING_LENGTH), workspaceDir);
|
|
214
|
+
const sessionId = (event.sessionKey as string | undefined) ?? ctx.sessionId ?? 'unknown';
|
|
215
|
+
wctx.trajectory?.recordAssistantTurn?.({
|
|
216
|
+
sessionId,
|
|
217
|
+
runId: 'before_message_write_fallback',
|
|
218
|
+
provider: 'unknown',
|
|
219
|
+
model: 'unknown',
|
|
220
|
+
rawText: content,
|
|
221
|
+
sanitizedText: sanitized,
|
|
222
|
+
usageJson: {},
|
|
223
|
+
empathySignalJson: { detected: false, severity: 'mild', confidence: 1 },
|
|
224
|
+
createdAt: new Date().toISOString(),
|
|
225
|
+
});
|
|
226
|
+
} catch (err) {
|
|
227
|
+
ctx.logger?.warn?.(`[PD:before_message_write] SQLite fallback write failed: ${String(err)}`);
|
|
228
|
+
}
|
|
229
|
+
} else if (msg.role === 'user') {
|
|
230
|
+
try {
|
|
231
|
+
const wctx = WorkspaceContext.fromHookContext({ workspaceDir, logger: ctx.logger });
|
|
232
|
+
const sessionId = (event.sessionKey as string | undefined) ?? ctx.sessionId ?? 'unknown';
|
|
233
|
+
wctx.trajectory?.recordUserTurn?.({
|
|
234
|
+
sessionId,
|
|
235
|
+
turnIndex: 0,
|
|
236
|
+
rawText: content.slice(0, MAX_STRING_LENGTH),
|
|
237
|
+
correctionDetected: false,
|
|
238
|
+
createdAt: new Date().toISOString(),
|
|
239
|
+
});
|
|
240
|
+
} catch (err) {
|
|
241
|
+
ctx.logger?.warn?.(`[PD:before_message_write] SQLite user turn fallback failed: ${String(err)}`);
|
|
242
|
+
}
|
|
270
243
|
}
|
|
271
244
|
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}).length;
|
|
282
|
-
|
|
283
|
-
const messages = lines.filter(line => {
|
|
284
|
-
try { return JSON.parse(line).type === 'message'; } catch { return false; }
|
|
285
|
-
}).length;
|
|
286
|
-
|
|
287
|
-
return {
|
|
288
|
-
date: getTodayFilename(),
|
|
289
|
-
totalRecords: lines.length,
|
|
290
|
-
toolCalls,
|
|
291
|
-
llmOutputs,
|
|
292
|
-
messages,
|
|
293
|
-
generatedAt: new Date().toISOString()
|
|
294
|
-
};
|
|
295
|
-
}
|
|
245
|
+
// ERR-002: Structured observability — no silent fallback
|
|
246
|
+
SystemLogger.log(workspaceDir, 'CONVERSATION_HOOK_BLOCKED', JSON.stringify({
|
|
247
|
+
reason: accessCheck.reason,
|
|
248
|
+
nextAction: accessCheck.nextAction,
|
|
249
|
+
hook: 'llm_output',
|
|
250
|
+
fallback: 'before_message_write',
|
|
251
|
+
role: msg.role,
|
|
252
|
+
}));
|
|
253
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -16,9 +16,13 @@ import type {
|
|
|
16
16
|
PluginHookSubagentSpawningEvent,
|
|
17
17
|
PluginHookSubagentSpawningResult,
|
|
18
18
|
PluginHookSubagentContext,
|
|
19
|
+
PluginHookBeforeMessageWriteEvent,
|
|
19
20
|
} from './openclaw-sdk.js';
|
|
20
21
|
import * as path from 'path';
|
|
21
22
|
import { loadFeatureFlagFromConfig } from './core/pd-config-loader.js';
|
|
23
|
+
import { checkConversationAccessConfig } from './core/config-health.js';
|
|
24
|
+
export { checkConversationAccessConfig } from './core/config-health.js';
|
|
25
|
+
export type { ConversationAccessCheckResult } from './core/config-health.js';
|
|
22
26
|
import { classifyTask } from './core/local-worker-routing.js';
|
|
23
27
|
import { completeShadowObservation, recordShadowRouting } from './core/shadow-observation-registry.js';
|
|
24
28
|
import { getCommandDescription } from './i18n/commands.js';
|
|
@@ -28,8 +32,8 @@ import { handleBeforeToolCall } from './hooks/gate.js';
|
|
|
28
32
|
import { handleAfterToolCall } from './hooks/pain.js';
|
|
29
33
|
import { handleBeforeReset, handleBeforeCompaction, handleAfterCompaction } from './hooks/lifecycle.js';
|
|
30
34
|
import { handleLlmOutput } from './hooks/llm.js';
|
|
31
|
-
import { handleSubagentEnded } from './hooks/subagent.js';
|
|
32
35
|
import * as TrajectoryCollector from './hooks/trajectory-collector.js';
|
|
36
|
+
import { handleSubagentEnded } from './hooks/subagent.js';
|
|
33
37
|
import { handleInitStrategy } from './commands/strategy.js';
|
|
34
38
|
import { handleBootstrapTools, handleResearchTools } from './commands/capabilities.js';
|
|
35
39
|
import { handleThinkingOs } from './commands/thinking-os.js';
|
|
@@ -71,57 +75,8 @@ const startedWorkspaces = new Set<string>();
|
|
|
71
75
|
const pendingShadowObservations = new Map<string, string>();
|
|
72
76
|
|
|
73
77
|
// ── Conversation Access Health Check (PRI-343) ────────────────────────────
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
// trajectory hooks are silently blocked by OpenClaw, causing evidence
|
|
77
|
-
// to always be empty (PRI-338 root cause).
|
|
78
|
-
|
|
79
|
-
/** Keep in sync with @principles/core CONVERSATION_ACCESS_CONFIG_KEY */
|
|
80
|
-
const CONVERSATION_ACCESS_CONFIG_KEY = 'allowConversationAccess' as const;
|
|
81
|
-
|
|
82
|
-
export interface ConversationAccessCheckResult {
|
|
83
|
-
authorized: boolean;
|
|
84
|
-
reason?: string;
|
|
85
|
-
nextAction?: string;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const CONVERSATION_ACCESS_FIX_COMMAND =
|
|
89
|
-
'openclaw config set plugins.entries.principles-disciple.hooks.allowConversationAccess true --strict-json';
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* PRI-343: Pure function — checks if pluginConfig has hooks.allowConversationAccess === true.
|
|
93
|
-
* Returns a structured result with reason and nextAction when not authorized (ERR-002).
|
|
94
|
-
*/
|
|
95
|
-
export function checkConversationAccessConfig(pluginConfig: unknown): ConversationAccessCheckResult {
|
|
96
|
-
if (pluginConfig === null || pluginConfig === undefined || typeof pluginConfig !== 'object' || Array.isArray(pluginConfig)) {
|
|
97
|
-
return {
|
|
98
|
-
authorized: false,
|
|
99
|
-
reason: 'pluginConfig is missing or invalid — conversation hooks cannot be registered',
|
|
100
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
101
|
-
};
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
const config = pluginConfig as Record<string, unknown>;
|
|
105
|
-
|
|
106
|
-
if (typeof config.hooks !== 'object' || config.hooks === null || Array.isArray(config.hooks)) {
|
|
107
|
-
return {
|
|
108
|
-
authorized: false,
|
|
109
|
-
reason: 'allowConversationAccess is not set to true',
|
|
110
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
const hooks = config.hooks as Record<string, unknown>;
|
|
115
|
-
if (hooks[CONVERSATION_ACCESS_CONFIG_KEY] !== true) {
|
|
116
|
-
return {
|
|
117
|
-
authorized: false,
|
|
118
|
-
reason: 'allowConversationAccess is not set to true',
|
|
119
|
-
nextAction: CONVERSATION_ACCESS_FIX_COMMAND,
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
return { authorized: true };
|
|
124
|
-
}
|
|
78
|
+
// Re-exported from core/config-health.ts for backward compatibility.
|
|
79
|
+
// Implementation moved to avoid circular imports with trajectory-collector.ts.
|
|
125
80
|
|
|
126
81
|
// ── Feature Flag Loader (plugin I/O boundary) ─────────────────────────────
|
|
127
82
|
// Reads workspace feature-flags.yaml and checks a specific flag.
|
|
@@ -436,36 +391,6 @@ const plugin = {
|
|
|
436
391
|
})
|
|
437
392
|
);
|
|
438
393
|
|
|
439
|
-
// ── Hook: Trajectory Collection (Behavior Evolution Phase 0) ──
|
|
440
|
-
// Note: after_tool_call and llm_output are safe to collect
|
|
441
|
-
api.on(
|
|
442
|
-
'after_tool_call',
|
|
443
|
-
guardHook('hook:after_tool_call.trajectory', api.logger, (event: PluginHookAfterToolCallEvent, ctx: PluginHookToolContext): void => {
|
|
444
|
-
try {
|
|
445
|
-
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.after_tool_call');
|
|
446
|
-
if (!workspaceDir) return;
|
|
447
|
-
TrajectoryCollector.handleAfterToolCall(event, { ...ctx, workspaceDir });
|
|
448
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Reason: catch binding intentionally unused
|
|
449
|
-
} catch (_err) {
|
|
450
|
-
// Non-critical: don't log, just skip
|
|
451
|
-
}
|
|
452
|
-
})
|
|
453
|
-
);
|
|
454
|
-
|
|
455
|
-
api.on(
|
|
456
|
-
'llm_output',
|
|
457
|
-
guardHook('hook:llm_output.trajectory', api.logger, (event: PluginHookLlmOutputEvent, ctx: PluginHookAgentContext): void => {
|
|
458
|
-
try {
|
|
459
|
-
const workspaceDir = resolveToolHookWorkspaceDirSafe(ctx, api, 'trajectory.llm_output');
|
|
460
|
-
if (!workspaceDir) return;
|
|
461
|
-
TrajectoryCollector.handleLlmOutput(event, { ...ctx, workspaceDir });
|
|
462
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Reason: catch binding intentionally unused
|
|
463
|
-
} catch (_err) {
|
|
464
|
-
// Non-critical: don't log, just skip
|
|
465
|
-
}
|
|
466
|
-
})
|
|
467
|
-
);
|
|
468
|
-
|
|
469
394
|
// ── Hook: Subagent Loop Closure ──
|
|
470
395
|
api.on(
|
|
471
396
|
'subagent_spawning',
|
|
@@ -598,6 +523,31 @@ const plugin = {
|
|
|
598
523
|
return handleAfterCompaction(event, { ...ctx, workspaceDir: wsResult.workspaceDir });
|
|
599
524
|
}));
|
|
600
525
|
|
|
526
|
+
// ── Hook: Before Message Write (PRI-346) ──
|
|
527
|
+
// Fallback trajectory collection when llm_output is blocked by
|
|
528
|
+
// missing allowConversationAccess. Not in CONVERSATION_HOOK_NAMES
|
|
529
|
+
// so OpenClaw always delivers it.
|
|
530
|
+
api.on(
|
|
531
|
+
'before_message_write',
|
|
532
|
+
guardHook('hook:before_message_write', api.logger, (event: PluginHookBeforeMessageWriteEvent, ctx: PluginHookAgentContext): void => {
|
|
533
|
+
const wsResult = resolveHookWorkspaceDir(ctx, api, 'before_message_write');
|
|
534
|
+
if (!wsResult.ok) {
|
|
535
|
+
api.logger.warn(`[PD:before_message_write] workspaceDir resolution failed: ${wsResult.reason}`);
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
try {
|
|
539
|
+
TrajectoryCollector.handleBeforeMessageWrite(event, {
|
|
540
|
+
...ctx,
|
|
541
|
+
workspaceDir: wsResult.workspaceDir,
|
|
542
|
+
pluginConfig: api.pluginConfig,
|
|
543
|
+
});
|
|
544
|
+
} catch (err) {
|
|
545
|
+
// Non-critical: don't surface to user
|
|
546
|
+
api.logger.warn(`[PD:before_message_write] error: ${String(err)}`);
|
|
547
|
+
}
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
|
|
601
551
|
// ── Service Registration (surface-guarded) ──
|
|
602
552
|
// PRI-294: EvolutionWorker service registration removed — it starts via
|
|
603
553
|
// before_prompt_build hook gate, not via api.registerService. The surface
|
|
@@ -93,8 +93,8 @@ describe('surface-guard', () => {
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
it('allows override for quiet surface', () => {
|
|
96
|
-
const result = isSurfaceEnabled('hook:
|
|
97
|
-
'hook:
|
|
96
|
+
const result = isSurfaceEnabled('hook:subagent_spawning', {
|
|
97
|
+
'hook:subagent_spawning': true,
|
|
98
98
|
});
|
|
99
99
|
expect(result.enabled).toBe(true);
|
|
100
100
|
});
|
|
@@ -308,15 +308,13 @@ describe('surface-guard', () => {
|
|
|
308
308
|
}
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
-
it('
|
|
312
|
-
const
|
|
313
|
-
s => s.id === 'hook:
|
|
311
|
+
it('subagent hook disabledReason is opt-in and ADR-anchored (PRI-298)', () => {
|
|
312
|
+
const subagent = PLUGIN_SURFACE_REGISTRY.find(
|
|
313
|
+
s => s.id === 'hook:subagent_spawning',
|
|
314
314
|
);
|
|
315
|
-
expect(
|
|
316
|
-
const reason =
|
|
315
|
+
expect(subagent?.disabledReason).toBeDefined();
|
|
316
|
+
const reason = subagent!.disabledReason!.toLowerCase();
|
|
317
317
|
// Quiet hook copy is opt-in / opt-out anchored on a real ADR section
|
|
318
|
-
// (no MVP-phase residue, no promise of a feature-flag override that
|
|
319
|
-
// the production guard path does not actually consume — chatgpt P2).
|
|
320
318
|
expect(reason).toContain('opt-in');
|
|
321
319
|
expect(reason).toContain('default off');
|
|
322
320
|
expect(reason).toMatch(/adr-?0014/);
|
|
@@ -125,6 +125,7 @@ describe('PRI-212 plugin core anti-growth guard', () => {
|
|
|
125
125
|
'workspace-guidance-migrator.ts',
|
|
126
126
|
'surface-guard.ts',
|
|
127
127
|
'pd-config-loader.ts',
|
|
128
|
+
'config-health.ts', // PRI-346: conversation access check extracted to avoid circular imports
|
|
128
129
|
] as const;
|
|
129
130
|
|
|
130
131
|
// Category 6: Test files
|
|
@@ -139,13 +139,12 @@ describe('PRI-294: Surface registry coverage audit', () => {
|
|
|
139
139
|
'hook:before_tool_call',
|
|
140
140
|
'hook:after_tool_call',
|
|
141
141
|
'hook:llm_output',
|
|
142
|
-
'hook:after_tool_call.trajectory',
|
|
143
|
-
'hook:llm_output.trajectory',
|
|
144
142
|
'hook:subagent_spawning',
|
|
145
143
|
'hook:subagent_ended',
|
|
146
144
|
'hook:before_reset',
|
|
147
145
|
'hook:before_compaction',
|
|
148
146
|
'hook:after_compaction',
|
|
147
|
+
'hook:before_message_write', // PRI-346: SQLite fallback trajectory collection
|
|
149
148
|
// Services registered via guardService
|
|
150
149
|
'service:correction-observer',
|
|
151
150
|
'service:trajectory',
|
|
@@ -257,8 +256,6 @@ describe('PRI-294: MVP core hooks enabled, non-core disabled', () => {
|
|
|
257
256
|
];
|
|
258
257
|
|
|
259
258
|
const QUIET_HOOKS = [
|
|
260
|
-
'hook:after_tool_call.trajectory',
|
|
261
|
-
'hook:llm_output.trajectory',
|
|
262
259
|
'hook:subagent_spawning',
|
|
263
260
|
'hook:subagent_ended',
|
|
264
261
|
'hook:before_reset',
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trajectory Collector — before_message_write hook tests (PRI-346)
|
|
3
|
+
*
|
|
4
|
+
* Cases A–F verify:
|
|
5
|
+
* A: hook registration via api.on('before_message_write', ...)
|
|
6
|
+
* B: assistant message → SQLite fallback when unauthorized
|
|
7
|
+
* C: user message → user_turns; non-user/assistant → skip
|
|
8
|
+
* D: authorized → no SQLite write (de-duplication)
|
|
9
|
+
* E: CONVERSATION_HOOK_BLOCKED observability log
|
|
10
|
+
* F: privacy / path redaction in sanitized text
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
14
|
+
import type { OpenClawPluginApi, PluginHookBeforeMessageWriteEvent, PluginHookAgentContext } from '../../src/openclaw-sdk.js';
|
|
15
|
+
|
|
16
|
+
// Mock heavy dependencies before importing the module under test
|
|
17
|
+
const mockRecordAssistantTurn = vi.fn(() => 42);
|
|
18
|
+
const mockRecordUserTurn = vi.fn(() => 1);
|
|
19
|
+
|
|
20
|
+
vi.mock('../../src/core/workspace-context.js', () => {
|
|
21
|
+
return {
|
|
22
|
+
WorkspaceContext: {
|
|
23
|
+
fromHookContext: vi.fn(() => ({
|
|
24
|
+
trajectory: {
|
|
25
|
+
recordAssistantTurn: mockRecordAssistantTurn,
|
|
26
|
+
recordUserTurn: mockRecordUserTurn,
|
|
27
|
+
},
|
|
28
|
+
workspaceDir: '/mock/workspace',
|
|
29
|
+
stateDir: '/mock/workspace/.state',
|
|
30
|
+
})),
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
vi.mock('../../src/core/system-logger.js', () => ({
|
|
36
|
+
SystemLogger: {
|
|
37
|
+
log: vi.fn(),
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('../../src/hooks/message-sanitize.js', () => ({
|
|
42
|
+
sanitizeForEvidence: vi.fn((text: string, _wsDir?: string) => {
|
|
43
|
+
// Simulate path redaction: replace C:\Users\... patterns
|
|
44
|
+
return text.replace(/C:\\Users\\[^\s]+/gi, '[PATH_REDACTED]');
|
|
45
|
+
}),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// fs mock: prevent real file I/O from writeTrajectoryRecord
|
|
49
|
+
vi.mock('fs', () => {
|
|
50
|
+
const memfs: Record<string, string> = {};
|
|
51
|
+
return {
|
|
52
|
+
existsSync: vi.fn(() => true),
|
|
53
|
+
mkdirSync: vi.fn(),
|
|
54
|
+
promises: {
|
|
55
|
+
mkdir: vi.fn(),
|
|
56
|
+
appendFile: vi.fn(async (filepath: string, data: string) => {
|
|
57
|
+
memfs[filepath] = (memfs[filepath] ?? '') + data;
|
|
58
|
+
}),
|
|
59
|
+
},
|
|
60
|
+
appendFile: vi.fn(),
|
|
61
|
+
__memfs: memfs,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
import { handleBeforeMessageWrite } from '../../src/hooks/trajectory-collector.js';
|
|
66
|
+
import { WorkspaceContext } from '../../src/core/workspace-context.js';
|
|
67
|
+
import { SystemLogger } from '../../src/core/system-logger.js';
|
|
68
|
+
import plugin from '../../src/index.js';
|
|
69
|
+
|
|
70
|
+
function makeEvent(role: string, content: string | unknown[]): PluginHookBeforeMessageWriteEvent {
|
|
71
|
+
return {
|
|
72
|
+
message: { role, content },
|
|
73
|
+
sessionKey: 'sess-001',
|
|
74
|
+
sessionId: 'sess-001',
|
|
75
|
+
agentId: 'main',
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const unauthorizedConfig = { hooks: { allowConversationAccess: false } };
|
|
80
|
+
const authorizedConfig = { hooks: { allowConversationAccess: true } };
|
|
81
|
+
|
|
82
|
+
function makeCtx(pluginConfig: unknown, workspaceDir: string | null = '/mock/workspace') {
|
|
83
|
+
return {
|
|
84
|
+
workspaceDir: workspaceDir === null ? undefined : workspaceDir,
|
|
85
|
+
pluginConfig,
|
|
86
|
+
sessionId: 'sess-001',
|
|
87
|
+
agentId: 'main',
|
|
88
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
89
|
+
} as PluginHookAgentContext & { workspaceDir?: string; pluginConfig?: unknown };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
describe('PRI-346: before_message_write hook', () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.clearAllMocks();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── Case A: hook registration ──────────────────────────────────────────────
|
|
98
|
+
describe('Case A — hook is registered', () => {
|
|
99
|
+
it('calls api.on with "before_message_write"', () => {
|
|
100
|
+
const onSpy = vi.fn();
|
|
101
|
+
const mockApi = {
|
|
102
|
+
rootDir: '/mock',
|
|
103
|
+
pluginConfig: { language: 'en' },
|
|
104
|
+
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
105
|
+
config: {},
|
|
106
|
+
registerCommand: vi.fn(),
|
|
107
|
+
registerService: vi.fn(),
|
|
108
|
+
registerTool: vi.fn(),
|
|
109
|
+
registerHttpRoute: vi.fn(),
|
|
110
|
+
on: onSpy,
|
|
111
|
+
} as unknown as OpenClawPluginApi;
|
|
112
|
+
|
|
113
|
+
plugin.register(mockApi);
|
|
114
|
+
|
|
115
|
+
const registeredEvents = onSpy.mock.calls.map((c: unknown[]) => c[0]);
|
|
116
|
+
expect(registeredEvents).toContain('before_message_write');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── Case B: assistant message → SQLite when unauthorized ───────────────────
|
|
121
|
+
describe('Case B — assistant message writes to SQLite when unauthorized', () => {
|
|
122
|
+
it('calls recordAssistantTurn once', () => {
|
|
123
|
+
const event = makeEvent('assistant', 'Hello, how can I help?');
|
|
124
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
125
|
+
|
|
126
|
+
handleBeforeMessageWrite(event, ctx);
|
|
127
|
+
|
|
128
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
129
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
130
|
+
expect(call.sessionId).toBe('sess-001');
|
|
131
|
+
expect(call.runId).toBe('before_message_write_fallback');
|
|
132
|
+
expect(call.provider).toBe('unknown');
|
|
133
|
+
expect(call.model).toBe('unknown');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── Case C: user → user_turns; tool → skip ─────────────────────────────────
|
|
138
|
+
describe('Case C — user message and non-user/assistant', () => {
|
|
139
|
+
it('calls recordUserTurn for role=user', () => {
|
|
140
|
+
const event = makeEvent('user', 'Fix this bug please');
|
|
141
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
142
|
+
|
|
143
|
+
handleBeforeMessageWrite(event, ctx);
|
|
144
|
+
|
|
145
|
+
expect(mockRecordUserTurn).toHaveBeenCalledTimes(1);
|
|
146
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('skips writing for role=tool', () => {
|
|
150
|
+
const event = makeEvent('tool', 'tool output here');
|
|
151
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
152
|
+
|
|
153
|
+
handleBeforeMessageWrite(event, ctx);
|
|
154
|
+
|
|
155
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
156
|
+
expect(mockRecordUserTurn).not.toHaveBeenCalled();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('handles array content (multipart messages)', () => {
|
|
160
|
+
const content = [
|
|
161
|
+
{ type: 'text', text: 'Part one' },
|
|
162
|
+
{ type: 'image_url', url: 'http://example.com/img.png' },
|
|
163
|
+
{ type: 'text', text: 'Part two' },
|
|
164
|
+
];
|
|
165
|
+
const event = makeEvent('assistant', content);
|
|
166
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
167
|
+
|
|
168
|
+
handleBeforeMessageWrite(event, ctx);
|
|
169
|
+
|
|
170
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
171
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
172
|
+
expect(call.rawText).toContain('Part one');
|
|
173
|
+
expect(call.rawText).toContain('Part two');
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Case D: de-duplication (authorized → no SQLite) ────────────────────────
|
|
178
|
+
describe('Case D — authorized config skips SQLite (de-dup)', () => {
|
|
179
|
+
it('does NOT call recordAssistantTurn when authorized', () => {
|
|
180
|
+
const event = makeEvent('assistant', 'Normal response');
|
|
181
|
+
const ctx = makeCtx(authorizedConfig);
|
|
182
|
+
|
|
183
|
+
handleBeforeMessageWrite(event, ctx);
|
|
184
|
+
|
|
185
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
186
|
+
expect(mockRecordUserTurn).not.toHaveBeenCalled();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('does NOT call SystemLogger.log when authorized', () => {
|
|
190
|
+
const event = makeEvent('assistant', 'Normal response');
|
|
191
|
+
const ctx = makeCtx(authorizedConfig);
|
|
192
|
+
|
|
193
|
+
handleBeforeMessageWrite(event, ctx);
|
|
194
|
+
|
|
195
|
+
expect(SystemLogger.log).not.toHaveBeenCalled();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ── Case E: CONVERSATION_HOOK_BLOCKED observability ─────────────────────────
|
|
200
|
+
describe('Case E — CONVERSATION_HOOK_BLOCKED logged when unauthorized', () => {
|
|
201
|
+
it('logs with reason + nextAction', () => {
|
|
202
|
+
const event = makeEvent('assistant', 'Some response');
|
|
203
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
204
|
+
|
|
205
|
+
handleBeforeMessageWrite(event, ctx);
|
|
206
|
+
|
|
207
|
+
expect(SystemLogger.log).toHaveBeenCalledWith(
|
|
208
|
+
'/mock/workspace',
|
|
209
|
+
'CONVERSATION_HOOK_BLOCKED',
|
|
210
|
+
expect.any(String),
|
|
211
|
+
);
|
|
212
|
+
const payload = JSON.parse(
|
|
213
|
+
(SystemLogger.log as ReturnType<typeof vi.fn>).mock.calls[0][2] as string
|
|
214
|
+
);
|
|
215
|
+
expect(payload.reason).toBeDefined();
|
|
216
|
+
expect(payload.nextAction).toBeDefined();
|
|
217
|
+
expect(payload.hook).toBe('llm_output');
|
|
218
|
+
expect(payload.fallback).toBe('before_message_write');
|
|
219
|
+
expect(payload.role).toBe('assistant');
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// ── Case F: privacy / path redaction ────────────────────────────────────────
|
|
224
|
+
describe('Case F — sensitive path is redacted in sanitizedText', () => {
|
|
225
|
+
it('sanitizedText does not contain the raw path', () => {
|
|
226
|
+
const sensitiveContent = 'The file is at C:\\Users\\sensitive\\path\\secret.txt please check it';
|
|
227
|
+
const event = makeEvent('assistant', sensitiveContent);
|
|
228
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
229
|
+
|
|
230
|
+
handleBeforeMessageWrite(event, ctx);
|
|
231
|
+
|
|
232
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
233
|
+
const call = mockRecordAssistantTurn.mock.calls[0][0];
|
|
234
|
+
expect(call.sanitizedText).not.toContain('C:\\Users\\sensitive\\path');
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ── Edge cases ─────────────────────────────────────────────────────────────
|
|
239
|
+
describe('Edge cases', () => {
|
|
240
|
+
it('returns early when workspaceDir is missing', () => {
|
|
241
|
+
mockRecordAssistantTurn.mockReset();
|
|
242
|
+
mockRecordUserTurn.mockReset();
|
|
243
|
+
const event = makeEvent('assistant', 'Hello');
|
|
244
|
+
const ctx = makeCtx(unauthorizedConfig, null);
|
|
245
|
+
|
|
246
|
+
// Should not throw
|
|
247
|
+
handleBeforeMessageWrite(event, ctx);
|
|
248
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('returns early when message is null/undefined', () => {
|
|
252
|
+
const event = { message: null as unknown as { role?: string }, sessionKey: 's' } as PluginHookBeforeMessageWriteEvent;
|
|
253
|
+
const ctx = makeCtx(unauthorizedConfig);
|
|
254
|
+
|
|
255
|
+
handleBeforeMessageWrite(event, ctx);
|
|
256
|
+
expect(mockRecordAssistantTurn).not.toHaveBeenCalled();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('handles missing pluginConfig gracefully', () => {
|
|
260
|
+
const event = makeEvent('assistant', 'Hello');
|
|
261
|
+
const ctx = makeCtx(undefined);
|
|
262
|
+
|
|
263
|
+
handleBeforeMessageWrite(event, ctx);
|
|
264
|
+
|
|
265
|
+
// pluginConfig undefined → unauthorized → fallback fires
|
|
266
|
+
expect(mockRecordAssistantTurn).toHaveBeenCalledTimes(1);
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
@@ -186,18 +186,16 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
|
186
186
|
}
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
it('after_tool_call has
|
|
189
|
+
it('after_tool_call has one registration: core only', () => {
|
|
190
190
|
const afterToolCallRegs = registrations.filter(r => r.event === 'after_tool_call');
|
|
191
|
-
expect(afterToolCallRegs.length).toBe(
|
|
191
|
+
expect(afterToolCallRegs.length).toBe(1);
|
|
192
192
|
expect(afterToolCallRegs[0].surfaceId).toBe('hook:after_tool_call');
|
|
193
|
-
expect(afterToolCallRegs[1].surfaceId).toBe('hook:after_tool_call.trajectory');
|
|
194
193
|
});
|
|
195
194
|
|
|
196
|
-
it('llm_output has
|
|
195
|
+
it('llm_output has one registration: core only', () => {
|
|
197
196
|
const llmOutputRegs = registrations.filter(r => r.event === 'llm_output');
|
|
198
|
-
expect(llmOutputRegs.length).toBe(
|
|
197
|
+
expect(llmOutputRegs.length).toBe(1);
|
|
199
198
|
expect(llmOutputRegs[0].surfaceId).toBe('hook:llm_output');
|
|
200
|
-
expect(llmOutputRegs[1].surfaceId).toBe('hook:llm_output.trajectory');
|
|
201
199
|
});
|
|
202
200
|
|
|
203
201
|
it('total api.on registrations with guardHook match registry hook count', () => {
|
|
@@ -375,14 +373,14 @@ describe('MVP Surface Registry Guard (PRI-289)', () => {
|
|
|
375
373
|
|
|
376
374
|
it('isSurfaceEnabled returns false for quiet surfaces by default', async () => {
|
|
377
375
|
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
378
|
-
const result = isSurfaceEnabled('hook:
|
|
376
|
+
const result = isSurfaceEnabled('hook:subagent_spawning');
|
|
379
377
|
expect(result.enabled).toBe(false);
|
|
380
378
|
expect(result.reason).toBeDefined();
|
|
381
379
|
});
|
|
382
380
|
|
|
383
381
|
it('isSurfaceEnabled allows quiet surfaces with explicit override', async () => {
|
|
384
382
|
const { isSurfaceEnabled } = await import('../../src/core/surface-guard.js');
|
|
385
|
-
const result = isSurfaceEnabled('hook:
|
|
383
|
+
const result = isSurfaceEnabled('hook:subagent_spawning', { 'hook:subagent_spawning': true });
|
|
386
384
|
expect(result.enabled).toBe(true);
|
|
387
385
|
});
|
|
388
386
|
|