wendkeep 0.87.0 → 0.89.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.
Files changed (65) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.en.md +3 -2
  3. package/README.md +3 -2
  4. package/bin/wendkeep.mjs +1 -0
  5. package/docs/en/commands/ecosystem-bridges.md +172 -0
  6. package/docs/en/commands/observer-security.md +154 -0
  7. package/docs/en/commands/observer.md +30 -12
  8. package/docs/en/commands/verify.md +6 -0
  9. package/docs/pt-BR/commands/ecosystem-bridges.md +169 -0
  10. package/docs/pt-BR/commands/observer-security.md +154 -0
  11. package/docs/pt-BR/commands/observer.md +30 -12
  12. package/docs/pt-BR/commands/verify.md +6 -0
  13. package/hooks/observer-publish.mjs +3 -1
  14. package/package.json +2 -1
  15. package/packages/cli/src/index.mjs +10 -1
  16. package/packages/harness/src/sensors-core.mjs +49 -3
  17. package/packages/integrations/src/bridge-config.mjs +139 -0
  18. package/packages/integrations/src/bridge-contract.mjs +316 -0
  19. package/packages/integrations/src/bridge-diagnostics.mjs +45 -0
  20. package/packages/integrations/src/canonical-bridge-authority.mjs +32 -0
  21. package/packages/integrations/src/capabilities.mjs +34 -0
  22. package/packages/integrations/src/ecosystem-bridge.mjs +82 -0
  23. package/packages/integrations/src/index.mjs +6 -0
  24. package/packages/integrations/src/spec-kit-adapter.mjs +259 -0
  25. package/packages/integrations/src/superpowers-adapter.mjs +269 -0
  26. package/packages/mcp/src/executor.mjs +35 -2
  27. package/packages/observer/package.json +16 -0
  28. package/packages/observer/src/audit.mjs +1 -0
  29. package/packages/observer/src/authz.mjs +38 -0
  30. package/packages/observer/src/encryption.mjs +75 -0
  31. package/packages/observer/src/index.mjs +7 -0
  32. package/packages/observer/src/policy.mjs +305 -0
  33. package/packages/observer/src/purge.mjs +100 -0
  34. package/packages/observer/src/redaction.mjs +54 -0
  35. package/packages/observer/src/retention.mjs +39 -0
  36. package/packages/observer/src/token-registry.mjs +122 -0
  37. package/schema/ecosystem-bridge-artifact-manifest-v1.schema.json +30 -0
  38. package/schema/ecosystem-bridge-v1.schema.json +65 -0
  39. package/schema/observer/006-observer-security.sql +64 -0
  40. package/schema/observer-policy-v1.schema.json +63 -0
  41. package/schema/sync-event-v1.schema.json +10 -0
  42. package/schema/wendkeep.evidence-envelope-v2.schema.json +39 -0
  43. package/schema/wendkeep.sensors.schema.json +14 -0
  44. package/src/doctor.mjs +6 -1
  45. package/src/ecosystem-bridge-artifact-collector.mjs +111 -0
  46. package/src/ecosystem-bridge-baseline.mjs +58 -0
  47. package/src/ecosystem-bridge-proof.mjs +97 -0
  48. package/src/ecosystem-bridges.mjs +227 -0
  49. package/src/evidence-envelope.mjs +2 -0
  50. package/src/observer-auth.mjs +8 -0
  51. package/src/observer-privacy.mjs +7 -3
  52. package/src/observer-publish.mjs +31 -0
  53. package/src/observer-server.mjs +179 -20
  54. package/src/observer-sql-migrate.mjs +5 -2
  55. package/src/observer-sql-publish.mjs +114 -39
  56. package/src/observer-sql-store.mjs +299 -45
  57. package/src/observer-transcript-store.mjs +23 -8
  58. package/src/observer.mjs +145 -12
  59. package/src/sync-protocol.mjs +20 -0
  60. package/src/task-contracts.mjs +19 -0
  61. package/src/task.mjs +82 -0
  62. package/src/verify.mjs +9 -0
  63. package/web/observer/app.mjs +107 -31
  64. package/web/observer/index.html +7 -0
  65. package/web/observer/styles.css +5 -0
@@ -0,0 +1,75 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
2
+
3
+ function encryptionError(code, message) {
4
+ return Object.assign(new Error(message), { code });
5
+ }
6
+
7
+ function resolveKey(adapter, operation) {
8
+ const candidate = adapter?.keyProvider?.({ keyId: adapter.keyId, operation });
9
+ if (candidate && typeof candidate.then === 'function') {
10
+ throw encryptionError('observer_encryption_key_invalid', 'Observer key provider deve ser síncrono para SQLite.');
11
+ }
12
+ const key = candidate == null ? null : Buffer.from(candidate);
13
+ if (!key || key.byteLength !== 32) {
14
+ throw encryptionError('observer_encryption_key_unavailable', 'Chave externa AES-256-GCM indisponível.');
15
+ }
16
+ return key;
17
+ }
18
+
19
+ export function createObserverEncryption({ keyProvider, required = false, keyId = '' } = {}) {
20
+ if (required && typeof keyProvider !== 'function') {
21
+ throw encryptionError('observer_encryption_key_unavailable', 'Key provider externo é obrigatório.');
22
+ }
23
+ return { algorithm: 'AES-256-GCM', keyProvider, required: Boolean(required), keyId: String(keyId || '') };
24
+ }
25
+
26
+ export function observerEncryptionFromEnvironment({ env = process.env, required = false } = {}) {
27
+ const raw = String(env.WENDKEEP_OBSERVER_ENCRYPTION_KEY || '').trim();
28
+ if (!raw) {
29
+ if (required) throw encryptionError('observer_encryption_key_unavailable', 'WENDKEEP_OBSERVER_ENCRYPTION_KEY é obrigatória.');
30
+ return null;
31
+ }
32
+ const key = /^[a-f0-9]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
33
+ if (key.byteLength !== 32) throw encryptionError('observer_encryption_key_invalid', 'WENDKEEP_OBSERVER_ENCRYPTION_KEY deve ter 32 bytes em hex/base64.');
34
+ return createObserverEncryption({
35
+ required: true,
36
+ keyId: String(env.WENDKEEP_OBSERVER_ENCRYPTION_KEY_ID || 'observer-local-v1'),
37
+ keyProvider: () => key,
38
+ });
39
+ }
40
+
41
+ export function encryptObserverValue(adapter, value, { aad = '' } = {}) {
42
+ if (!adapter?.required && typeof adapter?.keyProvider !== 'function') return null;
43
+ const key = resolveKey(adapter, 'encrypt');
44
+ const iv = randomBytes(12);
45
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
46
+ if (aad) cipher.setAAD(Buffer.from(String(aad), 'utf8'));
47
+ const plaintext = Buffer.from(String(value ?? ''), 'utf8');
48
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
49
+ return {
50
+ schema_version: 1,
51
+ algorithm: 'AES-256-GCM',
52
+ key_id: adapter.keyId,
53
+ iv: iv.toString('base64'),
54
+ ciphertext: ciphertext.toString('base64'),
55
+ auth_tag: cipher.getAuthTag().toString('base64'),
56
+ };
57
+ }
58
+
59
+ export function decryptObserverValue(adapter, envelope, { aad = '' } = {}) {
60
+ if (!envelope) return '';
61
+ try {
62
+ if (envelope.algorithm !== 'AES-256-GCM') throw new Error('unsupported envelope');
63
+ const key = resolveKey({ ...adapter, keyId: envelope.key_id || adapter?.keyId }, 'decrypt');
64
+ const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(envelope.iv, 'base64'));
65
+ if (aad) decipher.setAAD(Buffer.from(String(aad), 'utf8'));
66
+ decipher.setAuthTag(Buffer.from(envelope.auth_tag, 'base64'));
67
+ return Buffer.concat([
68
+ decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
69
+ decipher.final(),
70
+ ]).toString('utf8');
71
+ } catch (cause) {
72
+ if (cause?.code === 'observer_encryption_key_unavailable') throw cause;
73
+ throw encryptionError('observer_decryption_failed', 'Conteúdo protegido indisponível ou chave incorreta.');
74
+ }
75
+ }
@@ -0,0 +1,7 @@
1
+ export * from './policy.mjs';
2
+ export * from './redaction.mjs';
3
+ export * from './authz.mjs';
4
+ export * from './token-registry.mjs';
5
+ export * from './encryption.mjs';
6
+ export * from './retention.mjs';
7
+ export * from './purge.mjs';
@@ -0,0 +1,305 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { redactObserverValue } from './redaction.mjs';
3
+
4
+ const CAPTURE = {
5
+ document: new Set(['none', 'metadata', 'selected', 'full']),
6
+ transcript: new Set(['none', 'metadata', 'messages', 'full']),
7
+ prompt: new Set(['none', 'redacted', 'full']),
8
+ response: new Set(['none', 'redacted', 'full']),
9
+ usage: new Set(['none', 'aggregate', 'calls']),
10
+ };
11
+
12
+ const DEFAULTS = Object.freeze({
13
+ document_capture: 'metadata',
14
+ transcript_capture: 'metadata',
15
+ prompt_capture: 'redacted',
16
+ response_capture: 'redacted',
17
+ usage_capture: 'aggregate',
18
+ require_loopback_auth: false,
19
+ encryption_required: false,
20
+ });
21
+
22
+ const SESSION_FIELDS = [
23
+ 'session_id', 'sessionId', 'provider', 'status', 'change_slug', 'changeSlug',
24
+ ];
25
+ const SESSION_TIMESTAMPS = ['started_at', 'startedAt', 'ended_at', 'endedAt'];
26
+ const AGENT_FIELDS = [
27
+ ...SESSION_FIELDS, 'agent_id', 'agentId', 'parent_agent_id', 'parentAgentId',
28
+ 'role', 'agent_type', 'agentType', 'workflow', 'model', 'effort',
29
+ ];
30
+
31
+ function structuralFields(strings, integers = [], timestamps = []) {
32
+ return Object.freeze({
33
+ strings: Object.freeze(strings),
34
+ integers: Object.freeze(integers),
35
+ timestamps: Object.freeze(timestamps),
36
+ });
37
+ }
38
+
39
+ export const OBSERVER_EVENT_STRUCTURAL_CONTRACT = Object.freeze({
40
+ envelope: structuralFields(['event_id', 'kind', 'project_id'], ['schema_version'], ['occurred_at']),
41
+ payload: Object.freeze({
42
+ 'document.upsert': structuralFields([
43
+ 'document_id', 'documentId', 'logical_path', 'logicalPath', 'entity_type', 'entityType',
44
+ 'source_session_id', 'sourceSessionId', 'source_turn_id', 'sourceTurnId',
45
+ 'operation', 'op',
46
+ ], ['revision'], ['captured_at', 'capturedAt']),
47
+ 'document.delete': structuralFields([
48
+ 'logical_path', 'logicalPath', 'entity_type', 'entityType',
49
+ 'source_session_id', 'sourceSessionId', 'source_turn_id', 'sourceTurnId',
50
+ 'operation', 'op',
51
+ ], ['revision']),
52
+ 'session.upsert': structuralFields([...SESSION_FIELDS], [], [...SESSION_TIMESTAMPS]),
53
+ 'agent.upsert': structuralFields([...AGENT_FIELDS], [], [...SESSION_TIMESTAMPS]),
54
+ 'usage.rollup': structuralFields([
55
+ ...AGENT_FIELDS, 'rollup_key', 'rollupKey', 'model_provider', 'modelProvider',
56
+ 'cost_status', 'costStatus', 'pricing_source', 'pricingSource', 'pricing_version', 'pricingVersion',
57
+ ], ['revision'], [...SESSION_TIMESTAMPS]),
58
+ llm_call: structuralFields([
59
+ ...AGENT_FIELDS, 'call_id', 'callId', 'model_provider', 'modelProvider',
60
+ 'cost_status', 'costStatus', 'transcript_id', 'transcriptId',
61
+ ], ['sequence'], [...SESSION_TIMESTAMPS, 'occurred_at']),
62
+ 'transcript.upsert': structuralFields([
63
+ ...AGENT_FIELDS, 'transcript_id', 'transcriptId', 'coverage', 'source',
64
+ ], [], [...SESSION_TIMESTAMPS]),
65
+ }),
66
+ });
67
+
68
+ function policyError(message) {
69
+ return Object.assign(new Error(message), { code: 'observer_policy_invalid' });
70
+ }
71
+
72
+ function assertObject(value, label) {
73
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw policyError(`${label} inválido.`);
74
+ }
75
+
76
+ function assertKnownKeys(value, allowed, label) {
77
+ const unknown = Object.keys(value).find((key) => !allowed.has(key));
78
+ if (unknown) throw policyError(`${label} contém campo desconhecido: ${unknown}`);
79
+ }
80
+
81
+ function captureFor(policy, dataClass) {
82
+ return policy[`${dataClass}_capture`];
83
+ }
84
+
85
+ function globMatches(pattern, value) {
86
+ if (!pattern) return true;
87
+ const escaped = String(pattern).replace(/[.+^${}()|[\]\\]/g, '\\$&')
88
+ .replaceAll('**', '\u0000').replaceAll('*', '[^/]*').replaceAll('\u0000', '.*');
89
+ return new RegExp(`^${escaped}$`, 'u').test(String(value || '').replaceAll('\\', '/'));
90
+ }
91
+
92
+ function ruleMatches(rule, context) {
93
+ return (!rule.project_id || rule.project_id === context.projectId)
94
+ && (!rule.data_class || rule.data_class === context.dataClass)
95
+ && (!rule.entity_type || rule.entity_type === context.entityType)
96
+ && globMatches(rule.path, context.path);
97
+ }
98
+
99
+ export function createObserverPolicy(input = {}) {
100
+ assertObject(input, 'policy');
101
+ assertKnownKeys(input, new Set([
102
+ 'document_capture', 'transcript_capture', 'prompt_capture', 'response_capture', 'usage_capture',
103
+ 'require_loopback_auth', 'encryption_required', 'rules', 'redaction', 'retention',
104
+ ]), 'policy');
105
+ for (const field of ['require_loopback_auth', 'encryption_required']) {
106
+ if (Object.hasOwn(input, field) && typeof input[field] !== 'boolean') throw policyError(`${field} deve ser boolean.`);
107
+ }
108
+ if (Object.hasOwn(input, 'rules') && !Array.isArray(input.rules)) throw policyError('rules deve ser array.');
109
+ if (Object.hasOwn(input, 'redaction')) {
110
+ assertObject(input.redaction, 'redaction');
111
+ assertKnownKeys(input.redaction, new Set(['rules']), 'redaction');
112
+ if (Object.hasOwn(input.redaction, 'rules') && !Array.isArray(input.redaction.rules)) throw policyError('redaction.rules deve ser array.');
113
+ }
114
+ if (Object.hasOwn(input, 'retention')) {
115
+ assertObject(input.retention, 'retention');
116
+ assertKnownKeys(input.retention, new Set(['document', 'transcript', 'prompt', 'response', 'usage', 'documents', 'calls', 'transcripts']), 'retention');
117
+ if (Object.values(input.retention).some((value) => !Number.isInteger(value) || value < 0)) throw policyError('retention deve usar inteiros não negativos.');
118
+ }
119
+ const policy = {
120
+ ...DEFAULTS,
121
+ ...input,
122
+ rules: Array.isArray(input.rules) ? input.rules.map((rule) => ({ ...rule })) : [],
123
+ redaction: input.redaction && typeof input.redaction === 'object' ? structuredClone(input.redaction) : {},
124
+ retention: input.retention && typeof input.retention === 'object' ? structuredClone(input.retention) : {},
125
+ };
126
+ for (const rule of policy.rules) {
127
+ assertObject(rule, 'rule');
128
+ assertKnownKeys(rule, new Set(['project_id', 'data_class', 'path', 'entity_type', 'capture']), 'rule');
129
+ }
130
+ for (const rule of policy.redaction.rules || []) {
131
+ assertObject(rule, 'redaction rule');
132
+ assertKnownKeys(rule, new Set(['pattern', 'replacement']), 'redaction rule');
133
+ if (String(rule.replacement || '').length > 160) throw policyError('replacement de redaction excede o limite.');
134
+ }
135
+ for (const rule of policy.redaction.rules || []) {
136
+ const pattern = String(rule?.pattern || '');
137
+ if (!pattern || pattern.length > 512 || /\\[1-9]/.test(pattern)
138
+ || /\((?:[^()\\]|\\.)*[+*](?:[^()\\]|\\.)*\)\s*(?:[+*]|\{\d*,?\d*\})/.test(pattern)) {
139
+ throw policyError('regra de redaction insegura ou inválida.');
140
+ }
141
+ try { new RegExp(pattern, 'giu'); }
142
+ catch { throw policyError('regra de redaction insegura ou inválida.'); }
143
+ }
144
+ for (const dataClass of Object.keys(CAPTURE)) {
145
+ const capture = captureFor(policy, dataClass);
146
+ if (!CAPTURE[dataClass].has(capture)) throw policyError(`capture inválido para ${dataClass}: ${capture}`);
147
+ }
148
+ for (const rule of policy.rules) {
149
+ if (!CAPTURE[rule.data_class]?.has(rule.capture)) {
150
+ throw policyError(`regra de capture inválida para ${rule.data_class || 'classe ausente'}`);
151
+ }
152
+ }
153
+ return policy;
154
+ }
155
+
156
+ export function saveObserverPolicy(db, projectId, policyInput, { updatedAt = new Date().toISOString() } = {}) {
157
+ const policy = createObserverPolicy(policyInput);
158
+ db.prepare(`INSERT INTO observer_retention_policies(project_id, policy_json, updated_at)
159
+ VALUES (?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET policy_json = excluded.policy_json, updated_at = excluded.updated_at`)
160
+ .run(projectId, JSON.stringify(policy), new Date(updatedAt).toISOString());
161
+ return policy;
162
+ }
163
+
164
+ export function readObserverPolicy(db, projectId) {
165
+ const row = db.prepare('SELECT policy_json FROM observer_retention_policies WHERE project_id = ?').get(projectId);
166
+ return row ? createObserverPolicy(JSON.parse(row.policy_json)) : createObserverPolicy();
167
+ }
168
+
169
+ export function evaluateObserverPolicy(policyInput, context = {}) {
170
+ const policy = policyInput?.rules ? policyInput : createObserverPolicy(policyInput);
171
+ const dataClass = String(context.dataClass || 'document');
172
+ if (!CAPTURE[dataClass]) throw policyError(`classe de dado desconhecida: ${dataClass}`);
173
+ let capture = captureFor(policy, dataClass);
174
+ for (const rule of policy.rules) {
175
+ if (ruleMatches(rule, { ...context, dataClass })) capture = rule.capture;
176
+ }
177
+ return {
178
+ data_class: dataClass,
179
+ capture,
180
+ encryption_required: Boolean(policy.encryption_required),
181
+ retention_days: Number(policy.retention?.[dataClass] ?? 0) || 0,
182
+ };
183
+ }
184
+
185
+ function metadataOnly(payload, capture) {
186
+ return {
187
+ ...payload,
188
+ content: '',
189
+ coverage: payload.coverage ? 'summary_only' : payload.coverage,
190
+ capture,
191
+ };
192
+ }
193
+
194
+ function protectText(value, capture, redaction) {
195
+ if (capture === 'none') return '';
196
+ if (capture === 'redacted') return redactObserverValue(value, redaction);
197
+ return String(value ?? '');
198
+ }
199
+
200
+ function preserveStructuralFields(original, protectedValue, contract) {
201
+ for (const key of contract?.strings || []) {
202
+ if (!Object.hasOwn(original, key)) continue;
203
+ const value = original[key];
204
+ if (value !== null && typeof value !== 'string') throw policyError(`${key} estrutural deve ser string ou null.`);
205
+ protectedValue[key] = value;
206
+ }
207
+ for (const key of contract?.integers || []) {
208
+ if (!Object.hasOwn(original, key)) continue;
209
+ const value = original[key];
210
+ if (!Number.isInteger(value) || value < 0) throw policyError(`${key} estrutural deve ser inteiro não negativo.`);
211
+ protectedValue[key] = value;
212
+ }
213
+ for (const key of contract?.timestamps || []) {
214
+ if (!Object.hasOwn(original, key)) continue;
215
+ const value = original[key];
216
+ if (value !== null && (typeof value !== 'string' || Number.isNaN(Date.parse(value)))) {
217
+ throw policyError(`${key} estrutural deve ser date-time ou null.`);
218
+ }
219
+ protectedValue[key] = value;
220
+ }
221
+ return protectedValue;
222
+ }
223
+
224
+ function protectedContentHash(value) {
225
+ return createHash('sha256').update(String(value ?? '')).digest('hex');
226
+ }
227
+
228
+ function transcriptMessagesOnly(value) {
229
+ const source = String(value || '');
230
+ const sanitizeMessage = (item) => {
231
+ if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
232
+ const role = String(item.role || '');
233
+ if (!['user', 'assistant', 'system'].includes(role) || typeof item.content !== 'string') return null;
234
+ return { role, content: item.content };
235
+ };
236
+ const sanitizeMessages = (items) => items.map(sanitizeMessage).filter(Boolean);
237
+ try {
238
+ const parsed = JSON.parse(source);
239
+ if (Array.isArray(parsed)) return JSON.stringify(sanitizeMessages(parsed));
240
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed) && Array.isArray(parsed.messages)) {
241
+ return JSON.stringify({ messages: sanitizeMessages(parsed.messages) });
242
+ }
243
+ return '';
244
+ } catch {
245
+ const lines = source.split(/\r?\n/u).filter(Boolean);
246
+ const items = lines.map((line) => {
247
+ try { return JSON.parse(line); } catch { return null; }
248
+ });
249
+ if (!lines.length || items.some((item) => item === null)) return '';
250
+ return sanitizeMessages(items).map(JSON.stringify).join('\n');
251
+ }
252
+ }
253
+
254
+ export function protectObserverEvent(event, { policy: policyInput = {} } = {}) {
255
+ const policy = policyInput?.rules ? policyInput : createObserverPolicy(policyInput);
256
+ const payload = structuredClone(event?.payload || {});
257
+ const context = {
258
+ projectId: String(event?.project_id || ''),
259
+ path: String(payload.logical_path || ''),
260
+ entityType: String(payload.entity_type || ''),
261
+ };
262
+ let protectedPayload = preserveStructuralFields(
263
+ payload,
264
+ redactObserverValue(payload, policy.redaction),
265
+ OBSERVER_EVENT_STRUCTURAL_CONTRACT.payload[event?.kind],
266
+ );
267
+ if (event?.kind === 'document.delete') {
268
+ delete protectedPayload.content;
269
+ delete protectedPayload.content_hash;
270
+ delete protectedPayload.contentHash;
271
+ } else if (event?.kind?.startsWith('document.')) {
272
+ const decision = evaluateObserverPolicy(policy, { ...context, dataClass: 'document' });
273
+ if (decision.capture === 'none') return null;
274
+ if (decision.capture === 'metadata' || decision.capture === 'selected') protectedPayload = metadataOnly(protectedPayload, decision.capture);
275
+ else protectedPayload.capture = decision.capture;
276
+ } else if (event?.kind === 'transcript.upsert') {
277
+ const decision = evaluateObserverPolicy(policy, { ...context, dataClass: 'transcript' });
278
+ if (decision.capture === 'none') return null;
279
+ if (decision.capture === 'metadata') protectedPayload = metadataOnly(protectedPayload, decision.capture);
280
+ else if (decision.capture === 'messages') {
281
+ protectedPayload.content = transcriptMessagesOnly(protectedPayload.content);
282
+ protectedPayload.capture = decision.capture;
283
+ }
284
+ else protectedPayload.capture = decision.capture;
285
+ } else if (event?.kind === 'usage.rollup') {
286
+ const usageDecision = evaluateObserverPolicy(policy, { ...context, dataClass: 'usage' });
287
+ if (usageDecision.capture === 'none') return null;
288
+ protectedPayload.capture = usageDecision.capture;
289
+ } else if (event?.kind === 'llm_call') {
290
+ const usageDecision = evaluateObserverPolicy(policy, { ...context, dataClass: 'usage' });
291
+ if (usageDecision.capture !== 'calls') return null;
292
+ const promptDecision = evaluateObserverPolicy(policy, { ...context, dataClass: 'prompt' });
293
+ const responseDecision = evaluateObserverPolicy(policy, { ...context, dataClass: 'response' });
294
+ protectedPayload.prompt_text = protectText(payload.prompt_text ?? payload.prompt, promptDecision.capture, policy.redaction);
295
+ protectedPayload.response_text = protectText(payload.response_text ?? payload.response, responseDecision.capture, policy.redaction);
296
+ delete protectedPayload.prompt;
297
+ delete protectedPayload.response;
298
+ protectedPayload.capture = usageDecision.capture;
299
+ }
300
+ if (event?.kind === 'document.upsert' || event?.kind === 'transcript.upsert') {
301
+ protectedPayload.content_hash = protectedContentHash(protectedPayload.content);
302
+ delete protectedPayload.contentHash;
303
+ }
304
+ return { ...event, payload: protectedPayload };
305
+ }
@@ -0,0 +1,100 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ const TABLES = {
4
+ documents: { table: 'documents', time: 'captured_at', id: 'document_id' },
5
+ calls: { table: 'llm_calls', time: 'occurred_at', id: 'call_pk' },
6
+ transcripts: { table: 'transcripts', time: 'occurred_at', id: 'transcript_pk' },
7
+ };
8
+
9
+ function canonical(value) {
10
+ if (Array.isArray(value)) return value.map(canonical);
11
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
12
+ return value;
13
+ }
14
+
15
+ function digest(value) {
16
+ return `sha256:${createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')}`;
17
+ }
18
+
19
+ export function verifyObserverPurgeReceipt(receipt = {}) {
20
+ const candidate = { ...receipt };
21
+ const expected = String(candidate.receipt_hash || '');
22
+ delete candidate.receipt_hash;
23
+ delete candidate.idempotent;
24
+ return Boolean(expected) && digest(candidate) === expected;
25
+ }
26
+
27
+ export function purgeObserverData(db, {
28
+ projectId, before, classes = Object.keys(TABLES), now = new Date().toISOString(), dryRun = false,
29
+ operationId = '', beforeCommit, receiptSink,
30
+ } = {}) {
31
+ const normalizedClasses = [...new Set(classes)].sort();
32
+ if (!normalizedClasses.length || normalizedClasses.some((item) => !TABLES[item])) {
33
+ throw Object.assign(new Error('classes de purge inválidas.'), { code: 'observer_purge_invalid' });
34
+ }
35
+ const cutoff = new Date(before).toISOString();
36
+ const requestHash = digest({ project_id: projectId, before: cutoff, classes: normalizedClasses });
37
+ db.exec('BEGIN IMMEDIATE');
38
+ let transactionOpen = true;
39
+ try {
40
+ const requestedReceiptId = operationId
41
+ ? digest({ request_hash: requestHash, operation_id: String(operationId) }).slice(7, 39)
42
+ : '';
43
+ const candidates = Object.fromEntries(normalizedClasses.map((dataClass) => {
44
+ const spec = TABLES[dataClass];
45
+ const rows = db.prepare(`SELECT ${spec.id} AS id FROM ${spec.table} WHERE project_id = ? AND ${spec.time} < ? ORDER BY ${spec.id}`).all(projectId, cutoff);
46
+ return [dataClass, rows.map((row) => row.id)];
47
+ }));
48
+ const counts = Object.fromEntries(normalizedClasses.map((dataClass) => [dataClass, candidates[dataClass].length]));
49
+ if (dryRun) {
50
+ db.exec('COMMIT');
51
+ transactionOpen = false;
52
+ return { dry_run: true, project_id: projectId, before: cutoff, classes: normalizedClasses, counts };
53
+ }
54
+ const noCandidates = Object.values(counts).every((count) => count === 0);
55
+ if (noCandidates) {
56
+ const previous = requestedReceiptId
57
+ ? db.prepare('SELECT receipt_json FROM observer_purge_receipts WHERE receipt_id = ?').get(requestedReceiptId)
58
+ : db.prepare('SELECT receipt_json FROM observer_purge_receipts WHERE project_id = ? AND request_hash = ? ORDER BY purged_at DESC LIMIT 1').get(projectId, requestHash);
59
+ if (previous) {
60
+ db.exec('COMMIT');
61
+ transactionOpen = false;
62
+ const replay = { ...JSON.parse(previous.receipt_json), idempotent: true };
63
+ receiptSink?.(replay);
64
+ return replay;
65
+ }
66
+ }
67
+ const operationExists = requestedReceiptId && db.prepare('SELECT receipt_id FROM observer_purge_receipts WHERE receipt_id = ?').get(requestedReceiptId);
68
+ const receiptId = operationExists
69
+ ? digest({ request_hash: requestHash, operation_id: String(operationId), candidates }).slice(7, 39)
70
+ : requestedReceiptId || digest({ request_hash: requestHash, candidates }).slice(7, 39);
71
+ const receipt = {
72
+ schema_version: 1, receipt_id: receiptId, project_id: projectId, before: cutoff,
73
+ classes: normalizedClasses, counts, purged_at: new Date(now).toISOString(),
74
+ };
75
+ receipt.receipt_hash = digest(receipt);
76
+ for (const dataClass of normalizedClasses) {
77
+ const spec = TABLES[dataClass];
78
+ if (dataClass === 'documents' && db.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name = 'evidence_chunks_fts'").get().count) {
79
+ db.prepare('DELETE FROM evidence_chunks_fts WHERE project_id = ? AND logical_path IN (SELECT logical_path FROM documents WHERE project_id = ? AND captured_at < ?)').run(projectId, projectId, cutoff);
80
+ }
81
+ db.prepare(`DELETE FROM ${spec.table} WHERE project_id = ? AND ${spec.time} < ?`).run(projectId, cutoff);
82
+ const kind = dataClass === 'documents' ? 'document.%' : dataClass === 'calls' ? 'llm_call' : 'transcript.upsert';
83
+ const comparator = dataClass === 'documents' ? 'LIKE' : '=';
84
+ db.prepare(`DELETE FROM ingest_events WHERE project_id = ? AND kind ${comparator} ? AND occurred_at < ?`).run(projectId, kind, cutoff);
85
+ }
86
+ beforeCommit?.(receipt);
87
+ db.prepare(`INSERT INTO observer_purge_receipts(receipt_id, project_id, request_hash, cutoff_at, classes_json, counts_json, purged_at, receipt_hash, receipt_json)
88
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(
89
+ receipt.receipt_id, projectId, requestHash, cutoff, JSON.stringify(normalizedClasses), JSON.stringify(counts),
90
+ receipt.purged_at, receipt.receipt_hash, JSON.stringify(receipt),
91
+ );
92
+ db.exec('COMMIT');
93
+ transactionOpen = false;
94
+ receiptSink?.(receipt);
95
+ return { ...receipt, idempotent: false };
96
+ } catch (error) {
97
+ if (transactionOpen) db.exec('ROLLBACK');
98
+ throw error;
99
+ }
100
+ }
@@ -0,0 +1,54 @@
1
+ import { basename } from 'node:path';
2
+
3
+ const SENSITIVE_KEY = /(?:authorization|password|passwd|secret|api[_-]?key|connection[_-]?string|cookie)|^(?:token|access[_-]?token|refresh[_-]?token|auth[_-]?token|bearer[_-]?token)$/i;
4
+ const PATH_KEY = /^(?:transcript_path|agent_transcript_path|transcriptPath|agentTranscriptPath)$/i;
5
+ const BUILT_INS = [
6
+ [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'],
7
+ [/\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis):\/\/([^\s/:@]+):([^\s/@]+)@/gi, (_all, user) => `postgres://${user}:[REDACTED]@`],
8
+ [/\b(https?:\/\/)([^\s/:@]+):([^\s/@]+)@/gi, '$1$2:[REDACTED]@'],
9
+ [/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[EMAIL]'],
10
+ [/(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{2}\)?[\s.-]?)?\d{4,5}[\s.-]?\d{4}\b/g, '[PHONE]'],
11
+ [/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, '[ACCESS_KEY]'],
12
+ ];
13
+
14
+ function sourceLabel(value) {
15
+ return basename(String(value || '').replaceAll('\\', '/'));
16
+ }
17
+
18
+ function customRules(config = {}) {
19
+ return (Array.isArray(config.rules) ? config.rules : []).flatMap((rule) => {
20
+ try {
21
+ if (!rule?.pattern) return [];
22
+ return [[new RegExp(String(rule.pattern), 'giu'), String(rule.replacement || '[REDACTED]')]];
23
+ } catch {
24
+ return [];
25
+ }
26
+ });
27
+ }
28
+
29
+ export function redactObserverText(value, config = {}) {
30
+ let result = String(value ?? '');
31
+ for (const [pattern, replacement] of [...BUILT_INS, ...customRules(config)]) {
32
+ result = result.replace(pattern, replacement);
33
+ }
34
+ return result;
35
+ }
36
+
37
+ export function redactObserverValue(value, config = {}, key = '') {
38
+ if (PATH_KEY.test(key)) return sourceLabel(value);
39
+ if (SENSITIVE_KEY.test(key)) return value ? '[REDACTED]' : value;
40
+ if (typeof value === 'string') return redactObserverText(value, config);
41
+ if (Array.isArray(value)) return value.map((item) => redactObserverValue(item, config));
42
+ if (!value || typeof value !== 'object') return value;
43
+ return Object.fromEntries(Object.entries(value).map(([childKey, item]) => [
44
+ childKey,
45
+ redactObserverValue(item, config, childKey),
46
+ ]));
47
+ }
48
+
49
+ export function sanitizeObserverAuditMetadata(metadata = {}, config = {}) {
50
+ const allowed = new Set(['route', 'method', 'remote_address', 'user_agent', 'reason', 'resource_id']);
51
+ return Object.fromEntries(Object.entries(metadata || {})
52
+ .filter(([key]) => allowed.has(key))
53
+ .map(([key, value]) => [key, redactObserverValue(value, config, key)]));
54
+ }
@@ -0,0 +1,39 @@
1
+ import { purgeObserverData } from './purge.mjs';
2
+
3
+ const CLASSES = ['documents', 'calls', 'transcripts'];
4
+
5
+ export function observerRetentionCutoffs(policy = {}, now = new Date()) {
6
+ const timestamp = new Date(now);
7
+ if (Number.isNaN(timestamp.getTime())) throw Object.assign(new Error('retention clock inválido.'), { code: 'observer_retention_invalid' });
8
+ return Object.fromEntries(CLASSES.flatMap((dataClass) => {
9
+ const days = Number(policy[dataClass] ?? 0);
10
+ if (!Number.isInteger(days) || days < 0) throw Object.assign(new Error(`retention inválida para ${dataClass}.`), { code: 'observer_retention_invalid' });
11
+ return days === 0 ? [] : [[dataClass, new Date(timestamp.getTime() - days * 86_400_000).toISOString()]];
12
+ }));
13
+ }
14
+
15
+ export function runObserverRetention(db, {
16
+ projectId, policy = {}, clock = () => new Date(), dryRun = false, operationId = '', receiptSink,
17
+ } = {}) {
18
+ const observedAt = clock();
19
+ const cutoffs = observerRetentionCutoffs(policy, observedAt);
20
+ const receipts = [];
21
+ for (const [dataClass, before] of Object.entries(cutoffs)) {
22
+ receipts.push(purgeObserverData(db, {
23
+ projectId,
24
+ before,
25
+ classes: [dataClass],
26
+ dryRun,
27
+ operationId: operationId ? `${operationId}:${dataClass}` : '',
28
+ now: observedAt,
29
+ receiptSink,
30
+ }));
31
+ }
32
+ return {
33
+ schema_version: 1,
34
+ project_id: projectId,
35
+ observed_at: new Date(observedAt).toISOString(),
36
+ cutoffs,
37
+ receipts,
38
+ };
39
+ }