codegate-ai 0.14.0 → 0.14.2

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/config.d.ts CHANGED
@@ -56,6 +56,22 @@ export interface CodeGateConfig {
56
56
  workflow_audits?: WorkflowAuditConfig;
57
57
  suppress_findings: string[];
58
58
  suppression_rules?: SuppressionRule[];
59
+ /**
60
+ * Timeout (in milliseconds) applied to Layer 3 remote resource fetches
61
+ * (npm/PyPI registry lookups, git ls-remote, and any http/sse MCP probes).
62
+ * Kept deliberately low so a slow or deliberately stalling host cannot
63
+ * hang a scan. Overridable via `CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS`.
64
+ */
65
+ layer3_remote_fetch_timeout_ms: number;
66
+ /**
67
+ * Maximum response size (in bytes) accepted from a Layer 3 remote fetch.
68
+ * A declared `Content-Length` above this value is rejected immediately,
69
+ * and the streaming reader aborts once the running byte count exceeds
70
+ * this limit (defends against servers that lie about or omit
71
+ * `Content-Length`). Overridable via
72
+ * `CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES`.
73
+ */
74
+ layer3_remote_fetch_max_bytes: number;
59
75
  }
60
76
  export interface CliConfigOverrides {
61
77
  format?: OutputFormat;
@@ -68,6 +84,10 @@ export interface ResolveConfigOptions {
68
84
  cli?: CliConfigOverrides;
69
85
  }
70
86
  export declare const DEFAULT_CONFIG: CodeGateConfig;
87
+ /** Env var name that overrides `layer3_remote_fetch_timeout_ms`. */
88
+ export declare const LAYER3_REMOTE_FETCH_TIMEOUT_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS";
89
+ /** Env var name that overrides `layer3_remote_fetch_max_bytes`. */
90
+ export declare const LAYER3_REMOTE_FETCH_MAX_BYTES_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES";
71
91
  export declare function resolveEffectiveConfig(options: ResolveConfigOptions): CodeGateConfig;
72
92
  export declare function computeExitCode(findings: Finding[], threshold: SeverityThreshold): number;
73
93
  export declare function applyConfigPolicy(report: CodeGateReport, config: CodeGateConfig): CodeGateReport;
package/dist/config.js CHANGED
@@ -49,7 +49,32 @@ export const DEFAULT_CONFIG = {
49
49
  workflow_audits: { enabled: false },
50
50
  suppress_findings: [],
51
51
  suppression_rules: [],
52
+ layer3_remote_fetch_timeout_ms: 5000,
53
+ layer3_remote_fetch_max_bytes: 1_048_576,
52
54
  };
55
+ /** Env var name that overrides `layer3_remote_fetch_timeout_ms`. */
56
+ export const LAYER3_REMOTE_FETCH_TIMEOUT_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_TIMEOUT_MS";
57
+ /** Env var name that overrides `layer3_remote_fetch_max_bytes`. */
58
+ export const LAYER3_REMOTE_FETCH_MAX_BYTES_ENV = "CODEGATE_LAYER3_REMOTE_FETCH_MAX_BYTES";
59
+ function normalizePositiveInteger(value) {
60
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
61
+ return Math.floor(value);
62
+ }
63
+ if (typeof value === "string" && value.trim().length > 0) {
64
+ const parsed = Number(value.trim());
65
+ if (Number.isFinite(parsed) && parsed > 0) {
66
+ return Math.floor(parsed);
67
+ }
68
+ }
69
+ return undefined;
70
+ }
71
+ function readEnvOverride(name) {
72
+ const raw = process.env[name];
73
+ if (raw === undefined) {
74
+ return undefined;
75
+ }
76
+ return normalizePositiveInteger(raw);
77
+ }
53
78
  function normalizeOutputFormat(value) {
54
79
  if (!value) {
55
80
  return undefined;
@@ -351,6 +376,8 @@ export function resolveEffectiveConfig(options) {
351
376
  ...(globalConfig.suppression_rules ?? []),
352
377
  ...(projectConfig.suppression_rules ?? []),
353
378
  ],
379
+ layer3_remote_fetch_timeout_ms: pickFirst(readEnvOverride(LAYER3_REMOTE_FETCH_TIMEOUT_ENV), normalizePositiveInteger(projectConfig.layer3_remote_fetch_timeout_ms), normalizePositiveInteger(globalConfig.layer3_remote_fetch_timeout_ms), DEFAULT_CONFIG.layer3_remote_fetch_timeout_ms) ?? DEFAULT_CONFIG.layer3_remote_fetch_timeout_ms,
380
+ layer3_remote_fetch_max_bytes: pickFirst(readEnvOverride(LAYER3_REMOTE_FETCH_MAX_BYTES_ENV), normalizePositiveInteger(projectConfig.layer3_remote_fetch_max_bytes), normalizePositiveInteger(globalConfig.layer3_remote_fetch_max_bytes), DEFAULT_CONFIG.layer3_remote_fetch_max_bytes) ?? DEFAULT_CONFIG.layer3_remote_fetch_max_bytes,
354
381
  };
355
382
  }
356
383
  export function computeExitCode(findings, threshold) {
@@ -8,7 +8,23 @@ export interface ResourceRequest {
8
8
  export interface ResourceFetcherOptions {
9
9
  maxRetries?: number;
10
10
  timeoutMs?: number;
11
+ /**
12
+ * Maximum number of bytes accepted in the response body. Enforced against
13
+ * both the declared `Content-Length` header (if present) and the running
14
+ * byte count during streaming read. Defaults to 1 MiB.
15
+ */
16
+ maxBytes?: number;
11
17
  }
18
+ export declare const DEFAULT_FETCH_TIMEOUT_MS = 5000;
19
+ export declare const DEFAULT_FETCH_MAX_BYTES = 1048576;
20
+ /**
21
+ * Extract Layer 3 remote-fetch limits from the resolved CodeGate config.
22
+ * Kept here so callers don't have to remember the config field names.
23
+ */
24
+ export declare function resourceFetcherOptionsFromConfig(config: {
25
+ layer3_remote_fetch_timeout_ms: number;
26
+ layer3_remote_fetch_max_bytes: number;
27
+ }): ResourceFetcherOptions;
12
28
  export interface ResourceFetcherDeps {
13
29
  fetch: (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
14
30
  runCommand: (command: string, args: string[]) => Promise<SandboxCommandResult>;
@@ -1,4 +1,16 @@
1
1
  import { runSandboxCommand } from "./sandbox.js";
2
+ export const DEFAULT_FETCH_TIMEOUT_MS = 5000;
3
+ export const DEFAULT_FETCH_MAX_BYTES = 1_048_576;
4
+ /**
5
+ * Extract Layer 3 remote-fetch limits from the resolved CodeGate config.
6
+ * Kept here so callers don't have to remember the config field names.
7
+ */
8
+ export function resourceFetcherOptionsFromConfig(config) {
9
+ return {
10
+ timeoutMs: config.layer3_remote_fetch_timeout_ms,
11
+ maxBytes: config.layer3_remote_fetch_max_bytes,
12
+ };
13
+ }
2
14
  function defaultDeps() {
3
15
  return {
4
16
  fetch: (input, init) => fetch(input, init),
@@ -26,17 +38,87 @@ function endpointFor(request) {
26
38
  }
27
39
  return request.locator;
28
40
  }
29
- async function parseResponse(response) {
41
+ /**
42
+ * Read a response body while enforcing `maxBytes`. Returns the collected
43
+ * string, or throws a tagged error if the declared `Content-Length` or the
44
+ * streamed size exceeds the cap.
45
+ */
46
+ async function readBodyWithLimit(response, maxBytes) {
47
+ const declared = response.headers.get("content-length");
48
+ if (declared !== null) {
49
+ const parsed = Number(declared);
50
+ if (Number.isFinite(parsed) && parsed > maxBytes) {
51
+ // Drain & release the stream without reading bytes.
52
+ try {
53
+ await response.body?.cancel();
54
+ }
55
+ catch {
56
+ // no-op: cancel failures are non-fatal.
57
+ }
58
+ throw new Error(`response_too_large: declared Content-Length ${parsed} exceeds limit ${maxBytes}`);
59
+ }
60
+ }
61
+ const body = response.body;
62
+ if (!body) {
63
+ // No stream (e.g., HEAD or empty body): fall back to text().
64
+ const text = await response.text();
65
+ if (Buffer.byteLength(text, "utf8") > maxBytes) {
66
+ throw new Error(`response_too_large: body ${Buffer.byteLength(text, "utf8")} > ${maxBytes}`);
67
+ }
68
+ return text;
69
+ }
70
+ const reader = body.getReader();
71
+ const chunks = [];
72
+ let total = 0;
73
+ try {
74
+ while (true) {
75
+ const { done, value } = await reader.read();
76
+ if (done) {
77
+ break;
78
+ }
79
+ if (!value) {
80
+ continue;
81
+ }
82
+ total += value.byteLength;
83
+ if (total > maxBytes) {
84
+ try {
85
+ await reader.cancel();
86
+ }
87
+ catch {
88
+ // no-op
89
+ }
90
+ throw new Error(`response_too_large: streamed ${total} > ${maxBytes}`);
91
+ }
92
+ chunks.push(value);
93
+ }
94
+ }
95
+ finally {
96
+ try {
97
+ reader.releaseLock();
98
+ }
99
+ catch {
100
+ // releaseLock throws if the reader was already cancelled; ignore.
101
+ }
102
+ }
103
+ const buffer = Buffer.concat(chunks.map((chunk) => Buffer.from(chunk)));
104
+ return buffer.toString("utf8");
105
+ }
106
+ async function parseResponse(response, maxBytes) {
30
107
  const contentType = response.headers.get("content-type") ?? "";
108
+ const text = await readBodyWithLimit(response, maxBytes);
31
109
  if (contentType.includes("application/json")) {
32
- return (await response.json());
110
+ return JSON.parse(text);
33
111
  }
34
- return await response.text();
112
+ return text;
35
113
  }
36
114
  function timeoutError(error) {
37
115
  const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
38
116
  return message.includes("timeout") || message.includes("aborted");
39
117
  }
118
+ function isResponseTooLarge(error) {
119
+ const message = error instanceof Error ? error.message : String(error);
120
+ return message.startsWith("response_too_large");
121
+ }
40
122
  export async function fetchResourceMetadata(request, customDeps = defaultDeps(), options = {}) {
41
123
  const deps = customDeps;
42
124
  const startedAt = deps.now();
@@ -63,13 +145,19 @@ export async function fetchResourceMetadata(request, customDeps = defaultDeps(),
63
145
  };
64
146
  }
65
147
  const endpoint = endpointFor(request);
66
- const timeoutMs = options.timeoutMs ?? 5000;
148
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
149
+ const maxBytes = options.maxBytes ?? DEFAULT_FETCH_MAX_BYTES;
67
150
  for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
68
151
  try {
69
152
  const controller = new AbortController();
70
153
  const timer = setTimeout(() => controller.abort(), timeoutMs);
71
- const response = await deps.fetch(endpoint, { signal: controller.signal });
72
- clearTimeout(timer);
154
+ let response;
155
+ try {
156
+ response = await deps.fetch(endpoint, { signal: controller.signal });
157
+ }
158
+ finally {
159
+ clearTimeout(timer);
160
+ }
73
161
  if (response.status === 401 || response.status === 403) {
74
162
  return {
75
163
  status: "auth_failure",
@@ -90,14 +178,24 @@ export async function fetchResourceMetadata(request, customDeps = defaultDeps(),
90
178
  error: `HTTP ${response.status}`,
91
179
  };
92
180
  }
181
+ const metadata = await parseResponse(response, maxBytes);
93
182
  return {
94
183
  status: "ok",
95
184
  attempts: attempt + 1,
96
185
  elapsedMs: deps.now() - startedAt,
97
- metadata: await parseResponse(response),
186
+ metadata,
98
187
  };
99
188
  }
100
189
  catch (error) {
190
+ // Size-limit breaches are deterministic — do not retry, surface as network_error.
191
+ if (isResponseTooLarge(error)) {
192
+ return {
193
+ status: "network_error",
194
+ attempts: attempt + 1,
195
+ elapsedMs: deps.now() - startedAt,
196
+ error: error instanceof Error ? error.message : String(error),
197
+ };
198
+ }
101
199
  if (attempt < maxRetries) {
102
200
  await deps.sleep(100 * (attempt + 1));
103
201
  continue;
@@ -1,4 +1,4 @@
1
- import { type ResourceFetchResult, type ResourceRequest } from "./resource-fetcher.js";
1
+ import { type ResourceFetchResult, type ResourceFetcherOptions, type ResourceRequest } from "./resource-fetcher.js";
2
2
  export interface AcquiredToolDescription {
3
3
  name: string;
4
4
  description: string;
@@ -19,4 +19,7 @@ export interface ToolDescriptionAcquisitionResult {
19
19
  export interface ToolDescriptionAcquisitionDeps {
20
20
  fetchMetadata: (request: ResourceRequest) => Promise<ResourceFetchResult>;
21
21
  }
22
+ export interface ToolDescriptionAcquisitionOptions {
23
+ fetchOptions?: ResourceFetcherOptions;
24
+ }
22
25
  export declare function acquireToolDescriptions(candidate: ToolDescriptionCandidate, customDeps?: ToolDescriptionAcquisitionDeps): Promise<ToolDescriptionAcquisitionResult>;
@@ -1,7 +1,7 @@
1
1
  import { fetchResourceMetadata, } from "./resource-fetcher.js";
2
- function defaultDeps() {
2
+ function defaultDeps(options = {}) {
3
3
  return {
4
- fetchMetadata: async (request) => fetchResourceMetadata(request),
4
+ fetchMetadata: async (request) => fetchResourceMetadata(request, undefined, options.fetchOptions),
5
5
  };
6
6
  }
7
7
  function parseTools(metadata) {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * URL validation and normalisation helpers for Layer 3 remote resource handling.
3
+ *
4
+ * These helpers guarantee that remote resources (HTTP/SSE MCP endpoints,
5
+ * skill-referenced URLs) use a safe, canonical form before they are fed into
6
+ * finding `rule_id` / `file_path` fields or into the fetcher.
7
+ *
8
+ * Historically, L3 resource IDs were composed as `${kind}:${url}` which, for
9
+ * http/sse kinds, produced malformed values like `http:https://mcp.linear.app/mcp`
10
+ * (the kind collides with the URL's own scheme). `buildResourceId` avoids that
11
+ * double-scheme shape by reusing the URL itself as the id for http/sse kinds.
12
+ */
13
+ export type RemoteScheme = "http" | "https";
14
+ export interface NormalizeRemoteUrlResult {
15
+ ok: true;
16
+ url: string;
17
+ scheme: RemoteScheme;
18
+ }
19
+ export interface NormalizeRemoteUrlError {
20
+ ok: false;
21
+ reason: "empty" | "unsupported_scheme" | "missing_host" | "missing_scheme" | "invalid_url";
22
+ }
23
+ /**
24
+ * Validate and canonicalise a remote URL. Rejects non http/https schemes,
25
+ * missing hosts, and malformed inputs. Normalises a bare-host path to a
26
+ * single trailing slash and strips trailing slashes from longer paths.
27
+ */
28
+ export declare function normalizeRemoteUrl(input: string): NormalizeRemoteUrlResult | NormalizeRemoteUrlError;
29
+ export type DeepScanResourceKind = "npm" | "pypi" | "git" | "http" | "sse";
30
+ /**
31
+ * Build a canonical resource id used for findings (`rule_id`, `file_path`) and
32
+ * for consent prompts. For http/sse kinds the id is the URL itself (no
33
+ * `http:` / `sse:` prefix) to avoid the malformed `http:https://...` shape.
34
+ * For npm/pypi/git, the `<kind>:<locator>` prefix is preserved because those
35
+ * locators are not URLs and other code (e.g. `isRegistryMetadataResource`)
36
+ * keys on that prefix.
37
+ */
38
+ export declare function buildResourceId(kind: DeepScanResourceKind, locator: string): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Validate and canonicalise a remote URL. Rejects non http/https schemes,
3
+ * missing hosts, and malformed inputs. Normalises a bare-host path to a
4
+ * single trailing slash and strips trailing slashes from longer paths.
5
+ */
6
+ export function normalizeRemoteUrl(input) {
7
+ if (typeof input !== "string" || input.trim().length === 0) {
8
+ return { ok: false, reason: "empty" };
9
+ }
10
+ const trimmed = input.trim();
11
+ // Quick reject for bare `http:` / `https:` without `//` and host.
12
+ if (/^https?:\/?$/iu.test(trimmed)) {
13
+ return { ok: false, reason: "missing_host" };
14
+ }
15
+ // Must start with http:// or https:// (case-insensitive).
16
+ if (!/^https?:\/\//iu.test(trimmed)) {
17
+ return { ok: false, reason: "missing_scheme" };
18
+ }
19
+ let parsed;
20
+ try {
21
+ parsed = new URL(trimmed);
22
+ }
23
+ catch {
24
+ return { ok: false, reason: "invalid_url" };
25
+ }
26
+ const scheme = parsed.protocol.replace(":", "").toLowerCase();
27
+ if (scheme !== "http" && scheme !== "https") {
28
+ return { ok: false, reason: "unsupported_scheme" };
29
+ }
30
+ if (parsed.hostname.length === 0) {
31
+ return { ok: false, reason: "missing_host" };
32
+ }
33
+ // Normalise trailing slashes: keep `/` for root paths, strip for others.
34
+ if (parsed.pathname.length > 1 && parsed.pathname.endsWith("/")) {
35
+ parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
36
+ }
37
+ return {
38
+ ok: true,
39
+ url: parsed.toString(),
40
+ scheme: scheme,
41
+ };
42
+ }
43
+ /**
44
+ * Build a canonical resource id used for findings (`rule_id`, `file_path`) and
45
+ * for consent prompts. For http/sse kinds the id is the URL itself (no
46
+ * `http:` / `sse:` prefix) to avoid the malformed `http:https://...` shape.
47
+ * For npm/pypi/git, the `<kind>:<locator>` prefix is preserved because those
48
+ * locators are not URLs and other code (e.g. `isRegistryMetadataResource`)
49
+ * keys on that prefix.
50
+ */
51
+ export function buildResourceId(kind, locator) {
52
+ if (kind === "http" || kind === "sse") {
53
+ return locator;
54
+ }
55
+ return `${kind}:${locator}`;
56
+ }
package/dist/pipeline.js CHANGED
@@ -202,9 +202,6 @@ function layer3ErrorFinding(resourceId, status, description) {
202
202
  suppressed: false,
203
203
  });
204
204
  }
205
- function isRegistryMetadataResource(resourceId) {
206
- return (resourceId.startsWith("npm:") || resourceId.startsWith("pypi:") || resourceId.startsWith("git:"));
207
- }
208
205
  export function layer3OutcomesToFindings(outcomes, options = {}) {
209
206
  const findings = [];
210
207
  for (const outcome of outcomes) {
@@ -219,11 +216,17 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
219
216
  const parsed = parseLayer3Response(outcome.resourceId, outcome.result.metadata);
220
217
  const derived = deriveLayer3ToolFindings(outcome.resourceId, outcome.result.metadata, options);
221
218
  const combined = [...parsed, ...derived];
219
+ // If a Layer 3 resource was fetched successfully but carries no
220
+ // actionable metadata (no `findings[]`, no `tools[]`), that is not an
221
+ // issue with the scan target itself — it usually means the default
222
+ // no-outbound-call resource executor recorded only a URL stub, or that
223
+ // a host-configured MCP endpoint simply returned an unrecognised
224
+ // payload. Previously we emitted a LOW `layer3-network_error`
225
+ // "schema mismatch" finding whose `file_path` was the remote URL,
226
+ // which leaked host-level noise into every per-target scan report.
227
+ // Fetch-level anomalies that are unrelated to the scan target are now
228
+ // dropped silently for all resource kinds.
222
229
  if (combined.length === 0) {
223
- if (isRegistryMetadataResource(outcome.resourceId)) {
224
- continue;
225
- }
226
- findings.push(layer3ErrorFinding(outcome.resourceId, "network_error", "Deep scan response schema mismatch: expected metadata.findings[] or metadata.tools[]"));
227
230
  continue;
228
231
  }
229
232
  findings.push(...combined);
package/dist/scan.js CHANGED
@@ -2,6 +2,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { basename, join, relative, resolve, sep } from "node:path";
4
4
  import { collectLocalTextAnalysisTargets, } from "./layer3-dynamic/local-text-analysis.js";
5
+ import { buildResourceId, normalizeRemoteUrl } from "./layer3-dynamic/url-validation.js";
5
6
  import { runStaticPipeline } from "./pipeline.js";
6
7
  import { applyReportSummary } from "./report-summary.js";
7
8
  import { parseConfigContent, parseConfigFile, } from "./layer1-discovery/config-parser.js";
@@ -120,6 +121,47 @@ function isRegularFile(path) {
120
121
  return false;
121
122
  }
122
123
  }
124
+ /** True when `candidatePath` resolves at or below `root`. */
125
+ function isPathInside(root, candidatePath) {
126
+ const resolvedCandidate = resolve(candidatePath);
127
+ const resolvedRoot = resolve(root);
128
+ if (resolvedCandidate === resolvedRoot) {
129
+ return true;
130
+ }
131
+ const rel = relative(resolvedRoot, resolvedCandidate);
132
+ if (rel === "" || rel === ".") {
133
+ return true;
134
+ }
135
+ if (rel.startsWith("..")) {
136
+ return false;
137
+ }
138
+ // On Windows, relative() may return an absolute path across drives.
139
+ if (rel.includes(":")) {
140
+ return false;
141
+ }
142
+ return true;
143
+ }
144
+ /**
145
+ * Decide whether a user-scope candidate at `candidatePath` should be attached
146
+ * to a scan of `scanTarget` rooted at `homeDir`.
147
+ *
148
+ * User-scope patterns (e.g. `~/.agents/skills/&ast;/SKILL.md`) walk the whole
149
+ * home directory, so they can match files belonging to completely unrelated
150
+ * skills or agents. When the scan target is itself a specific location
151
+ * **inside** the user's home — e.g. scanning a single skill directory — any
152
+ * user-scope match outside that scan target belongs to a different scan and
153
+ * must not be attributed here.
154
+ *
155
+ * When the scan target lives outside the home directory (for example a
156
+ * project root in a workspace), user-scope matches are accepted as legitimate
157
+ * host-wide context for that scan.
158
+ */
159
+ function shouldKeepUserScopeCandidate(scanTarget, homeDir, candidatePath) {
160
+ if (isPathInside(homeDir, scanTarget)) {
161
+ return isPathInside(scanTarget, candidatePath);
162
+ }
163
+ return true;
164
+ }
123
165
  function toUserReportPath(pattern) {
124
166
  const normalized = normalizeUserScopePattern(pattern);
125
167
  return `~/${normalized}`;
@@ -260,6 +302,14 @@ function collectSelectedCandidates(absoluteTarget, walkedFiles, patterns, option
260
302
  const userPattern = normalizeUserScopePattern(candidate.pattern);
261
303
  if (userPattern.includes("*")) {
262
304
  for (const match of collectUserScopeWildcardMatches(options.homeDir, userPattern)) {
305
+ // A scan whose target itself lives under the user's home directory
306
+ // (e.g. a single skill at `~/.codex/skills/foo`) must only report
307
+ // findings about files inside that target. User-scope wildcards
308
+ // walk the whole home tree, so they can match sibling skills or
309
+ // other agents that belong to different scans; drop those here.
310
+ if (!shouldKeepUserScopeCandidate(absoluteTarget, options.homeDir, match.absolutePath)) {
311
+ continue;
312
+ }
263
313
  const reportPath = toUserReportPath(match.relativePath);
264
314
  if (!matchesCollectionKinds(reportPath, options.collectKinds)) {
265
315
  continue;
@@ -279,6 +329,9 @@ function collectSelectedCandidates(absoluteTarget, walkedFiles, patterns, option
279
329
  if (!existsSync(absolutePath) || !isRegularFile(absolutePath)) {
280
330
  continue;
281
331
  }
332
+ if (!shouldKeepUserScopeCandidate(absoluteTarget, options.homeDir, absolutePath)) {
333
+ continue;
334
+ }
282
335
  const reportPath = toUserReportPath(userPattern);
283
336
  if (!matchesCollectionKinds(reportPath, options.collectKinds)) {
284
337
  continue;
@@ -486,18 +539,21 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
486
539
  continue;
487
540
  }
488
541
  if (typeof config.url === "string" && isHttpLikeUrl(config.url)) {
489
- const kind = inferHttpKind(config.url);
490
- const id = `${kind}:${config.url}`;
491
- if (!resources.has(id)) {
492
- resources.set(id, {
493
- id,
494
- request: {
542
+ const normalized = normalizeRemoteUrl(config.url);
543
+ if (normalized.ok) {
544
+ const kind = inferHttpKind(normalized.url);
545
+ const id = buildResourceId(kind, normalized.url);
546
+ if (!resources.has(id)) {
547
+ resources.set(id, {
495
548
  id,
496
- kind,
497
- locator: config.url,
498
- },
499
- commandPreview: `GET ${config.url} (from ${filePath} -> ${container.key}.${serverName}.url)`,
500
- });
549
+ request: {
550
+ id,
551
+ kind,
552
+ locator: normalized.url,
553
+ },
554
+ commandPreview: `GET ${normalized.url} (from ${filePath} -> ${container.key}.${serverName}.url)`,
555
+ });
556
+ }
501
557
  }
502
558
  }
503
559
  if (Array.isArray(config.command) &&
@@ -524,8 +580,12 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
524
580
  if (typeof config.url !== "string" || !isHttpLikeUrl(config.url)) {
525
581
  return;
526
582
  }
527
- const kind = inferHttpKind(config.url);
528
- const id = `${kind}:${config.url}`;
583
+ const normalized = normalizeRemoteUrl(config.url);
584
+ if (!normalized.ok) {
585
+ return;
586
+ }
587
+ const kind = inferHttpKind(normalized.url);
588
+ const id = buildResourceId(kind, normalized.url);
529
589
  if (resources.has(id)) {
530
590
  return;
531
591
  }
@@ -534,9 +594,9 @@ function collectDeepScanResourcesFromParsed(value, filePath, resources) {
534
594
  request: {
535
595
  id,
536
596
  kind,
537
- locator: config.url,
597
+ locator: normalized.url,
538
598
  },
539
- commandPreview: `GET ${config.url} (from ${filePath} -> ${remoteArray.key}.${index}.url)`,
599
+ commandPreview: `GET ${normalized.url} (from ${filePath} -> ${remoteArray.key}.${index}.url)`,
540
600
  });
541
601
  });
542
602
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codegate-ai",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Pre-flight security scanner for AI coding tool configurations.",
5
5
  "license": "MIT",
6
6
  "type": "module",