@aixle/insights 0.2.1-staging → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -15
- package/dist/auth/credentials.js +24 -8
- package/dist/auth/exchange.d.ts +1 -1
- package/dist/auth/exchange.js +1 -1
- package/dist/auth/flow.d.ts +1 -8
- package/dist/auth/flow.js +5 -26
- package/dist/auth/keycloak.d.ts +1 -1
- package/dist/auth/keycloak.js +1 -20
- package/dist/cli.d.ts +3 -5
- package/dist/cli.js +20 -68
- package/dist/collect-cursor-payloads.d.ts +3 -3
- package/dist/cursor-checkpoints.d.ts +2 -2
- package/dist/cursor-payload-contract.d.ts +5 -5
- package/dist/health.d.ts +0 -2
- package/dist/health.js +0 -12
- package/dist/hooks/cursor-hooks-mapper.d.ts +3 -3
- package/dist/hooks/cursor-hooks-mapper.js +1 -1
- package/dist/hooks/cursor-hooks-reader.js +8 -1
- package/dist/install/index.d.ts +4 -6
- package/dist/install/index.js +1 -6
- package/dist/lib/config.d.ts +2 -2
- package/dist/lib/config.js +28 -20
- package/dist/lib/parse-error.d.ts +21 -0
- package/dist/lib/parse-error.js +25 -0
- package/dist/lib/project-resolver.js +37 -3
- package/dist/lib/repo-path-safety.d.ts +35 -0
- package/dist/lib/repo-path-safety.js +102 -0
- package/dist/lib/spawn-arg-safety.d.ts +25 -0
- package/dist/lib/spawn-arg-safety.js +49 -0
- package/dist/lib/transport-security.d.ts +0 -1
- package/dist/lib/transport-security.js +1 -1
- package/dist/readers/claude.d.ts +7 -2
- package/dist/readers/claude.js +44 -15
- package/dist/readers/cursor.d.ts +7 -7
- package/dist/readers/cursor.js +27 -6
- package/dist/risk-scanner.js +7 -0
- package/dist/server.d.ts +0 -17
- package/dist/server.js +2 -25
- package/dist/state.js +38 -35
- package/dist/sync.d.ts +11 -2
- package/dist/sync.js +32 -14
- package/package.json +8 -4
- package/dist/install/cursor.d.ts +0 -34
- package/dist/install/cursor.js +0 -193
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { CursorDb90Payload } from "./readers/cursor.js";
|
|
2
2
|
/** Ingest paths for Cursor payloads emitted by telemetry-mcp. */
|
|
3
3
|
export type CursorIngestPath = "daily_tab" | "daily_composer" | "legacy_request" | "recent_commit" | "mcp_transcript" | "cursor_hook";
|
|
4
4
|
export interface PayloadValidationResult {
|
|
@@ -6,12 +6,12 @@ export interface PayloadValidationResult {
|
|
|
6
6
|
errors: string[];
|
|
7
7
|
path: CursorIngestPath | "unknown";
|
|
8
8
|
}
|
|
9
|
-
export declare function inferIngestPath(payload:
|
|
10
|
-
export declare function validateCursorPayload(payload:
|
|
9
|
+
export declare function inferIngestPath(payload: CursorDb90Payload): CursorIngestPath | "unknown";
|
|
10
|
+
export declare function validateCursorPayload(payload: CursorDb90Payload): PayloadValidationResult;
|
|
11
11
|
export interface DryRunMatrixRow {
|
|
12
12
|
path: CursorIngestPath | "unknown";
|
|
13
13
|
count: number;
|
|
14
14
|
sample_occurred_at: string | null;
|
|
15
15
|
}
|
|
16
|
-
export declare function summarizeDryRunMatrix(payloads:
|
|
17
|
-
export declare function printCursorDryRunValidationReport(payloads:
|
|
16
|
+
export declare function summarizeDryRunMatrix(payloads: CursorDb90Payload[]): DryRunMatrixRow[];
|
|
17
|
+
export declare function printCursorDryRunValidationReport(payloads: CursorDb90Payload[]): boolean;
|
package/dist/health.d.ts
CHANGED
|
@@ -16,8 +16,6 @@ export interface HealthSnapshot {
|
|
|
16
16
|
hooks_queue_depth: number;
|
|
17
17
|
/** Best-effort merge of credential-scoped `mcp_operator` (latest `last_sync_at`). */
|
|
18
18
|
persisted: McpOperatorState | null;
|
|
19
|
-
/** True only when buildHealthSnapshot caught an unexpected error building the snapshot. */
|
|
20
|
-
snapshot_failed?: boolean;
|
|
21
19
|
/** In-process telemetry (same fields as `getSyncTelemetry`). */
|
|
22
20
|
process: {
|
|
23
21
|
last_sync_at: string | null;
|
package/dist/health.js
CHANGED
|
@@ -122,7 +122,6 @@ export async function buildHealthSnapshot() {
|
|
|
122
122
|
hooks_installed: hooksReport.hooks_json_installed,
|
|
123
123
|
hooks_queue_depth: hooksReport.queue_depth,
|
|
124
124
|
persisted: null,
|
|
125
|
-
snapshot_failed: true,
|
|
126
125
|
process: {
|
|
127
126
|
last_sync_at: telemetry.lastSyncAt,
|
|
128
127
|
last_result: telemetry.lastResult,
|
|
@@ -141,18 +140,11 @@ export function healthSnapshotToStatusPayload(snapshot) {
|
|
|
141
140
|
const lastSyncAt = proc.last_sync_at ?? pers?.last_sync_at ?? null;
|
|
142
141
|
const lastResult = proc.last_result ?? snapshotToResult(pers?.last_result ?? null);
|
|
143
142
|
const errors = mergeErrors(proc.recent_errors, pers?.recent_errors ?? []);
|
|
144
|
-
const needsInit = !snapshot.authenticated && snapshot.snapshot_failed !== true;
|
|
145
|
-
const onboardingMessage = needsInit
|
|
146
|
-
? "aixle-insights is installed but not connected. Run `aixle-insights init` to start tracking — " +
|
|
147
|
-
"activity since install is not lost; the first sync after init backfills all of it."
|
|
148
|
-
: null;
|
|
149
143
|
return {
|
|
150
144
|
authenticated: snapshot.authenticated,
|
|
151
145
|
configured: snapshot.configured,
|
|
152
146
|
host: snapshot.host,
|
|
153
147
|
ingest_tools: snapshot.ingest_tools,
|
|
154
|
-
needs_init: needsInit,
|
|
155
|
-
onboarding_message: onboardingMessage,
|
|
156
148
|
last_sync_at: lastSyncAt,
|
|
157
149
|
last_result: lastResult,
|
|
158
150
|
sessions_synced: lastResult?.sent ?? 0,
|
|
@@ -199,9 +191,5 @@ export function formatHealthForCli(snapshot) {
|
|
|
199
191
|
else {
|
|
200
192
|
lines.push(" (none)");
|
|
201
193
|
}
|
|
202
|
-
if (typeof sta["onboarding_message"] === "string") {
|
|
203
|
-
lines.push("");
|
|
204
|
-
lines.push(sta["onboarding_message"]);
|
|
205
|
-
}
|
|
206
194
|
return lines.join("\n");
|
|
207
195
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { HookLogEvent } from "./hooks-config.js";
|
|
2
|
-
import type {
|
|
2
|
+
import type { CursorDb90Payload } from "../readers/cursor.js";
|
|
3
3
|
export { HookLogEvent };
|
|
4
4
|
/** State key prefix for hook event dedup in state.sessions. */
|
|
5
5
|
export declare const CURSOR_HOOK_STATE_PREFIX: "cursor:hook:";
|
|
@@ -16,7 +16,7 @@ export declare function shouldIngestHookEvent(event: HookLogEvent, verbose?: boo
|
|
|
16
16
|
/** Warn-only version gate — Cursor builds can have unusual version strings. */
|
|
17
17
|
export declare function warnOnCursorVersion(event: HookLogEvent, verbose: boolean): void;
|
|
18
18
|
/**
|
|
19
|
-
* Map a validated hook event to the
|
|
19
|
+
* Map a validated hook event to the CursorDb90Payload contract.
|
|
20
20
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
21
21
|
*/
|
|
22
|
-
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null):
|
|
22
|
+
export declare function mapHookEventToPayload(event: HookLogEvent, projectId?: string | null): CursorDb90Payload;
|
|
@@ -47,7 +47,7 @@ export function warnOnCursorVersion(event, verbose) {
|
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
|
-
* Map a validated hook event to the
|
|
50
|
+
* Map a validated hook event to the CursorDb90Payload contract.
|
|
51
51
|
* Call shouldIngestHookEvent() before this — it does not re-validate.
|
|
52
52
|
*/
|
|
53
53
|
export function mapHookEventToPayload(event, projectId) {
|
|
@@ -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
|
package/dist/install/index.d.ts
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { type InstallClaudeUserMcpOptions, type InstallResult } from "./claude.js";
|
|
2
|
-
|
|
3
|
-
export type
|
|
4
|
-
export type { InstallClaudeUserMcpOptions, InstallCursorUserMcpOptions, InstallResult, UninstallResult, };
|
|
2
|
+
export type SupportedEditor = "claude";
|
|
3
|
+
export type { InstallClaudeUserMcpOptions, InstallResult };
|
|
5
4
|
/**
|
|
6
|
-
* Editor dispatch for MCP install hooks.
|
|
5
|
+
* Editor dispatch for MCP install hooks. Only Claude Code is supported in this story.
|
|
7
6
|
*/
|
|
8
|
-
export declare function installEditorMcp(editor: SupportedEditor, options?: InstallClaudeUserMcpOptions
|
|
7
|
+
export declare function installEditorMcp(editor: SupportedEditor, options?: InstallClaudeUserMcpOptions): InstallResult;
|
|
9
8
|
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
10
|
-
export { installCursorUserMcp, uninstallCursorUserMcp, defaultCursorUserConfigPath, } from "./cursor.js";
|
package/dist/install/index.js
CHANGED
|
@@ -1,16 +1,11 @@
|
|
|
1
1
|
import { installClaudeUserMcp, } from "./claude.js";
|
|
2
|
-
import { installCursorUserMcp, } from "./cursor.js";
|
|
3
2
|
/**
|
|
4
|
-
* Editor dispatch for MCP install hooks.
|
|
3
|
+
* Editor dispatch for MCP install hooks. Only Claude Code is supported in this story.
|
|
5
4
|
*/
|
|
6
5
|
export function installEditorMcp(editor, options = {}) {
|
|
7
6
|
if (editor === "claude") {
|
|
8
7
|
return installClaudeUserMcp(options);
|
|
9
8
|
}
|
|
10
|
-
if (editor === "cursor") {
|
|
11
|
-
return installCursorUserMcp(options);
|
|
12
|
-
}
|
|
13
9
|
return { kind: "error", message: `Unsupported editor for MCP install: ${String(editor)}` };
|
|
14
10
|
}
|
|
15
11
|
export { installClaudeUserMcp, defaultClaudeUserConfigPath, desiredAixleInsightsEntry, aixleInsightsEntryMatchesDesired, } from "./claude.js";
|
|
16
|
-
export { installCursorUserMcp, uninstallCursorUserMcp, defaultCursorUserConfigPath, } from "./cursor.js";
|
package/dist/lib/config.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
package/dist/lib/config.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { mcpLog } from "../log.js";
|
|
4
|
+
import { describeReadFailure } from "./parse-error.js";
|
|
4
5
|
/**
|
|
5
6
|
* Load a connector's `config.json` from disk. Returns `{}` on missing or
|
|
6
7
|
* malformed files — callers fall back to env vars / CLI flags / defaults.
|
|
7
8
|
*
|
|
8
|
-
* @param configDir Directory containing `config.json`, typically the
|
|
9
|
-
*
|
|
9
|
+
* @param configDir Directory containing `config.json`, typically the app home directory
|
|
10
|
+
* (`~/.aixle-insights`, or `AIXLE_INSIGHTS_HOME` when set).
|
|
10
11
|
* @param parsePricing Optional callback that extracts a connector-specific
|
|
11
12
|
* pricing shape from the raw parsed JSON. Returns
|
|
12
13
|
* `undefined` when the pricing block is missing or invalid.
|
|
@@ -16,29 +17,36 @@ import { mcpLog } from "../log.js";
|
|
|
16
17
|
*/
|
|
17
18
|
export function loadBaseConfig(configDir, parsePricing) {
|
|
18
19
|
const configPath = join(configDir, "config.json");
|
|
20
|
+
let parsed;
|
|
19
21
|
try {
|
|
20
|
-
|
|
21
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
22
|
-
const obj = parsed;
|
|
23
|
-
const result = {
|
|
24
|
-
token: typeof obj.token === "string" ? obj.token : undefined,
|
|
25
|
-
host: typeof obj.host === "string" ? obj.host : undefined,
|
|
26
|
-
project_id: typeof obj.project_id === "string" ? obj.project_id : undefined,
|
|
27
|
-
};
|
|
28
|
-
if (parsePricing) {
|
|
29
|
-
const pricing = parsePricing(obj);
|
|
30
|
-
if (pricing !== undefined)
|
|
31
|
-
result.pricing = pricing;
|
|
32
|
-
}
|
|
33
|
-
return result;
|
|
34
|
-
}
|
|
22
|
+
parsed = JSON.parse(readFileSync(configPath, "utf-8"));
|
|
35
23
|
}
|
|
36
24
|
catch (err) {
|
|
37
25
|
const code = err?.code;
|
|
38
26
|
if (code !== "ENOENT") {
|
|
39
|
-
// Config file exists but
|
|
40
|
-
|
|
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);
|
|
41
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 {};
|
|
39
|
+
}
|
|
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;
|
|
42
50
|
}
|
|
43
|
-
return
|
|
51
|
+
return result;
|
|
44
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
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { isSafeSshHost } from "./spawn-arg-safety.js";
|
|
3
|
+
import { safeGitRepoPath } from "./repo-path-safety.js";
|
|
2
4
|
import { evaluateTransportSecurity } from "./transport-security.js";
|
|
3
5
|
/** Coerce empty string to undefined so "" is treated as "not set" */
|
|
4
6
|
function coerce(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
|
+
}
|
|
@@ -9,5 +9,4 @@ export interface TransportSecurityOptions {
|
|
|
9
9
|
allowInsecureHttp: boolean;
|
|
10
10
|
label: string;
|
|
11
11
|
}
|
|
12
|
-
export declare function isLoopbackHost(hostname: string): boolean;
|
|
13
12
|
export declare function evaluateTransportSecurity(rawUrl: string, options: TransportSecurityOptions): TransportSecurityResult;
|
|
@@ -8,7 +8,7 @@ function isIpv4Loopback(hostname) {
|
|
|
8
8
|
octet <= 255 &&
|
|
9
9
|
String(octet) === parts[index]) && octets[0] === 127;
|
|
10
10
|
}
|
|
11
|
-
|
|
11
|
+
function isLoopbackHost(hostname) {
|
|
12
12
|
const normalized = hostname.toLowerCase();
|
|
13
13
|
return normalized === "localhost" ||
|
|
14
14
|
normalized === "::1" ||
|
package/dist/readers/claude.d.ts
CHANGED
|
@@ -59,7 +59,13 @@ export interface ClaudeTranscriptTurn {
|
|
|
59
59
|
toolUses: ClaudeCollectedToolUse[];
|
|
60
60
|
navToolCalls: number;
|
|
61
61
|
totalToolCalls: number;
|
|
62
|
-
|
|
62
|
+
/**
|
|
63
|
+
* Fingerprint of the turn's content (prompt + assistant text + tool-use set).
|
|
64
|
+
* A turn keeps the same turnId as Claude appends more tool_use blocks to it,
|
|
65
|
+
* so sync compares this hash to detect appended derivatives and re-emit them
|
|
66
|
+
* instead of skipping the turn forever on its unchanged id (DB90DV-259).
|
|
67
|
+
*/
|
|
68
|
+
contentHash: string;
|
|
63
69
|
}
|
|
64
70
|
/** Payload shape for the parent chat turn (carries full token cost). */
|
|
65
71
|
export interface ClaudePayload extends IngestPayload {
|
|
@@ -90,7 +96,6 @@ export interface ClaudePayload extends IngestPayload {
|
|
|
90
96
|
cost_model: "token_count";
|
|
91
97
|
nav_tool_calls: number;
|
|
92
98
|
total_tool_calls: number;
|
|
93
|
-
message_ids?: string[];
|
|
94
99
|
};
|
|
95
100
|
}
|
|
96
101
|
/** Payload shape for derivative tool-use children (cost_usd: 0, no tokens). */
|