wendkeep 0.86.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.
- package/.githooks/commit-msg +16 -0
- package/.githooks/prepare-commit-msg +16 -0
- package/CHANGELOG.md +32 -0
- package/README.en.md +3 -1
- package/README.md +3 -1
- package/docs/en/commands/commit.md +159 -0
- package/docs/en/commands/observer-security.md +154 -0
- package/docs/en/commands/observer.md +30 -12
- package/docs/pt-BR/commands/commit.md +159 -0
- package/docs/pt-BR/commands/observer-security.md +154 -0
- package/docs/pt-BR/commands/observer.md +30 -12
- package/hooks/observer-publish.mjs +3 -1
- package/package.json +6 -2
- package/packages/cli/src/index.mjs +11 -1
- package/packages/commit/package.json +6 -0
- package/packages/commit/src/cli.mjs +89 -0
- package/packages/commit/src/commit-input.mjs +181 -0
- package/packages/commit/src/commit-message.mjs +51 -0
- package/packages/commit/src/commit-policy.mjs +144 -0
- package/packages/commit/src/git-runtime.mjs +428 -0
- package/packages/commit/src/index.mjs +28 -0
- package/packages/commit/src/proof-validation.mjs +443 -0
- package/packages/mcp/src/executor.mjs +35 -2
- package/packages/observer/package.json +16 -0
- package/packages/observer/src/audit.mjs +1 -0
- package/packages/observer/src/authz.mjs +38 -0
- package/packages/observer/src/encryption.mjs +75 -0
- package/packages/observer/src/index.mjs +7 -0
- package/packages/observer/src/policy.mjs +305 -0
- package/packages/observer/src/purge.mjs +100 -0
- package/packages/observer/src/redaction.mjs +54 -0
- package/packages/observer/src/retention.mjs +39 -0
- package/packages/observer/src/token-registry.mjs +122 -0
- package/schema/commit-message-v1.schema.json +75 -0
- package/schema/observer/006-observer-security.sql +64 -0
- package/schema/observer-policy-v1.schema.json +63 -0
- package/schema/sync-event-v1.schema.json +10 -0
- package/scripts/validate-commit-range.mjs +244 -0
- package/src/doctor.mjs +7 -0
- package/src/git-commit-hooks.mjs +112 -0
- package/src/init.mjs +13 -0
- package/src/observer-auth.mjs +8 -0
- package/src/observer-privacy.mjs +7 -3
- package/src/observer-publish.mjs +31 -0
- package/src/observer-server.mjs +179 -20
- package/src/observer-sql-migrate.mjs +5 -2
- package/src/observer-sql-publish.mjs +114 -39
- package/src/observer-sql-store.mjs +299 -45
- package/src/observer-transcript-store.mjs +23 -8
- package/src/observer.mjs +145 -12
- package/src/skills-seed.mjs +79 -0
- package/src/sync-protocol.mjs +20 -0
- package/web/observer/app.mjs +107 -31
- package/web/observer/index.html +7 -0
- package/web/observer/styles.css +5 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
const ROLES = new Set(['viewer', 'auditor', 'publisher', 'admin']);
|
|
4
|
+
|
|
5
|
+
export function hashObserverToken(token) {
|
|
6
|
+
const value = String(token || '');
|
|
7
|
+
if (!value) throw Object.assign(new Error('token vazio.'), { code: 'observer_token_invalid' });
|
|
8
|
+
return `sha256:${createHash('sha256').update(value, 'utf8').digest('hex')}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function json(value) { return JSON.stringify(value); }
|
|
12
|
+
function parse(value) { try { return JSON.parse(value || '[]'); } catch { return []; } }
|
|
13
|
+
function validTime(value, field) {
|
|
14
|
+
const parsed = new Date(value);
|
|
15
|
+
if (Number.isNaN(parsed.getTime())) throw Object.assign(new Error(`${field} inválido.`), { code: 'observer_token_invalid' });
|
|
16
|
+
return parsed.toISOString();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function registerObserverToken(db, {
|
|
20
|
+
tokenId = randomBytes(12).toString('hex'), token, role, projectIds = [], scopes = [],
|
|
21
|
+
createdAt = new Date().toISOString(), expiresAt, rotatedFrom = null,
|
|
22
|
+
} = {}) {
|
|
23
|
+
if (!ROLES.has(role)) throw Object.assign(new Error('role inválida.'), { code: 'observer_role_invalid' });
|
|
24
|
+
if (!Array.isArray(projectIds) || projectIds.length === 0) throw Object.assign(new Error('projectIds é obrigatório.'), { code: 'observer_projects_invalid' });
|
|
25
|
+
if (!Array.isArray(scopes) || scopes.length === 0) throw Object.assign(new Error('scopes é obrigatório.'), { code: 'observer_scopes_invalid' });
|
|
26
|
+
const row = {
|
|
27
|
+
token_id: String(tokenId), token_hash: hashObserverToken(token), role,
|
|
28
|
+
project_ids_json: json([...new Set(projectIds.map(String))].sort()),
|
|
29
|
+
scopes_json: json([...new Set(scopes.map(String))].sort()),
|
|
30
|
+
created_at: validTime(createdAt, 'createdAt'), expires_at: validTime(expiresAt, 'expiresAt'),
|
|
31
|
+
revoked_at: null, rotated_from: rotatedFrom ? String(rotatedFrom) : null,
|
|
32
|
+
};
|
|
33
|
+
db.prepare(`INSERT INTO observer_tokens(token_id, token_hash, role, project_ids_json, scopes_json, created_at, expires_at, revoked_at, rotated_from)
|
|
34
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(...Object.values(row));
|
|
35
|
+
return { token_id: row.token_id, role, project_ids: parse(row.project_ids_json), scopes: parse(row.scopes_json), expires_at: row.expires_at, rotated_from: row.rotated_from };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function resolveObserverPrincipal(db, token, { now = new Date().toISOString() } = {}) {
|
|
39
|
+
let tokenHash;
|
|
40
|
+
try { tokenHash = hashObserverToken(token); } catch { return { ok: false, code: 'observer_token_missing' }; }
|
|
41
|
+
const row = db.prepare('SELECT * FROM observer_tokens WHERE token_hash = ?').get(tokenHash);
|
|
42
|
+
if (!row) return { ok: false, code: 'observer_token_invalid' };
|
|
43
|
+
const timestamp = new Date(now).getTime();
|
|
44
|
+
if (row.revoked_at && new Date(row.revoked_at).getTime() <= timestamp) return { ok: false, code: 'observer_token_revoked', token_id: row.token_id };
|
|
45
|
+
if (new Date(row.expires_at).getTime() <= timestamp) return { ok: false, code: 'observer_token_expired', token_id: row.token_id };
|
|
46
|
+
return {
|
|
47
|
+
ok: true, token_id: row.token_id, role: row.role,
|
|
48
|
+
project_ids: parse(row.project_ids_json), scopes: parse(row.scopes_json), expires_at: row.expires_at,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function ensureObserverBootstrapToken(db, {
|
|
53
|
+
token, tokenId = '', role, projectIds = [], scopes = [], expiresAt, createdAt = new Date().toISOString(), now = createdAt,
|
|
54
|
+
} = {}) {
|
|
55
|
+
const tokenHash = hashObserverToken(token);
|
|
56
|
+
const existing = db.prepare('SELECT * FROM observer_tokens WHERE token_hash = ?').get(tokenHash);
|
|
57
|
+
if (existing) {
|
|
58
|
+
const expired = new Date(existing.expires_at).getTime() <= new Date(validTime(now, 'now')).getTime();
|
|
59
|
+
return {
|
|
60
|
+
created: false,
|
|
61
|
+
token_id: existing.token_id,
|
|
62
|
+
role: existing.role,
|
|
63
|
+
project_ids: parse(existing.project_ids_json),
|
|
64
|
+
scopes: parse(existing.scopes_json),
|
|
65
|
+
expires_at: existing.expires_at,
|
|
66
|
+
revoked: Boolean(existing.revoked_at),
|
|
67
|
+
expired,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
if (!Array.isArray(projectIds) || projectIds.length === 0 || projectIds.includes('*')) {
|
|
71
|
+
throw Object.assign(new Error('bootstrap exige projectIds explícitos e não aceita wildcard.'), { code: 'observer_bootstrap_projects_invalid' });
|
|
72
|
+
}
|
|
73
|
+
const normalizedExpiry = validTime(expiresAt, 'expiresAt');
|
|
74
|
+
if (new Date(normalizedExpiry).getTime() <= new Date(validTime(now, 'now')).getTime()) {
|
|
75
|
+
throw Object.assign(new Error('bootstrap exige expiração futura.'), { code: 'observer_bootstrap_expired' });
|
|
76
|
+
}
|
|
77
|
+
const id = String(tokenId || `bootstrap-${tokenHash.slice(-24)}`);
|
|
78
|
+
if (db.prepare('SELECT token_id FROM observer_tokens WHERE token_id = ?').get(id)) {
|
|
79
|
+
throw Object.assign(new Error('tokenId de bootstrap já pertence a outra credencial.'), { code: 'observer_bootstrap_token_id_conflict' });
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
created: true,
|
|
83
|
+
...registerObserverToken(db, {
|
|
84
|
+
tokenId: id,
|
|
85
|
+
token,
|
|
86
|
+
role,
|
|
87
|
+
projectIds,
|
|
88
|
+
scopes,
|
|
89
|
+
createdAt,
|
|
90
|
+
expiresAt: normalizedExpiry,
|
|
91
|
+
}),
|
|
92
|
+
revoked: false,
|
|
93
|
+
expired: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function revokeObserverToken(db, { tokenId, revokedAt = new Date().toISOString() } = {}) {
|
|
98
|
+
const result = db.prepare('UPDATE observer_tokens SET revoked_at = ? WHERE token_id = ? AND revoked_at IS NULL')
|
|
99
|
+
.run(validTime(revokedAt, 'revokedAt'), String(tokenId || ''));
|
|
100
|
+
return { token_id: String(tokenId || ''), revoked: Number(result.changes) > 0 };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function rotateObserverToken(db, {
|
|
104
|
+
tokenId, newTokenId, newToken, rotatedAt = new Date().toISOString(), expiresAt,
|
|
105
|
+
} = {}) {
|
|
106
|
+
const current = db.prepare('SELECT * FROM observer_tokens WHERE token_id = ?').get(String(tokenId || ''));
|
|
107
|
+
if (!current || current.revoked_at) throw Object.assign(new Error('token não pode ser rotacionado.'), { code: 'observer_token_rotation_invalid' });
|
|
108
|
+
db.exec('BEGIN IMMEDIATE');
|
|
109
|
+
try {
|
|
110
|
+
revokeObserverToken(db, { tokenId, revokedAt: rotatedAt });
|
|
111
|
+
const created = registerObserverToken(db, {
|
|
112
|
+
tokenId: newTokenId, token: newToken, role: current.role,
|
|
113
|
+
projectIds: parse(current.project_ids_json), scopes: parse(current.scopes_json),
|
|
114
|
+
createdAt: rotatedAt, expiresAt, rotatedFrom: tokenId,
|
|
115
|
+
});
|
|
116
|
+
db.exec('COMMIT');
|
|
117
|
+
return created;
|
|
118
|
+
} catch (error) {
|
|
119
|
+
db.exec('ROLLBACK');
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
}
|