principles-disciple 1.91.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.
@@ -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.91.0",
5
+ "version": "1.92.0",
6
6
  "activation": {
7
7
  "onCapabilities": [
8
8
  "hook"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "principles-disciple",
3
- "version": "1.91.0",
3
+ "version": "1.92.0",
4
4
  "description": "Native OpenClaw plugin for Principles Disciple",
5
5
  "type": "module",
6
6
  "main": "./dist/bundle.js",
@@ -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
  /**
@@ -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 ?? undefined,
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)
@@ -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 { handleBeforeMessageWrite, sanitizeAssistantText } from '../../src/hooks/message-sanitize';
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
  });