impulso 0.22.0 → 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/harnesses/claude/hooks/telematics.cjs +216 -0
- package/harnesses/opencode/bin/darwin-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/darwin-arm64/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-amd64/impulso-hub +0 -0
- package/harnesses/opencode/bin/linux-arm64/impulso-hub +0 -0
- package/harnesses/opencode/plugin.js +4 -0
- package/harnesses/opencode/telematics/plugin.js +175 -0
- package/package.json +4 -2
- package/shared/telematics.cjs +444 -0
|
@@ -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 };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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.
|
|
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",
|
|
@@ -63,7 +63,9 @@
|
|
|
63
63
|
"shared/tokensave-freshness.cjs",
|
|
64
64
|
"skills",
|
|
65
65
|
"package.json",
|
|
66
|
-
"README.md"
|
|
66
|
+
"README.md",
|
|
67
|
+
"harnesses/opencode/telematics/",
|
|
68
|
+
"shared/telematics.cjs"
|
|
67
69
|
],
|
|
68
70
|
"dependencies": {
|
|
69
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
|
+
};
|