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
@@ -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
+ }
@@ -0,0 +1,64 @@
1
+ -- wendkeep:structural
2
+ -- Security lifecycle: hash-only credentials, access audit, retention, purge receipts and encrypted columns.
3
+
4
+ ALTER TABLE llm_calls ADD COLUMN prompt_envelope TEXT NOT NULL DEFAULT '';
5
+ ALTER TABLE llm_calls ADD COLUMN response_envelope TEXT NOT NULL DEFAULT '';
6
+ ALTER TABLE llm_calls ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
7
+ ALTER TABLE documents ADD COLUMN content_envelope TEXT NOT NULL DEFAULT '';
8
+ ALTER TABLE documents ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
9
+ ALTER TABLE transcripts ADD COLUMN metadata_envelope TEXT NOT NULL DEFAULT '';
10
+ ALTER TABLE project_snapshots ADD COLUMN snapshot_envelope TEXT NOT NULL DEFAULT '';
11
+
12
+ CREATE TABLE IF NOT EXISTS observer_tokens (
13
+ token_id TEXT PRIMARY KEY,
14
+ token_hash TEXT NOT NULL UNIQUE,
15
+ role TEXT NOT NULL CHECK(role IN ('viewer', 'auditor', 'publisher', 'admin')),
16
+ project_ids_json TEXT NOT NULL,
17
+ scopes_json TEXT NOT NULL,
18
+ created_at TEXT NOT NULL,
19
+ expires_at TEXT NOT NULL,
20
+ revoked_at TEXT,
21
+ rotated_from TEXT REFERENCES observer_tokens(token_id) ON DELETE SET NULL
22
+ );
23
+
24
+ CREATE INDEX IF NOT EXISTS idx_observer_tokens_hash_active ON observer_tokens(token_hash, revoked_at, expires_at);
25
+
26
+ CREATE TABLE IF NOT EXISTS observer_access_audit (
27
+ audit_id TEXT PRIMARY KEY,
28
+ project_id TEXT NOT NULL,
29
+ token_id TEXT REFERENCES observer_tokens(token_id) ON DELETE SET NULL,
30
+ capability TEXT NOT NULL,
31
+ outcome TEXT NOT NULL,
32
+ occurred_at TEXT NOT NULL,
33
+ metadata_json TEXT NOT NULL DEFAULT '{}'
34
+ );
35
+
36
+ CREATE INDEX IF NOT EXISTS idx_observer_audit_project_time ON observer_access_audit(project_id, occurred_at);
37
+
38
+ CREATE TABLE IF NOT EXISTS observer_retention_policies (
39
+ project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
40
+ policy_json TEXT NOT NULL,
41
+ updated_at TEXT NOT NULL
42
+ );
43
+
44
+ CREATE TABLE IF NOT EXISTS observer_purge_receipts (
45
+ receipt_id TEXT PRIMARY KEY,
46
+ project_id TEXT NOT NULL,
47
+ request_hash TEXT NOT NULL,
48
+ cutoff_at TEXT NOT NULL,
49
+ classes_json TEXT NOT NULL,
50
+ counts_json TEXT NOT NULL,
51
+ purged_at TEXT NOT NULL,
52
+ receipt_hash TEXT NOT NULL UNIQUE,
53
+ receipt_json TEXT NOT NULL
54
+ );
55
+
56
+ CREATE INDEX IF NOT EXISTS idx_observer_purge_project_time ON observer_purge_receipts(project_id, purged_at);
57
+ CREATE INDEX IF NOT EXISTS idx_observer_purge_request ON observer_purge_receipts(project_id, request_hash, purged_at);
58
+
59
+ CREATE TABLE IF NOT EXISTS observer_security_backfill (
60
+ project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,
61
+ status TEXT NOT NULL,
62
+ protected_rows INTEGER NOT NULL DEFAULT 0,
63
+ updated_at TEXT NOT NULL
64
+ );
@@ -0,0 +1,63 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://wendkeep.dev/schema/observer-policy-v1.schema.json",
4
+ "title": "WendKeep Observer policy v1",
5
+ "type": "object",
6
+ "additionalProperties": false,
7
+ "properties": {
8
+ "document_capture": { "enum": ["none", "metadata", "selected", "full"] },
9
+ "transcript_capture": { "enum": ["none", "metadata", "messages", "full"] },
10
+ "prompt_capture": { "enum": ["none", "redacted", "full"] },
11
+ "response_capture": { "enum": ["none", "redacted", "full"] },
12
+ "usage_capture": { "enum": ["none", "aggregate", "calls"] },
13
+ "require_loopback_auth": { "type": "boolean" },
14
+ "encryption_required": { "type": "boolean" },
15
+ "rules": {
16
+ "type": "array",
17
+ "items": {
18
+ "type": "object",
19
+ "additionalProperties": false,
20
+ "required": ["data_class", "capture"],
21
+ "properties": {
22
+ "project_id": { "type": "string", "minLength": 1, "maxLength": 121 },
23
+ "data_class": { "enum": ["document", "transcript", "prompt", "response", "usage"] },
24
+ "path": { "type": "string", "maxLength": 2048 },
25
+ "entity_type": { "type": "string", "maxLength": 160 },
26
+ "capture": { "enum": ["none", "metadata", "selected", "full", "messages", "redacted", "aggregate", "calls"] }
27
+ }
28
+ }
29
+ },
30
+ "redaction": {
31
+ "type": "object",
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "rules": {
35
+ "type": "array",
36
+ "items": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["pattern"],
40
+ "properties": {
41
+ "pattern": { "type": "string", "minLength": 1, "maxLength": 512 },
42
+ "replacement": { "type": "string", "maxLength": 160 }
43
+ }
44
+ }
45
+ }
46
+ }
47
+ },
48
+ "retention": {
49
+ "type": "object",
50
+ "additionalProperties": false,
51
+ "properties": {
52
+ "document": { "type": "integer", "minimum": 0 },
53
+ "transcript": { "type": "integer", "minimum": 0 },
54
+ "prompt": { "type": "integer", "minimum": 0 },
55
+ "response": { "type": "integer", "minimum": 0 },
56
+ "usage": { "type": "integer", "minimum": 0 },
57
+ "documents": { "type": "integer", "minimum": 0 },
58
+ "calls": { "type": "integer", "minimum": 0 },
59
+ "transcripts": { "type": "integer", "minimum": 0 }
60
+ }
61
+ }
62
+ }
63
+ }
@@ -20,6 +20,16 @@
20
20
  "observed_at": { "type": "string", "format": "date-time" },
21
21
  "operation": { "enum": ["put", "tombstone", "resolve"] },
22
22
  "privacy": { "enum": ["shared", "private"] },
23
+ "policy_ref": {
24
+ "type": "object",
25
+ "additionalProperties": false,
26
+ "required": ["policy_id", "version", "hash"],
27
+ "properties": {
28
+ "policy_id": { "type": "string", "minLength": 1, "maxLength": 160 },
29
+ "version": { "type": "integer", "minimum": 1 },
30
+ "hash": { "type": "string", "pattern": "^sha256:[a-f0-9]{64}$" }
31
+ }
32
+ },
23
33
  "payload": {}
24
34
  }
25
35
  }
@@ -8,3 +8,11 @@ export function observerAuthHeaders(token, headers = {}) {
8
8
  ? { ...headers, authorization: `Bearer ${resolved}` }
9
9
  : { ...headers };
10
10
  }
11
+
12
+ export {
13
+ registerObserverToken,
14
+ resolveObserverPrincipal,
15
+ revokeObserverToken,
16
+ rotateObserverToken,
17
+ } from '../packages/observer/src/token-registry.mjs';
18
+ export { authorizeObserverPrincipal } from '../packages/observer/src/authz.mjs';
@@ -1,4 +1,5 @@
1
1
  import { basename } from 'node:path';
2
+ import { redactObserverText, redactObserverValue } from '../packages/observer/src/redaction.mjs';
2
3
 
3
4
  const TRANSCRIPT_PATH_KEY = /^(?:transcript_path|agent_transcript_path|transcriptPath|agentTranscriptPath)$/i;
4
5
  const TRANSCRIPT_PATH_LINE = /^(\s*["']?(?:transcript_path|agent_transcript_path|transcriptPath|agentTranscriptPath)["']?\s*:\s*)(["']?)(.*?)(\2)(\s*,?\s*)$/gmi;
@@ -8,16 +9,19 @@ function sourceLabel(value) {
8
9
  }
9
10
 
10
11
  export function sanitizeObserverContent(content) {
11
- return String(content || '').replace(TRANSCRIPT_PATH_LINE, (_line, prefix, quote, value, _closing, suffix) => (
12
+ const safePaths = String(content || '').replace(TRANSCRIPT_PATH_LINE, (_line, prefix, quote, value, _closing, suffix) => (
12
13
  `${prefix}${quote}${sourceLabel(value)}${quote}${suffix}`
13
14
  ));
15
+ return redactObserverText(safePaths);
14
16
  }
15
17
 
16
18
  export function sanitizeObserverMetadata(value) {
17
19
  if (Array.isArray(value)) return value.map(sanitizeObserverMetadata);
18
20
  if (!value || typeof value !== 'object') return value;
19
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [
21
+ return redactObserverValue(Object.fromEntries(Object.entries(value).map(([key, item]) => [
20
22
  key,
21
23
  TRANSCRIPT_PATH_KEY.test(key) ? sourceLabel(item) : sanitizeObserverMetadata(item),
22
- ]));
24
+ ])));
23
25
  }
26
+
27
+ export { redactObserverText, redactObserverValue };
@@ -3,10 +3,37 @@ import { join } from 'node:path';
3
3
  import { publishObserverSqlIncremental } from './observer-sql-publish.mjs';
4
4
  import { observerAuthHeaders, resolveObserverToken } from './observer-auth.mjs';
5
5
  import { readProjectForValidation } from '../packages/vault/src/validate-memory.mjs';
6
+ import { createObserverPolicy } from '../packages/observer/src/policy.mjs';
7
+ import { createObserverEncryption } from '../packages/observer/src/encryption.mjs';
6
8
 
7
9
  const OUTBOX_REL = join('.brain', 'observer-outbox');
8
10
  const REQUEST_TIMEOUT_MS = 500;
9
11
 
12
+ function decodeObserverKey(value) {
13
+ const raw = String(value || '').trim();
14
+ const key = /^[a-f0-9]{64}$/i.test(raw) ? Buffer.from(raw, 'hex') : Buffer.from(raw, 'base64');
15
+ if (key.byteLength !== 32) throw Object.assign(new Error('Observer outbox key deve ter 32 bytes em hex/base64.'), { code: 'observer_encryption_key_invalid' });
16
+ return key;
17
+ }
18
+
19
+ export function resolveObserverPublisherSecurity({ env = process.env } = {}) {
20
+ const policyFile = String(env.WENDKEEP_OBSERVER_POLICY_FILE || '').trim();
21
+ const policy = policyFile
22
+ ? createObserverPolicy(JSON.parse(readFileSync(policyFile, 'utf8')))
23
+ : createObserverPolicy();
24
+ const keyEnvName = String(env.WENDKEEP_OBSERVER_OUTBOX_KEY_ENV || '').trim();
25
+ if (!keyEnvName) return { policy, outboxEncryption: null };
26
+ const key = decodeObserverKey(env[keyEnvName]);
27
+ return {
28
+ policy,
29
+ outboxEncryption: createObserverEncryption({
30
+ required: true,
31
+ keyId: String(env.WENDKEEP_OBSERVER_OUTBOX_KEY_ID || keyEnvName),
32
+ keyProvider: () => key,
33
+ }),
34
+ };
35
+ }
36
+
10
37
  function outboxDir(vaultBase) {
11
38
  return join(vaultBase, OUTBOX_REL);
12
39
  }
@@ -92,6 +119,8 @@ export async function publishObserverSnapshot({
92
119
  now = new Date(),
93
120
  input = {},
94
121
  token = process.env.WENDKEEP_OBSERVER_TOKEN || '',
122
+ policy = null,
123
+ outboxEncryption = null,
95
124
  } = {}) {
96
125
  try {
97
126
  const project = readProjectForValidation(vaultBase);
@@ -103,6 +132,8 @@ export async function publishObserverSnapshot({
103
132
  input,
104
133
  now,
105
134
  token,
135
+ policy,
136
+ outboxEncryption,
106
137
  });
107
138
  // SQLite is the only live authority. The legacy snapshot store remains
108
139
  // readable solely as a migration source for pre-SQL installations.