@aixle/insights 0.2.1-staging → 0.2.1

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 (44) hide show
  1. package/README.md +63 -15
  2. package/dist/auth/credentials.js +24 -8
  3. package/dist/auth/exchange.d.ts +1 -1
  4. package/dist/auth/exchange.js +1 -1
  5. package/dist/auth/flow.d.ts +1 -8
  6. package/dist/auth/flow.js +5 -26
  7. package/dist/auth/keycloak.d.ts +1 -1
  8. package/dist/auth/keycloak.js +1 -20
  9. package/dist/cli.d.ts +3 -5
  10. package/dist/cli.js +20 -68
  11. package/dist/collect-cursor-payloads.d.ts +3 -3
  12. package/dist/cursor-checkpoints.d.ts +2 -2
  13. package/dist/cursor-payload-contract.d.ts +5 -5
  14. package/dist/health.d.ts +0 -2
  15. package/dist/health.js +0 -12
  16. package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
  17. package/dist/hooks/cursor-hooks-mapper.js +1 -1
  18. package/dist/hooks/cursor-hooks-reader.js +8 -1
  19. package/dist/install/index.d.ts +4 -6
  20. package/dist/install/index.js +1 -6
  21. package/dist/lib/config.d.ts +2 -2
  22. package/dist/lib/config.js +28 -20
  23. package/dist/lib/parse-error.d.ts +21 -0
  24. package/dist/lib/parse-error.js +25 -0
  25. package/dist/lib/project-resolver.js +37 -3
  26. package/dist/lib/repo-path-safety.d.ts +35 -0
  27. package/dist/lib/repo-path-safety.js +102 -0
  28. package/dist/lib/spawn-arg-safety.d.ts +25 -0
  29. package/dist/lib/spawn-arg-safety.js +49 -0
  30. package/dist/lib/transport-security.d.ts +0 -1
  31. package/dist/lib/transport-security.js +1 -1
  32. package/dist/readers/claude.d.ts +7 -2
  33. package/dist/readers/claude.js +44 -15
  34. package/dist/readers/cursor.d.ts +7 -7
  35. package/dist/readers/cursor.js +27 -6
  36. package/dist/risk-scanner.js +7 -0
  37. package/dist/server.d.ts +0 -17
  38. package/dist/server.js +2 -25
  39. package/dist/state.js +38 -35
  40. package/dist/sync.d.ts +11 -2
  41. package/dist/sync.js +32 -14
  42. package/package.json +8 -4
  43. package/dist/install/cursor.d.ts +0 -34
  44. package/dist/install/cursor.js +0 -193
@@ -1,4 +1,5 @@
1
1
  import { createReadStream, statSync } from "node:fs";
2
+ import { createHash } from "node:crypto";
2
3
  import { finished } from "node:stream/promises";
3
4
  import { createInterface } from "node:readline";
4
5
  import { join } from "node:path";
@@ -48,20 +49,30 @@ function isSecretEnvName(name) {
48
49
  return true;
49
50
  return /(?:^|_)(?:KEY|PASS|PWD)(?:_|$)/.test(n);
50
51
  }
52
+ // A credential value that follows a flag or `=`: either a single/double quoted
53
+ // string (which may contain spaces) or an unquoted run of non-space chars.
54
+ const VALUE = String.raw `(?:"[^"]*"|'[^']*'|\S+)`;
55
+ // Secret-bearing long-form flags. Matched with either `=` or a space separator,
56
+ // and the value may be quoted (so `--token "secret value"` is fully redacted).
57
+ const SECRET_FLAGS = "password|token|secret|api[-_]?key|access[-_]?key|auth[-_]?key|" +
58
+ "access-key-id|secret-access-key|service-account-key|client-secret";
51
59
  export function scrubBashCommand(cmd) {
52
60
  return (cmd
53
61
  // Authorization / Bearer / Basic headers (curl -H, fetch headers, etc.)
54
62
  .replace(/\b(Authorization\s*:\s*)(Bearer|Basic|Token)\s+\S+/gi, "$1$2 [REDACTED]")
55
- // Inline env-var assignments carrying secrets (AWS_SECRET_ACCESS_KEY=…, db_password=…)
56
- .replace(/\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\S+/g, (match, name) => isSecretEnvName(name) ? `${name}=[REDACTED]` : match)
57
- // --password=, --token=, --secret=, --api-key= flags
58
- .replace(/(--(?:password|token|secret|api[-_]?key|access[-_]?key|auth[-_]?key)=)\S+/gi, "$1[REDACTED]")
59
- // -p / --password <value> flag-value pairs
60
- .replace(/((?:^|\s)-p\s+)\S+/g, "$1[REDACTED]")
63
+ // Inline env-var assignments carrying secrets (AWS_SECRET_ACCESS_KEY=…, db_password="a b").
64
+ // Value may be quoted so assignments with spaces inside quotes are fully redacted.
65
+ // eslint-disable-next-line security/detect-non-literal-regexp
66
+ .replace(new RegExp(String.raw `\b([A-Za-z_][A-Za-z0-9_]*)\s*=\s*${VALUE}`, "g"), (match, name) => isSecretEnvName(name) ? `${name}=[REDACTED]` : match)
67
+ // Secret flags in both --flag=value and --flag value forms, quoted or bare.
68
+ // eslint-disable-next-line security/detect-non-literal-regexp
69
+ .replace(new RegExp(String.raw `(--(?:${SECRET_FLAGS}))(=|\s+)${VALUE}`, "gi"), "$1$2[REDACTED]")
70
+ // -p / --password <value> flag-value pairs (value may be quoted)
71
+ // eslint-disable-next-line security/detect-non-literal-regexp
72
+ .replace(new RegExp(String.raw `((?:^|\s)-p\s+)${VALUE}`, "g"), "$1[REDACTED]")
61
73
  // AWS CLI --profile (may embed customer identifiers)
62
- .replace(/(--profile\s+)\S+/g, "$1[REDACTED]")
63
- // Cloud CLI credential flags: --access-key-id, --secret-access-key, --service-account-key
64
- .replace(/(--(?:access-key-id|secret-access-key|service-account-key|client-secret)\s+)\S+/gi, "$1[REDACTED]")
74
+ // eslint-disable-next-line security/detect-non-literal-regexp
75
+ .replace(new RegExp(String.raw `(--profile\s+)${VALUE}`, "g"), "$1[REDACTED]")
65
76
  // curl | sh / curl | bash patterns (remote code execution)
66
77
  .replace(/\|\s*(sh|bash|zsh|dash)\b/g, "| [SHELL REDACTED]"));
67
78
  }
@@ -205,10 +216,29 @@ function newTurn(sessionId, turnIndex, filePath, fileSize, occurredAt, promptId)
205
216
  toolUses: [],
206
217
  navToolCalls: 0,
207
218
  totalToolCalls: 0,
208
- messageIds: [],
219
+ contentHash: "",
209
220
  persisted: false,
210
221
  };
211
222
  }
223
+ /**
224
+ * Stable fingerprint of a turn's emittable content. Includes the tool-use set
225
+ * (id + name + summary) so appending a tool_use to an already-synced turn
226
+ * changes the hash and triggers a re-emit (DB90DV-259).
227
+ */
228
+ function computeTurnContentHash(turn) {
229
+ const toolFingerprint = turn.toolUses
230
+ .map((t) => `${t.id}:${t.eventType}:${t.summary}`)
231
+ .join("");
232
+ const material = [
233
+ turn.model ?? "",
234
+ turn.tokensIn,
235
+ turn.tokensOut,
236
+ turn.promptText,
237
+ turn.assistantText,
238
+ toolFingerprint,
239
+ ].join("");
240
+ return createHash("sha256").update(material).digest("hex").slice(0, 32);
241
+ }
212
242
  function appendText(existing, addition) {
213
243
  if (!addition.trim())
214
244
  return existing;
@@ -370,9 +400,6 @@ export async function parseTranscriptFile(filePath, verbose = false) {
370
400
  }
371
401
  if (entry.message.model)
372
402
  currentTurn.model = entry.message.model;
373
- if (entry.message.id && !currentTurn.messageIds.includes(entry.message.id)) {
374
- currentTurn.messageIds.push(entry.message.id);
375
- }
376
403
  currentTurn.occurredAt = timestamp > currentTurn.occurredAt ? timestamp : currentTurn.occurredAt;
377
404
  const text = extractContentText(entry.message.content).join("\n\n").trim();
378
405
  currentTurn.assistantText = appendText(currentTurn.assistantText, text);
@@ -391,7 +418,10 @@ export async function parseTranscriptFile(filePath, verbose = false) {
391
418
  return turns;
392
419
  }
393
420
  flushCurrentTurn();
394
- return finalizedTurns.map(({ persisted: _persisted, ...turn }) => turn);
421
+ return finalizedTurns.map(({ persisted: _persisted, ...turn }) => ({
422
+ ...turn,
423
+ contentHash: computeTurnContentHash(turn),
424
+ }));
395
425
  }
396
426
  /** Converts a Claude transcript turn to parent chat and derivative tool-use payloads. */
397
427
  export function mapTranscriptTurn(turn, options) {
@@ -424,7 +454,6 @@ export function mapTranscriptTurn(turn, options) {
424
454
  // Zero is intentional: confirms no tool activity on this turn (not an omission).
425
455
  nav_tool_calls: turn.navToolCalls,
426
456
  total_tool_calls: turn.totalToolCalls,
427
- message_ids: turn.messageIds.length > 0 ? turn.messageIds : undefined,
428
457
  },
429
458
  };
430
459
  if (turn.model)
@@ -85,7 +85,7 @@ export interface PricingConfig {
85
85
  chat_output_per_mtok: number;
86
86
  }
87
87
  export declare const DEFAULT_CURSOR_PRICING: PricingConfig;
88
- export type CursorPayloadMetadata = {
88
+ export type Db90CursorPayloadMetadata = {
89
89
  session_id?: string;
90
90
  cursor_session_id: string | null;
91
91
  workspace: string;
@@ -113,7 +113,7 @@ export type CursorPayloadMetadata = {
113
113
  duration_ms?: number;
114
114
  model_resolution?: "settings_json" | "state_vscdb" | "unresolved";
115
115
  };
116
- export interface CursorPayload extends IngestPayload {
116
+ export interface CursorDb90Payload extends IngestPayload {
117
117
  tool_name: "cursor";
118
118
  event_type: "completion" | "chat" | "commit";
119
119
  model: string;
@@ -122,16 +122,16 @@ export interface CursorPayload extends IngestPayload {
122
122
  cost_usd: number;
123
123
  occurred_at: string;
124
124
  project_id?: string;
125
- metadata: CursorPayloadMetadata;
125
+ metadata: Db90CursorPayloadMetadata;
126
126
  }
127
127
  export declare function toEpochMs(timestamp: number | string | null | undefined): number | null;
128
- export declare function mapDailyStats(entry: DailyStatsEntry, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: CursorPayloadMetadata["model_resolution"]): CursorPayload[];
128
+ export declare function mapDailyStats(entry: DailyStatsEntry, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload[];
129
129
  /**
130
130
  * Maps Cursor’s latest-commit snapshot (`aiCodeTracking.recentCommit`) to a single commit-classified event.
131
131
  * Cursor only keeps one recent commit row (overwritten on each new commit).
132
132
  * Line-cost math still follows the chat-style line proxy (`computeLineCost("chat", …)`); only `event_type` differs.
133
133
  */
134
- export declare function mapRecentCommit(entry: RecentCommitSnapshot, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: CursorPayloadMetadata["model_resolution"]): CursorPayload | null;
135
- export declare function mapEvent(row: CursorRow, workspacePath: string, projectId?: string, pricing?: PricingConfig): CursorPayload | null;
136
- export declare function mapTranscriptTurn(turn: CursorTranscriptTurn, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: CursorPayloadMetadata["model_resolution"]): CursorPayload;
134
+ export declare function mapRecentCommit(entry: RecentCommitSnapshot, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload | null;
135
+ export declare function mapEvent(row: CursorRow, workspacePath: string, projectId?: string, pricing?: PricingConfig): CursorDb90Payload | null;
136
+ export declare function mapTranscriptTurn(turn: CursorTranscriptTurn, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?: Db90CursorPayloadMetadata["model_resolution"]): CursorDb90Payload;
137
137
  export {};
@@ -74,7 +74,10 @@ export function probeCursorGlobalStateDb(verbose = false, baseDir) {
74
74
  export function findCursorDbs(baseDir) {
75
75
  const dir = join(baseDir ?? cursorUserDir(), "workspaceStorage");
76
76
  try {
77
- return glob.sync(join(dir, "**", "cursor.db"));
77
+ // Pattern stays forward-slash and the directory goes through `cwd`. Building
78
+ // it with `join` emitted `\` on Windows, and glob treats `\` as an escape
79
+ // character on every platform, so this silently matched nothing there.
80
+ return glob.sync("**/cursor.db", { cwd: dir, absolute: true });
78
81
  }
79
82
  catch {
80
83
  return [];
@@ -244,7 +247,14 @@ export function findStateVscDbs(baseDir) {
244
247
  const results = [];
245
248
  results.push(join(userDir, "globalStorage", "state.vscdb"));
246
249
  try {
247
- results.push(...glob.sync(join(userDir, "workspaceStorage", "**", "state.vscdb")));
250
+ // Forward-slash pattern + `cwd`, as above. This one mattered most: the
251
+ // globalStorage path is pushed unconditionally, so on Windows the function
252
+ // still returned a result while silently dropping every per-workspace
253
+ // state.vscdb — partial data loss that looked like working software.
254
+ results.push(...glob.sync("workspaceStorage/**/state.vscdb", {
255
+ cwd: userDir,
256
+ absolute: true,
257
+ }));
248
258
  }
249
259
  catch {
250
260
  /* ignore */
@@ -451,8 +461,10 @@ function toIsoFromMs(value) {
451
461
  function coerceTranscriptTimestamp(value) {
452
462
  if (value == null)
453
463
  return null;
464
+ // Route numbers through toEpochMs (via toIsoString) so epoch *seconds* are scaled
465
+ // to ms — otherwise e.g. 1720000000 (2024 in seconds) is misread as a 1970 ms value.
454
466
  if (typeof value === "number")
455
- return toIsoFromMs(value);
467
+ return toIsoString(value);
456
468
  if (typeof value !== "string")
457
469
  return null;
458
470
  const trimmed = value.trim();
@@ -707,17 +719,26 @@ export async function parseCursorTranscriptFile(filePath, composerHeaders, verbo
707
719
  if (texts.length === 0)
708
720
  continue;
709
721
  if (entry.role === "user") {
722
+ // Normalize/filter before noting the time: a whitespace- or wrapper-only line
723
+ // must not set currentTurnOccurredAt (nor start a new turn) with nothing to append,
724
+ // otherwise the next real message inherits this stale timestamp.
725
+ const fragments = texts.map(stripUserQueryWrapper).filter((text) => text.length > 0);
726
+ if (fragments.length === 0)
727
+ continue;
710
728
  if (currentPromptParts.length > 0 || currentAssistantParts.length > 0) {
711
729
  finalizeTurn();
712
730
  currentPromptParts = [];
713
731
  currentAssistantParts = [];
714
732
  }
715
733
  noteLineTime(entry);
716
- currentPromptParts.push(...texts.map(stripUserQueryWrapper).filter((text) => text.length > 0));
734
+ currentPromptParts.push(...fragments);
717
735
  }
718
736
  else if (entry.role === "assistant") {
737
+ const fragments = texts.map((text) => text.trim()).filter((text) => text.length > 0);
738
+ if (fragments.length === 0)
739
+ continue;
719
740
  noteLineTime(entry);
720
- currentAssistantParts.push(...texts.map((text) => text.trim()).filter((text) => text.length > 0));
741
+ currentAssistantParts.push(...fragments);
721
742
  }
722
743
  }
723
744
  }
@@ -864,7 +885,7 @@ export function mapDailyStats(entry, projectId, pricing = DEFAULT_CURSOR_PRICING
864
885
  eventType: "chat",
865
886
  tokensIn: composerSuggested,
866
887
  tokensOut: composerAccepted,
867
- costUsd: computeLineCost("chat", composerSuggested, pricing),
888
+ costUsd: computeLineCost("chat", composerAccepted, pricing),
868
889
  occurredAt,
869
890
  dbPath,
870
891
  date,
@@ -12,6 +12,10 @@ const CATEGORIES = {
12
12
  weight: 3,
13
13
  patterns: [
14
14
  /\b\d{3}-\d{2}-\d{4}\b/g, // SSN
15
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
16
+ `{3}` inside `(?:…)?`. Every branch is a fixed-length digit run
17
+ anchored by \b, so the match is bounded and cannot backtrack
18
+ super-linearly. */
15
19
  /\b(?:4[0-9]{12}(?:[0-9]{3})?|5[1-5][0-9]{14}|3[47][0-9]{13}|3(?:0[0-5]|[68][0-9])[0-9]{11}|6(?:011|5[0-9]{2})[0-9]{12}|(?:2131|1800|35\d{3})\d{11})\b/g, // Credit card
16
20
  ],
17
21
  },
@@ -19,6 +23,9 @@ const CATEGORIES = {
19
23
  weight: 1,
20
24
  patterns: [
21
25
  /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/gi, // Email
26
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged for
27
+ `?` nested in `?`. All quantified groups are fixed-length digit or
28
+ separator classes anchored by \b; matching is bounded. */
22
29
  /\b(?:\+?1[-.\s]?)?(?:\([0-9]{3}\)|[0-9]{3})[-.\s]?[0-9]{3}[-.\s]?[0-9]{4}\b/g, // Phone
23
30
  ],
24
31
  },
package/dist/server.d.ts CHANGED
@@ -7,25 +7,8 @@ export declare const SYNC_NOW_INPUT_SCHEMA: z.ZodObject<{
7
7
  cursor: "cursor";
8
8
  }>>>;
9
9
  }, z.core.$strict>;
10
- /**
11
- * Whether a `credential_validation_failed` warning should mirror to stderr.
12
- * Mirrors for one-shot/startup sources so an installed-but-uninitialized MCP is
13
- * visible, but NOT for the recurring background `"interval"` tick — that fires
14
- * every SYNC_INTERVAL_MS for the whole process lifetime and would spam the logs.
15
- * The event is always written to mcp.log regardless of this return value.
16
- */
17
- export declare function shouldMirrorMissingCredentials(source: string): boolean;
18
10
  /** Structured status for `aixle_insights_status` — tolerates missing/malformed credentials and state. */
19
11
  export declare function buildAixleInsightsStatusPayload(): Promise<Record<string, unknown>>;
20
12
  /** In-process MCP server instance (stdio not attached). */
21
13
  export declare function createAixleInsightsMcpServer(): McpServer;
22
- /**
23
- * Subscribes `shutdown` to the given stream's "end" and "close" events.
24
- * Closing stdin is how the OS signals a stdio-transport MCP server that its
25
- * parent process is gone — this fires even when the parent can't deliver a
26
- * signal (e.g. it was itself SIGKILL'd, or the OS reparented this process
27
- * without ever sending one). Exported standalone so it's testable with a
28
- * plain EventEmitter instead of the real process.stdin.
29
- */
30
- export declare function wireParentExitShutdown(stdin: Pick<NodeJS.ReadStream, "on">, shutdown: () => void): void;
31
14
  export declare function startServer(): Promise<void>;
package/dist/server.js CHANGED
@@ -62,16 +62,6 @@ function migrateAllLegacyState(creds) {
62
62
  function syncResultOk(result) {
63
63
  return !result.locked && result.failed === 0;
64
64
  }
65
- /**
66
- * Whether a `credential_validation_failed` warning should mirror to stderr.
67
- * Mirrors for one-shot/startup sources so an installed-but-uninitialized MCP is
68
- * visible, but NOT for the recurring background `"interval"` tick — that fires
69
- * every SYNC_INTERVAL_MS for the whole process lifetime and would spam the logs.
70
- * The event is always written to mcp.log regardless of this return value.
71
- */
72
- export function shouldMirrorMissingCredentials(source) {
73
- return source !== "interval";
74
- }
75
65
  // Process-lifetime cache keyed on the inputs that drive resolveProjectId: host,
76
66
  // lookup token, and the current repo's git remote. Re-resolve when any of them
77
67
  // changes (re-auth, repo cwd change). `source: "none"` is never cached so a
@@ -101,7 +91,7 @@ export async function buildAixleInsightsStatusPayload() {
101
91
  async function executeSync(parsed) {
102
92
  const creds = await loadCredentials();
103
93
  if (!creds || !credentialsHaveAnyToken(creds)) {
104
- mcpLog.warn("credential_validation_failed", { source: "aixle_insights_sync_now", reason: "missing_credentials" }, shouldMirrorMissingCredentials("aixle_insights_sync_now"));
94
+ mcpLog.warn("credential_validation_failed", { source: "aixle_insights_sync_now", reason: "missing_credentials" }, false);
105
95
  return { ok: false, error: "missing_credentials" };
106
96
  }
107
97
  migrateAllLegacyState(creds);
@@ -196,18 +186,6 @@ export function createAixleInsightsMcpServer() {
196
186
  }, authenticateHandler);
197
187
  return server;
198
188
  }
199
- /**
200
- * Subscribes `shutdown` to the given stream's "end" and "close" events.
201
- * Closing stdin is how the OS signals a stdio-transport MCP server that its
202
- * parent process is gone — this fires even when the parent can't deliver a
203
- * signal (e.g. it was itself SIGKILL'd, or the OS reparented this process
204
- * without ever sending one). Exported standalone so it's testable with a
205
- * plain EventEmitter instead of the real process.stdin.
206
- */
207
- export function wireParentExitShutdown(stdin, shutdown) {
208
- stdin.on("end", shutdown);
209
- stdin.on("close", shutdown);
210
- }
211
189
  export async function startServer() {
212
190
  const server = createAixleInsightsMcpServer();
213
191
  const transport = new StdioServerTransport();
@@ -228,13 +206,12 @@ export async function startServer() {
228
206
  };
229
207
  process.on("SIGINT", onSignal);
230
208
  process.on("SIGTERM", onSignal);
231
- wireParentExitShutdown(process.stdin, onSignal);
232
209
  const runBackground = async (source) => {
233
210
  if (shuttingDown)
234
211
  return;
235
212
  const creds = await loadCredentials();
236
213
  if (!creds || !credentialsHaveAnyToken(creds)) {
237
- mcpLog.warn("credential_validation_failed", { source, reason: "missing_credentials" }, shouldMirrorMissingCredentials(source));
214
+ mcpLog.warn("credential_validation_failed", { source, reason: "missing_credentials" }, false);
238
215
  return;
239
216
  }
240
217
  try {
package/dist/state.js CHANGED
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { homedir } from "node:os";
4
4
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { mcpLog } from "./log.js";
6
+ import { describeReadFailure } from "./lib/parse-error.js";
6
7
  export function getAppDir() {
7
8
  const override = process.env["AIXLE_INSIGHTS_HOME"]?.trim();
8
9
  if (override && override.length > 0)
@@ -90,48 +91,50 @@ export function migrateLegacyState(dir, host, token) {
90
91
  }
91
92
  export function readState(dir, host, token) {
92
93
  const filePath = stateFilePath(dir ?? getAppDir(), host, token);
94
+ let parsed;
93
95
  try {
94
- const parsed = JSON.parse(readFileSync(filePath, "utf-8"));
95
- if (typeof parsed === "object" && parsed !== null) {
96
- const p = parsed;
97
- if (typeof p.version === "number" &&
98
- typeof p.sessions === "object" &&
99
- p.sessions !== null) {
100
- const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
101
- ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
102
- : undefined;
103
- const out = {
104
- version: p.version,
105
- sessions: p.sessions,
106
- };
107
- if (lastRecentCommitHashes !== undefined) {
108
- out.lastRecentCommitHashes = lastRecentCommitHashes;
109
- }
110
- if ("mcp_operator" in p) {
111
- const mcp = parseMcpOperator(p.mcp_operator);
112
- if (mcp)
113
- out.mcp_operator = mcp;
114
- }
115
- if ("rate_limited_until" in p) {
116
- if (typeof p.rate_limited_until === "string") {
117
- out.rate_limited_until = p.rate_limited_until;
118
- }
119
- else if (p.rate_limited_until === null) {
120
- out.rate_limited_until = null;
121
- }
122
- }
123
- return out;
124
- }
125
- }
96
+ parsed = JSON.parse(readFileSync(filePath, "utf-8"));
126
97
  }
127
98
  catch (err) {
128
99
  const code = err?.code;
129
100
  if (code !== "ENOENT") {
130
- // State file exists but failed to parse — distinguishes tampering from "never created".
131
- mcpLog.warn("state_parse_failed", { path: filePath, error: err instanceof Error ? err.message : String(err) }, false);
101
+ // State file exists but is not valid JSON — distinguishes tampering from "never created".
102
+ // ENOENT stays silent: that is the normal first-run case.
103
+ mcpLog.warn("state_parse_failed", { path: filePath, ...describeReadFailure(err) }, false);
104
+ }
105
+ return { version: 1, sessions: {} };
106
+ }
107
+ const p = typeof parsed === "object" && parsed !== null ? parsed : null;
108
+ if (p === null || typeof p.version !== "number" || typeof p.sessions !== "object" || p.sessions === null) {
109
+ // Valid JSON, wrong shape. This fallback discards every dedup checkpoint and causes a full
110
+ // re-send, so it is the most consequential of the four to have been silent. (DB90DV-699)
111
+ mcpLog.warn("state_parse_failed", { path: filePath, reason: "invalid_shape" }, false);
112
+ return { version: 1, sessions: {} };
113
+ }
114
+ const lastRecentCommitHashes = Array.isArray(p.lastRecentCommitHashes)
115
+ ? p.lastRecentCommitHashes.filter((h) => typeof h === "string")
116
+ : undefined;
117
+ const out = {
118
+ version: p.version,
119
+ sessions: p.sessions,
120
+ };
121
+ if (lastRecentCommitHashes !== undefined) {
122
+ out.lastRecentCommitHashes = lastRecentCommitHashes;
123
+ }
124
+ if ("mcp_operator" in p) {
125
+ const mcp = parseMcpOperator(p.mcp_operator);
126
+ if (mcp)
127
+ out.mcp_operator = mcp;
128
+ }
129
+ if ("rate_limited_until" in p) {
130
+ if (typeof p.rate_limited_until === "string") {
131
+ out.rate_limited_until = p.rate_limited_until;
132
+ }
133
+ else if (p.rate_limited_until === null) {
134
+ out.rate_limited_until = null;
132
135
  }
133
136
  }
134
- return { version: 1, sessions: {} };
137
+ return out;
135
138
  }
136
139
  /** Atomic write: write to a temp file then rename over the target. */
137
140
  export function writeState(state, dir, host, token) {
package/dist/sync.d.ts CHANGED
@@ -2,7 +2,7 @@ import type { TelemetryToolId, StoredCredentials } from "./auth/credentials.js";
2
2
  import { type PricingTable } from "./pricing.js";
3
3
  import type { PricingConfig } from "./readers/cursor.js";
4
4
  import { type ProjectResolution } from "./lib/index.js";
5
- import type { CursorPayload } from "./readers/cursor.js";
5
+ import type { CursorDb90Payload } from "./readers/cursor.js";
6
6
  /** Prefix for Claude Code session keys in shared MCP state. */
7
7
  export declare const CLAUDE_STATE_PREFIX: "claude_code:";
8
8
  export { CURSOR_WATERMARK_KEY, CURSOR_EVENTS_WATERMARK_KEY, CURSOR_DAILY_STATS_WATERMARK_KEY, CURSOR_RECENT_COMMIT_WATERMARK_KEY, CURSOR_TRANSCRIPT_TURN_PREFIX, cursorTranscriptTurnStateKey, filterRecentCommitsByHashDedup, } from "./cursor-checkpoints.js";
@@ -67,7 +67,16 @@ export declare function getSyncTelemetry(): {
67
67
  lastResult: SyncResult | null;
68
68
  recentErrors: string[];
69
69
  };
70
- export declare function cursorRepoPathFromPayload(payload: CursorPayload): string | undefined;
70
+ /**
71
+ * The repo path for a Cursor payload, normalized and safe to hand to project
72
+ * resolution. Returns an absolute, `..`-collapsed path or undefined.
73
+ *
74
+ * `workspace_folder` comes from the workspace's own `workspace.json` and
75
+ * `workspace` from a composer `uri.fsPath` or the `state.vscdb` path — all
76
+ * untrusted. When the preferred field is unusable we fall through to the other
77
+ * rather than giving up. See DB90DV-547.
78
+ */
79
+ export declare function cursorRepoPathFromPayload(payload: CursorDb90Payload): string | undefined;
71
80
  /**
72
81
  * Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
73
82
  */
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:";
@@ -103,8 +104,8 @@ function explicitProjectId(projectId, projectIdSource) {
103
104
  : undefined;
104
105
  }
105
106
  async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose, cache, allowInsecureHttp = false) {
106
- const normalized = repoPath?.trim();
107
- if (!normalized)
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;
@@ -118,15 +119,25 @@ async function resolveProjectIdForRepoPathCached(repoPath, host, token, verbose,
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
  }
@@ -194,21 +205,27 @@ 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
  }
208
221
  }
209
222
  const sKey = sessionStateKey(turn.turnId);
210
223
  const known = state.sessions[sKey];
211
- if (known) {
224
+ // A turn keeps its turnId as Claude appends more tool_use blocks to it, so a
225
+ // plain "already known → skip" would drop derivatives appended after an earlier
226
+ // mid-turn sync. Skip only when the content fingerprint is unchanged; otherwise
227
+ // re-emit so the newly appended tool uses are sent (DB90DV-259).
228
+ if (known && known.contentHash && known.contentHash === turn.contentHash) {
212
229
  totalSkipped++;
213
230
  if (verbose) {
214
231
  console.log(`[verbose] Skipping already-synced Claude turn ${turn.turnId}`);
@@ -257,6 +274,7 @@ async function runClaudeSlice(options) {
257
274
  console.log(`[verbose] Sending Claude ${payload.event_type} ${payload.metadata.session_id}`);
258
275
  }
259
276
  const ok = await postEvent(payload, host, token, {
277
+ allowInsecureHttp,
260
278
  on429: (retryAfter, quotaExceeded) => {
261
279
  const currentBackoff = backoffUntilByCredential.get(backoffKey);
262
280
  const nextBackoff = new Date(Math.max(currentBackoff?.getTime() ?? 0, Date.now() + retryAfter * 1000));
@@ -284,7 +302,7 @@ async function runClaudeSlice(options) {
284
302
  }
285
303
  if (allOk) {
286
304
  totalSent += payloads.length;
287
- state = markSessionSent(state, sKey, turn.fileSize);
305
+ state = markSessionSent(state, sKey, turn.fileSize, turn.contentHash);
288
306
  writeState(state, appDir, host, token);
289
307
  }
290
308
  else if (shouldStopForBackoff) {
@@ -353,7 +371,7 @@ async function runCursorSlice(params) {
353
371
  const inScope = [];
354
372
  for (const payload of group.payloads) {
355
373
  const ws = cursorRepoPathFromPayload(payload);
356
- if (ws && (ws === scopeDir || ws.startsWith(scopeDir + "/"))) {
374
+ if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
357
375
  // Same fallback as Claude: when the pre-resolved projectId is null, do a
358
376
  // per-payload lookup from the payload's workspace. Cache dedupes by path.
359
377
  const resolved = projectId ??
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aixle/insights",
3
- "version": "0.2.1-staging",
3
+ "version": "0.2.1",
4
4
  "description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,7 +19,7 @@
19
19
  "provenance": false
20
20
  },
21
21
  "engines": {
22
- "node": ">=20"
22
+ "node": ">=20.19.0"
23
23
  },
24
24
  "license": "MIT",
25
25
  "repository": {
@@ -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,8 +61,11 @@
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
- "vitest": "^4.1.0"
69
+ "vitest": "4.1.9"
66
70
  }
67
71
  }
@@ -1,34 +0,0 @@
1
- import { type InstallResult } from "./claude.js";
2
- export interface InstallCursorUserMcpOptions {
3
- /** Tests: full path to the Cursor MCP config file (default ~/.cursor/mcp.json). */
4
- cursorConfigPath?: string;
5
- force?: boolean;
6
- }
7
- export type UninstallResult = {
8
- kind: "restored";
9
- backupPath: string;
10
- } | {
11
- kind: "removed";
12
- } | {
13
- kind: "noop";
14
- } | {
15
- kind: "error";
16
- message: string;
17
- };
18
- export declare function defaultCursorUserConfigPath(): string;
19
- /**
20
- * Merges top-level `mcpServers.aixle-insights` into ~/.cursor/mcp.json (or overridden path).
21
- * Preserves all other keys and MCP server entries. Backs up the existing file
22
- * (once) before the first write, and removes legacy "db90"/"insights" keys so
23
- * Cursor does not spawn duplicate aixle-insights servers.
24
- */
25
- export declare function installCursorUserMcp(options?: InstallCursorUserMcpOptions): InstallResult;
26
- /**
27
- * Removes the aixle-insights entry from ~/.cursor/mcp.json without disturbing any
28
- * other server entry — including ones the user or Cursor added after install.
29
- * Works on the CURRENT file (never a full-file revert to the backup). If a
30
- * pre-install backup exists, the user's ORIGINAL aixle-insights entry (if any)
31
- * is restored from it; otherwise our key is simply removed. The backup file is
32
- * cleaned up either way.
33
- */
34
- export declare function uninstallCursorUserMcp(options?: InstallCursorUserMcpOptions): UninstallResult;