@aixle/insights 0.2.1-staging → 0.2.3-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.
package/README.md CHANGED
@@ -274,7 +274,8 @@ After the script reports success: **quit and reopen Claude Code / Cursor** so ea
274
274
 
275
275
  ## Requirements
276
276
 
277
- - Node.js 20.
277
+ - Node.js >= 20.19.0 — matches `engines.node` in `package.json`, and
278
+ `src/test/supply-chain-contract.test.ts` fails if the two drift apart.
278
279
  - macOS / Linux / Windows. On Windows, the package writes a `cmd /c npx …` wrapper in `~/.claude.json` so Claude Code can spawn the MCP server reliably.
279
280
  - `better-sqlite3` is a native module. After a Node upgrade, if SQLite reads start failing, rebuild it from the tools workspace:
280
281
 
@@ -6,9 +6,16 @@ import { markSessionSent } from "../state.js";
6
6
  import { postEvent } from "../client.js";
7
7
  import { mcpLog } from "../log.js";
8
8
  import { shouldIngestHookEvent, mapHookEventToPayload, hookDedupeKey, warnOnCursorVersion, CURSOR_HOOK_STATE_PREFIX, } from "./cursor-hooks-mapper.js";
9
+ import { isRepoPathWithinRoot, normalizeRepoPathCandidate } from "../lib/repo-path-safety.js";
9
10
  export { CURSOR_HOOK_STATE_PREFIX };
11
+ /**
12
+ * `workspace` is `workspace_roots[0]` from the on-disk hooks queue — an
13
+ * arbitrary JSON string. A plain prefix match would accept
14
+ * `<scopeDir>/../../elsewhere` (DB90DV-547).
15
+ */
10
16
  function isUnderScopeDir(workspace, scopeDir) {
11
- return workspace === scopeDir || workspace.startsWith(scopeDir + "/");
17
+ const normalized = normalizeRepoPathCandidate(workspace);
18
+ return normalized !== null && isRepoPathWithinRoot(normalized, scopeDir);
12
19
  }
13
20
  /**
14
21
  * The forwarder redacts the home directory to "~" in workspace_roots for
@@ -1,5 +1,7 @@
1
1
  import { execFileSync } from "node:child_process";
2
2
  import { evaluateTransportSecurity } from "./transport-security.js";
3
+ import { isSafeSshHost } from "./spawn-arg-safety.js";
4
+ import { safeGitRepoPath } from "./repo-path-safety.js";
3
5
  /** Coerce empty string to undefined so "" is treated as "not set" */
4
6
  function coerce(val) {
5
7
  return val === "" ? undefined : val;
@@ -48,8 +50,19 @@ export function getGitRemote(verbose) {
48
50
  }
49
51
  }
50
52
  export function getGitRemoteForPath(repoPath, verbose) {
53
+ // The spawn boundary. `repoPath` is untrusted (Cursor workspace.json, a
54
+ // composer uri.fsPath, a hook workspace_root, or a Claude transcript cwd), so
55
+ // it must resolve to a real directory before git reads its .git/config.
56
+ // Supersedes the isSafeSpawnPathArg check from DB90DV-546, which this
57
+ // subsumes. See DB90DV-547.
58
+ const safePath = safeGitRepoPath(repoPath);
59
+ if (safePath === null) {
60
+ if (verbose)
61
+ console.log(`[verbose] Refusing git for unsafe repo path: ${repoPath}`);
62
+ return null;
63
+ }
51
64
  try {
52
- const out = execFileSync("git", ["-C", repoPath, "remote", "get-url", "origin"], {
65
+ const out = execFileSync("git", ["-C", safePath, "remote", "get-url", "origin"], {
53
66
  encoding: "utf-8",
54
67
  stdio: ["ignore", "pipe", "pipe"],
55
68
  timeout: 5000,
@@ -75,12 +88,21 @@ export function canonicalizeGitRemote(remote, verbose) {
75
88
  if (!trimmed)
76
89
  return remote;
77
90
  const scp = trimmed.match(/^([\w.-]+)@([^:/]+):(.+)$/);
91
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Flagged only
92
+ because safe-regex counts `?` as a repetition. Every group is separated by
93
+ a literal delimiter (`@`, `:`, `/`) that its neighbours exclude, so there
94
+ is no backtracking ambiguity. Input is a git remote URL, bounded length. */
78
95
  const sshUrl = trimmed.match(/^ssh:\/\/(?:([\w.-]+)@)?([^:/]+)(?::\d+)?\/(.+)$/i);
79
96
  const host = scp?.[2] ?? sshUrl?.[2];
80
- if (!host)
97
+ // An unvalidated host would be parsed by ssh as an option (DB90DV-546); an
98
+ // unvalidated `resolved` would be spliced back into the remote and sent to
99
+ // the lookup endpoint. Both fail open — the remote is returned unchanged.
100
+ if (!host || !isSafeSshHost(host))
81
101
  return trimmed;
82
102
  const resolved = resolveSshHostName(host, verbose);
83
- if (!resolved || resolved.toLowerCase() === host.toLowerCase())
103
+ if (!resolved || !isSafeSshHost(resolved))
104
+ return trimmed;
105
+ if (resolved.toLowerCase() === host.toLowerCase())
84
106
  return trimmed;
85
107
  if (verbose)
86
108
  console.log(`[verbose] Resolved SSH host alias ${host} -> ${resolved}`);
@@ -90,6 +112,13 @@ export function canonicalizeGitRemote(remote, verbose) {
90
112
  return `ssh://${user}${resolved}/${sshUrl[3]}`;
91
113
  }
92
114
  function resolveSshHostName(host, verbose) {
115
+ // Defense in depth: the only caller already checks, but this function is the
116
+ // spawn boundary and must not depend on callers getting it right.
117
+ if (!isSafeSshHost(host)) {
118
+ if (verbose)
119
+ console.log(`[verbose] Refusing ssh -G for option-shaped host: ${host}`);
120
+ return null;
121
+ }
93
122
  try {
94
123
  const out = execFileSync("ssh", ["-G", host], {
95
124
  encoding: "utf-8",
@@ -129,6 +158,11 @@ export function repoNameToGitRemoteCandidates(repoName) {
129
158
  if (trimmed.includes("://") || trimmed.includes("@")) {
130
159
  return [trimmed];
131
160
  }
161
+ /* eslint-disable-next-line security/detect-unsafe-regex -- Star height 2
162
+ (`+` inside `(…)*`), but the inner group is prefixed by `/`, which is not
163
+ in [\w.-]. There is no ambiguous overlap, so matching stays linear. The
164
+ input is a short `owner/repo` slug that already failed the "://" and "@"
165
+ checks above. */
132
166
  if (/^[\w.-]+\/[\w.-]+(\/[\w.-]+)*$/.test(trimmed)) {
133
167
  return [`https://github.com/${trimmed}`, `git@github.com:${trimmed}.git`];
134
168
  }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Normalize an untrusted repo-path candidate. Pure — never touches the
3
+ * filesystem, so it is safe to call on every payload in a sync.
4
+ *
5
+ * Returns an absolute, `..`-collapsed path, or null when the value cannot be a
6
+ * legitimate workspace path. Rejecting relative values is deliberate: `git -C`
7
+ * would resolve a relative path against *this* process's cwd, which has nothing
8
+ * to do with where the value came from. It also rejects Cursor's literal
9
+ * `"unknown"` placeholder for global hook events.
10
+ */
11
+ export declare function normalizeRepoPathCandidate(value: string | undefined | null): string | null;
12
+ /**
13
+ * True when `candidate` is `root` itself or lives beneath it.
14
+ *
15
+ * Compares with a trailing `sep` so `/repos/project-evil` is not treated as
16
+ * inside `/repos/project`, and resolves symlinks so a link inside the root
17
+ * cannot point out of it.
18
+ *
19
+ * When either side does not exist, `realpathSync` throws and the normalized
20
+ * paths are compared instead. That loses nothing — a path that does not exist
21
+ * cannot be a symlink, and `resolve()` has already collapsed `..` — and it keeps
22
+ * containment usable for scope filtering, which legitimately runs against
23
+ * payload paths naming directories this machine no longer has.
24
+ */
25
+ export declare function isRepoPathWithinRoot(candidate: string, root: string): boolean;
26
+ /**
27
+ * The last check before `git -C <path>` runs. Requires the value to resolve to a
28
+ * real directory: a missing path, a dangling symlink, or a regular file is not a
29
+ * workspace. (Cursor's `metadata.workspace` is often the `state.vscdb` file
30
+ * itself, which git would only error on anyway.)
31
+ *
32
+ * Returns the canonical real path so `git` runs against exactly what was
33
+ * checked, narrowing the window between the check and the spawn.
34
+ */
35
+ export declare function safeGitRepoPath(value: string | undefined | null): string | null;
@@ -0,0 +1,102 @@
1
+ import { realpathSync, statSync } from "node:fs";
2
+ import { isAbsolute, resolve, sep } from "node:path";
3
+ import { isSafeSpawnPathArg } from "./spawn-arg-safety.js";
4
+ /**
5
+ * Containment for untrusted filesystem paths that end up in `git -C <path>`.
6
+ *
7
+ * Every repo path this package resolves is untrusted text: Cursor's
8
+ * `workspace.json` `folder`, a composer's `workspaceIdentifier.uri.fsPath`, a
9
+ * hook's `workspace_roots[0]`, or a Claude transcript's `cwd`. None is validated
10
+ * by its producer — `fileUriToPath` (`readers/cursor.ts:189`) even passes a
11
+ * non-`file://` value straight through.
12
+ *
13
+ * `execFileSync` stops shell injection, but not `git -C ../../../elsewhere`: git
14
+ * would read that directory's `.git/config` and this package would ship the
15
+ * remote it found to the DB90 API. See DB90DV-547.
16
+ *
17
+ * Semantics are ported from `validatedRealPathWithinRoot`
18
+ * (`readers/cursor-sqlite.ts:23`), which already guards the Cursor SQLite
19
+ * reader the same way.
20
+ */
21
+ function realPathOrNull(path) {
22
+ try {
23
+ return realpathSync(path);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /**
30
+ * Normalize an untrusted repo-path candidate. Pure — never touches the
31
+ * filesystem, so it is safe to call on every payload in a sync.
32
+ *
33
+ * Returns an absolute, `..`-collapsed path, or null when the value cannot be a
34
+ * legitimate workspace path. Rejecting relative values is deliberate: `git -C`
35
+ * would resolve a relative path against *this* process's cwd, which has nothing
36
+ * to do with where the value came from. It also rejects Cursor's literal
37
+ * `"unknown"` placeholder for global hook events.
38
+ */
39
+ export function normalizeRepoPathCandidate(value) {
40
+ if (typeof value !== "string")
41
+ return null;
42
+ const trimmed = value.trim();
43
+ // Rejects empty, NUL-containing, and option-shaped values (DB90DV-546).
44
+ if (!isSafeSpawnPathArg(trimmed))
45
+ return null;
46
+ if (!isAbsolute(trimmed))
47
+ return null;
48
+ return resolve(trimmed);
49
+ }
50
+ /**
51
+ * True when `candidate` is `root` itself or lives beneath it.
52
+ *
53
+ * Compares with a trailing `sep` so `/repos/project-evil` is not treated as
54
+ * inside `/repos/project`, and resolves symlinks so a link inside the root
55
+ * cannot point out of it.
56
+ *
57
+ * When either side does not exist, `realpathSync` throws and the normalized
58
+ * paths are compared instead. That loses nothing — a path that does not exist
59
+ * cannot be a symlink, and `resolve()` has already collapsed `..` — and it keeps
60
+ * containment usable for scope filtering, which legitimately runs against
61
+ * payload paths naming directories this machine no longer has.
62
+ */
63
+ export function isRepoPathWithinRoot(candidate, root) {
64
+ const normalizedCandidate = resolve(candidate);
65
+ const normalizedRoot = resolve(root);
66
+ const realCandidate = realPathOrNull(normalizedCandidate);
67
+ const realRoot = realPathOrNull(normalizedRoot);
68
+ // Compare like with like: mixing a realpath against a normalized path would
69
+ // false-negative on macOS, where /var is a symlink to /private/var.
70
+ const bothResolve = realCandidate !== null && realRoot !== null;
71
+ const left = bothResolve ? realCandidate : normalizedCandidate;
72
+ const right = bothResolve ? realRoot : normalizedRoot;
73
+ if (left === right)
74
+ return true;
75
+ const rootWithSep = right.endsWith(sep) ? right : `${right}${sep}`;
76
+ return left.startsWith(rootWithSep);
77
+ }
78
+ /**
79
+ * The last check before `git -C <path>` runs. Requires the value to resolve to a
80
+ * real directory: a missing path, a dangling symlink, or a regular file is not a
81
+ * workspace. (Cursor's `metadata.workspace` is often the `state.vscdb` file
82
+ * itself, which git would only error on anyway.)
83
+ *
84
+ * Returns the canonical real path so `git` runs against exactly what was
85
+ * checked, narrowing the window between the check and the spawn.
86
+ */
87
+ export function safeGitRepoPath(value) {
88
+ const normalized = normalizeRepoPathCandidate(value);
89
+ if (normalized === null)
90
+ return null;
91
+ const real = realPathOrNull(normalized);
92
+ if (real === null)
93
+ return null;
94
+ try {
95
+ if (!statSync(real).isDirectory())
96
+ return null;
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ return real;
102
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Guards for untrusted values that end up in the argv of a spawned process.
3
+ *
4
+ * `execFileSync` prevents *shell* injection but not *argv-option* injection: a
5
+ * value beginning with `-` is parsed by the child as a command-line option. Git
6
+ * remotes and workspace paths are untrusted text — they come from a repo the
7
+ * developer cloned, from Cursor's `workspace.json`, or from a Claude transcript
8
+ * — so every value derived from them must be checked before it reaches `git`
9
+ * or `ssh`. See DB90DV-546.
10
+ */
11
+ /**
12
+ * True when `host` is safe to pass as an argv element to `ssh`. Accepts real
13
+ * hostnames, IPv4 literals, and `~/.ssh/config` host aliases.
14
+ */
15
+ export declare function isSafeSshHost(host: string): boolean;
16
+ /**
17
+ * True when `value` is safe to pass as a filesystem-path argv element (e.g.
18
+ * after `git -C`). Deliberately permissive about path *content* — real
19
+ * workspace paths contain spaces, dashes and drive letters. It only rejects
20
+ * what makes the child misread the value as an option, plus embedded NUL.
21
+ *
22
+ * This is an argv guard, not a containment check: verifying the path points
23
+ * somewhere legitimate is DB90DV-547.
24
+ */
25
+ export declare function isSafeSpawnPathArg(value: string): boolean;
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Guards for untrusted values that end up in the argv of a spawned process.
3
+ *
4
+ * `execFileSync` prevents *shell* injection but not *argv-option* injection: a
5
+ * value beginning with `-` is parsed by the child as a command-line option. Git
6
+ * remotes and workspace paths are untrusted text — they come from a repo the
7
+ * developer cloned, from Cursor's `workspace.json`, or from a Claude transcript
8
+ * — so every value derived from them must be checked before it reaches `git`
9
+ * or `ssh`. See DB90DV-546.
10
+ */
11
+ /** Longest legal DNS name (253) with headroom for an ssh_config alias. */
12
+ const MAX_HOST_LENGTH = 255;
13
+ /**
14
+ * Dot-separated labels of alphanumerics, `-` and `_`. A label may not start or
15
+ * end with `-`, which is what blocks option injection (`-oProxyCommand=…`).
16
+ * Whitespace, `=`, quotes, backslashes, newlines and NUL are all excluded.
17
+ * Underscores are allowed because `~/.ssh/config` aliases commonly use them.
18
+ * IPv6 literals are not covered — the SCP/`ssh://` host capture in
19
+ * `project-resolver.ts` cannot produce one, since it excludes `:`.
20
+ */
21
+ const HOST_LABEL = "[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?";
22
+ const HOST_PATTERN = new RegExp(`^${HOST_LABEL}(?:\\.${HOST_LABEL})*$`);
23
+ /**
24
+ * True when `host` is safe to pass as an argv element to `ssh`. Accepts real
25
+ * hostnames, IPv4 literals, and `~/.ssh/config` host aliases.
26
+ */
27
+ export function isSafeSshHost(host) {
28
+ if (host.length === 0 || host.length > MAX_HOST_LENGTH)
29
+ return false;
30
+ return HOST_PATTERN.test(host);
31
+ }
32
+ /**
33
+ * True when `value` is safe to pass as a filesystem-path argv element (e.g.
34
+ * after `git -C`). Deliberately permissive about path *content* — real
35
+ * workspace paths contain spaces, dashes and drive letters. It only rejects
36
+ * what makes the child misread the value as an option, plus embedded NUL.
37
+ *
38
+ * This is an argv guard, not a containment check: verifying the path points
39
+ * somewhere legitimate is DB90DV-547.
40
+ */
41
+ export function isSafeSpawnPathArg(value) {
42
+ if (value.length === 0)
43
+ return false;
44
+ if (value.startsWith("-"))
45
+ return false;
46
+ if (value.includes("\0"))
47
+ return false;
48
+ return true;
49
+ }
@@ -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 */
@@ -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/sync.d.ts CHANGED
@@ -67,6 +67,15 @@ export declare function getSyncTelemetry(): {
67
67
  lastResult: SyncResult | null;
68
68
  recentErrors: string[];
69
69
  };
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
+ */
70
79
  export declare function cursorRepoPathFromPayload(payload: CursorPayload): string | undefined;
71
80
  /**
72
81
  * Parallel multi-tool cycle under the global advisory ingest lock (`state.lock`).
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,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
  }
@@ -353,7 +366,7 @@ async function runCursorSlice(params) {
353
366
  const inScope = [];
354
367
  for (const payload of group.payloads) {
355
368
  const ws = cursorRepoPathFromPayload(payload);
356
- if (ws && (ws === scopeDir || ws.startsWith(scopeDir + "/"))) {
369
+ if (ws && isRepoPathWithinRoot(ws, scopeDir)) {
357
370
  // Same fallback as Claude: when the pre-resolved projectId is null, do a
358
371
  // per-payload lookup from the payload's workspace. Cache dedupes by path.
359
372
  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.3-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": {
@@ -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,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"