impulso 0.22.0 → 0.24.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.
@@ -0,0 +1,444 @@
1
+ 'use strict';
2
+ // impulso — shared secure telematics writer
3
+ //
4
+ // NDJSON append-only event log. Config is loaded once from
5
+ // <home>/config.json. All emit failures are silent —
6
+ // telematics must never throw or disrupt the harness.
7
+ //
8
+ // Interfaces:
9
+ // loadTelematicsConfig(home, _fs?) -> { enabled, sensitiveKeys }
10
+ // redact(value, sensitiveKeys) -> redacted copy
11
+ // sanitizeId(raw) -> string
12
+ // createTelematics({ harness, sessionId, processId, cwd, home,
13
+ // now, uuid, fs })
14
+ // -> { enabled, sessionId, emit(eventType, payload), close() }
15
+
16
+ const path = require('node:path');
17
+ const crypto = require('node:crypto');
18
+
19
+ // ----------------------------------------------------------------
20
+ // BUILTIN_SENSITIVE_KEYS — always active, case-insensitive.
21
+ // Matches common credential / token / secret key names across
22
+ // providers. Supplemented by config telematics.sensitiveKeys.
23
+ // ----------------------------------------------------------------
24
+ const BUILTIN_SENSITIVE_KEYS = [
25
+ 'apiKey',
26
+ 'api_key',
27
+ 'authorization',
28
+ 'token',
29
+ 'accessToken',
30
+ 'refreshToken',
31
+ 'secret',
32
+ 'password',
33
+ 'passwd',
34
+ 'credential',
35
+ 'cookie',
36
+ 'set-cookie',
37
+ 'privateKey',
38
+ 'clientSecret',
39
+ ];
40
+
41
+ // ----------------------------------------------------------------
42
+ // Pattern-based redaction. Applied to string values during walk.
43
+ // ----------------------------------------------------------------
44
+ const PATTERN_REDACTIONS = [
45
+ // Bearer tokens: "Bearer eyJ..." -> "Bearer [REDACTED]"
46
+ { pattern: /\bBearer\s+([^\s"',;)}\]]+)/gi, replace: 'Bearer [REDACTED]' },
47
+ // OpenAI / similar sk- keys: sk-<40+ chars>
48
+ { pattern: /\bsk-[a-zA-Z0-9_-]{20,}\b/g, replace: '[REDACTED_SK_KEY]' },
49
+ // GitHub classic tokens: ghp_xxx, gho_xxx, ghu_xxx, ghs_xxx, ghr_xxx
50
+ { pattern: /\bgh[pousr]_[a-zA-Z0-9]{36,}\b/g, replace: '[REDACTED_GH_TOKEN]' },
51
+ // AWS access key IDs: AKIA... (20 uppercase alphanumeric)
52
+ { pattern: /\bAKIA[A-Z0-9]{16}\b/g, replace: '[REDACTED_AWS_KEY]' },
53
+ // PEM private key blocks (detect header line)
54
+ {
55
+ pattern:
56
+ /-----BEGIN (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |ENCRYPTED )?PRIVATE KEY-----/g,
57
+ replace: '[REDACTED_PEM]',
58
+ },
59
+ ];
60
+
61
+ // ----------------------------------------------------------------
62
+ // loadTelematicsConfig
63
+ //
64
+ // Reads <home>/config.json. Returns { enabled, sensitiveKeys }.
65
+ // Missing file, malformed JSON, absent telematics key,
66
+ // telematics.enabled !== true → disabled.
67
+ // telematics.sensitiveKeys: string array or absent.
68
+ // Unknown keys ignored. Never writes or repairs config.
69
+ // ----------------------------------------------------------------
70
+ function loadTelematicsConfig(homeDir, _fs, _path) {
71
+ const fs = _fs || require('node:fs');
72
+ const p = _path || path;
73
+ const configPath = p.join(homeDir, 'config.json');
74
+ try {
75
+ if (!fs.existsSync(configPath)) {
76
+ return { enabled: false, sensitiveKeys: [] };
77
+ }
78
+ const raw = fs.readFileSync(configPath, 'utf8');
79
+ const cfg = JSON.parse(raw);
80
+ if (!cfg || typeof cfg !== 'object') {
81
+ return { enabled: false, sensitiveKeys: [] };
82
+ }
83
+ const telematics = cfg.telematics;
84
+ if (!telematics || typeof telematics !== 'object') {
85
+ return { enabled: false, sensitiveKeys: [] };
86
+ }
87
+ if (telematics.enabled !== true) {
88
+ return { enabled: false, sensitiveKeys: [] };
89
+ }
90
+ // Parse sensitiveKeys: optional array of nonempty strings
91
+ let extra = [];
92
+ if (Array.isArray(telematics.sensitiveKeys)) {
93
+ extra = telematics.sensitiveKeys
94
+ .filter(function (k) {
95
+ return typeof k === 'string' && k.length > 0;
96
+ })
97
+ .map(function (k) {
98
+ return k.toLowerCase();
99
+ });
100
+ }
101
+ return { enabled: true, sensitiveKeys: extra };
102
+ } catch (_) {
103
+ return { enabled: false, sensitiveKeys: [] };
104
+ }
105
+ }
106
+
107
+ // ----------------------------------------------------------------
108
+ // sanitizeId
109
+ //
110
+ // Trims whitespace and strips everything except [A-Za-z0-9._-].
111
+ // Rejects `.`, `..`, and empty results to prevent path traversal
112
+ // when the ID is used as a directory name in path.join.
113
+ // Returns empty string when input is null/undefined or result is
114
+ // empty/rejected after filtering.
115
+ // ----------------------------------------------------------------
116
+ function sanitizeId(raw) {
117
+ if (raw == null || typeof raw !== 'string') return '';
118
+ const cleaned = raw.trim().replace(/[^A-Za-z0-9._-]/g, '');
119
+ // Reject dot, dot-dot, and empty — these cause path.join traversal
120
+ if (cleaned === '.' || cleaned === '..' || cleaned.length === 0) return '';
121
+ return cleaned;
122
+ }
123
+
124
+ // ----------------------------------------------------------------
125
+ // redact
126
+ //
127
+ // Deep-walks value without mutation.
128
+ // 1. Key name matching (case-insensitive) → "[REDACTED]", no descent.
129
+ // 2. String pattern scanning for secrets (Bearer, sk-, GitHub, AWS, PEM).
130
+ // 3. key=value pairs: if key matches sensitive set, redact value.
131
+ // 4. JSON/query strings: URL query-style &key=value redaction.
132
+ // 5. Cycles, unsupported values, getters/proxies →
133
+ // "[UNSERIALIZABLE]"; never throws.
134
+ //
135
+ // Returns a redacted deep copy.
136
+ // ----------------------------------------------------------------
137
+ function redact(value, sensitiveKeys) {
138
+ // Built-ins always apply. Configured keys are additive.
139
+ const extraLen = Array.isArray(sensitiveKeys) ? sensitiveKeys.length : 0;
140
+
141
+ const keySet = new Set();
142
+ for (let i = 0; i < BUILTIN_SENSITIVE_KEYS.length; i++) {
143
+ keySet.add(BUILTIN_SENSITIVE_KEYS[i].toLowerCase());
144
+ }
145
+ for (let j = 0; j < extraLen; j++) {
146
+ keySet.add(String(sensitiveKeys[j]).toLowerCase());
147
+ }
148
+
149
+ const seen = new WeakSet(); // cycle detection
150
+
151
+ function walk(v, depth) {
152
+ if (depth === undefined) depth = 0;
153
+ if (depth > 100) return '[UNSERIALIZABLE]'; // depth guard
154
+ if (v === null || v === undefined) return v;
155
+ if (typeof v !== 'object') {
156
+ if (typeof v === 'string') {
157
+ return redactString(v, keySet);
158
+ }
159
+ return v;
160
+ }
161
+
162
+ // Cycle / unsupported check
163
+ if (typeof v === 'object') {
164
+ try {
165
+ // Detect getters/proxies by attempting key enumeration
166
+ Object.keys(v);
167
+ } catch (_) {
168
+ return '[UNSERIALIZABLE]';
169
+ }
170
+ try {
171
+ if (seen.has(v)) return '[UNSERIALIZABLE]';
172
+ seen.add(v);
173
+ } catch (_) {
174
+ return '[UNSERIALIZABLE]';
175
+ }
176
+ }
177
+
178
+ if (Array.isArray(v)) {
179
+ const arrOut = [];
180
+ for (let ai = 0; ai < v.length; ai++) {
181
+ try {
182
+ arrOut.push(walk(v[ai], depth + 1));
183
+ } catch (_) {
184
+ arrOut.push('[UNSERIALIZABLE]');
185
+ }
186
+ }
187
+ return arrOut;
188
+ }
189
+
190
+ // Object
191
+ const out = {};
192
+ let keys;
193
+ try {
194
+ keys = Object.keys(v);
195
+ } catch (_) {
196
+ return '[UNSERIALIZABLE]';
197
+ }
198
+ for (let ki = 0; ki < keys.length; ki++) {
199
+ const k = keys[ki];
200
+ const klower = String(k).toLowerCase();
201
+ try {
202
+ if (keySet.has(klower)) {
203
+ out[k] = '[REDACTED]';
204
+ } else {
205
+ out[k] = walk(v[k], depth + 1);
206
+ }
207
+ } catch (_) {
208
+ out[k] = '[UNSERIALIZABLE]';
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ return walk(value);
215
+ }
216
+
217
+ // ----------------------------------------------------------------
218
+ // redactString — apply pattern redactions to string values
219
+ // ----------------------------------------------------------------
220
+ function redactString(str, keySet) {
221
+ let result = str;
222
+ for (let i = 0; i < PATTERN_REDACTIONS.length; i++) {
223
+ result = result.replace(PATTERN_REDACTIONS[i].pattern, PATTERN_REDACTIONS[i].replace);
224
+ }
225
+
226
+ // Redact key=value pairs where key matches sensitive set.
227
+ // Matches sequences like: key=value, key="value", key='value'
228
+ // in URLs, headers, JSON-embedded query strings.
229
+ const eqPattern = /([\w.-]+)\s*=\s*("[^"]*"|'[^']*'|[^\s&;,}'"\]]+)/gi;
230
+ result = result.replace(eqPattern, function (_match, key, _value) {
231
+ const lower = key.toLowerCase();
232
+ if (keySet.has(lower)) {
233
+ return key + '=[REDACTED]';
234
+ }
235
+ return _match;
236
+ });
237
+
238
+ return result;
239
+ }
240
+
241
+ // ----------------------------------------------------------------
242
+ // createTelematics
243
+ //
244
+ // Factory. Loads config exactly once during construction. Returns
245
+ // a telematics instance:
246
+ //
247
+ // .enabled boolean — false when config absent/disabled
248
+ // .sessionId string | null — sanitised session id (null when disabled)
249
+ // .emit(eventType, payload) writes a single NDJSON envelope; never throws
250
+ // .close() closes the underlying fd; never throws
251
+ //
252
+ // Envelope keys per spec:
253
+ // schemaVersion — integer 1
254
+ // eventId — UUID per event
255
+ // eventType — string
256
+ // timestamp — UTC ISO-8601
257
+ // monotonicMs — ms since construction
258
+ // harness — "claude" | "opencode"
259
+ // sessionId — sanitised session id
260
+ // processId — numeric PID
261
+ // cwd — current working directory
262
+ // payload — redacted event payload
263
+ // ----------------------------------------------------------------
264
+ function createTelematics(deps) {
265
+ const harness = deps.harness;
266
+ const sessionId = deps.sessionId;
267
+ const processId = deps.processId;
268
+ const cwd = deps.cwd;
269
+ const home = deps.home;
270
+ const now = deps.now;
271
+ const uuidFn = deps.uuid;
272
+ const fs = deps.fs;
273
+
274
+ // One-time config load
275
+ const cfg = loadTelematicsConfig(home, fs, path);
276
+
277
+ if (!cfg.enabled) {
278
+ return {
279
+ enabled: false,
280
+ sessionId: null,
281
+ emit: function () {
282
+ /* noop */
283
+ },
284
+ close: function () {
285
+ /* noop */
286
+ },
287
+ };
288
+ }
289
+
290
+ // Build effective sensitive key set
291
+ const sensitiveKeys = [];
292
+ for (let i = 0; i < BUILTIN_SENSITIVE_KEYS.length; i++) {
293
+ if (sensitiveKeys.indexOf(BUILTIN_SENSITIVE_KEYS[i].toLowerCase()) === -1) {
294
+ sensitiveKeys.push(BUILTIN_SENSITIVE_KEYS[i].toLowerCase());
295
+ }
296
+ }
297
+ if (Array.isArray(cfg.sensitiveKeys)) {
298
+ for (let j = 0; j < cfg.sensitiveKeys.length; j++) {
299
+ const ek = String(cfg.sensitiveKeys[j]).toLowerCase();
300
+ if (sensitiveKeys.indexOf(ek) === -1) {
301
+ sensitiveKeys.push(ek);
302
+ }
303
+ }
304
+ }
305
+
306
+ // Sanitize sessionId or generate one
307
+ let sid = sanitizeId(sessionId);
308
+ if (!sid) {
309
+ if (typeof uuidFn === 'function') {
310
+ sid = sanitizeId(String(uuidFn()));
311
+ }
312
+ if (!sid) {
313
+ sid = 'gen-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
314
+ sid = sanitizeId(sid);
315
+ }
316
+ }
317
+ if (!sid) {
318
+ sid = 'gen-' + Date.now();
319
+ }
320
+
321
+ // Construction timestamp for monotonicMs baseline
322
+ const startMs = Date.now();
323
+
324
+ // Log directory: <home>/logs/<sanitized-session-id>/
325
+ const logDir = path.join(home, 'logs', sid);
326
+ const logFile = path.join(logDir, 'events.ndjson');
327
+
328
+ let fd = null;
329
+ let ensured = false;
330
+
331
+ // ----------------------------------------------------------------
332
+ // ensureLogDir — create dir mode 0700, open file mode 0600
333
+ // Refuses symlinks, nonregular files, and paths that resolve
334
+ // outside <home>/logs/. Fails closed: if we can't secure it,
335
+ // telemetry stays disabled for the process (emit becomes noop
336
+ // after failed ensure).
337
+ // ----------------------------------------------------------------
338
+ function ensureLogDir() {
339
+ if (ensured) return fd !== null;
340
+ ensured = true;
341
+ try {
342
+ // Resolve logDir and verify it stays under <home>/logs/
343
+ const resolved = path.resolve(logDir);
344
+ const logsPrefix = path.resolve(home, 'logs') + path.sep;
345
+ if (!resolved.startsWith(logsPrefix)) {
346
+ return false; // path escaped the logs sandbox — fail closed
347
+ }
348
+
349
+ // Create log directory chain with 0700
350
+ fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
351
+ // Explicit chmod to counter umask
352
+ fs.chmodSync(logDir, 0o700);
353
+
354
+ // Check if log file exists and is regular + not symlink
355
+ let stat;
356
+ let exists = false;
357
+ try {
358
+ stat = fs.lstatSync(logFile);
359
+ exists = true;
360
+ } catch (_) {
361
+ /* file does not exist — ok */
362
+ }
363
+
364
+ if (exists) {
365
+ // Refuse nonregular targets or symlinks
366
+ if (!stat.isFile() || stat.isSymbolicLink()) {
367
+ return false; // fail closed
368
+ }
369
+ // Chmod existing file before use
370
+ fs.chmodSync(logFile, 0o600);
371
+ }
372
+
373
+ // Open for append
374
+ fd = fs.openSync(logFile, 'a', 0o600);
375
+ // Chmod again after open (best-effort)
376
+ try {
377
+ fs.fchmodSync(fd, 0o600);
378
+ } catch (_) {
379
+ /* best effort */
380
+ }
381
+ return true;
382
+ } catch (_) {
383
+ return false; // fail closed
384
+ }
385
+ }
386
+
387
+ function emit(eventType, payload) {
388
+ try {
389
+ if (!ensureLogDir()) return;
390
+ const monotonicMs = Date.now() - startMs;
391
+ let eventId;
392
+ try {
393
+ eventId = uuidFn ? String(uuidFn()) : crypto.randomUUID();
394
+ } catch (_) {
395
+ eventId = 'fallback-' + Date.now() + '-' + Math.random().toString(36).slice(2, 10);
396
+ }
397
+ const redactedPayload = redact(payload, sensitiveKeys);
398
+ const envelope = {
399
+ schemaVersion: 1,
400
+ eventId: eventId,
401
+ eventType: eventType,
402
+ timestamp: typeof now === 'function' ? now() : new Date().toISOString(),
403
+ monotonicMs: monotonicMs,
404
+ harness: harness,
405
+ sessionId: sid,
406
+ processId: processId,
407
+ cwd: cwd,
408
+ payload: redactedPayload,
409
+ };
410
+ const line = JSON.stringify(envelope) + '\n';
411
+ if (fd !== null) {
412
+ fs.writeSync(fd, line);
413
+ }
414
+ } catch (_) {
415
+ // Silent — telematics must never throw
416
+ }
417
+ }
418
+
419
+ function close() {
420
+ if (fd !== null) {
421
+ try {
422
+ fs.closeSync(fd);
423
+ } catch (_) {
424
+ /* silent */
425
+ }
426
+ fd = null;
427
+ }
428
+ }
429
+
430
+ return {
431
+ enabled: true,
432
+ sessionId: sid,
433
+ emit: emit,
434
+ close: close,
435
+ };
436
+ }
437
+
438
+ module.exports = {
439
+ createTelematics: createTelematics,
440
+ redact: redact,
441
+ loadTelematicsConfig: loadTelematicsConfig,
442
+ sanitizeId: sanitizeId,
443
+ BUILTIN_SENSITIVE_KEYS: BUILTIN_SENSITIVE_KEYS,
444
+ };