principles-disciple 1.90.0 → 1.92.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
CHANGED
package/package.json
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { PluginHookBeforeMessageWriteEvent, PluginHookBeforeMessageWriteResult } from '../openclaw-sdk.js';
|
|
2
|
+
import {
|
|
3
|
+
sanitizeString as coreSanitizeString,
|
|
4
|
+
sanitizeValue as coreSanitizeValue,
|
|
5
|
+
sanitizeToolParams as coreSanitizeToolParams,
|
|
6
|
+
convergePath,
|
|
7
|
+
MAX_EVIDENCE_VALUE_CHARS,
|
|
8
|
+
} from '@principles/core/runtime-v2';
|
|
2
9
|
|
|
3
10
|
const INTERNAL_TAG_PATTERNS = [
|
|
4
11
|
/\[EMOTIONAL_DAMAGE_DETECTED(?::(?:mild|moderate|severe))?\]/gi,
|
|
@@ -21,6 +28,41 @@ function isAssistantMessageWithContent(
|
|
|
21
28
|
);
|
|
22
29
|
}
|
|
23
30
|
|
|
31
|
+
// Re-export core constants and functions for backward compatibility
|
|
32
|
+
export { MAX_EVIDENCE_VALUE_CHARS, convergePath };
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Sanitize a single string value for evidence storage.
|
|
36
|
+
* Delegates to core sanitizer with optional workspaceDir for path convergence.
|
|
37
|
+
*/
|
|
38
|
+
export function sanitizeForEvidence(value: unknown, workspaceDir?: string): string {
|
|
39
|
+
if (value === null || value === undefined) return '';
|
|
40
|
+
return coreSanitizeString(String(value), workspaceDir);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Recursively sanitize any value for evidence storage.
|
|
45
|
+
* Delegates to core sanitizer.
|
|
46
|
+
*/
|
|
47
|
+
export function sanitizeValueForEvidence(value: unknown, workspaceDir?: string): unknown {
|
|
48
|
+
return coreSanitizeValue(value, 0, workspaceDir);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sanitize tool-call params for evidence/trajectory storage.
|
|
53
|
+
* Delegates to core sanitizer — accepts unknown, runtime-validates.
|
|
54
|
+
*
|
|
55
|
+
* ERR-001: no `as` casts on input
|
|
56
|
+
* ERR-055: ANY-segment sensitive field matching
|
|
57
|
+
* ERR-056: token redaction on ALL strings via recursive sanitizeValue
|
|
58
|
+
*/
|
|
59
|
+
export function sanitizeToolParamsForEvidence(
|
|
60
|
+
params: unknown,
|
|
61
|
+
workspaceDir?: string,
|
|
62
|
+
): Record<string, unknown> {
|
|
63
|
+
return coreSanitizeToolParams(params, workspaceDir);
|
|
64
|
+
}
|
|
65
|
+
|
|
24
66
|
export function sanitizeAssistantText(text: string): string {
|
|
25
67
|
let result = text;
|
|
26
68
|
for (const pattern of INTERNAL_TAG_PATTERNS) {
|
package/src/hooks/pain.ts
CHANGED
|
@@ -13,7 +13,7 @@ import type { PluginHookAfterToolCallEvent, PluginHookToolContext, OpenClawPlugi
|
|
|
13
13
|
import { resolveWorkspaceDirForRuntimeV2 } from '../utils/workspace-resolver.js';
|
|
14
14
|
import { PainToPrincipleService, PrincipleTreeLedgerAdapter, type PainDetectedData, type PainEvidenceEntry, MAX_EVIDENCE_ENTRIES, MAX_EVIDENCE_NOTE_CHARS } from '@principles/core/runtime-v2';
|
|
15
15
|
import { evaluatePainDiagnosticGate } from '../core/pain-diagnostic-gate.js';
|
|
16
|
-
import { sanitizeAssistantText } from './message-sanitize.js';
|
|
16
|
+
import { sanitizeAssistantText, sanitizeForEvidence, sanitizeToolParamsForEvidence } from './message-sanitize.js';
|
|
17
17
|
import { loadPdConfigForPlugin } from '../core/pd-config-loader.js';
|
|
18
18
|
|
|
19
19
|
/**
|
|
@@ -36,7 +36,7 @@ const WRITE_TOOLS = ['write', 'edit', 'apply_patch', 'write_file', 'edit_file',
|
|
|
36
36
|
function createPainToPrincipleService(wctx: WorkspaceContext): PainToPrincipleService {
|
|
37
37
|
const ledgerAdapter = new PrincipleTreeLedgerAdapter({ stateDir: wctx.stateDir });
|
|
38
38
|
// PRI-306: Load .pd/config.yaml and pass effectiveConfig to PainToPrincipleService
|
|
39
|
-
// so
|
|
39
|
+
// so config-driven runtime binding resolution is used.
|
|
40
40
|
const configResult = loadPdConfigForPlugin(wctx.workspaceDir);
|
|
41
41
|
return new PainToPrincipleService({
|
|
42
42
|
workspaceDir: wctx.workspaceDir,
|
|
@@ -348,7 +348,7 @@ export function handleAfterToolCall(
|
|
|
348
348
|
errorMessage: event.error ? String(event.error) : undefined,
|
|
349
349
|
gfiBefore,
|
|
350
350
|
gfiAfter: updatedState.currentGfi,
|
|
351
|
-
paramsJson: event.params,
|
|
351
|
+
paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
|
|
352
352
|
});
|
|
353
353
|
|
|
354
354
|
const injectedProbationIds = getInjectedProbationIds(sessionId, effectiveWorkspaceDir);
|
|
@@ -409,7 +409,7 @@ export function handleAfterToolCall(
|
|
|
409
409
|
exitCode,
|
|
410
410
|
gfiBefore,
|
|
411
411
|
gfiAfter: resetState.currentGfi,
|
|
412
|
-
paramsJson: event.params,
|
|
412
|
+
paramsJson: sanitizeToolParamsForEvidence(event.params, effectiveWorkspaceDir),
|
|
413
413
|
});
|
|
414
414
|
|
|
415
415
|
const filePath = params.file_path || params.path || params.file;
|
|
@@ -512,7 +512,7 @@ export function handleAfterToolCall(
|
|
|
512
512
|
reason: `Tool ${event.toolName} failed on ${relPath}`,
|
|
513
513
|
severity: painScore >= 70 ? 'severe' : painScore >= 40 ? 'moderate' : 'mild',
|
|
514
514
|
origin: 'system_infer',
|
|
515
|
-
text: params.text ?? params.content
|
|
515
|
+
text: sanitizeForEvidence(params.text ?? params.content, effectiveWorkspaceDir) || undefined,
|
|
516
516
|
});
|
|
517
517
|
|
|
518
518
|
// Pain signal emitted via emitPainDetectedEvent below — no .pain_flag file written (M8: single-path chain)
|
package/src/hooks/prompt.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
extractMessageContent,
|
|
31
31
|
isMinimalTrigger,
|
|
32
32
|
} from '@principles/core/prompt-builder';
|
|
33
|
+
import { sanitizeForEvidence } from './message-sanitize.js';
|
|
33
34
|
|
|
34
35
|
// ---------------------------------------------------------------------------
|
|
35
36
|
// Static file cache — avoids re-reading rarely-changing files every message
|
|
@@ -574,7 +575,7 @@ The empathy observer subagent handles pain detection independently.
|
|
|
574
575
|
confidence: result.confidence,
|
|
575
576
|
detection_mode: 'structured',
|
|
576
577
|
deduped: false,
|
|
577
|
-
trigger_text_excerpt: latestUserMessage.substring(0, 120),
|
|
578
|
+
trigger_text_excerpt: sanitizeForEvidence(latestUserMessage, workspaceDir).substring(0, 120),
|
|
578
579
|
raw_score: painScore,
|
|
579
580
|
calibrated_score: painScore,
|
|
580
581
|
eventId,
|
|
@@ -589,7 +590,7 @@ The empathy observer subagent handles pain detection independently.
|
|
|
589
590
|
severity: result.severity,
|
|
590
591
|
origin: 'system_infer',
|
|
591
592
|
confidence: result.confidence,
|
|
592
|
-
text: latestUserMessage,
|
|
593
|
+
text: sanitizeForEvidence(latestUserMessage, workspaceDir),
|
|
593
594
|
});
|
|
594
595
|
} catch (error) {
|
|
595
596
|
logger?.warn?.(`[PD:Empathy] Failed to persist trajectory: ${String(error)}`);
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
handleBeforeMessageWrite,
|
|
4
|
+
sanitizeAssistantText,
|
|
5
|
+
sanitizeForEvidence,
|
|
6
|
+
sanitizeToolParamsForEvidence,
|
|
7
|
+
sanitizeValueForEvidence,
|
|
8
|
+
MAX_EVIDENCE_VALUE_CHARS,
|
|
9
|
+
} from '../../src/hooks/message-sanitize';
|
|
3
10
|
|
|
4
11
|
describe('message-sanitize hook', () => {
|
|
5
12
|
it('removes empathy control tags from assistant text', () => {
|
|
@@ -9,28 +16,229 @@ describe('message-sanitize hook', () => {
|
|
|
9
16
|
|
|
10
17
|
it('returns modified message for assistant role', () => {
|
|
11
18
|
const result = handleBeforeMessageWrite({
|
|
12
|
-
message: {
|
|
13
|
-
role: 'assistant',
|
|
14
|
-
content: 'hello [EMOTIONAL_DAMAGE_DETECTED] world'
|
|
15
|
-
}
|
|
19
|
+
message: { role: 'assistant', content: 'hello [EMOTIONAL_DAMAGE_DETECTED] world' }
|
|
16
20
|
} as any);
|
|
17
|
-
|
|
18
21
|
expect(result).toEqual({
|
|
19
|
-
message: {
|
|
20
|
-
role: 'assistant',
|
|
21
|
-
content: 'hello world'
|
|
22
|
-
}
|
|
22
|
+
message: { role: 'assistant', content: 'hello world' }
|
|
23
23
|
});
|
|
24
24
|
});
|
|
25
25
|
|
|
26
26
|
it('ignores non-assistant messages', () => {
|
|
27
27
|
const result = handleBeforeMessageWrite({
|
|
28
|
-
message: {
|
|
29
|
-
role: 'user',
|
|
30
|
-
content: '[EMOTIONAL_DAMAGE_DETECTED]'
|
|
31
|
-
}
|
|
28
|
+
message: { role: 'user', content: '[EMOTIONAL_DAMAGE_DETECTED]' }
|
|
32
29
|
} as any);
|
|
33
|
-
|
|
34
30
|
expect(result).toBeUndefined();
|
|
35
31
|
});
|
|
32
|
+
|
|
33
|
+
// ── sanitizeForEvidence ──
|
|
34
|
+
|
|
35
|
+
it('binds long string values to MAX_EVIDENCE_VALUE_CHARS', () => {
|
|
36
|
+
const segment = ' data ';
|
|
37
|
+
const long = segment.repeat(100);
|
|
38
|
+
const result = sanitizeForEvidence(long);
|
|
39
|
+
expect(result).toMatch(/___TRUNCATED___$/);
|
|
40
|
+
expect(result.length).toBeLessThanOrEqual(MAX_EVIDENCE_VALUE_CHARS + 20);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('redacts OpenAI-style secret keys (sk-*)', () => {
|
|
44
|
+
const token = 'sk-proj-' + 'a'.repeat(30);
|
|
45
|
+
const result = sanitizeForEvidence(`token is ${token}`);
|
|
46
|
+
expect(result).toContain('___REDACTED___');
|
|
47
|
+
expect(result).not.toContain(token);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('redacts JWT-like patterns (eyJ*)', () => {
|
|
51
|
+
const jwt = 'eyJ' + 'a'.repeat(30) + '.bc';
|
|
52
|
+
const result = sanitizeForEvidence(`Bearer ${jwt}`);
|
|
53
|
+
expect(result).toContain('___REDACTED___');
|
|
54
|
+
expect(result).not.toContain(jwt);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('redacts long base64-like strings', () => {
|
|
58
|
+
const token = 'A'.repeat(50);
|
|
59
|
+
const result = sanitizeForEvidence(`hash: ${token}`);
|
|
60
|
+
expect(result).toContain('___REDACTED___');
|
|
61
|
+
expect(result).not.toContain(token);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('redacts GitHub PATs (ghp_*)', () => {
|
|
65
|
+
const token = 'ghp_' + 'a'.repeat(40);
|
|
66
|
+
const result = sanitizeForEvidence(token);
|
|
67
|
+
expect(result).toContain('___REDACTED___');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('strips internal PD tags from evidence', () => {
|
|
71
|
+
const result = sanitizeForEvidence('[EMOTIONAL_DAMAGE_DETECTED:severe] something went wrong');
|
|
72
|
+
expect(result).not.toContain('EMOTIONAL_DAMAGE_DETECTED');
|
|
73
|
+
expect(result).toContain('something went wrong');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('returns safe string for non-string values', () => {
|
|
77
|
+
expect(sanitizeForEvidence(42)).toBe('42');
|
|
78
|
+
expect(sanitizeForEvidence(null)).toBe('');
|
|
79
|
+
expect(sanitizeForEvidence(undefined)).toBe('');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('converges absolute paths to basename when no workspaceDir', () => {
|
|
83
|
+
const result = sanitizeForEvidence('/home/user/secrets/token.json');
|
|
84
|
+
expect(result).toBe('<path:token.json>');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('converges absolute paths to repo-relative when workspaceDir matches', () => {
|
|
88
|
+
const result = sanitizeForEvidence('/workspace/my-repo/src/index.ts', '/workspace/my-repo');
|
|
89
|
+
expect(result).toBe('src/index.ts');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// ── Path substring sanitization ──
|
|
93
|
+
|
|
94
|
+
it('replaces Windows absolute path in command with basename', () => {
|
|
95
|
+
const result = sanitizeForEvidence('cd D:\\Code\\principles && git status');
|
|
96
|
+
expect(result).not.toContain('D:\\Code\\principles');
|
|
97
|
+
expect(result).toContain('principles');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('replaces Windows absolute path in reason with basename', () => {
|
|
101
|
+
const result = sanitizeForEvidence('error in C:\\Users\\Administrator\\secret.txt');
|
|
102
|
+
expect(result).not.toContain('C:\\Users\\Administrator');
|
|
103
|
+
expect(result).not.toContain('Administrator');
|
|
104
|
+
expect(result).toContain('secret.txt');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('replaces POSIX absolute path in string', () => {
|
|
108
|
+
const result = sanitizeForEvidence('failed to read /home/user/project/src/config.ts');
|
|
109
|
+
expect(result).not.toContain('/home/user/project');
|
|
110
|
+
expect(result).toContain('config.ts');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('converges workspace-internal path in string to repo-relative', () => {
|
|
114
|
+
const result = sanitizeForEvidence(
|
|
115
|
+
'edit failed on D:\\Code\\principles\\src\\index.ts',
|
|
116
|
+
'D:\\Code\\principles',
|
|
117
|
+
);
|
|
118
|
+
expect(result).not.toContain('D:\\Code\\principles');
|
|
119
|
+
expect(result).toContain('src\\index.ts');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ── sanitizeToolParamsForEvidence ──
|
|
123
|
+
|
|
124
|
+
it('redacts long content/text/input/new_string fields', () => {
|
|
125
|
+
const params = {
|
|
126
|
+
file_path: 'src/file.ts',
|
|
127
|
+
content: ' data chunk '.repeat(60),
|
|
128
|
+
text: ' report line '.repeat(60),
|
|
129
|
+
};
|
|
130
|
+
const result = sanitizeToolParamsForEvidence(params);
|
|
131
|
+
expect(result.file_path).toBe('src/file.ts');
|
|
132
|
+
expect(result.content).toMatch(/___TRUNCATED___$/);
|
|
133
|
+
expect(result.text).toMatch(/___TRUNCATED___$/);
|
|
134
|
+
expect(result.content.length).toBeLessThan(500);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('keeps short normal fields intact', () => {
|
|
138
|
+
const params = { file_path: 'src/index.ts', content: 'short-content', query: 'SELECT * FROM users' };
|
|
139
|
+
const result = sanitizeToolParamsForEvidence(params);
|
|
140
|
+
expect(result.file_path).toBe('src/index.ts');
|
|
141
|
+
expect(result.content).toBe('short-content');
|
|
142
|
+
expect(result.query).toBe('SELECT * FROM users');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('redacts token-like strings inside content/text fields', () => {
|
|
146
|
+
const token = 'sk-proj-' + 'a'.repeat(30);
|
|
147
|
+
const params = { content: `key is ${token}` };
|
|
148
|
+
const result = sanitizeToolParamsForEvidence(params);
|
|
149
|
+
expect(result.content).toContain('___REDACTED___');
|
|
150
|
+
expect(result.content).not.toContain(token);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── edit params: nested edits array ──
|
|
154
|
+
|
|
155
|
+
it('sanitizes edit params with nested edits[].oldText/newText', () => {
|
|
156
|
+
const secretToken = 'sk-proj-' + 'a'.repeat(30);
|
|
157
|
+
const params = {
|
|
158
|
+
file_path: '/repo/src/config.ts',
|
|
159
|
+
edits: [
|
|
160
|
+
{ oldText: secretToken, newText: 'safe-value' },
|
|
161
|
+
{ oldText: 'const x = 1;', newText: ' data '.repeat(200) },
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
const result = sanitizeToolParamsForEvidence(params, '/repo') as Record<string, unknown>;
|
|
165
|
+
const edits = result.edits as Array<Record<string, unknown>>;
|
|
166
|
+
// Token in oldText redacted
|
|
167
|
+
expect(edits[0].oldText).toContain('___REDACTED___');
|
|
168
|
+
expect(edits[0].oldText).not.toContain(secretToken);
|
|
169
|
+
// Short value preserved
|
|
170
|
+
expect(edits[0].newText).toBe('safe-value');
|
|
171
|
+
// Long newText truncated
|
|
172
|
+
expect(edits[1].newText).toMatch(/___TRUNCATED___$/);
|
|
173
|
+
// file_path converged to relative (workspaceDir provided)
|
|
174
|
+
expect(result.file_path).toBe('src/config.ts');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── command/query: token redaction ──
|
|
178
|
+
|
|
179
|
+
it('redacts tokens in command and query fields', () => {
|
|
180
|
+
const jwt = 'eyJ' + 'a'.repeat(30) + '.bc';
|
|
181
|
+
const skToken = 'sk-proj-' + 'a'.repeat(30);
|
|
182
|
+
const params = {
|
|
183
|
+
command: `curl -H "Authorization: Bearer ${jwt}" https://api.example.com`,
|
|
184
|
+
query: `SELECT * FROM users WHERE api_key = "${skToken}"`,
|
|
185
|
+
};
|
|
186
|
+
const result = sanitizeToolParamsForEvidence(params);
|
|
187
|
+
expect(result.command).toContain('___REDACTED___');
|
|
188
|
+
expect(result.command).not.toContain(jwt);
|
|
189
|
+
expect(result.query).toContain('___REDACTED___');
|
|
190
|
+
expect(result.query).not.toContain(skToken);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── null/array/string input: no throw ──
|
|
194
|
+
|
|
195
|
+
it('handles null input without throwing', () => {
|
|
196
|
+
expect(() => sanitizeToolParamsForEvidence(null)).not.toThrow();
|
|
197
|
+
expect(sanitizeToolParamsForEvidence(null)).toEqual({});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('handles array input without throwing', () => {
|
|
201
|
+
const result = sanitizeToolParamsForEvidence(['a', 'b', 'c']);
|
|
202
|
+
expect(result['<array-input>']).toBeDefined();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('handles string input without throwing', () => {
|
|
206
|
+
const result = sanitizeToolParamsForEvidence('raw string input');
|
|
207
|
+
expect(result['<string-input>']).toBeDefined();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('handles undefined input without throwing', () => {
|
|
211
|
+
expect(sanitizeToolParamsForEvidence(undefined)).toEqual({});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('handles number input without throwing', () => {
|
|
215
|
+
expect(sanitizeToolParamsForEvidence(42)).toEqual({});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── sanitizeValueForEvidence: recursive ──
|
|
219
|
+
|
|
220
|
+
it('recursively sanitizes nested objects', () => {
|
|
221
|
+
const token = 'sk-proj-' + 'a'.repeat(30);
|
|
222
|
+
const input = { a: { b: { c: token } } };
|
|
223
|
+
const result = sanitizeValueForEvidence(input) as Record<string, unknown>;
|
|
224
|
+
const nested = (result.a as Record<string, unknown>).b as Record<string, unknown>;
|
|
225
|
+
expect(nested.c).toContain('___REDACTED___');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it('respects max array items limit', () => {
|
|
229
|
+
const input = { items: Array.from({ length: 100 }, (_, i) => `item-${i}`) };
|
|
230
|
+
const result = sanitizeValueForEvidence(input) as Record<string, unknown>;
|
|
231
|
+
const items = result.items as unknown[];
|
|
232
|
+
expect(items.length).toBeLessThanOrEqual(22);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('respects max depth limit', () => {
|
|
236
|
+
const deep: any = { a: { b: { c: { d: { e: 'too deep' } } } } };
|
|
237
|
+
const result = sanitizeValueForEvidence(deep, 0) as Record<string, unknown>;
|
|
238
|
+
const a = result.a as Record<string, unknown>;
|
|
239
|
+
const b = a.b as Record<string, unknown>;
|
|
240
|
+
const c = b.c as Record<string, unknown>;
|
|
241
|
+
const d = c.d as Record<string, unknown>;
|
|
242
|
+
expect(d.e).toBe('<max-depth>');
|
|
243
|
+
});
|
|
36
244
|
});
|