wendkeep 0.87.0 → 0.88.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 (36) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.en.md +1 -1
  3. package/README.md +1 -1
  4. package/docs/en/commands/observer-security.md +154 -0
  5. package/docs/en/commands/observer.md +30 -12
  6. package/docs/pt-BR/commands/observer-security.md +154 -0
  7. package/docs/pt-BR/commands/observer.md +30 -12
  8. package/hooks/observer-publish.mjs +3 -1
  9. package/package.json +2 -1
  10. package/packages/mcp/src/executor.mjs +35 -2
  11. package/packages/observer/package.json +16 -0
  12. package/packages/observer/src/audit.mjs +1 -0
  13. package/packages/observer/src/authz.mjs +38 -0
  14. package/packages/observer/src/encryption.mjs +75 -0
  15. package/packages/observer/src/index.mjs +7 -0
  16. package/packages/observer/src/policy.mjs +305 -0
  17. package/packages/observer/src/purge.mjs +100 -0
  18. package/packages/observer/src/redaction.mjs +54 -0
  19. package/packages/observer/src/retention.mjs +39 -0
  20. package/packages/observer/src/token-registry.mjs +122 -0
  21. package/schema/observer/006-observer-security.sql +64 -0
  22. package/schema/observer-policy-v1.schema.json +63 -0
  23. package/schema/sync-event-v1.schema.json +10 -0
  24. package/src/observer-auth.mjs +8 -0
  25. package/src/observer-privacy.mjs +7 -3
  26. package/src/observer-publish.mjs +31 -0
  27. package/src/observer-server.mjs +179 -20
  28. package/src/observer-sql-migrate.mjs +5 -2
  29. package/src/observer-sql-publish.mjs +114 -39
  30. package/src/observer-sql-store.mjs +299 -45
  31. package/src/observer-transcript-store.mjs +23 -8
  32. package/src/observer.mjs +145 -12
  33. package/src/sync-protocol.mjs +20 -0
  34. package/web/observer/app.mjs +107 -31
  35. package/web/observer/index.html +7 -0
  36. package/web/observer/styles.css +5 -0
@@ -155,13 +155,16 @@ function changeResult(tool, args, vaultBase) {
155
155
 
156
156
  async function observerQuery(args, ctx) {
157
157
  const {
158
- openObserverDatabase,
158
+ bootstrapObserverDatabase,
159
159
  readSqlProjectOverview,
160
160
  readUsageBreakdown,
161
161
  readUsageCalls,
162
162
  readUsageSummary,
163
163
  searchSqlDocuments,
164
164
  } = await import('../../../src/observer-sql-store.mjs');
165
+ const { authorizeObserverPrincipal, recordObserverAudit } = await import('../../observer/src/authz.mjs');
166
+ const { resolveObserverPrincipal } = await import('../../observer/src/token-registry.mjs');
167
+ const { observerEncryptionFromEnvironment } = await import('../../observer/src/encryption.mjs');
165
168
  const kind = String(args.query || 'overview').trim();
166
169
  const filters = args.payload?.filters && typeof args.payload.filters === 'object'
167
170
  ? args.payload.filters
@@ -169,7 +172,37 @@ async function observerQuery(args, ctx) {
169
172
  const dataDir = resolve(process.env.WENDKEEP_OBSERVER_DATA_DIR || join(homedir(), '.wendkeep-observer'));
170
173
  let db;
171
174
  try {
172
- db = openObserverDatabase(dataDir);
175
+ const encryption = observerEncryptionFromEnvironment();
176
+ ({ db } = bootstrapObserverDatabase(dataDir, { security: { encryption } }));
177
+ const capability = {
178
+ overview: 'project:read',
179
+ usage_summary: 'usage:summary:read',
180
+ usage_breakdown: 'usage:breakdown:read',
181
+ usage_calls: 'usage:calls:read',
182
+ memory_search: 'memory:content:read',
183
+ }[kind] || 'project:read';
184
+ const rawToken = String(process.env.WENDKEEP_OBSERVER_TOKEN || '');
185
+ const requireToken = ['usage:calls:read', 'memory:content:read'].includes(capability)
186
+ || process.env.WENDKEEP_OBSERVER_REQUIRE_LOOPBACK_AUTH === '1';
187
+ if (rawToken || requireToken) {
188
+ const principal = resolveObserverPrincipal(db, rawToken);
189
+ const authorized = authorizeObserverPrincipal(principal, {
190
+ projectId: ctx.resolution.projectId,
191
+ capability,
192
+ });
193
+ if (!authorized.ok) {
194
+ throw Object.assign(new Error('Observer query requires a project-scoped token and capability'), {
195
+ code: authorized.code || 'MCP_OBSERVER_AUTH_REQUIRED',
196
+ });
197
+ }
198
+ if (['usage:calls:read', 'memory:content:read'].includes(capability)) recordObserverAudit(db, {
199
+ projectId: ctx.resolution.projectId,
200
+ tokenId: principal.token_id,
201
+ capability,
202
+ outcome: 'allowed',
203
+ metadata: { route: `mcp:${kind}`, method: 'MCP' },
204
+ });
205
+ }
173
206
  switch (kind) {
174
207
  case 'overview': return sanitize(readSqlProjectOverview(db, ctx.resolution.projectId));
175
208
  case 'usage_summary': return sanitize(readUsageSummary(db, ctx.resolution.projectId, filters));
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@wendkeep/observer",
3
+ "private": true,
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.mjs",
7
+ "./policy": "./src/policy.mjs",
8
+ "./redaction": "./src/redaction.mjs",
9
+ "./authz": "./src/authz.mjs",
10
+ "./token-registry": "./src/token-registry.mjs",
11
+ "./encryption": "./src/encryption.mjs",
12
+ "./retention": "./src/retention.mjs",
13
+ "./purge": "./src/purge.mjs",
14
+ "./audit": "./src/audit.mjs"
15
+ }
16
+ }
@@ -0,0 +1 @@
1
+ export { recordObserverAudit } from './authz.mjs';
@@ -0,0 +1,38 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { sanitizeObserverAuditMetadata } from './redaction.mjs';
3
+
4
+ const ROLE_CAPABILITIES = {
5
+ viewer: new Set(['project:read', 'usage:summary:read', 'usage:breakdown:read', 'memory:metadata:read', 'sync:read']),
6
+ auditor: new Set(['project:read', 'usage:summary:read', 'usage:breakdown:read', 'usage:calls:read', 'transcript:read', 'memory:metadata:read', 'memory:content:read', 'audit:read', 'sync:read']),
7
+ publisher: new Set(['project:read', 'project:write', 'ingest:write', 'memory:write', 'snapshot:write', 'sync:write']),
8
+ admin: new Set(['*']),
9
+ };
10
+
11
+ function scopeMatches(scopes, capability) {
12
+ return scopes.includes('*') || scopes.includes(capability)
13
+ || scopes.some((scope) => scope.endsWith(':*') && capability.startsWith(scope.slice(0, -1)));
14
+ }
15
+
16
+ export function authorizeObserverPrincipal(principal, { projectId, capability } = {}) {
17
+ if (!principal?.ok) return { ok: false, status: 401, code: principal?.code || 'observer_auth_required' };
18
+ if (!principal.project_ids?.includes('*') && !principal.project_ids?.includes(projectId)) {
19
+ return { ok: false, status: 403, code: 'observer_project_forbidden' };
20
+ }
21
+ const grants = ROLE_CAPABILITIES[principal.role] || new Set();
22
+ if (!grants.has('*') && !grants.has(capability)) return { ok: false, status: 403, code: 'observer_role_forbidden' };
23
+ if (!scopeMatches(principal.scopes || [], capability)) return { ok: false, status: 403, code: 'observer_scope_forbidden' };
24
+ return { ok: true, status: 200, code: 'observer_authorized' };
25
+ }
26
+
27
+ export function recordObserverAudit(db, {
28
+ auditId, projectId, tokenId = '', capability, outcome, occurredAt = new Date().toISOString(), metadata = {},
29
+ } = {}) {
30
+ const safeMetadata = sanitizeObserverAuditMetadata(metadata);
31
+ const id = String(auditId || createHash('sha256').update(JSON.stringify({ projectId, tokenId, capability, outcome, occurredAt, safeMetadata })).digest('hex').slice(0, 32));
32
+ const persistedTokenId = tokenId && db.prepare('SELECT token_id FROM observer_tokens WHERE token_id = ?').get(tokenId)
33
+ ? tokenId
34
+ : null;
35
+ db.prepare(`INSERT OR IGNORE INTO observer_access_audit(audit_id, project_id, token_id, capability, outcome, occurred_at, metadata_json)
36
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(id, projectId, persistedTokenId, capability, outcome, new Date(occurredAt).toISOString(), JSON.stringify(safeMetadata));
37
+ return { audit_id: id, project_id: projectId, token_id: tokenId, capability, outcome, occurred_at: new Date(occurredAt).toISOString(), metadata: safeMetadata };
38
+ }
@@ -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
+ }