impulso 0.21.1 → 0.23.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/README.md CHANGED
@@ -206,7 +206,7 @@ tokensave init # build the tokensave index (once per repo)
206
206
  ### Maintenance
207
207
 
208
208
  ```bash
209
- ./doctor.sh # health check: binaries, symlinks, configs, MCP
209
+ impulso-hub doctor # health check: binaries, symlinks, configs, MCP
210
210
  ./harnesses/claude/uninstall.sh # reverse the claude install (targeted, safe)
211
211
  ./harnesses/opencode/uninstall.sh # remove the opencode profile
212
212
  ```
@@ -300,7 +300,7 @@ in the `opencode` block only.
300
300
  opencode ships logical aliases, not concrete model IDs. Every agent in the
301
301
  `opencode` block resolves through an `llm/<key>` alias that must be mapped in
302
302
  your opencode provider config — Impulso provides no fallback. An undefined alias
303
- fails at dispatch. `doctor.sh` warns when aliases are active (spec §7).
303
+ fails at dispatch. `impulso-hub doctor` warns when aliases are active (spec §7).
304
304
 
305
305
  | Agent | Shipped alias |
306
306
  | -------------------- | -------------------------- |
@@ -390,7 +390,6 @@ impulso/
390
390
  ├── .mcp.json # tokensave MCP (plugin route + project-local)
391
391
  ├── run_here.sh # boots opencode against this repo
392
392
  ├── run_here_claude.sh # boots Claude Code against this repo
393
- ├── doctor.sh # health checks: binaries, symlinks, configs, MCP
394
393
  ├── opencode/ # self-boot config: opencode.jsonc + AGENTS.md -> shared/ + symlinks
395
394
  ├── claude/ # self-boot config: settings.json + CLAUDE.md + AGENTS.md -> shared/ + symlinks
396
395
  ├── skills/ # harness-agnostic skill files (SKILL.md per dir)
@@ -33,7 +33,7 @@ content, never `rm -rf`'s `~/.claude`:
33
33
  Health check for the whole harness (binaries, symlinks, configs, MCP):
34
34
 
35
35
  ```bash
36
- ./doctor.sh
36
+ impulso-hub doctor
37
37
  ```
38
38
 
39
39
  Launch with the isolated profile:
@@ -0,0 +1,216 @@
1
+ // impulso passive telematics adapter — Claude Code
2
+ //
3
+ // Reads Claude hook JSON from stdin, maps it to telematics events, and emits
4
+ // to the local NDJSON log. Never writes stdout, never blocks, never throws.
5
+ // Telemetry is disabled by default — requires { telematics: { enabled: true } }
6
+ // in <IMPULSO_HOME>/config.json (IMPULSO_HOME or ~/.impulso).
7
+ // See shared/telematics.cjs for config format.
8
+ //
9
+ // Hook event type passed via IMPULSO_HOOK_EVENT env var. See
10
+ // claude/settings.json and hooks/hooks.json for dual-registration entries.
11
+
12
+ 'use strict';
13
+
14
+ const os = require('node:os');
15
+ const path = require('node:path');
16
+ const fs = require('node:fs');
17
+ const crypto = require('node:crypto');
18
+ const { createTelematics } = require(path.join(__dirname, '../../../shared/telematics.cjs'));
19
+
20
+ const hookEvent = process.env.IMPULSO_HOOK_EVENT || 'unknown';
21
+
22
+ const chunks = [];
23
+ let done = false;
24
+ process.stdin.setEncoding('utf8');
25
+ process.stdin.on('readable', function () {
26
+ let c;
27
+ while ((c = process.stdin.read()) !== null) chunks.push(c);
28
+ });
29
+
30
+ function finish(raw) {
31
+ if (done) return;
32
+ done = true;
33
+ clearTimeout(timer);
34
+
35
+ let input;
36
+ try {
37
+ input = JSON.parse(raw || '{}');
38
+ } catch (_) {
39
+ process.exit(0);
40
+ return;
41
+ }
42
+
43
+ // Resolve impulso home: IMPULSO_HOME env, else ~/.impulso
44
+ let impulsoHome = process.env.IMPULSO_HOME;
45
+ if (!impulsoHome || impulsoHome.length === 0) {
46
+ impulsoHome = path.join(os.homedir(), '.impulso');
47
+ }
48
+
49
+ let t;
50
+ try {
51
+ t = createTelematics({
52
+ harness: 'claude',
53
+ sessionId: input.session_id || null,
54
+ processId: process.pid,
55
+ cwd: input.cwd || process.cwd(),
56
+ home: impulsoHome,
57
+ now: function () {
58
+ return new Date().toISOString();
59
+ },
60
+ uuid: function () {
61
+ return crypto.randomUUID();
62
+ },
63
+ fs: fs,
64
+ });
65
+ } catch (_) {
66
+ process.exit(0);
67
+ return;
68
+ }
69
+
70
+ if (!t.enabled) {
71
+ process.exit(0);
72
+ return;
73
+ }
74
+
75
+ // Emit session.started once per native session across separate hook
76
+ // processes. Uses a lock file (<IMPULSO_HOME>/telematics-locks/<sid>.started)
77
+ // with atomic O_CREAT|O_EXCL to coordinate. If another process already
78
+ // locked this session, skip. If lock creation fails for any reason, skip
79
+ // (best-effort, passive — session.started is supplementary telemetry).
80
+ let lockFd = null;
81
+ try {
82
+ const locksDir = path.join(impulsoHome, 'telematics-locks');
83
+ const lockFile = path.join(locksDir, t.sessionId + '.started');
84
+ fs.mkdirSync(locksDir, { recursive: true, mode: 0o700 });
85
+ try {
86
+ fs.chmodSync(locksDir, 0o700);
87
+ } catch (_) {
88
+ /* best effort */
89
+ }
90
+ // wx = write + exclusive — fails with EEXIST if lock already held
91
+ lockFd = fs.openSync(lockFile, 'wx', 0o600);
92
+ fs.writeSync(lockFd, String(process.pid));
93
+ // Lock acquired — emit session.started
94
+ try {
95
+ t.emit('session.started', {
96
+ nativeSessionId: input.session_id || null,
97
+ sessionIdSource: input.session_id ? 'native' : 'generated',
98
+ });
99
+ } catch (_) {
100
+ /* silent */
101
+ }
102
+ } catch (_) {
103
+ // EEXIST or any fs failure → lock not acquired, skip session.started
104
+ } finally {
105
+ if (lockFd !== null) {
106
+ try {
107
+ fs.closeSync(lockFd);
108
+ } catch (_) {
109
+ /* silent */
110
+ }
111
+ }
112
+ }
113
+
114
+ try {
115
+ const event = mapEvent(hookEvent, input, t);
116
+ if (event) {
117
+ t.emit(event.eventType, event.data);
118
+ }
119
+ } catch (_) {
120
+ /* silent */
121
+ }
122
+
123
+ try {
124
+ t.close();
125
+ } catch (_) {
126
+ /* silent */
127
+ }
128
+
129
+ process.exit(0);
130
+ }
131
+
132
+ process.stdin.on('end', function () {
133
+ finish(chunks.join(''));
134
+ });
135
+ const timer = setTimeout(function () {
136
+ finish(chunks.join(''));
137
+ }, 1000);
138
+
139
+ // ----------------------------------------------------------------
140
+ // event mapping — direct host-payload translation per spec
141
+ // ----------------------------------------------------------------
142
+ function mapEvent(hook, input) {
143
+ switch (hook) {
144
+ case 'UserPromptSubmit':
145
+ return {
146
+ eventType: 'message.submitted',
147
+ data: {
148
+ message: typeof input.prompt === 'string' ? input.prompt : '',
149
+ messageId: input.session_id || undefined,
150
+ },
151
+ };
152
+ case 'PreToolUse':
153
+ return {
154
+ eventType: 'tool.requested',
155
+ data: {
156
+ toolName: input.tool_name || '',
157
+ toolUseId: input.tool_use_id || undefined,
158
+ input: input.tool_input || {},
159
+ },
160
+ };
161
+ case 'PostToolUse':
162
+ return {
163
+ eventType: 'tool.completed',
164
+ data: {
165
+ toolName: input.tool_name || '',
166
+ toolUseId: input.tool_use_id || undefined,
167
+ output: input.tool_response || {},
168
+ status: getToolStatus(input.tool_response),
169
+ },
170
+ };
171
+ case 'SubagentStart':
172
+ return {
173
+ eventType: 'lifecycle',
174
+ data: {
175
+ name: 'subagent',
176
+ phase: 'started',
177
+ details: { agent_type: input.agent_type || null },
178
+ },
179
+ };
180
+ case 'SubagentStop':
181
+ // Best-effort session.ended from SubagentStop
182
+ return {
183
+ eventType: 'session.ended',
184
+ data: {
185
+ reason: 'subagent_stopped',
186
+ },
187
+ };
188
+ case 'Notification':
189
+ // Error/notification events from host
190
+ return {
191
+ eventType: 'error',
192
+ data: {
193
+ operation: input.operation || undefined,
194
+ name: input.name || undefined,
195
+ message: input.message || undefined,
196
+ stack: input.stack || undefined,
197
+ },
198
+ };
199
+ default:
200
+ return null;
201
+ }
202
+ }
203
+
204
+ function getToolStatus(response) {
205
+ if (!response) return 'unknown';
206
+ if (typeof response.exitCode === 'number') {
207
+ return response.exitCode === 0 ? 'success' : 'error';
208
+ }
209
+ if (typeof response.exit_code === 'number') {
210
+ return response.exit_code === 0 ? 'success' : 'error';
211
+ }
212
+ return 'completed';
213
+ }
214
+
215
+ // export for testing
216
+ module.exports = { mapEvent, getToolStatus };
@@ -17,6 +17,7 @@ import { registerCommands } from './commands-loader.js';
17
17
  import { ImpulsoDirectSpeechPlugin } from './directspeech/plugin.js';
18
18
  import { ImpulsoOrchestrationPlugin } from './orchestration/index.js';
19
19
  import { ImpulsoSessionNamePlugin } from './session-name/plugin.js';
20
+ import { ImpulsoTelematicsPlugin } from './telematics/plugin.js';
20
21
  import { installHub } from '../../scripts/install-hub.js';
21
22
 
22
23
  const here = path.dirname(fileURLToPath(import.meta.url));
@@ -56,6 +57,9 @@ export const ImpulsoPlugin = async (ctx) => {
56
57
  // Session-name plugin (X-Session-Name header for proxy cache locality)
57
58
  sub.push(await ImpulsoSessionNamePlugin(ctx));
58
59
 
60
+ // Telematics plugin — passive observer, must be last to observe final permission state
61
+ sub.push(await ImpulsoTelematicsPlugin(ctx));
62
+
59
63
  const hooks = composeHooks(sub);
60
64
 
61
65
  // Wrap the composed config hook with our own skills/commands registration.
@@ -0,0 +1,175 @@
1
+ // impulso — opencode passive telematics observer plugin
2
+ //
3
+ // Observes runtime hooks and emits NDJSON telemetry events via the shared
4
+ // writer. Passive only: never calls a model, never injects chat/system content,
5
+ // never modifies request/response or hook output, never invokes impulso-hub,
6
+ // never spawns tools. Zero model-token impact.
7
+ //
8
+ // Hooks:
9
+ // chat.message → message.submitted / message.received (by role)
10
+ // tool.execute.before → tool.requested
11
+ // tool.execute.after → tool.completed (with paired durationMs)
12
+ // permission.ask → permission.requested + permission.resolved
13
+ // event → lifecycle
14
+ //
15
+ // Initialize once for plugin lifetime; config read exactly once.
16
+
17
+ import os from 'node:os';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require_ = createRequire(import.meta.url);
21
+
22
+ function defaultCreateTelematics(ctx) {
23
+ const { createTelematics } = require_('../../../shared/telematics.cjs');
24
+ const fs = require_('node:fs');
25
+ const crypto = require_('node:crypto');
26
+
27
+ const home = process.env.IMPULSO_HOME || require_('node:path').join(os.homedir(), '.impulso');
28
+
29
+ // Record the raw sessionId before sanitization for sessionIdSource
30
+ const rawSessionId = ctx.sessionID || null;
31
+
32
+ return {
33
+ telematics: createTelematics({
34
+ harness: 'opencode',
35
+ sessionId: ctx.sessionID || null,
36
+ processId: process.pid,
37
+ cwd: ctx.directory || process.cwd(),
38
+ home,
39
+ now: () => new Date().toISOString(),
40
+ uuid: () => crypto.randomUUID(),
41
+ fs,
42
+ }),
43
+ rawSessionId,
44
+ };
45
+ }
46
+
47
+ export async function ImpulsoTelematicsPlugin(ctx, _createTelematics) {
48
+ const createTel = _createTelematics || defaultCreateTelematics;
49
+
50
+ // One-time construct: create the telematics instance
51
+ const { telematics: t, rawSessionId } = createTel(ctx);
52
+
53
+ // Duration trackers — keyed by callID for tools, session-permission pair for perms
54
+ const toolStarts = new Map(); // callID → timestamp ms
55
+ const permStarts = new Map(); // `${sessionID}::${permission}` → timestamp ms
56
+
57
+ // Emit session.started on init
58
+ if (t.enabled) {
59
+ t.emit('session.started', {
60
+ nativeSessionId: rawSessionId || null,
61
+ sessionIdSource: rawSessionId ? 'native' : 'generated',
62
+ });
63
+ }
64
+
65
+ const hooks = {};
66
+
67
+ // ---- event ---------------------------------------------------
68
+ hooks.event = async (input) => {
69
+ if (!t.enabled) return;
70
+ t.emit('lifecycle', {
71
+ name: (input && input.type) || 'unknown',
72
+ phase: input && input.phase,
73
+ details: input,
74
+ });
75
+ };
76
+
77
+ // ---- chat.message --------------------------------------------
78
+ hooks['chat.message'] = async (input, _output) => {
79
+ if (!t.enabled) return;
80
+ if (!input) return;
81
+
82
+ // Determine direction from input.role
83
+ // user → submitted, assistant/system → received
84
+ const role = input.role || 'unknown';
85
+ if (role === 'user') {
86
+ t.emit('message.submitted', {
87
+ message: input,
88
+ messageId: input.id,
89
+ });
90
+ } else {
91
+ t.emit('message.received', {
92
+ message: input,
93
+ messageId: input.id,
94
+ role: input.role,
95
+ });
96
+ }
97
+ };
98
+
99
+ // ---- tool.execute.before -------------------------------------
100
+ hooks['tool.execute.before'] = async (input, _output) => {
101
+ if (!t.enabled) return;
102
+ if (!input) return;
103
+
104
+ // Record start time for duration pairing
105
+ if (input.callID) {
106
+ toolStarts.set(input.callID, Date.now());
107
+ }
108
+
109
+ t.emit('tool.requested', {
110
+ toolName: input.tool,
111
+ toolUseId: input.callID,
112
+ input: input.args,
113
+ });
114
+ };
115
+
116
+ // ---- tool.execute.after --------------------------------------
117
+ hooks['tool.execute.after'] = async (input, output) => {
118
+ if (!t.enabled) return;
119
+ if (!input) return;
120
+
121
+ const start = input.callID ? toolStarts.get(input.callID) : undefined;
122
+ if (input.callID) {
123
+ toolStarts.delete(input.callID);
124
+ }
125
+
126
+ const durationMs = start != null ? Date.now() - start : undefined;
127
+
128
+ let status;
129
+ if (output && output.metadata && output.metadata.exitCode !== undefined) {
130
+ status = output.metadata.exitCode === 0 ? 'success' : 'error';
131
+ }
132
+
133
+ t.emit('tool.completed', {
134
+ toolName: input.tool,
135
+ toolUseId: input.callID,
136
+ output,
137
+ durationMs,
138
+ status,
139
+ });
140
+ };
141
+
142
+ // ---- permission.ask ------------------------------------------
143
+ hooks['permission.ask'] = async (input, output) => {
144
+ if (!t.enabled) return;
145
+ if (!input) return;
146
+
147
+ const start = Date.now();
148
+
149
+ // Record start so we can compute duration
150
+ const permKey = `${input.sessionID || ''}::${input.permission || ''}`;
151
+ permStarts.set(permKey, start);
152
+
153
+ t.emit('permission.requested', {
154
+ permission: input.permission,
155
+ request: input,
156
+ });
157
+
158
+ // Observe output.decision synchronously after our handler runs
159
+ // (telematics plugin is composed last, so output is in final state)
160
+ const decision = output && output.decision;
161
+
162
+ const durationMs = Date.now() - start;
163
+ permStarts.delete(permKey);
164
+
165
+ t.emit('permission.resolved', {
166
+ permission: input.permission,
167
+ decision,
168
+ durationMs,
169
+ });
170
+ };
171
+
172
+ return hooks;
173
+ }
174
+
175
+ export default ImpulsoTelematicsPlugin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "impulso",
3
- "version": "0.21.1",
3
+ "version": "0.23.0",
4
4
  "description": "Impulso — plugin bundle for opencode and Claude Code: Plannotator skills + Impulso-DirectSpeech (rethemed ex-Caveman). Harness-neutral repo; per-harness glue under harnesses/.",
5
5
  "type": "module",
6
6
  "main": "harnesses/opencode/plugin.js",
@@ -19,7 +19,6 @@
19
19
  "install:claude": "harnesses/claude/install.sh",
20
20
  "uninstall:opencode": "harnesses/opencode/uninstall.sh",
21
21
  "uninstall:claude": "harnesses/claude/uninstall.sh",
22
- "doctor": "./doctor.sh",
23
22
  "sync:commands": "node harnesses/opencode/sync-commands.js && prettier --write commands/",
24
23
  "sync:agents": "node scripts/sync-agents.cjs",
25
24
  "check:canaries": "node scripts/check-canaries.cjs",
@@ -64,7 +63,9 @@
64
63
  "shared/tokensave-freshness.cjs",
65
64
  "skills",
66
65
  "package.json",
67
- "README.md"
66
+ "README.md",
67
+ "harnesses/opencode/telematics/",
68
+ "shared/telematics.cjs"
68
69
  ],
69
70
  "dependencies": {
70
71
  "@opencode-ai/plugin": "^1.17.14"
@@ -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
+ };