@aixle/insights 0.2.0 → 0.2.2-staging
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -9
- package/dist/auth/credentials.d.ts +7 -1
- package/dist/auth/credentials.js +71 -14
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +8 -1
- package/dist/auth/flow.js +27 -5
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +20 -1
- package/dist/cli.d.ts +5 -3
- package/dist/cli.js +69 -21
- package/dist/collect-cursor-payloads.d.ts +4 -3
- package/dist/collect-cursor-payloads.js +8 -5
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/cursor-payload-contract.js +6 -0
- package/dist/cursor-settings.d.ts +9 -4
- package/dist/cursor-settings.js +80 -10
- package/dist/health.d.ts +3 -1
- package/dist/health.js +13 -1
- 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.d.ts +2 -0
- package/dist/hooks/cursor-hooks-reader.js +10 -3
- package/dist/install/cursor.d.ts +34 -0
- package/dist/install/cursor.js +193 -0
- package/dist/install/index.d.ts +6 -4
- package/dist/install/index.js +6 -1
- package/dist/lib/client.d.ts +7 -0
- package/dist/lib/client.js +17 -0
- package/dist/lib/config.js +7 -2
- package/dist/lib/project-resolver.d.ts +5 -4
- package/dist/lib/project-resolver.js +57 -11
- 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 +1 -0
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +54 -6
- package/dist/readers/claude.js +154 -2
- package/dist/readers/cursor.d.ts +10 -7
- package/dist/readers/cursor.js +113 -17
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +20 -3
- package/dist/server.js +101 -67
- package/dist/state.js +7 -2
- package/dist/sync.d.ts +13 -2
- package/dist/sync.js +86 -58
- package/package.json +6 -2
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, copyFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired } from "./claude.js";
|
|
6
|
+
export function defaultCursorUserConfigPath() {
|
|
7
|
+
return join(homedir(), ".cursor", "mcp.json");
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Legacy keys to clean up on install: "db90" (pre-rebrand) and "insights" (an
|
|
11
|
+
* earlier local-dev-only convention documented in scripts/reset-local-env.mjs,
|
|
12
|
+
* never the production installer's key — see DB90DV-560 orientation.md).
|
|
13
|
+
*/
|
|
14
|
+
const LEGACY_MCP_KEYS = ["db90", "insights"];
|
|
15
|
+
const AIXLE_INSIGHTS_MCP_KEY = "aixle-insights";
|
|
16
|
+
const BACKUP_SUFFIX = ".aixle-insights-backup";
|
|
17
|
+
function readRootObject(path) {
|
|
18
|
+
if (!existsSync(path)) {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const raw = readFileSync(path, "utf-8");
|
|
23
|
+
if (!raw.trim()) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "error",
|
|
30
|
+
message: `${path}: top-level JSON value must be an object.`,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
return parsed;
|
|
34
|
+
}
|
|
35
|
+
catch (e) {
|
|
36
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
37
|
+
return { kind: "error", message: `Cannot read ${path}: ${msg}` };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function atomicWriteJson(path, data) {
|
|
41
|
+
try {
|
|
42
|
+
const dir = dirname(path);
|
|
43
|
+
mkdirSync(dir, { recursive: true });
|
|
44
|
+
const serialized = `${JSON.stringify(data, null, 2)}\n`;
|
|
45
|
+
const tmpPath = join(dir, `.aixle-insights-cursor-json-${randomBytes(8).toString("hex")}.tmp`);
|
|
46
|
+
writeFileSync(tmpPath, serialized, "utf-8");
|
|
47
|
+
renameSync(tmpPath, path);
|
|
48
|
+
return { kind: "installed" };
|
|
49
|
+
}
|
|
50
|
+
catch (e) {
|
|
51
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
52
|
+
return { kind: "error", message: `Failed to write ${path}: ${msg}` };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Copy the current file to a backup path, unless a backup already exists (never clobber the user's original). */
|
|
56
|
+
function backupIfNeeded(path) {
|
|
57
|
+
if (!existsSync(path))
|
|
58
|
+
return;
|
|
59
|
+
const backupPath = path + BACKUP_SUFFIX;
|
|
60
|
+
if (existsSync(backupPath))
|
|
61
|
+
return;
|
|
62
|
+
copyFileSync(path, backupPath);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Merges top-level `mcpServers.aixle-insights` into ~/.cursor/mcp.json (or overridden path).
|
|
66
|
+
* Preserves all other keys and MCP server entries. Backs up the existing file
|
|
67
|
+
* (once) before the first write, and removes legacy "db90"/"insights" keys so
|
|
68
|
+
* Cursor does not spawn duplicate aixle-insights servers.
|
|
69
|
+
*/
|
|
70
|
+
export function installCursorUserMcp(options = {}) {
|
|
71
|
+
const path = options.cursorConfigPath ?? defaultCursorUserConfigPath();
|
|
72
|
+
const force = options.force === true;
|
|
73
|
+
const desired = desiredAixleInsightsEntry();
|
|
74
|
+
const rootRead = readRootObject(path);
|
|
75
|
+
if ("kind" in rootRead && typeof rootRead.kind === "string" && rootRead.kind === "error") {
|
|
76
|
+
return rootRead;
|
|
77
|
+
}
|
|
78
|
+
const root = {
|
|
79
|
+
...rootRead,
|
|
80
|
+
};
|
|
81
|
+
const rawServers = root["mcpServers"];
|
|
82
|
+
let mcpServers;
|
|
83
|
+
if (rawServers === undefined) {
|
|
84
|
+
mcpServers = {};
|
|
85
|
+
}
|
|
86
|
+
else if (typeof rawServers === "object" &&
|
|
87
|
+
rawServers !== null &&
|
|
88
|
+
!Array.isArray(rawServers)) {
|
|
89
|
+
mcpServers = { ...rawServers };
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
return {
|
|
93
|
+
kind: "error",
|
|
94
|
+
message: `Invalid Cursor MCP config at ${path}: "mcpServers" must be an object when present.`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const existing = mcpServers[AIXLE_INSIGHTS_MCP_KEY];
|
|
98
|
+
const hasLegacyKey = LEGACY_MCP_KEYS.some((k) => mcpServers[k] !== undefined);
|
|
99
|
+
if (existing !== undefined && aixleInsightsEntryMatchesDesired(existing, desired)) {
|
|
100
|
+
if (!hasLegacyKey) {
|
|
101
|
+
return { kind: "already-configured" };
|
|
102
|
+
}
|
|
103
|
+
backupIfNeeded(path);
|
|
104
|
+
for (const key of LEGACY_MCP_KEYS)
|
|
105
|
+
delete mcpServers[key];
|
|
106
|
+
root["mcpServers"] = mcpServers;
|
|
107
|
+
return atomicWriteJson(path, root);
|
|
108
|
+
}
|
|
109
|
+
if (existing !== undefined && !force) {
|
|
110
|
+
return {
|
|
111
|
+
kind: "requires-force",
|
|
112
|
+
detail: 'A different "aixle-insights" MCP server entry already exists in ~/.cursor/mcp.json. Re-run with `init --force` to replace only that entry.',
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
backupIfNeeded(path);
|
|
116
|
+
for (const key of LEGACY_MCP_KEYS)
|
|
117
|
+
delete mcpServers[key];
|
|
118
|
+
mcpServers[AIXLE_INSIGHTS_MCP_KEY] = { command: desired.command, args: desired.args };
|
|
119
|
+
root["mcpServers"] = mcpServers;
|
|
120
|
+
return atomicWriteJson(path, root);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Removes the aixle-insights entry from ~/.cursor/mcp.json without disturbing any
|
|
124
|
+
* other server entry — including ones the user or Cursor added after install.
|
|
125
|
+
* Works on the CURRENT file (never a full-file revert to the backup). If a
|
|
126
|
+
* pre-install backup exists, the user's ORIGINAL aixle-insights entry (if any)
|
|
127
|
+
* is restored from it; otherwise our key is simply removed. The backup file is
|
|
128
|
+
* cleaned up either way.
|
|
129
|
+
*/
|
|
130
|
+
export function uninstallCursorUserMcp(options = {}) {
|
|
131
|
+
const path = options.cursorConfigPath ?? defaultCursorUserConfigPath();
|
|
132
|
+
const backupPath = path + BACKUP_SUFFIX;
|
|
133
|
+
const hasBackup = existsSync(backupPath);
|
|
134
|
+
const cleanupBackup = () => {
|
|
135
|
+
if (hasBackup) {
|
|
136
|
+
try {
|
|
137
|
+
unlinkSync(backupPath);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* non-fatal */
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
if (!existsSync(path)) {
|
|
145
|
+
cleanupBackup();
|
|
146
|
+
return { kind: "noop" };
|
|
147
|
+
}
|
|
148
|
+
const rootRead = readRootObject(path);
|
|
149
|
+
if ("kind" in rootRead && typeof rootRead.kind === "string" && rootRead.kind === "error") {
|
|
150
|
+
return rootRead;
|
|
151
|
+
}
|
|
152
|
+
const root = { ...rootRead };
|
|
153
|
+
const rawServers = root["mcpServers"];
|
|
154
|
+
if (typeof rawServers !== "object" || rawServers === null || Array.isArray(rawServers)) {
|
|
155
|
+
cleanupBackup();
|
|
156
|
+
return { kind: "noop" };
|
|
157
|
+
}
|
|
158
|
+
const mcpServers = { ...rawServers };
|
|
159
|
+
// If a backup exists, recover the user's ORIGINAL aixle-insights entry (if they
|
|
160
|
+
// had one we overwrote) rather than just deleting ours — but only that one key,
|
|
161
|
+
// leaving every current sibling entry intact.
|
|
162
|
+
let restoredPrior = false;
|
|
163
|
+
if (hasBackup) {
|
|
164
|
+
const backupRead = readRootObject(backupPath);
|
|
165
|
+
const backupIsObject = !("kind" in backupRead &&
|
|
166
|
+
typeof backupRead.kind === "string" &&
|
|
167
|
+
backupRead.kind === "error");
|
|
168
|
+
if (backupIsObject) {
|
|
169
|
+
const backupServers = backupRead["mcpServers"];
|
|
170
|
+
if (typeof backupServers === "object" && backupServers !== null && !Array.isArray(backupServers)) {
|
|
171
|
+
const priorEntry = backupServers[AIXLE_INSIGHTS_MCP_KEY];
|
|
172
|
+
if (priorEntry !== undefined) {
|
|
173
|
+
mcpServers[AIXLE_INSIGHTS_MCP_KEY] = priorEntry;
|
|
174
|
+
restoredPrior = true;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!restoredPrior) {
|
|
180
|
+
if (mcpServers[AIXLE_INSIGHTS_MCP_KEY] === undefined) {
|
|
181
|
+
cleanupBackup();
|
|
182
|
+
return { kind: "noop" };
|
|
183
|
+
}
|
|
184
|
+
delete mcpServers[AIXLE_INSIGHTS_MCP_KEY];
|
|
185
|
+
}
|
|
186
|
+
root["mcpServers"] = mcpServers;
|
|
187
|
+
const writeResult = atomicWriteJson(path, root);
|
|
188
|
+
if (writeResult.kind === "error") {
|
|
189
|
+
return writeResult;
|
|
190
|
+
}
|
|
191
|
+
cleanupBackup();
|
|
192
|
+
return restoredPrior ? { kind: "restored", backupPath } : { kind: "removed" };
|
|
193
|
+
}
|
package/dist/install/index.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./claude.js";
|
|
2
|
-
|
|
3
|
-
export type
|
|
2
|
+
import { type InstallCursorUserMcpOptions, type UninstallResult } from "./cursor.js";
|
|
3
|
+
export type SupportedEditor = "claude" | "cursor";
|
|
4
|
+
export type { InstallClaudeUserMcpOptions, InstallCursorUserMcpOptions, InstallResult, UninstallResult, };
|
|
4
5
|
/**
|
|
5
|
-
* Editor dispatch for MCP install hooks.
|
|
6
|
+
* Editor dispatch for MCP install hooks.
|
|
6
7
|
*/
|
|
7
|
-
export declare function installEditorMcp(editor: SupportedEditor, options?: InstallClaudeUserMcpOptions): InstallResult;
|
|
8
|
+
export declare function installEditorMcp(editor: SupportedEditor, options?: InstallClaudeUserMcpOptions & InstallCursorUserMcpOptions): InstallResult;
|
|
8
9
|
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
10
|
+
export { installCursorUserMcp, uninstallCursorUserMcp, defaultCursorUserConfigPath, } from "./cursor.js";
|
package/dist/install/index.js
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { installClaudeUserMcp, } from "./claude.js";
|
|
2
|
+
import { installCursorUserMcp, } from "./cursor.js";
|
|
2
3
|
/**
|
|
3
|
-
* Editor dispatch for MCP install hooks.
|
|
4
|
+
* Editor dispatch for MCP install hooks.
|
|
4
5
|
*/
|
|
5
6
|
export function installEditorMcp(editor, options = {}) {
|
|
6
7
|
if (editor === "claude") {
|
|
7
8
|
return installClaudeUserMcp(options);
|
|
8
9
|
}
|
|
10
|
+
if (editor === "cursor") {
|
|
11
|
+
return installCursorUserMcp(options);
|
|
12
|
+
}
|
|
9
13
|
return { kind: "error", message: `Unsupported editor for MCP install: ${String(editor)}` };
|
|
10
14
|
}
|
|
11
15
|
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
16
|
+
export { installCursorUserMcp, uninstallCursorUserMcp, defaultCursorUserConfigPath, } from "./cursor.js";
|
package/dist/lib/client.d.ts
CHANGED
|
@@ -8,6 +8,13 @@ export interface IngestPayload {
|
|
|
8
8
|
[key: string]: unknown;
|
|
9
9
|
}
|
|
10
10
|
export interface PostEventOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Skip the HTTPS-or-loopback gate for this host. Only ever set this to
|
|
13
|
+
* `true` when the caller has independently confirmed the user explicitly
|
|
14
|
+
* consented via `init --insecure` for this exact credential (see
|
|
15
|
+
* `StoredCredentials.insecureHttpAllowed`). Defaults to `false`.
|
|
16
|
+
*/
|
|
17
|
+
allowInsecureHttp?: boolean;
|
|
11
18
|
/** Override default console.error on non-ok HTTP response. */
|
|
12
19
|
onHttpError?: (status: number, statusText: string, body: string) => void;
|
|
13
20
|
/** Override default console.error on network-level failure. */
|
package/dist/lib/client.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { evaluateTransportSecurity } from "./transport-security.js";
|
|
1
2
|
/**
|
|
2
3
|
* POST a single event payload to the db90 ingest endpoint.
|
|
3
4
|
*
|
|
@@ -10,6 +11,22 @@
|
|
|
10
11
|
* Never throws — callers can rely on Promise.allSettled-style aggregation.
|
|
11
12
|
*/
|
|
12
13
|
export async function postEvent(payload, host, token, options = {}) {
|
|
14
|
+
const transportSecurity = evaluateTransportSecurity(host, {
|
|
15
|
+
allowInsecureHttp: options.allowInsecureHttp === true,
|
|
16
|
+
label: "DB90 ingest host",
|
|
17
|
+
});
|
|
18
|
+
if (!transportSecurity.ok) {
|
|
19
|
+
// Deliberately does NOT call options.onNetworkError/onHttpError: the retry
|
|
20
|
+
// wrapper in src/client.ts treats those as transient and retries with
|
|
21
|
+
// backoff. A scheme rejection is permanent — retrying wastes ~21s per
|
|
22
|
+
// event for nothing. Bare console.error mirrors this file's existing
|
|
23
|
+
// unrecoverable-failure logging style.
|
|
24
|
+
console.error(`Blocked event send — ${transportSecurity.error}`);
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
if (transportSecurity.warning) {
|
|
28
|
+
console.error(`Warning: ${transportSecurity.warning}`);
|
|
29
|
+
}
|
|
13
30
|
const url = `${host.replace(/\/$/, "")}/api/v1/ingest/events`;
|
|
14
31
|
const headers = {
|
|
15
32
|
"Content-Type": "application/json",
|
package/dist/lib/config.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import { mcpLog } from "../log.js";
|
|
3
4
|
/**
|
|
4
5
|
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
5
6
|
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
@@ -32,8 +33,12 @@ export function loadBaseConfig(configDir, parsePricing) {
|
|
|
32
33
|
return result;
|
|
33
34
|
}
|
|
34
35
|
}
|
|
35
|
-
catch {
|
|
36
|
-
|
|
36
|
+
catch (err) {
|
|
37
|
+
const code = err?.code;
|
|
38
|
+
if (code !== "ENOENT") {
|
|
39
|
+
// Config file exists but failed to parse — distinguishes tampering from "never created".
|
|
40
|
+
mcpLog.warn("config_parse_failed", { path: configPath, error: err instanceof Error ? err.message : String(err) }, false);
|
|
41
|
+
}
|
|
37
42
|
}
|
|
38
43
|
return {};
|
|
39
44
|
}
|
|
@@ -6,8 +6,8 @@ export interface LookupResult {
|
|
|
6
6
|
project_id: string;
|
|
7
7
|
name: string;
|
|
8
8
|
}
|
|
9
|
-
export declare function resolveProjectId(flagValue: string | undefined, configValue: string | undefined, host: string, token: string, verbose: boolean): Promise<ProjectResolution>;
|
|
10
|
-
export declare function resolveProjectIdForRepoPath(repoPath: string, host: string, token: string, verbose: boolean): Promise<ProjectResolution>;
|
|
9
|
+
export declare function resolveProjectId(flagValue: string | undefined, configValue: string | undefined, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<ProjectResolution>;
|
|
10
|
+
export declare function resolveProjectIdForRepoPath(repoPath: string, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<ProjectResolution>;
|
|
11
11
|
export declare function getGitRemote(verbose: boolean): string | null;
|
|
12
12
|
export declare function getGitRemoteForPath(repoPath: string, verbose: boolean): string | null;
|
|
13
13
|
/**
|
|
@@ -24,7 +24,7 @@ export declare function canonicalizeGitRemote(remote: string, verbose: boolean):
|
|
|
24
24
|
* git remote. Expand to remotes the API lookup understands (`Project.normalize_git_remote`).
|
|
25
25
|
*/
|
|
26
26
|
export declare function repoNameToGitRemoteCandidates(repoName: string): string[];
|
|
27
|
-
export declare function lookupProjectByRepoName(repoName: string, host: string, token: string, verbose: boolean): Promise<LookupResult | "not-found" | null>;
|
|
27
|
+
export declare function lookupProjectByRepoName(repoName: string, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<LookupResult | "not-found" | null>;
|
|
28
28
|
/** Payload shape shared by db90-cursor and telemetry-mcp commit mappers. */
|
|
29
29
|
export interface CommitAttributionPayload {
|
|
30
30
|
event_type?: string;
|
|
@@ -44,5 +44,6 @@ export declare function enrichCommitProjectAttribution(payloads: CommitAttributi
|
|
|
44
44
|
host: string;
|
|
45
45
|
token: string;
|
|
46
46
|
verbose?: boolean;
|
|
47
|
+
allowInsecureHttp?: boolean;
|
|
47
48
|
}): Promise<void>;
|
|
48
|
-
export declare function lookupProjectByRemote(gitRemote: string, host: string, token: string, verbose: boolean): Promise<LookupResult | "not-found" | null>;
|
|
49
|
+
export declare function lookupProjectByRemote(gitRemote: string, host: string, token: string, verbose: boolean, allowInsecureHttp?: boolean): Promise<LookupResult | "not-found" | null>;
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { evaluateTransportSecurity } from "./transport-security.js";
|
|
3
|
+
import { isSafeSshHost } from "./spawn-arg-safety.js";
|
|
4
|
+
import { safeGitRepoPath } from "./repo-path-safety.js";
|
|
2
5
|
/** Coerce empty string to undefined so "" is treated as "not set" */
|
|
3
6
|
function coerce(val) {
|
|
4
7
|
return val === "" ? undefined : val;
|
|
5
8
|
}
|
|
6
|
-
export async function resolveProjectId(flagValue, configValue, host, token, verbose) {
|
|
9
|
+
export async function resolveProjectId(flagValue, configValue, host, token, verbose, allowInsecureHttp = false) {
|
|
7
10
|
const flag = coerce(flagValue);
|
|
8
11
|
const config = coerce(configValue);
|
|
9
12
|
if (flag !== undefined)
|
|
@@ -13,18 +16,18 @@ export async function resolveProjectId(flagValue, configValue, host, token, verb
|
|
|
13
16
|
const gitRemote = getGitRemote(verbose);
|
|
14
17
|
if (gitRemote === null)
|
|
15
18
|
return { projectId: null, source: "none" };
|
|
16
|
-
const result = await lookupProjectByRemote(gitRemote, host, token, verbose);
|
|
19
|
+
const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
|
|
17
20
|
if (result === "not-found")
|
|
18
21
|
return { projectId: null, source: "auto-detect-not-found" };
|
|
19
22
|
if (result !== null)
|
|
20
23
|
return { projectId: result.project_id, source: "auto-detect" };
|
|
21
24
|
return { projectId: null, source: "none" };
|
|
22
25
|
}
|
|
23
|
-
export async function resolveProjectIdForRepoPath(repoPath, host, token, verbose) {
|
|
26
|
+
export async function resolveProjectIdForRepoPath(repoPath, host, token, verbose, allowInsecureHttp = false) {
|
|
24
27
|
const gitRemote = getGitRemoteForPath(repoPath, verbose);
|
|
25
28
|
if (gitRemote === null)
|
|
26
29
|
return { projectId: null, source: "none" };
|
|
27
|
-
const result = await lookupProjectByRemote(gitRemote, host, token, verbose);
|
|
30
|
+
const result = await lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp);
|
|
28
31
|
if (result === "not-found")
|
|
29
32
|
return { projectId: null, source: "auto-detect-not-found" };
|
|
30
33
|
if (result !== null)
|
|
@@ -47,8 +50,19 @@ export function getGitRemote(verbose) {
|
|
|
47
50
|
}
|
|
48
51
|
}
|
|
49
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
|
+
}
|
|
50
64
|
try {
|
|
51
|
-
const out = execFileSync("git", ["-C",
|
|
65
|
+
const out = execFileSync("git", ["-C", safePath, "remote", "get-url", "origin"], {
|
|
52
66
|
encoding: "utf-8",
|
|
53
67
|
stdio: ["ignore", "pipe", "pipe"],
|
|
54
68
|
timeout: 5000,
|
|
@@ -74,12 +88,21 @@ export function canonicalizeGitRemote(remote, verbose) {
|
|
|
74
88
|
if (!trimmed)
|
|
75
89
|
return remote;
|
|
76
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. */
|
|
77
95
|
const sshUrl = trimmed.match(/^ssh:\/\/(?:([\w.-]+)@)?([^:/]+)(?::\d+)?\/(.+)$/i);
|
|
78
96
|
const host = scp?.[2] ?? sshUrl?.[2];
|
|
79
|
-
|
|
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))
|
|
80
101
|
return trimmed;
|
|
81
102
|
const resolved = resolveSshHostName(host, verbose);
|
|
82
|
-
if (!resolved || resolved
|
|
103
|
+
if (!resolved || !isSafeSshHost(resolved))
|
|
104
|
+
return trimmed;
|
|
105
|
+
if (resolved.toLowerCase() === host.toLowerCase())
|
|
83
106
|
return trimmed;
|
|
84
107
|
if (verbose)
|
|
85
108
|
console.log(`[verbose] Resolved SSH host alias ${host} -> ${resolved}`);
|
|
@@ -89,6 +112,13 @@ export function canonicalizeGitRemote(remote, verbose) {
|
|
|
89
112
|
return `ssh://${user}${resolved}/${sshUrl[3]}`;
|
|
90
113
|
}
|
|
91
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
|
+
}
|
|
92
122
|
try {
|
|
93
123
|
const out = execFileSync("ssh", ["-G", host], {
|
|
94
124
|
encoding: "utf-8",
|
|
@@ -128,12 +158,17 @@ export function repoNameToGitRemoteCandidates(repoName) {
|
|
|
128
158
|
if (trimmed.includes("://") || trimmed.includes("@")) {
|
|
129
159
|
return [trimmed];
|
|
130
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. */
|
|
131
166
|
if (/^[\w.-]+\/[\w.-]+(\/[\w.-]+)*$/.test(trimmed)) {
|
|
132
167
|
return [`https://github.com/${trimmed}`, `git@github.com:${trimmed}.git`];
|
|
133
168
|
}
|
|
134
169
|
return [];
|
|
135
170
|
}
|
|
136
|
-
export async function lookupProjectByRepoName(repoName, host, token, verbose) {
|
|
171
|
+
export async function lookupProjectByRepoName(repoName, host, token, verbose, allowInsecureHttp = false) {
|
|
137
172
|
const candidates = repoNameToGitRemoteCandidates(repoName);
|
|
138
173
|
if (candidates.length === 0) {
|
|
139
174
|
if (verbose)
|
|
@@ -141,7 +176,7 @@ export async function lookupProjectByRepoName(repoName, host, token, verbose) {
|
|
|
141
176
|
return "not-found";
|
|
142
177
|
}
|
|
143
178
|
for (const candidate of candidates) {
|
|
144
|
-
const result = await lookupProjectByRemote(candidate, host, token, verbose);
|
|
179
|
+
const result = await lookupProjectByRemote(candidate, host, token, verbose, allowInsecureHttp);
|
|
145
180
|
if (result === "not-found")
|
|
146
181
|
continue;
|
|
147
182
|
return result;
|
|
@@ -164,7 +199,7 @@ export async function enrichCommitProjectAttribution(payloads, options) {
|
|
|
164
199
|
const repoName = payload.metadata?.repo_name;
|
|
165
200
|
if (!repoName)
|
|
166
201
|
continue;
|
|
167
|
-
const result = await lookupProjectByRepoName(repoName, options.host, options.token, options.verbose ?? false);
|
|
202
|
+
const result = await lookupProjectByRepoName(repoName, options.host, options.token, options.verbose ?? false, options.allowInsecureHttp === true);
|
|
168
203
|
if (result && typeof result === "object" && "project_id" in result) {
|
|
169
204
|
payload.project_id = result.project_id;
|
|
170
205
|
if (options.verbose) {
|
|
@@ -173,7 +208,18 @@ export async function enrichCommitProjectAttribution(payloads, options) {
|
|
|
173
208
|
}
|
|
174
209
|
}
|
|
175
210
|
}
|
|
176
|
-
export async function lookupProjectByRemote(gitRemote, host, token, verbose) {
|
|
211
|
+
export async function lookupProjectByRemote(gitRemote, host, token, verbose, allowInsecureHttp = false) {
|
|
212
|
+
const transportSecurity = evaluateTransportSecurity(host, {
|
|
213
|
+
allowInsecureHttp,
|
|
214
|
+
label: "DB90 project-lookup host",
|
|
215
|
+
});
|
|
216
|
+
if (!transportSecurity.ok) {
|
|
217
|
+
console.error(`Blocked project lookup — ${transportSecurity.error}`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
if (transportSecurity.warning) {
|
|
221
|
+
console.error(`Warning: ${transportSecurity.warning}`);
|
|
222
|
+
}
|
|
177
223
|
const url = `${host.replace(/\/$/, "")}/api/v1/projects/lookup?git_remote=${encodeURIComponent(gitRemote)}`;
|
|
178
224
|
try {
|
|
179
225
|
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
|
|
@@ -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;
|