@aixle/insights 0.2.1-staging → 0.2.2-staging
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dist/hooks/cursor-hooks-reader.js +8 -1
- 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/readers/cursor.js +12 -2
- package/dist/risk-scanner.js +7 -0
- package/dist/sync.d.ts +9 -0
- package/dist/sync.js +25 -12
- package/package.json +7 -3
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
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|
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 */
|
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/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
|
|
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,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
|
|
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
|
}
|
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.2.2-staging",
|
|
4
4
|
"description": "stdio MCP server for AI coding-assistant telemetry — Claude transcript sync + Cursor SQLite ingest.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -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,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"
|