@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.
- package/README.md +63 -15
- package/dist/auth/credentials.js +24 -8
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +1 -8
- package/dist/auth/flow.js +5 -26
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +1 -20
- package/dist/cli.d.ts +3 -5
- package/dist/cli.js +20 -68
- package/dist/collect-cursor-payloads.d.ts +3 -3
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/health.d.ts +0 -2
- package/dist/health.js +0 -12
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.js +8 -1
- package/dist/install/index.d.ts +4 -6
- package/dist/install/index.js +1 -6
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +28 -20
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.js +37 -3
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +0 -1
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +7 -2
- package/dist/readers/claude.js +44 -15
- package/dist/readers/cursor.d.ts +7 -7
- package/dist/readers/cursor.js +27 -6
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +0 -17
- package/dist/server.js +2 -25
- package/dist/state.js +38 -35
- package/dist/sync.d.ts +11 -2
- package/dist/sync.js +32 -14
- package/package.json +8 -4
- package/dist/install/cursor.d.ts +0 -34
- package/dist/install/cursor.js +0 -193
package/dist/readers/claude.js
CHANGED
|
@@ -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
|
-
|
|
57
|
-
//
|
|
58
|
-
.replace(
|
|
59
|
-
//
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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 }) =>
|
|
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)
|
package/dist/readers/cursor.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
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?:
|
|
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?:
|
|
135
|
-
export declare function mapEvent(row: CursorRow, workspacePath: string, projectId?: string, pricing?: PricingConfig):
|
|
136
|
-
export declare function mapTranscriptTurn(turn: CursorTranscriptTurn, projectId?: string, pricing?: PricingConfig, model?: string, modelResolution?:
|
|
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 {};
|
package/dist/readers/cursor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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(...
|
|
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(...
|
|
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",
|
|
888
|
+
costUsd: computeLineCost("chat", composerAccepted, pricing),
|
|
868
889
|
occurredAt,
|
|
869
890
|
dbPath,
|
|
870
891
|
date,
|
package/dist/risk-scanner.js
CHANGED
|
@@ -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" },
|
|
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" },
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
|
107
|
-
if (
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
200
|
-
const inScope = cwd && (cwd
|
|
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
|
-
|
|
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
|
|
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
|
|
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": "^
|
|
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": "
|
|
69
|
+
"vitest": "4.1.9"
|
|
66
70
|
}
|
|
67
71
|
}
|
package/dist/install/cursor.d.ts
DELETED
|
@@ -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;
|