@aixle/insights 0.2.0 → 0.2.2-staging

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 (50) hide show
  1. package/README.md +38 -9
  2. package/dist/auth/credentials.d.ts +7 -1
  3. package/dist/auth/credentials.js +71 -14
  4. package/dist/auth/exchange.d.ts +1 -1
  5. package/dist/auth/exchange.js +1 -1
  6. package/dist/auth/flow.d.ts +8 -1
  7. package/dist/auth/flow.js +27 -5
  8. package/dist/auth/keycloak.d.ts +1 -1
  9. package/dist/auth/keycloak.js +20 -1
  10. package/dist/cli.d.ts +5 -3
  11. package/dist/cli.js +69 -21
  12. package/dist/collect-cursor-payloads.d.ts +4 -3
  13. package/dist/collect-cursor-payloads.js +8 -5
  14. package/dist/cursor-checkpoints.d.ts +2 -2
  15. package/dist/cursor-payload-contract.d.ts +5 -5
  16. package/dist/cursor-payload-contract.js +6 -0
  17. package/dist/cursor-settings.d.ts +9 -4
  18. package/dist/cursor-settings.js +80 -10
  19. package/dist/health.d.ts +3 -1
  20. package/dist/health.js +13 -1
  21. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  22. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  23. package/dist/hooks/cursor-hooks-reader.d.ts +2 -0
  24. package/dist/hooks/cursor-hooks-reader.js +10 -3
  25. package/dist/install/cursor.d.ts +34 -0
  26. package/dist/install/cursor.js +193 -0
  27. package/dist/install/index.d.ts +6 -4
  28. package/dist/install/index.js +6 -1
  29. package/dist/lib/client.d.ts +7 -0
  30. package/dist/lib/client.js +17 -0
  31. package/dist/lib/config.js +7 -2
  32. package/dist/lib/project-resolver.d.ts +5 -4
  33. package/dist/lib/project-resolver.js +57 -11
  34. package/dist/lib/repo-path-safety.d.ts +35 -0
  35. package/dist/lib/repo-path-safety.js +102 -0
  36. package/dist/lib/spawn-arg-safety.d.ts +25 -0
  37. package/dist/lib/spawn-arg-safety.js +49 -0
  38. package/dist/lib/transport-security.d.ts +1 -0
  39. package/dist/lib/transport-security.js +1 -1
  40. package/dist/readers/claude.d.ts +54 -6
  41. package/dist/readers/claude.js +154 -2
  42. package/dist/readers/cursor.d.ts +10 -7
  43. package/dist/readers/cursor.js +113 -17
  44. package/dist/risk-scanner.js +7 -0
  45. package/dist/server.d.ts +20 -3
  46. package/dist/server.js +101 -67
  47. package/dist/state.js +7 -2
  48. package/dist/sync.d.ts +13 -2
  49. package/dist/sync.js +86 -58
  50. package/package.json +6 -2
package/dist/sync.js CHANGED
@@ -11,6 +11,7 @@ import { postEvent, postEvents } from "./client.js";
11
11
  import { getCostWarning } from "./pricing.js";
12
12
  import { acquireSyncLock } from "./lock.js";
13
13
  import { getGitRemoteForPath, lookupProjectByRemote, } from "./lib/index.js";
14
+ import { isRepoPathWithinRoot, normalizeRepoPathCandidate, } from "./lib/repo-path-safety.js";
14
15
  import { mcpLog } from "./log.js";
15
16
  /** Prefix for Claude Code session keys in shared MCP state. */
16
17
  export const CLAUDE_STATE_PREFIX = "claude_code:";
@@ -102,9 +103,9 @@ function explicitProjectId(projectId, projectIdSource) {
102
103
  ? projectId
103
104
  : undefined;
104
105
  }
105
- async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache) {
106
- const normalized = repoPath?.trim();
107
- if (!normalized)
106
+ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
107
+ const normalized = normalizeRepoPathCandidate(repoPath);
108
+ if (normalized === null)
108
109
  return null;
109
110
  if (cache.has(normalized))
110
111
  return cache.get(normalized) ?? null;
@@ -113,25 +114,35 @@ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose,
113
114
  cache.set(normalized, null);
114
115
  return null;
115
116
  }
116
- const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose);
117
+ const result = await lookupProjectByRemote(canonicalRemote, host, token, verbose, allowInsecureHttp);
117
118
  const projectId = result && typeof result === "object" && "project_id" in result ? result.project_id : null;
118
119
  cache.set(normalized, projectId);
119
120
  return projectId;
120
121
  }
122
+ /**
123
+ * The repo path for a Cursor payload, normalized and safe to hand to project
124
+ * resolution. Returns an absolute, `..`-collapsed path or undefined.
125
+ *
126
+ * `workspace_folder` comes from the workspace's own `workspace.json` and
127
+ * `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
128
+ * untrusted. When the preferred field is unusable we fall through to the other
129
+ * rather than giving up. See DB90DV-547.
130
+ */
121
131
  export function cursorRepoPathFromPayload(payload) {
122
132
  const metadata = payload.metadata;
123
133
  if (!metadata)
124
134
  return undefined;
125
- if (typeof metadata.workspace_folder === "string" && metadata.workspace_folder.length > 0) {
126
- return metadata.workspace_folder;
127
- }
128
- if (typeof metadata.workspace === "string" && metadata.workspace.length > 0) {
129
- return metadata.workspace;
135
+ for (const candidate of [metadata.workspace_folder, metadata.workspace]) {
136
+ if (typeof candidate !== "string")
137
+ continue;
138
+ const normalized = normalizeRepoPathCandidate(candidate);
139
+ if (normalized !== null)
140
+ return normalized;
130
141
  }
131
142
  return undefined;
132
143
  }
133
144
  async function runClaudeSlice(options) {
134
- const { token, host, dryRun, verbose, projectId, pricing } = options;
145
+ const { token, host, dryRun, verbose, projectId, pricing, allowInsecureHttp = false } = options;
135
146
  const appDir = options.appDir ?? getAppDir();
136
147
  const backoffKey = credentialStateKey(host, token);
137
148
  const errors = [];
@@ -194,14 +205,16 @@ async function runClaudeSlice(options) {
194
205
  mcpLog.info("sync_noise_skip", { tool: "claude_code", reason: "local_command_noise" }, false);
195
206
  continue;
196
207
  }
197
- // When scopeDir is set, skip turns from other directories.
208
+ // When scopeDir is set, skip turns from other directories. `turn.cwd` is an
209
+ // arbitrary string from a transcript JSONL, so a plain prefix match would
210
+ // accept `<scopeDir>/../../elsewhere` (DB90DV-547).
198
211
  if (scopeDir) {
199
- const cwd = turn.cwd?.trim();
200
- const inScope = cwd && (cwd === scopeDir || cwd.startsWith(scopeDir + "/"));
212
+ const cwd = normalizeRepoPathCandidate(turn.cwd);
213
+ const inScope = cwd !== null && isRepoPathWithinRoot(cwd, scopeDir);
201
214
  if (!inScope) {
202
215
  totalSkipped++;
203
216
  if (verbose) {
204
- console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
217
+ console.log(`[verbose] Skipping Claude turn ${turn.turnId} — cwd=${turn.cwd ?? "(none)"} not under scopeDir=${scopeDir}`);
205
218
  }
206
219
  continue;
207
220
  }
@@ -222,13 +235,16 @@ async function runClaudeSlice(options) {
222
235
  // effectively one network call per unique cwd per sync.
223
236
  const resolvedProjectId = scopeDir
224
237
  ? (projectId ??
225
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
238
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
226
239
  undefined)
227
240
  : (explicitProject ??
228
- (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache)) ??
241
+ (await resolveProjectIdForRepoPathCached(turn.cwd, host, token, verbose, projectLookupCache, allowInsecureHttp)) ??
229
242
  undefined);
230
- const payload = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
231
- if (verbose && payload.cost_usd === null) {
243
+ const payloads = mapClaudeTranscriptTurn(turn, { projectId: resolvedProjectId, pricing });
244
+ if (!payloads?.length)
245
+ continue;
246
+ const parentPayload = payloads[0];
247
+ if (verbose && parentPayload?.cost_usd === null) {
232
248
  if (!turn.model) {
233
249
  if (turn.tokensIn > 0 || turn.tokensOut > 0) {
234
250
  console.warn(`[warn] Claude turn ${turn.turnId} has usage but no model — cost_usd will be null`);
@@ -241,45 +257,53 @@ async function runClaudeSlice(options) {
241
257
  }
242
258
  }
243
259
  if (dryRun) {
244
- console.log(`[dry-run] Would send Claude turn ${turn.turnId}:`);
245
- console.log(JSON.stringify(payload, null, 2));
246
- totalSent++;
260
+ for (const payload of payloads) {
261
+ console.log(`[dry-run] Would send Claude ${payload.event_type} ${payload.metadata.session_id}:`);
262
+ console.log(JSON.stringify(payload, null, 2));
263
+ }
264
+ totalSent += payloads.length;
247
265
  continue;
248
266
  }
249
- if (verbose) {
250
- console.log(`[verbose] Sending Claude turn ${turn.turnId} (${turn.tokensIn + turn.tokensOut} tokens)`);
251
- }
252
- const ok = await postEvent(payload, host, token, {
253
- on429: (retryAfter, quotaExceeded) => {
254
- const currentBackoff = backoffUntilByCredential.get(backoffKey);
255
- const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
256
- backoffUntilByCredential.set(backoffKey, nextBackoff);
257
- shouldStopForBackoff = true;
258
- const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
259
- mcpLog.warn("sync_rate_limit_pause", {
260
- tool: "claude_code",
261
- retry_until: nextBackoff.toISOString(),
262
- quota_exceeded: quotaExceeded,
263
- }, true);
264
- console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
265
- // Persist backoff to state so it survives process restarts
266
- state = { ...state, rate_limited_until: nextBackoff.toISOString() };
267
- writeState(state, appDir, host, token);
268
- },
269
- });
270
- if (ok) {
267
+ let allOk = true;
268
+ for (const payload of payloads) {
269
+ if (verbose) {
270
+ console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
271
+ }
272
+ const ok = await postEvent(payload, host, token, {
273
+ on429: (retryAfter, quotaExceeded) => {
274
+ const currentBackoff = backoffUntilByCredential.get(backoffKey);
275
+ const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
276
+ backoffUntilByCredential.set(backoffKey, nextBackoff);
277
+ shouldStopForBackoff = true;
278
+ const reason = quotaExceeded ? "Monthly quota exceeded" : "Rate limited";
279
+ mcpLog.warn("sync_rate_limit_pause", {
280
+ tool: "claude_code",
281
+ retry_until: nextBackoff.toISOString(),
282
+ quota_exceeded: quotaExceeded,
283
+ }, true);
284
+ console.warn(`[aixle-insights] ${reason}. Pausing until ${nextBackoff.toISOString()}.`);
285
+ // Persist backoff to state so it survives process restarts
286
+ state = { ...state, rate_limited_until: nextBackoff.toISOString() };
287
+ writeState(state, appDir, host, token);
288
+ },
289
+ });
290
+ if (!ok) {
291
+ allOk = false;
292
+ totalFailed++;
293
+ errors.push(`Failed to post Claude ${payload.event_type} for turn ${turn.turnId}`);
294
+ mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
295
+ break;
296
+ }
297
+ }
298
+ if (allOk) {
299
+ totalSent += payloads.length;
271
300
  state = markSessionSent(state, sKey, turn.fileSize);
272
301
  writeState(state, appDir, host, token);
273
- totalSent++;
274
302
  }
275
- else {
276
- totalFailed++;
277
- errors.push(`Failed to post Claude turn ${turn.turnId}`);
278
- mcpLog.error("sync_ingest_final_failure", { tool: "claude_code", session_id: turn.turnId }, true);
279
- if (shouldStopForBackoff) {
280
- break;
281
- }
303
+ else if (shouldStopForBackoff) {
304
+ break;
282
305
  }
306
+ // Non-backoff failure: continue to next turn (failed turn will retry next sync)
283
307
  }
284
308
  const result = { sent: totalSent, failed: totalFailed, skipped: totalSkipped };
285
309
  const currentBackoff = backoffUntilByCredential.get(backoffKey);
@@ -290,7 +314,7 @@ async function runClaudeSlice(options) {
290
314
  return result;
291
315
  }
292
316
  async function runCursorSlice(params) {
293
- const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
317
+ const { token, host, dryRun, verbose, projectId, projectIdSource, projectLookupToken, allowInsecureHttp = false, appDir, cursorBaseDir, cursorTranscriptProjectDirs, scopeDir, fullScan = false, cursorPricing = resolveCursorPricing(undefined, appDir), } = params;
294
318
  const backoffKey = credentialStateKey(host, token);
295
319
  // Read state first so we can restore a persisted rate-limit backoff from a prior process.
296
320
  const stateBefore = readState(appDir, host, token);
@@ -331,6 +355,7 @@ async function runCursorSlice(params) {
331
355
  host,
332
356
  token,
333
357
  projectLookupToken: lookupToken,
358
+ allowInsecureHttp,
334
359
  verbose,
335
360
  cursorBaseDir,
336
361
  cursorTranscriptProjectDirs,
@@ -341,11 +366,11 @@ async function runCursorSlice(params) {
341
366
  const inScope = [];
342
367
  for (const payload of group.payloads) {
343
368
  const ws = cursorRepoPathFromPayload(payload);
344
- if (ws && (ws === scopeDir || ws.startsWith(scopeDir + "/"))) {
369
+ if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
345
370
  // Same fallback as Claude: when the pre-resolved projectId is null, do a
346
371
  // per-payload lookup from the payload's workspace. Cache dedupes by path.
347
372
  const resolved = projectId ??
348
- (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache));
373
+ (await resolveProjectIdForRepoPathCached(ws, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp));
349
374
  if (resolved)
350
375
  payload.project_id = resolved;
351
376
  else
@@ -365,7 +390,7 @@ async function runCursorSlice(params) {
365
390
  continue;
366
391
  for (const payload of group.payloads) {
367
392
  const repoPath = cursorRepoPathFromPayload(payload);
368
- const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache);
393
+ const resolvedProjectId = await resolveProjectIdForRepoPathCached(repoPath, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp);
369
394
  if (resolvedProjectId)
370
395
  payload.project_id = resolvedProjectId;
371
396
  else
@@ -438,7 +463,7 @@ async function runCursorSlice(params) {
438
463
  totalSkipped++;
439
464
  continue;
440
465
  }
441
- const ok = await postEvent(payload, host, token, { on429 });
466
+ const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
442
467
  if (ok) {
443
468
  totalSent++;
444
469
  const turnId = payload.metadata.session_id;
@@ -455,7 +480,7 @@ async function runCursorSlice(params) {
455
480
  }
456
481
  continue;
457
482
  }
458
- const batchResult = await postEvents(group.payloads, host, token, { on429 });
483
+ const batchResult = await postEvents(group.payloads, host, token, { on429, allowInsecureHttp });
459
484
  totalSent += batchResult.sent;
460
485
  totalFailed += batchResult.failed;
461
486
  if (batchResult.failed > 0) {
@@ -501,10 +526,11 @@ async function runCursorSlice(params) {
501
526
  state: stateMut,
502
527
  host,
503
528
  token,
529
+ allowInsecureHttp,
504
530
  on429,
505
531
  resolveProjectId: explicitProject
506
532
  ? undefined
507
- : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache),
533
+ : (workspace) => resolveProjectIdForRepoPathCached(workspace, host, lookupToken, verbose, projectLookupCache, allowInsecureHttp),
508
534
  verbose,
509
535
  });
510
536
  totalSent += hooksResult.sent;
@@ -590,6 +616,7 @@ export async function syncTelemetryTools(options) {
590
616
  dryRun,
591
617
  verbose,
592
618
  projectId,
619
+ allowInsecureHttp: credentials.insecureHttpAllowed === true,
593
620
  pricing,
594
621
  appDir,
595
622
  transcriptBaseDirs: options.transcriptBaseDirs,
@@ -606,6 +633,7 @@ export async function syncTelemetryTools(options) {
606
633
  projectId,
607
634
  projectIdSource,
608
635
  projectLookupToken,
636
+ allowInsecureHttp: credentials.insecureHttpAllowed === true,
609
637
  appDir,
610
638
  cursorBaseDir: options.cursorBaseDir,
611
639
  cursorTranscriptProjectDirs: options.cursorTranscriptProjectDirs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.0",
3
+ "version": "0.2.2-staging",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -43,6 +43,7 @@
43
43
  "scripts": {
44
44
  "build": "tsc && node -e \"const{mkdirSync,copyFileSync}=require('fs');mkdirSync('dist/hooks',{recursive:true});copyFileSync('src/hooks/hook-forwarder.mjs','dist/hooks/hook-forwarder.mjs');\"",
45
45
  "test": "vitest run",
46
+ "lint": "eslint . --max-warnings 0",
46
47
  "verify:cursor-dry-run": "tsx scripts/verify-cursor-dry-run.ts",
47
48
  "audit:local-stores": "tsx scripts/audit-local-stores.ts",
48
49
  "dev": "tsx src/cli.ts",
@@ -51,7 +52,7 @@
51
52
  "dependencies": {
52
53
  "@modelcontextprotocol/sdk": "^1.29.0",
53
54
  "better-sqlite3": "^12.9.0",
54
- "glob": "^10.5.0",
55
+ "glob": "^13.0.6",
55
56
  "zod": "^3.23.0 || ^4.0.0"
56
57
  },
57
58
  "optionalDependencies": {
@@ -60,6 +61,9 @@
60
61
  "devDependencies": {
61
62
  "@types/better-sqlite3": "^7.6.8",
62
63
  "@types/node": "^24",
64
+ "@typescript-eslint/parser": "^8.65.0",
65
+ "eslint": "^10.8.0",
66
+ "eslint-plugin-security": "^4.0.1",
63
67
  "tsx": "^4.7.0",
64
68
  "typescript": "^5.3.3",
65
69
  "vitest": "^4.1.0"