twinclaw 1.0.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 (132) hide show
  1. package/README.md +66 -0
  2. package/bin/npm-twinclaw.js +17 -0
  3. package/bin/run-twinbot-cli.js +36 -0
  4. package/bin/twinbot.js +4 -0
  5. package/bin/twinclaw.js +4 -0
  6. package/dist/api/handlers/browser.js +160 -0
  7. package/dist/api/handlers/callback.js +80 -0
  8. package/dist/api/handlers/config-validate.js +19 -0
  9. package/dist/api/handlers/health.js +117 -0
  10. package/dist/api/handlers/local-state-backup.js +118 -0
  11. package/dist/api/handlers/persona-state.js +59 -0
  12. package/dist/api/handlers/skill-packages.js +94 -0
  13. package/dist/api/router.js +278 -0
  14. package/dist/api/runtime-event-producer.js +99 -0
  15. package/dist/api/shared.js +82 -0
  16. package/dist/api/websocket-hub.js +305 -0
  17. package/dist/config/config-loader.js +2 -0
  18. package/dist/config/env-schema.js +202 -0
  19. package/dist/config/env-validator.js +223 -0
  20. package/dist/config/identity-bootstrap.js +115 -0
  21. package/dist/config/json-config.js +344 -0
  22. package/dist/config/workspace.js +186 -0
  23. package/dist/core/channels-cli.js +77 -0
  24. package/dist/core/cli.js +119 -0
  25. package/dist/core/context-assembly.js +33 -0
  26. package/dist/core/doctor.js +365 -0
  27. package/dist/core/gateway-cli.js +323 -0
  28. package/dist/core/gateway.js +416 -0
  29. package/dist/core/heartbeat.js +54 -0
  30. package/dist/core/install-cli.js +320 -0
  31. package/dist/core/lane-executor.js +134 -0
  32. package/dist/core/logs-cli.js +70 -0
  33. package/dist/core/onboarding.js +760 -0
  34. package/dist/core/pairing-cli.js +78 -0
  35. package/dist/core/secret-vault-cli.js +204 -0
  36. package/dist/core/types.js +1 -0
  37. package/dist/index.js +404 -0
  38. package/dist/interfaces/dispatcher.js +214 -0
  39. package/dist/interfaces/telegram_handler.js +82 -0
  40. package/dist/interfaces/tui-dashboard.js +53 -0
  41. package/dist/interfaces/whatsapp_handler.js +94 -0
  42. package/dist/release/cli.js +97 -0
  43. package/dist/release/mvp-gate-cli.js +118 -0
  44. package/dist/release/twinbot-config-schema.js +162 -0
  45. package/dist/release/twinclaw-config-schema.js +162 -0
  46. package/dist/services/block-chunker.js +174 -0
  47. package/dist/services/browser-service.js +334 -0
  48. package/dist/services/context-lifecycle.js +314 -0
  49. package/dist/services/db.js +1055 -0
  50. package/dist/services/delivery-tracker.js +110 -0
  51. package/dist/services/dm-pairing.js +245 -0
  52. package/dist/services/embedding-service.js +125 -0
  53. package/dist/services/file-watcher.js +125 -0
  54. package/dist/services/inbound-debounce.js +92 -0
  55. package/dist/services/incident-manager.js +516 -0
  56. package/dist/services/job-scheduler.js +176 -0
  57. package/dist/services/local-state-backup.js +682 -0
  58. package/dist/services/mcp-client-adapter.js +291 -0
  59. package/dist/services/mcp-server-manager.js +143 -0
  60. package/dist/services/model-router.js +927 -0
  61. package/dist/services/mvp-gate.js +845 -0
  62. package/dist/services/orchestration-service.js +422 -0
  63. package/dist/services/persona-state.js +256 -0
  64. package/dist/services/policy-engine.js +92 -0
  65. package/dist/services/proactive-notifier.js +94 -0
  66. package/dist/services/queue-service.js +146 -0
  67. package/dist/services/release-pipeline.js +652 -0
  68. package/dist/services/runtime-budget-governor.js +415 -0
  69. package/dist/services/secret-vault.js +704 -0
  70. package/dist/services/semantic-memory.js +249 -0
  71. package/dist/services/skill-package-manager.js +806 -0
  72. package/dist/services/skill-registry.js +122 -0
  73. package/dist/services/streaming-output.js +75 -0
  74. package/dist/services/stt-service.js +39 -0
  75. package/dist/services/tts-service.js +44 -0
  76. package/dist/skills/builtin.js +250 -0
  77. package/dist/skills/shell.js +87 -0
  78. package/dist/skills/types.js +1 -0
  79. package/dist/types/api.js +1 -0
  80. package/dist/types/context-budget.js +1 -0
  81. package/dist/types/doctor.js +1 -0
  82. package/dist/types/file-watcher.js +1 -0
  83. package/dist/types/incident.js +1 -0
  84. package/dist/types/local-state-backup.js +1 -0
  85. package/dist/types/mcp.js +1 -0
  86. package/dist/types/messaging.js +1 -0
  87. package/dist/types/model-routing.js +1 -0
  88. package/dist/types/mvp-gate.js +2 -0
  89. package/dist/types/orchestration.js +1 -0
  90. package/dist/types/persona-state.js +22 -0
  91. package/dist/types/policy.js +1 -0
  92. package/dist/types/reasoning-graph.js +1 -0
  93. package/dist/types/release.js +1 -0
  94. package/dist/types/reliability.js +1 -0
  95. package/dist/types/runtime-budget.js +1 -0
  96. package/dist/types/scheduler.js +1 -0
  97. package/dist/types/secret-vault.js +1 -0
  98. package/dist/types/skill-packages.js +1 -0
  99. package/dist/types/websocket.js +14 -0
  100. package/dist/utils/logger.js +57 -0
  101. package/dist/utils/retry.js +61 -0
  102. package/dist/utils/secret-scan.js +208 -0
  103. package/mcp-servers.json +179 -0
  104. package/package.json +81 -0
  105. package/skill-packages.json +92 -0
  106. package/skill-packages.lock.json +5 -0
  107. package/src/skills/builtin.ts +275 -0
  108. package/src/skills/shell.ts +118 -0
  109. package/src/skills/types.ts +30 -0
  110. package/src/types/api.ts +252 -0
  111. package/src/types/blessed-contrib.d.ts +4 -0
  112. package/src/types/context-budget.ts +76 -0
  113. package/src/types/doctor.ts +29 -0
  114. package/src/types/file-watcher.ts +26 -0
  115. package/src/types/incident.ts +57 -0
  116. package/src/types/local-state-backup.ts +121 -0
  117. package/src/types/mcp.ts +106 -0
  118. package/src/types/messaging.ts +35 -0
  119. package/src/types/model-routing.ts +61 -0
  120. package/src/types/mvp-gate.ts +99 -0
  121. package/src/types/orchestration.ts +65 -0
  122. package/src/types/persona-state.ts +61 -0
  123. package/src/types/policy.ts +27 -0
  124. package/src/types/reasoning-graph.ts +58 -0
  125. package/src/types/release.ts +115 -0
  126. package/src/types/reliability.ts +43 -0
  127. package/src/types/runtime-budget.ts +85 -0
  128. package/src/types/scheduler.ts +47 -0
  129. package/src/types/secret-vault.ts +62 -0
  130. package/src/types/skill-packages.ts +81 -0
  131. package/src/types/sqlite-vec.d.ts +5 -0
  132. package/src/types/websocket.ts +122 -0
@@ -0,0 +1,704 @@
1
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import * as dbModule from './db.js';
3
+ import { getConfigValue, loadTwinBotJson } from '../config/config-loader.js';
4
+ const DEFAULT_ROTATION_WINDOW_HOURS = 24 * 30;
5
+ const DEFAULT_WARNING_WINDOW_HOURS = 24 * 3;
6
+ const DEFAULT_REQUIRED_SECRETS = ['API_SECRET'];
7
+ const MIN_REDACTION_VALUE_LENGTH = 6;
8
+ const SENSITIVE_ENV_NAME_PATTERN = /(api[_-]?key|token|secret|password)/i;
9
+ const ALLOWED_SCOPES = ['api', 'model', 'messaging', 'runtime', 'storage', 'integration'];
10
+ const ALLOWED_SOURCES = ['env', 'vault', 'runtime'];
11
+ function normalizeSecretName(name) {
12
+ const normalized = name.trim().toUpperCase();
13
+ if (!/^[A-Z0-9_]+$/.test(normalized)) {
14
+ throw new Error(`Secret name '${name}' is invalid. Use uppercase letters, numbers, and underscores only.`);
15
+ }
16
+ return normalized;
17
+ }
18
+ function normalizeSecretValue(value) {
19
+ if (typeof value !== 'string' || value.length === 0) {
20
+ throw new Error('Secret value must be a non-empty string.');
21
+ }
22
+ return value;
23
+ }
24
+ function normalizeHours(value, fallback) {
25
+ if (typeof value !== 'number') {
26
+ return fallback;
27
+ }
28
+ if (!Number.isFinite(value) || value <= 0) {
29
+ throw new Error('Rotation and warning windows must be positive numbers.');
30
+ }
31
+ return Math.floor(value);
32
+ }
33
+ function isSecretScope(value) {
34
+ return ALLOWED_SCOPES.includes(value);
35
+ }
36
+ function isSecretSource(value) {
37
+ return ALLOWED_SOURCES.includes(value);
38
+ }
39
+ function safeParseDate(value) {
40
+ if (!value) {
41
+ return null;
42
+ }
43
+ const parsed = Date.parse(value);
44
+ return Number.isFinite(parsed) ? parsed : null;
45
+ }
46
+ function deriveExpiryIso(nowMs, rotationWindowHours, explicitExpiry) {
47
+ if (explicitExpiry === null) {
48
+ return null;
49
+ }
50
+ if (typeof explicitExpiry === 'string') {
51
+ const parsed = safeParseDate(explicitExpiry);
52
+ if (parsed === null) {
53
+ throw new Error(`Invalid expiresAt value '${explicitExpiry}'. Expected ISO-8601.`);
54
+ }
55
+ return new Date(parsed).toISOString();
56
+ }
57
+ return new Date(nowMs + rotationWindowHours * 60 * 60 * 1000).toISOString();
58
+ }
59
+ function deriveMasterKey(raw) {
60
+ return createHash('sha256').update(raw).digest();
61
+ }
62
+ function splitRequiredSecrets(value) {
63
+ if (!value) {
64
+ return [];
65
+ }
66
+ return value
67
+ .split(',')
68
+ .map((item) => item.trim())
69
+ .filter(Boolean)
70
+ .map((item) => normalizeSecretName(item));
71
+ }
72
+ function resolveDatabaseFromModule() {
73
+ const maybeDb = dbModule.db;
74
+ if (!maybeDb || typeof maybeDb.prepare !== 'function') {
75
+ return null;
76
+ }
77
+ return maybeDb;
78
+ }
79
+ export class SecretVaultService {
80
+ now;
81
+ requiredSecrets;
82
+ runtimeSecretValues = new Map();
83
+ explicitMasterKey;
84
+ db;
85
+ cachedMasterKey = null;
86
+ tablesReady = false;
87
+ constructor(options = {}) {
88
+ this.now = options.now ?? (() => new Date());
89
+ this.explicitMasterKey = options.masterKey;
90
+ this.db = options.database === undefined ? resolveDatabaseFromModule() : options.database;
91
+ const configuredRequired = splitRequiredSecrets(getConfigValue('SECRET_VAULT_REQUIRED'));
92
+ const combinedRequired = [
93
+ ...(options.requiredSecrets ?? []),
94
+ ...configuredRequired,
95
+ ...DEFAULT_REQUIRED_SECRETS,
96
+ ];
97
+ this.requiredSecrets = new Set(combinedRequired.map((name) => normalizeSecretName(name)));
98
+ }
99
+ setSecret(input) {
100
+ const db = this.requireDatabase();
101
+ this.ensureTables();
102
+ const name = normalizeSecretName(input.name);
103
+ const value = normalizeSecretValue(input.value);
104
+ const nowMs = this.now().getTime();
105
+ const nowIso = new Date(nowMs).toISOString();
106
+ const scope = input.scope ?? 'runtime';
107
+ const source = input.source ?? 'vault';
108
+ if (!isSecretScope(scope)) {
109
+ throw new Error(`Invalid secret scope '${scope}'.`);
110
+ }
111
+ if (!isSecretSource(source)) {
112
+ throw new Error(`Invalid secret source '${source}'.`);
113
+ }
114
+ const rotationWindowHours = normalizeHours(input.rotationWindowHours, DEFAULT_ROTATION_WINDOW_HOURS);
115
+ const warningWindowHours = normalizeHours(input.warningWindowHours, Math.min(DEFAULT_WARNING_WINDOW_HOURS, rotationWindowHours));
116
+ const expiresAt = deriveExpiryIso(nowMs, rotationWindowHours, input.expiresAt);
117
+ const encrypted = this.encrypt(value);
118
+ const tx = db.transaction(() => {
119
+ const previous = db
120
+ .prepare(`SELECT version, ciphertext, iv, auth_tag
121
+ FROM secret_vault_values
122
+ WHERE secret_name = ?`)
123
+ .get(name);
124
+ if (previous) {
125
+ db.prepare(`INSERT INTO secret_vault_versions (
126
+ id, secret_name, version, ciphertext, iv, auth_tag, rotated_at, reason
127
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), name, previous.version, previous.ciphertext, previous.iv, previous.auth_tag, nowIso, 'set-overwrite');
128
+ }
129
+ db.prepare(`INSERT INTO secret_vault_registry (
130
+ name,
131
+ scope,
132
+ source,
133
+ required,
134
+ rotation_window_hours,
135
+ warning_window_hours,
136
+ status,
137
+ last_rotated_at,
138
+ expires_at,
139
+ revoked_at,
140
+ created_at,
141
+ updated_at
142
+ ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, NULL, ?, ?)
143
+ ON CONFLICT(name) DO UPDATE SET
144
+ scope = excluded.scope,
145
+ source = excluded.source,
146
+ required = excluded.required,
147
+ rotation_window_hours = excluded.rotation_window_hours,
148
+ warning_window_hours = excluded.warning_window_hours,
149
+ status = 'active',
150
+ last_rotated_at = excluded.last_rotated_at,
151
+ expires_at = excluded.expires_at,
152
+ revoked_at = NULL,
153
+ updated_at = excluded.updated_at`).run(name, scope, source, input.required ? 1 : 0, rotationWindowHours, warningWindowHours, nowIso, expiresAt, nowIso, nowIso);
154
+ db.prepare(`INSERT INTO secret_vault_values (
155
+ secret_name,
156
+ version,
157
+ ciphertext,
158
+ iv,
159
+ auth_tag,
160
+ updated_at
161
+ ) VALUES (?, ?, ?, ?, ?, ?)
162
+ ON CONFLICT(secret_name) DO UPDATE SET
163
+ version = excluded.version,
164
+ ciphertext = excluded.ciphertext,
165
+ iv = excluded.iv,
166
+ auth_tag = excluded.auth_tag,
167
+ updated_at = excluded.updated_at`).run(name, previous ? previous.version + 1 : 1, encrypted.ciphertext, encrypted.iv, encrypted.authTag, nowIso);
168
+ this.insertAuditEvent(db, name, 'set', 'success', 'Secret value updated.');
169
+ return this.getMetadataByNameInternal(name, db);
170
+ });
171
+ const metadata = tx();
172
+ if (!metadata) {
173
+ throw new Error(`Failed to persist secret metadata for '${name}'.`);
174
+ }
175
+ this.runtimeSecretValues.set(name, value);
176
+ return metadata;
177
+ }
178
+ rotateSecret(input) {
179
+ const db = this.requireDatabase();
180
+ this.ensureTables();
181
+ const name = normalizeSecretName(input.name);
182
+ const nextValue = normalizeSecretValue(input.nextValue);
183
+ const nowMs = this.now().getTime();
184
+ const nowIso = new Date(nowMs).toISOString();
185
+ try {
186
+ const tx = db.transaction(() => {
187
+ const existingRow = db
188
+ .prepare(`SELECT
189
+ r.name,
190
+ r.scope,
191
+ r.source,
192
+ r.required,
193
+ r.rotation_window_hours,
194
+ r.warning_window_hours,
195
+ r.status,
196
+ r.last_rotated_at,
197
+ r.expires_at,
198
+ r.revoked_at,
199
+ r.created_at,
200
+ r.updated_at,
201
+ COALESCE(v.version, 0) AS version,
202
+ v.ciphertext,
203
+ v.iv,
204
+ v.auth_tag
205
+ FROM secret_vault_registry r
206
+ LEFT JOIN secret_vault_values v ON v.secret_name = r.name
207
+ WHERE r.name = ?`)
208
+ .get(name);
209
+ if (!existingRow) {
210
+ throw new Error(`Secret '${name}' is not registered.`);
211
+ }
212
+ if (existingRow.status === 'revoked') {
213
+ throw new Error(`Secret '${name}' is revoked and must be reset before rotation.`);
214
+ }
215
+ if (!existingRow.ciphertext || !existingRow.iv || !existingRow.auth_tag) {
216
+ throw new Error(`Secret '${name}' has no stored value to rotate.`);
217
+ }
218
+ const nextRotationWindow = normalizeHours(input.rotationWindowHours, existingRow.rotation_window_hours || DEFAULT_ROTATION_WINDOW_HOURS);
219
+ const nextWarningWindow = normalizeHours(input.warningWindowHours, existingRow.warning_window_hours || DEFAULT_WARNING_WINDOW_HOURS);
220
+ const expiresAt = deriveExpiryIso(nowMs, nextRotationWindow, input.expiresAt);
221
+ const encrypted = this.encrypt(nextValue);
222
+ db.prepare(`INSERT INTO secret_vault_versions (
223
+ id, secret_name, version, ciphertext, iv, auth_tag, rotated_at, reason
224
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(randomUUID(), name, existingRow.version, existingRow.ciphertext, existingRow.iv, existingRow.auth_tag, nowIso, input.reason ?? 'manual-rotation');
225
+ db.prepare(`UPDATE secret_vault_values
226
+ SET version = ?,
227
+ ciphertext = ?,
228
+ iv = ?,
229
+ auth_tag = ?,
230
+ updated_at = ?
231
+ WHERE secret_name = ?`).run(existingRow.version + 1, encrypted.ciphertext, encrypted.iv, encrypted.authTag, nowIso, name);
232
+ db.prepare(`UPDATE secret_vault_registry
233
+ SET status = 'active',
234
+ rotation_window_hours = ?,
235
+ warning_window_hours = ?,
236
+ last_rotated_at = ?,
237
+ expires_at = ?,
238
+ revoked_at = NULL,
239
+ updated_at = ?
240
+ WHERE name = ?`).run(nextRotationWindow, nextWarningWindow, nowIso, expiresAt, nowIso, name);
241
+ this.insertAuditEvent(db, name, 'rotate', 'success', `Secret rotated successfully. reason=${input.reason ?? 'manual-rotation'}`);
242
+ return this.getMetadataByNameInternal(name, db);
243
+ });
244
+ const metadata = tx();
245
+ if (!metadata) {
246
+ throw new Error(`Rotation completed but metadata for '${name}' was not found.`);
247
+ }
248
+ this.runtimeSecretValues.set(name, nextValue);
249
+ return metadata;
250
+ }
251
+ catch (error) {
252
+ this.insertAuditEventSafe(name, 'rotate', 'failure', `Rotation rolled back. ${error instanceof Error ? error.message : String(error)}`);
253
+ throw new Error(`Secret rotation failed for '${name}'. Previous value remains active.`);
254
+ }
255
+ }
256
+ revokeSecret(input) {
257
+ const db = this.requireDatabase();
258
+ this.ensureTables();
259
+ const name = normalizeSecretName(input.name);
260
+ const nowIso = this.now().toISOString();
261
+ const tx = db.transaction(() => {
262
+ const existing = this.getMetadataByNameInternal(name, db);
263
+ if (!existing) {
264
+ throw new Error(`Secret '${name}' is not registered.`);
265
+ }
266
+ db.prepare(`UPDATE secret_vault_registry
267
+ SET status = 'revoked',
268
+ revoked_at = ?,
269
+ updated_at = ?
270
+ WHERE name = ?`).run(nowIso, nowIso, name);
271
+ this.insertAuditEvent(db, name, 'revoke', 'success', `Secret revoked. reason=${input.reason ?? 'manual-revoke'}`);
272
+ return this.getMetadataByNameInternal(name, db);
273
+ });
274
+ const metadata = tx();
275
+ if (!metadata) {
276
+ throw new Error(`Failed to revoke secret '${name}'.`);
277
+ }
278
+ this.runtimeSecretValues.delete(name);
279
+ return metadata;
280
+ }
281
+ listSecrets() {
282
+ if (!this.db) {
283
+ return [];
284
+ }
285
+ this.ensureTables();
286
+ const rows = this.db
287
+ .prepare(`SELECT
288
+ r.name,
289
+ r.scope,
290
+ r.source,
291
+ r.required,
292
+ r.rotation_window_hours,
293
+ r.warning_window_hours,
294
+ r.status,
295
+ r.last_rotated_at,
296
+ r.expires_at,
297
+ r.revoked_at,
298
+ r.created_at,
299
+ r.updated_at,
300
+ COALESCE(v.version, 0) AS version
301
+ FROM secret_vault_registry r
302
+ LEFT JOIN secret_vault_values v ON v.secret_name = r.name
303
+ ORDER BY r.name ASC`)
304
+ .all();
305
+ return rows.map((row) => this.toMetadata(row));
306
+ }
307
+ getMetadataByName(name) {
308
+ if (!this.db) {
309
+ return null;
310
+ }
311
+ this.ensureTables();
312
+ return this.getMetadataByNameInternal(normalizeSecretName(name), this.db);
313
+ }
314
+ /**
315
+ * Centralized runtime secret read path.
316
+ * Vault values take precedence over environment fallbacks.
317
+ */
318
+ readSecret(name) {
319
+ const normalized = normalizeSecretName(name);
320
+ const runtimeValue = this.runtimeSecretValues.get(normalized);
321
+ if (runtimeValue) {
322
+ return runtimeValue;
323
+ }
324
+ const vaultValue = this.readVaultSecretValue(normalized);
325
+ if (vaultValue) {
326
+ return vaultValue;
327
+ }
328
+ const envValue = getConfigValue(normalized, true);
329
+ if (typeof envValue === 'string' && envValue.length > 0) {
330
+ return envValue;
331
+ }
332
+ return null;
333
+ }
334
+ getSecretHealth(requiredSecrets = []) {
335
+ const metadata = this.listSecrets();
336
+ const metadataByName = new Map(metadata.map((item) => [item.name, item]));
337
+ const nowMs = this.now().getTime();
338
+ const requiredNames = new Set(this.requiredSecrets);
339
+ for (const item of requiredSecrets) {
340
+ requiredNames.add(normalizeSecretName(item));
341
+ }
342
+ for (const item of metadata) {
343
+ if (item.required) {
344
+ requiredNames.add(item.name);
345
+ }
346
+ }
347
+ const missingRequired = [];
348
+ const expired = [];
349
+ for (const name of requiredNames) {
350
+ const value = this.readSecret(name);
351
+ if (!value) {
352
+ missingRequired.push(name);
353
+ }
354
+ const row = metadataByName.get(name);
355
+ if (row?.status === 'expired') {
356
+ expired.push(name);
357
+ }
358
+ }
359
+ const warnings = [];
360
+ for (const row of metadata) {
361
+ if (row.status !== 'active' || !row.expiresAt) {
362
+ continue;
363
+ }
364
+ const expiresAtMs = safeParseDate(row.expiresAt);
365
+ if (expiresAtMs === null) {
366
+ continue;
367
+ }
368
+ const warningStartMs = expiresAtMs - row.warningWindowHours * 60 * 60 * 1000;
369
+ if (nowMs >= warningStartMs && nowMs < expiresAtMs) {
370
+ warnings.push(`${row.name} is nearing expiry (${row.expiresAt}).`);
371
+ }
372
+ }
373
+ return {
374
+ missingRequired: [...new Set(missingRequired)].sort(),
375
+ expired: [...new Set(expired)].sort(),
376
+ warnings,
377
+ hasIssues: missingRequired.length > 0 || expired.length > 0,
378
+ };
379
+ }
380
+ assertStartupPreflight(requiredSecrets = []) {
381
+ const health = this.getSecretHealth(requiredSecrets);
382
+ if (health.hasIssues) {
383
+ const reasons = [];
384
+ if (health.missingRequired.length > 0) {
385
+ reasons.push(`missing: ${health.missingRequired.join(', ')}`);
386
+ }
387
+ if (health.expired.length > 0) {
388
+ reasons.push(`expired: ${health.expired.join(', ')}`);
389
+ }
390
+ this.insertAuditEventSafe('*', 'preflight', 'failure', reasons.join(' | '));
391
+ throw new Error(`Secret preflight failed (${reasons.join('; ')}).`);
392
+ }
393
+ this.insertAuditEventSafe('*', 'preflight', 'success', 'Startup preflight passed.');
394
+ return health;
395
+ }
396
+ getDiagnostics(requiredSecrets = []) {
397
+ const metadata = this.listSecrets();
398
+ const health = this.getSecretHealth(requiredSecrets);
399
+ const nowMs = this.now().getTime();
400
+ const dueForRotation = metadata
401
+ .filter((item) => {
402
+ if (item.status !== 'active' || !item.expiresAt) {
403
+ return false;
404
+ }
405
+ const expiresAtMs = safeParseDate(item.expiresAt);
406
+ if (expiresAtMs === null) {
407
+ return false;
408
+ }
409
+ const warningStart = expiresAtMs - item.warningWindowHours * 60 * 60 * 1000;
410
+ return nowMs >= warningStart && nowMs < expiresAtMs;
411
+ })
412
+ .map((item) => item.name);
413
+ return {
414
+ health,
415
+ total: metadata.length,
416
+ active: metadata.filter((item) => item.status === 'active').length,
417
+ revoked: metadata.filter((item) => item.status === 'revoked').length,
418
+ expired: metadata.filter((item) => item.status === 'expired').length,
419
+ dueForRotation,
420
+ };
421
+ }
422
+ redact(text) {
423
+ if (!text) {
424
+ return text;
425
+ }
426
+ const values = this.collectRedactionValues();
427
+ let redacted = text;
428
+ for (const candidate of values) {
429
+ redacted = redacted.split(candidate).join('[REDACTED]');
430
+ }
431
+ return redacted;
432
+ }
433
+ ensureTables() {
434
+ if (this.tablesReady || !this.db) {
435
+ return;
436
+ }
437
+ this.db.exec(`
438
+ CREATE TABLE IF NOT EXISTS secret_vault_registry (
439
+ name TEXT PRIMARY KEY,
440
+ scope TEXT NOT NULL,
441
+ source TEXT NOT NULL,
442
+ required INTEGER NOT NULL DEFAULT 0,
443
+ rotation_window_hours INTEGER NOT NULL,
444
+ warning_window_hours INTEGER NOT NULL,
445
+ status TEXT NOT NULL,
446
+ last_rotated_at TEXT,
447
+ expires_at TEXT,
448
+ revoked_at TEXT,
449
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
450
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
451
+ );
452
+
453
+ CREATE TABLE IF NOT EXISTS secret_vault_values (
454
+ secret_name TEXT PRIMARY KEY,
455
+ version INTEGER NOT NULL,
456
+ ciphertext TEXT NOT NULL,
457
+ iv TEXT NOT NULL,
458
+ auth_tag TEXT NOT NULL,
459
+ updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
460
+ FOREIGN KEY(secret_name) REFERENCES secret_vault_registry(name) ON DELETE CASCADE
461
+ );
462
+
463
+ CREATE TABLE IF NOT EXISTS secret_vault_versions (
464
+ id TEXT PRIMARY KEY,
465
+ secret_name TEXT NOT NULL,
466
+ version INTEGER NOT NULL,
467
+ ciphertext TEXT NOT NULL,
468
+ iv TEXT NOT NULL,
469
+ auth_tag TEXT NOT NULL,
470
+ rotated_at TEXT NOT NULL,
471
+ reason TEXT,
472
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
473
+ FOREIGN KEY(secret_name) REFERENCES secret_vault_registry(name) ON DELETE CASCADE
474
+ );
475
+
476
+ CREATE TABLE IF NOT EXISTS secret_vault_audit_events (
477
+ id TEXT PRIMARY KEY,
478
+ secret_name TEXT NOT NULL,
479
+ action TEXT NOT NULL,
480
+ status TEXT NOT NULL,
481
+ detail TEXT NOT NULL,
482
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
483
+ );
484
+ `);
485
+ this.tablesReady = true;
486
+ }
487
+ requireDatabase() {
488
+ if (!this.db) {
489
+ throw new Error('Secret vault storage is unavailable in this runtime.');
490
+ }
491
+ return this.db;
492
+ }
493
+ resolveMasterKey() {
494
+ if (this.cachedMasterKey) {
495
+ return this.cachedMasterKey;
496
+ }
497
+ const source = this.explicitMasterKey ??
498
+ getConfigValue('SECRET_VAULT_MASTER_KEY', true) ??
499
+ getConfigValue('API_SECRET', true);
500
+ if (!source) {
501
+ throw new Error('Missing SECRET_VAULT_MASTER_KEY (or API_SECRET fallback) for secret encryption.');
502
+ }
503
+ this.cachedMasterKey = deriveMasterKey(source);
504
+ return this.cachedMasterKey;
505
+ }
506
+ encrypt(value) {
507
+ const iv = randomBytes(12);
508
+ const cipher = createCipheriv('aes-256-gcm', this.resolveMasterKey(), iv);
509
+ const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]);
510
+ const authTag = cipher.getAuthTag();
511
+ return {
512
+ ciphertext: encrypted.toString('base64'),
513
+ iv: iv.toString('base64'),
514
+ authTag: authTag.toString('base64'),
515
+ };
516
+ }
517
+ decrypt(payload) {
518
+ const decipher = createDecipheriv('aes-256-gcm', this.resolveMasterKey(), Buffer.from(payload.iv, 'base64'));
519
+ decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'));
520
+ const plain = Buffer.concat([
521
+ decipher.update(Buffer.from(payload.ciphertext, 'base64')),
522
+ decipher.final(),
523
+ ]);
524
+ return plain.toString('utf8');
525
+ }
526
+ readVaultSecretValue(name) {
527
+ if (!this.db) {
528
+ return null;
529
+ }
530
+ this.ensureTables();
531
+ const row = this.db
532
+ .prepare(`SELECT
533
+ r.name,
534
+ r.scope,
535
+ r.source,
536
+ r.required,
537
+ r.rotation_window_hours,
538
+ r.warning_window_hours,
539
+ r.status,
540
+ r.last_rotated_at,
541
+ r.expires_at,
542
+ r.revoked_at,
543
+ r.created_at,
544
+ r.updated_at,
545
+ COALESCE(v.version, 0) AS version,
546
+ v.ciphertext,
547
+ v.iv,
548
+ v.auth_tag
549
+ FROM secret_vault_registry r
550
+ LEFT JOIN secret_vault_values v ON v.secret_name = r.name
551
+ WHERE r.name = ?`)
552
+ .get(name);
553
+ if (!row || !row.ciphertext || !row.iv || !row.auth_tag) {
554
+ return null;
555
+ }
556
+ const metadata = this.toMetadata(row);
557
+ if (metadata.status !== 'active') {
558
+ return null;
559
+ }
560
+ try {
561
+ return this.decrypt({
562
+ ciphertext: row.ciphertext,
563
+ iv: row.iv,
564
+ authTag: row.auth_tag,
565
+ });
566
+ }
567
+ catch (error) {
568
+ const message = error instanceof Error ? error.message : String(error);
569
+ console.warn(`[SecretVault] Failed to decrypt secret '${name}': ${message}`);
570
+ return null;
571
+ }
572
+ }
573
+ getMetadataByNameInternal(name, db) {
574
+ const row = db
575
+ .prepare(`SELECT
576
+ r.name,
577
+ r.scope,
578
+ r.source,
579
+ r.required,
580
+ r.rotation_window_hours,
581
+ r.warning_window_hours,
582
+ r.status,
583
+ r.last_rotated_at,
584
+ r.expires_at,
585
+ r.revoked_at,
586
+ r.created_at,
587
+ r.updated_at,
588
+ COALESCE(v.version, 0) AS version
589
+ FROM secret_vault_registry r
590
+ LEFT JOIN secret_vault_values v ON v.secret_name = r.name
591
+ WHERE r.name = ?`)
592
+ .get(name);
593
+ if (!row) {
594
+ return null;
595
+ }
596
+ return this.toMetadata(row);
597
+ }
598
+ resolveStatus(row) {
599
+ if (row.status === 'revoked') {
600
+ return 'revoked';
601
+ }
602
+ const expiresAtMs = safeParseDate(row.expires_at);
603
+ if (expiresAtMs !== null && expiresAtMs <= this.now().getTime()) {
604
+ return 'expired';
605
+ }
606
+ return 'active';
607
+ }
608
+ toMetadata(row) {
609
+ const scope = isSecretScope(row.scope) ? row.scope : 'runtime';
610
+ const source = isSecretSource(row.source) ? row.source : 'vault';
611
+ return {
612
+ name: row.name,
613
+ scope,
614
+ source,
615
+ required: row.required === 1,
616
+ rotationWindowHours: row.rotation_window_hours,
617
+ warningWindowHours: row.warning_window_hours,
618
+ lastRotatedAt: row.last_rotated_at,
619
+ expiresAt: row.expires_at,
620
+ revokedAt: row.revoked_at,
621
+ status: this.resolveStatus(row),
622
+ version: row.version ?? 0,
623
+ createdAt: row.created_at,
624
+ updatedAt: row.updated_at,
625
+ };
626
+ }
627
+ collectRedactionValues() {
628
+ const values = new Set();
629
+ for (const value of this.runtimeSecretValues.values()) {
630
+ if (value.length >= MIN_REDACTION_VALUE_LENGTH) {
631
+ values.add(value);
632
+ }
633
+ }
634
+ const twinClawConfig = loadTwinBotJson();
635
+ for (const [name, value] of Object.entries(twinClawConfig)) {
636
+ if (!value) {
637
+ continue;
638
+ }
639
+ if (!SENSITIVE_ENV_NAME_PATTERN.test(name)) {
640
+ continue;
641
+ }
642
+ if (value.length >= MIN_REDACTION_VALUE_LENGTH) {
643
+ values.add(value);
644
+ }
645
+ }
646
+ for (const [name, value] of Object.entries(process.env)) {
647
+ if (!value) {
648
+ continue;
649
+ }
650
+ if (!SENSITIVE_ENV_NAME_PATTERN.test(name)) {
651
+ continue;
652
+ }
653
+ if (value.length >= MIN_REDACTION_VALUE_LENGTH) {
654
+ values.add(value);
655
+ }
656
+ }
657
+ if (this.db) {
658
+ this.ensureTables();
659
+ const rows = this.db
660
+ .prepare(`SELECT name FROM secret_vault_registry WHERE status = 'active'`)
661
+ .all();
662
+ for (const row of rows) {
663
+ const value = this.readVaultSecretValue(row.name);
664
+ if (value && value.length >= MIN_REDACTION_VALUE_LENGTH) {
665
+ values.add(value);
666
+ }
667
+ }
668
+ }
669
+ return [...values].sort((a, b) => b.length - a.length);
670
+ }
671
+ insertAuditEvent(db, secretName, action, status, detail) {
672
+ db.prepare(`INSERT INTO secret_vault_audit_events (
673
+ id,
674
+ secret_name,
675
+ action,
676
+ status,
677
+ detail,
678
+ created_at
679
+ ) VALUES (?, ?, ?, ?, ?, ?)`).run(randomUUID(), secretName, action, status, detail, this.now().toISOString());
680
+ }
681
+ insertAuditEventSafe(secretName, action, status, detail) {
682
+ if (!this.db) {
683
+ return;
684
+ }
685
+ try {
686
+ this.ensureTables();
687
+ this.insertAuditEvent(this.db, secretName, action, status, detail);
688
+ }
689
+ catch (error) {
690
+ const message = error instanceof Error ? error.message : String(error);
691
+ console.warn(`[SecretVault] Failed to persist audit event (${action}): ${message}`);
692
+ }
693
+ }
694
+ }
695
+ let defaultSecretVaultService = null;
696
+ export function getSecretVaultService() {
697
+ if (!defaultSecretVaultService) {
698
+ defaultSecretVaultService = new SecretVaultService();
699
+ }
700
+ return defaultSecretVaultService;
701
+ }
702
+ export function resetSecretVaultServiceForTests() {
703
+ defaultSecretVaultService = null;
704
+ }