@aixle/insights 0.2.0 → 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/dist/health.d.ts CHANGED
@@ -26,6 +26,6 @@ export interface HealthSnapshot {
26
26
  /** Pick the freshest persisted operator block by `last_sync_at` lexicographic (ISO-safe). */
27
27
  export declare function mergePersistedOperators(snapshots: McpOperatorState[]): McpOperatorState | null;
28
28
  export declare function buildHealthSnapshot(): Promise<HealthSnapshot>;
29
- /** MCP `db90_status` JSON — single source shared with CLI health. */
29
+ /** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
30
30
  export declare function healthSnapshotToStatusPayload(snapshot: HealthSnapshot): Record<string, unknown>;
31
31
  export declare function formatHealthForCli(snapshot: HealthSnapshot): string;
package/dist/health.js CHANGED
@@ -133,7 +133,7 @@ export async function buildHealthSnapshot() {
133
133
  };
134
134
  }
135
135
  }
136
- /** MCP `db90_status` JSON — single source shared with CLI health. */
136
+ /** MCP `aixle_insights_status` JSON — single source shared with CLI health. */
137
137
  export function healthSnapshotToStatusPayload(snapshot) {
138
138
  const proc = snapshot.process;
139
139
  const pers = snapshot.persisted;
@@ -8,6 +8,8 @@ export interface ProcessHooksQueueParams {
8
8
  state: State;
9
9
  host: string;
10
10
  token: string;
11
+ /** Mirrors StoredCredentials.insecureHttpAllowed — set when `init --insecure` was used for this host. */
12
+ allowInsecureHttp?: boolean;
11
13
  /** Called when a 429 is received. */
12
14
  on429: (retryAfter: number, quotaExceeded: boolean) => void;
13
15
  /** If true, skip events already in state.sessions. */
@@ -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
@@ -47,7 +54,7 @@ function rewriteQueueKeepingLines(queuePath, allLines, keepIndices) {
47
54
  * Partial failure: lines that succeeded are removed; lines that failed stay.
48
55
  */
49
56
  export async function processHooksQueue(params) {
50
- const { queuePath, scopeDir, host, token, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
57
+ const { queuePath, scopeDir, host, token, allowInsecureHttp = false, on429, skipSeen = true, resolveProjectId, verbose = false, } = params;
51
58
  if (!existsSync(queuePath)) {
52
59
  return { sent: 0, failed: 0, skipped: 0, state: params.state };
53
60
  }
@@ -97,7 +104,7 @@ export async function processHooksQueue(params) {
97
104
  projectId = await resolveProjectId(workspace);
98
105
  }
99
106
  const payload = mapHookEventToPayload(event, projectId);
100
- const ok = await postEvent(payload, host, token, { on429 });
107
+ const ok = await postEvent(payload, host, token, { on429, allowInsecureHttp });
101
108
  if (ok) {
102
109
  totalSent++;
103
110
  stateMut = markSessionSent(stateMut, dedupeKey, 0);
@@ -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. */
@@ -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",
@@ -12,8 +12,8 @@ export interface BaseConfig {
12
12
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
13
13
  * malformed files — callers fall back to env vars / CLI flags / defaults.
14
14
  *
15
- * @param configDir Directory containing `config.json`, typically the
16
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
15
+ * @param configDir Directory containing `config.json`, typically the app home directory
16
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
17
17
  * @param parsePricing Optional callback that extracts a connector-specific
18
18
  * pricing shape from the raw parsed JSON. Returns
19
19
  * `undefined` when the pricing block is missing or invalid.
@@ -1,11 +1,13 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { mcpLog } from "../log.js";
4
+ import { describeReadFailure } from "./parse-error.js";
3
5
  /**
4
6
  * Load a connector's `config.json` from disk. Returns `{}` on missing or
5
7
  * malformed files — callers fall back to env vars / CLI flags / defaults.
6
8
  *
7
- * @param configDir Directory containing `config.json`, typically the
8
- * connector's `APP_DIR` (`~/.db90-claude` / `~/.db90-cursor`).
9
+ * @param configDir Directory containing `config.json`, typically the app home directory
10
+ * (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
9
11
  * @param parsePricing Optional callback that extracts a connector-specific
10
12
  * pricing shape from the raw parsed JSON. Returns
11
13
  * `undefined` when the pricing block is missing or invalid.
@@ -15,25 +17,36 @@ import { join } from "node:path";
15
17
  */
16
18
  export function loadBaseConfig(configDir, parsePricing) {
17
19
  const configPath = join(configDir, "config.json");
20
+ let parsed;
18
21
  try {
19
- const parsed = JSON.parse(readFileSync(configPath, "utf-8"));
20
- if (typeof parsed === "object" && parsed !== null) {
21
- const obj = parsed;
22
- const result = {
23
- token: typeof obj.token === "string" ? obj.token : undefined,
24
- host: typeof obj.host === "string" ? obj.host : undefined,
25
- project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
26
- };
27
- if (parsePricing) {
28
- const pricing = parsePricing(obj);
29
- if (pricing !== undefined)
30
- result.pricing = pricing;
31
- }
32
- return result;
22
+ parsed = JSON.parse(readFileSync(configPath, "utf-8"));
23
+ }
24
+ catch (err) {
25
+ const code = err?.code;
26
+ if (code !== "ENOENT") {
27
+ // Config file exists but is not valid JSON — distinguishes tampering from "never created".
28
+ // ENOENT stays silent: this file is optional and most users never create it.
29
+ mcpLog.warn("config_parse_failed", { path: configPath, ...describeReadFailure(err) }, false);
33
30
  }
31
+ return {};
32
+ }
33
+ // Valid JSON, but not a config object. Arrays are rejected explicitly because
34
+ // `typeof [] === "object"` would otherwise let them reach the happy path and be handed
35
+ // to `parsePricing`. Previously every non-object fell through silently. (DB90DV-699)
36
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37
+ mcpLog.warn("config_parse_failed", { path: configPath, reason: "invalid_shape" }, false);
38
+ return {};
34
39
  }
35
- catch {
36
- // missing or invalid config — fall through
40
+ const obj = parsed;
41
+ const result = {
42
+ token: typeof obj.token === "string" ? obj.token : undefined,
43
+ host: typeof obj.host === "string" ? obj.host : undefined,
44
+ project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
45
+ };
46
+ if (parsePricing) {
47
+ const pricing = parsePricing(obj);
48
+ if (pricing !== undefined)
49
+ result.pricing = pricing;
37
50
  }
38
- return {};
51
+ return result;
39
52
  }
@@ -0,0 +1,21 @@
1
+ export type ReadFailureReason = "invalid_json" | "unreadable";
2
+ /**
3
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
4
+ * payload) into a log-safe reason + error string.
5
+ *
6
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
7
+ * short enough input, the entirety) of the unparsed content — e.g.
8
+ * `JSON.parse("example_local_fixture_1234567890")` produces
9
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
10
+ * leak exactly the secret content the parse-failure events exist to describe without
11
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
12
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
13
+ * the error name, never `.message`.
14
+ *
15
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
16
+ * errno `code`, which never contains file content and is more actionable than a bare name.
17
+ */
18
+ export declare function describeReadFailure(err: unknown): {
19
+ reason: ReadFailureReason;
20
+ error: string;
21
+ };
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Classifies a caught error from `JSON.parse(readFileSync(...))` (or a parsed keychain
3
+ * payload) into a log-safe reason + error string.
4
+ *
5
+ * V8's `JSON.parse` throws a `SyntaxError` whose `.message` can embed a prefix (or, for a
6
+ * short enough input, the entirety) of the unparsed content — e.g.
7
+ * `JSON.parse("example_local_fixture_1234567890")` produces
8
+ * `Unexpected token 'e', "example_lo"... is not valid JSON`. Logging that message would
9
+ * leak exactly the secret content the parse-failure events exist to describe without
10
+ * exposing (see `credentials_parse_failed` / `credentials_keytar_parse_failed` /
11
+ * `config_parse_failed` / `state_parse_failed`). So for a `SyntaxError` this reports only
12
+ * the error name, never `.message`.
13
+ *
14
+ * Any other error (fs I/O — `EACCES`, `EISDIR`, etc.) is reported as `unreadable` using its
15
+ * errno `code`, which never contains file content and is more actionable than a bare name.
16
+ */
17
+ export function describeReadFailure(err) {
18
+ if (err instanceof SyntaxError) {
19
+ return { reason: "invalid_json", error: "SyntaxError" };
20
+ }
21
+ const code = err?.code;
22
+ if (code)
23
+ return { reason: "unreadable", error: code };
24
+ return { reason: "unreadable", error: err instanceof Error ? err.name : "unknown_error" };
25
+ }
@@ -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 { isSafeSshHost } from "./spawn-arg-safety.js";
3
+ import { safeGitRepoPath } from "./repo-path-safety.js";
4
+ import { evaluateTransportSecurity } from "./transport-security.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", repoPath, "remote", "get-url", "origin"], {
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
- 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))
80
101
  return trimmed;
81
102
  const resolved = resolveSshHostName(host, verbose);
82
- if (!resolved || resolved.toLowerCase() === host.toLowerCase())
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;
@@ -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
+ }