codegate-ai 0.16.2 → 1.0.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.
Files changed (75) hide show
  1. package/README.md +13 -12
  2. package/dist/cli.d.ts +3 -1
  3. package/dist/cli.js +153 -44
  4. package/dist/commands/scan-command.d.ts +2 -1
  5. package/dist/commands/scan-command.js +6 -1
  6. package/dist/commands/trust.d.ts +28 -0
  7. package/dist/commands/trust.js +69 -0
  8. package/dist/config/inline-ignore.d.ts +10 -2
  9. package/dist/config/inline-ignore.js +5 -1
  10. package/dist/config/suppression-policy.d.ts +1 -1
  11. package/dist/config/suppression-policy.js +4 -1
  12. package/dist/config/trust.d.ts +2 -0
  13. package/dist/config/trust.js +19 -0
  14. package/dist/config.d.ts +14 -0
  15. package/dist/config.js +45 -1
  16. package/dist/content/content-bundle.d.ts +27 -0
  17. package/dist/content/content-bundle.js +73 -0
  18. package/dist/content/content-store.d.ts +27 -0
  19. package/dist/content/content-store.js +0 -0
  20. package/dist/content/content-updater.d.ts +32 -0
  21. package/dist/content/content-updater.js +111 -0
  22. package/dist/content/known-bad.d.ts +26 -0
  23. package/dist/content/known-bad.js +100 -0
  24. package/dist/content/publisher-key.d.ts +10 -0
  25. package/dist/content/publisher-key.js +10 -0
  26. package/dist/layer1-discovery/knowledge-base.js +33 -1
  27. package/dist/layer2-static/data/popular-mcp-packages.d.ts +16 -0
  28. package/dist/layer2-static/data/popular-mcp-packages.js +83 -0
  29. package/dist/layer2-static/detectors/known-bad.d.ts +22 -0
  30. package/dist/layer2-static/detectors/known-bad.js +116 -0
  31. package/dist/layer2-static/detectors/mcp-package-hygiene.d.ts +28 -0
  32. package/dist/layer2-static/detectors/mcp-package-hygiene.js +191 -0
  33. package/dist/layer2-static/detectors/rule-file.js +128 -75
  34. package/dist/layer2-static/detectors/skill-frontmatter.d.ts +6 -0
  35. package/dist/layer2-static/detectors/skill-frontmatter.js +130 -0
  36. package/dist/layer2-static/engine.d.ts +2 -0
  37. package/dist/layer2-static/engine.js +0 -0
  38. package/dist/layer2-static/rule-pack-loader.js +24 -1
  39. package/dist/layer2-static/state/scan-state.d.ts +7 -2
  40. package/dist/layer2-static/state/scan-state.js +74 -30
  41. package/dist/layer2-static/text/confusables.d.ts +7 -0
  42. package/dist/layer2-static/text/confusables.js +70 -0
  43. package/dist/layer2-static/text/edit-distance.d.ts +6 -0
  44. package/dist/layer2-static/text/edit-distance.js +33 -0
  45. package/dist/layer2-static/text/encoded-payloads.d.ts +16 -0
  46. package/dist/layer2-static/text/encoded-payloads.js +116 -0
  47. package/dist/layer2-static/text/normalize.d.ts +11 -0
  48. package/dist/layer2-static/text/normalize.js +19 -0
  49. package/dist/layer2-static/text/override-phrases.d.ts +14 -0
  50. package/dist/layer2-static/text/override-phrases.js +49 -0
  51. package/dist/layer2-static/text/threat-patterns.d.ts +33 -0
  52. package/dist/layer2-static/text/threat-patterns.js +45 -0
  53. package/dist/layer2-static/text/unicode.d.ts +33 -0
  54. package/dist/layer2-static/text/unicode.js +83 -0
  55. package/dist/layer3-dynamic/deep-resource-executor.d.ts +21 -0
  56. package/dist/layer3-dynamic/deep-resource-executor.js +73 -0
  57. package/dist/layer3-dynamic/meta-agent.js +2 -1
  58. package/dist/layer3-dynamic/registry-client.d.ts +26 -0
  59. package/dist/layer3-dynamic/registry-client.js +138 -0
  60. package/dist/layer3-dynamic/registry-findings.d.ts +7 -0
  61. package/dist/layer3-dynamic/registry-findings.js +64 -0
  62. package/dist/layer3-dynamic/tool-description-scanner.js +22 -13
  63. package/dist/layer3-dynamic/toxic-flow.d.ts +4 -0
  64. package/dist/layer3-dynamic/toxic-flow.js +41 -8
  65. package/dist/pipeline.d.ts +5 -2
  66. package/dist/pipeline.js +53 -16
  67. package/dist/report-summary.d.ts +1 -1
  68. package/dist/report-summary.js +9 -1
  69. package/dist/scan.d.ts +13 -0
  70. package/dist/scan.js +291 -17
  71. package/dist/types/finding.d.ts +14 -0
  72. package/dist/types/finding.js +14 -0
  73. package/dist/types/report.d.ts +2 -0
  74. package/dist/wrapper.js +2 -19
  75. package/package.json +1 -1
package/dist/pipeline.js CHANGED
@@ -2,6 +2,7 @@ import { runStaticEngine, } from "./layer2-static/engine.js";
2
2
  import { createEmptyReport } from "./types/report.js";
3
3
  import { scanToolDescriptions, } from "./layer3-dynamic/tool-description-scanner.js";
4
4
  import { detectToxicFlows } from "./layer3-dynamic/toxic-flow.js";
5
+ import { deriveRegistryFindings, } from "./layer3-dynamic/registry-findings.js";
5
6
  import { applyReportSummary } from "./report-summary.js";
6
7
  import { withFindingFingerprint } from "./report/finding-fingerprint.js";
7
8
  export async function runStaticPipeline(input) {
@@ -158,28 +159,32 @@ function parseToolClassifications(metadata, tools) {
158
159
  }
159
160
  return map;
160
161
  }
161
- function deriveLayer3ToolFindings(resourceId, metadata, options = {}) {
162
+ function analyzeLayer3Tools(resourceId, metadata, options = {}) {
162
163
  const toolEntries = parseToolEntries(metadata);
163
164
  if (toolEntries.length === 0) {
164
- return [];
165
+ return { findings: [], toolDescriptions: [], knownClassifications: {} };
165
166
  }
166
167
  const toolDescriptions = toolEntries.map((entry) => ({
167
168
  name: entry.name,
168
169
  description: entry.description,
169
170
  }));
170
171
  const knownClassifications = parseToolClassifications(metadata, toolEntries);
171
- return [
172
- ...scanToolDescriptions({
173
- serverId: resourceId,
174
- tools: toolDescriptions,
175
- unicodeAnalysis: options.unicodeAnalysis,
176
- }).map(withFindingFingerprint),
177
- ...detectToxicFlows({
178
- scopeId: resourceId,
179
- tools: toolDescriptions,
180
- knownClassifications,
181
- }).map(withFindingFingerprint),
182
- ];
172
+ return {
173
+ findings: [
174
+ ...scanToolDescriptions({
175
+ serverId: resourceId,
176
+ tools: toolDescriptions,
177
+ unicodeAnalysis: options.unicodeAnalysis,
178
+ }).map(withFindingFingerprint),
179
+ ...detectToxicFlows({
180
+ scopeId: resourceId,
181
+ tools: toolDescriptions,
182
+ knownClassifications,
183
+ }).map(withFindingFingerprint),
184
+ ],
185
+ toolDescriptions,
186
+ knownClassifications,
187
+ };
183
188
  }
184
189
  function layer3ErrorFinding(resourceId, status, description) {
185
190
  const severity = status === "timeout" ? "MEDIUM" : status === "skipped_without_consent" ? "INFO" : "LOW";
@@ -204,6 +209,25 @@ function layer3ErrorFinding(resourceId, status, description) {
204
209
  }
205
210
  export function layer3OutcomesToFindings(outcomes, options = {}) {
206
211
  const findings = [];
212
+ const workspaceTools = [];
213
+ const workspaceClassifications = {};
214
+ const workspaceOrigins = {};
215
+ const contributingServers = new Set();
216
+ const addWorkspaceTools = (resourceId, analysis) => {
217
+ for (const tool of analysis.toolDescriptions) {
218
+ // Keep workspace tool names unique across servers so origins stay unambiguous.
219
+ const uniqueName = workspaceOrigins[tool.name] === undefined ? tool.name : `${tool.name}@${resourceId}`;
220
+ workspaceTools.push({ name: uniqueName, description: tool.description });
221
+ workspaceOrigins[uniqueName] = resourceId;
222
+ const classifications = analysis.knownClassifications[tool.name];
223
+ if (classifications && classifications.length > 0) {
224
+ workspaceClassifications[uniqueName] = classifications;
225
+ }
226
+ }
227
+ if (analysis.toolDescriptions.length > 0) {
228
+ contributingServers.add(resourceId);
229
+ }
230
+ };
207
231
  for (const outcome of outcomes) {
208
232
  if (!outcome.approved || outcome.status === "skipped_without_consent") {
209
233
  findings.push(layer3ErrorFinding(outcome.resourceId, "skipped_without_consent", "Deep scan skipped because consent was not granted"));
@@ -214,8 +238,10 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
214
238
  continue;
215
239
  }
216
240
  const parsed = parseLayer3Response(outcome.resourceId, outcome.result.metadata);
217
- const derived = deriveLayer3ToolFindings(outcome.resourceId, outcome.result.metadata, options);
218
- const combined = [...parsed, ...derived];
241
+ const analysis = analyzeLayer3Tools(outcome.resourceId, outcome.result.metadata, options);
242
+ addWorkspaceTools(outcome.resourceId, analysis);
243
+ const registryFindings = deriveRegistryFindings(outcome.resourceId, outcome.result.metadata, options.registryHeuristics).map(withFindingFingerprint);
244
+ const combined = [...parsed, ...analysis.findings, ...registryFindings];
219
245
  // If a Layer 3 resource was fetched successfully but carries no
220
246
  // actionable metadata (no `findings[]`, no `tools[]`), that is not an
221
247
  // issue with the scan target itself — it usually means the default
@@ -231,6 +257,17 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
231
257
  }
232
258
  findings.push(...combined);
233
259
  }
260
+ // Cross-server pass: a toxic chain whose links live on different servers
261
+ // is invisible to the per-server analysis above.
262
+ if (contributingServers.size >= 2) {
263
+ findings.push(...detectToxicFlows({
264
+ scopeId: "workspace",
265
+ tools: workspaceTools,
266
+ knownClassifications: workspaceClassifications,
267
+ origins: workspaceOrigins,
268
+ crossOriginOnly: true,
269
+ }).map(withFindingFingerprint));
270
+ }
234
271
  return findings;
235
272
  }
236
273
  export function mergeLayer3Findings(baseReport, layer3Findings) {
@@ -1,4 +1,4 @@
1
- import type { Finding } from "./types/finding.js";
1
+ import { type Finding } from "./types/finding.js";
2
2
  import type { CodeGateReport, ReportSummary } from "./types/report.js";
3
3
  export type ReportThreshold = "critical" | "high" | "medium" | "low" | "info";
4
4
  export declare function computeExitCode(findings: Finding[], threshold?: ReportThreshold): number;
@@ -1,3 +1,4 @@
1
+ import { isUntrustedInlineSuppression } from "./types/finding.js";
1
2
  const SEVERITY_LEVEL = {
2
3
  CRITICAL: 4,
3
4
  HIGH: 3,
@@ -12,8 +13,13 @@ const THRESHOLD_LEVEL = {
12
13
  low: 1,
13
14
  info: 0,
14
15
  };
16
+ function countsTowardExitCode(finding) {
17
+ // Inline suppressions from untrusted content stay visible as suppressed
18
+ // but must not weaken gating.
19
+ return !finding.suppressed || isUntrustedInlineSuppression(finding);
20
+ }
15
21
  export function computeExitCode(findings, threshold = "high") {
16
- const unsuppressed = findings.filter((finding) => !finding.suppressed);
22
+ const unsuppressed = findings.filter(countsTowardExitCode);
17
23
  if (unsuppressed.length === 0) {
18
24
  return 0;
19
25
  }
@@ -32,11 +38,13 @@ export function summarizeFindings(findings, threshold = "high") {
32
38
  for (const finding of findings) {
33
39
  bySeverity[finding.severity] = (bySeverity[finding.severity] ?? 0) + 1;
34
40
  }
41
+ const suppressedUntrusted = findings.filter(isUntrustedInlineSuppression).length;
35
42
  return {
36
43
  total: findings.length,
37
44
  by_severity: bySeverity,
38
45
  fixable: findings.filter((finding) => finding.fixable).length,
39
46
  suppressed: findings.filter((finding) => finding.suppressed).length,
47
+ ...(suppressedUntrusted > 0 ? { suppressed_untrusted: suppressedUntrusted } : {}),
40
48
  exit_code: computeExitCode(findings, threshold),
41
49
  };
42
50
  }
package/dist/scan.d.ts CHANGED
@@ -6,6 +6,18 @@ import type { DiscoveryFormat } from "./types/discovery.js";
6
6
  import type { CodeGateReport } from "./types/report.js";
7
7
  import type { CodeGateConfig, ScanCollectionKind, ScanCollectionMode } from "./config.js";
8
8
  import type { DeepScanResource } from "./pipeline.js";
9
+ export declare const SKILL_BINARY_KIND: {
10
+ readonly Elf: "elf";
11
+ readonly MachO: "mach-o";
12
+ readonly Pe: "pe";
13
+ readonly Unknown: "binary";
14
+ };
15
+ export type SkillBinaryKind = (typeof SKILL_BINARY_KIND)[keyof typeof SKILL_BINARY_KIND];
16
+ export interface SkillBinaryArtifact {
17
+ reportPath: string;
18
+ kind: SkillBinaryKind;
19
+ executable: boolean;
20
+ }
9
21
  export interface ScanEngineInput {
10
22
  version: string;
11
23
  scanTarget: string;
@@ -43,6 +55,7 @@ export interface ScanDiscoveryContext {
43
55
  walked: WalkResult;
44
56
  selected: ScanDiscoveryCandidate[];
45
57
  parsedCandidates?: ParsedScanDiscoveryCandidate[];
58
+ skillBinaries?: SkillBinaryArtifact[];
46
59
  }
47
60
  export interface ScanDiscoveryContextOptions {
48
61
  includeUserScope?: boolean;
package/dist/scan.js CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
1
+ import { closeSync, existsSync, openSync, readSync, readdirSync, readFileSync, statSync, } from "node:fs";
2
2
  import { homedir } from "node:os";
3
- import { basename, join, relative, resolve, sep } from "node:path";
3
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
4
4
  import { collectLocalTextAnalysisTargets, } from "./layer3-dynamic/local-text-analysis.js";
5
5
  import { buildResourceId, normalizeRemoteUrl } from "./layer3-dynamic/url-validation.js";
6
6
  import { runStaticPipeline } from "./pipeline.js";
@@ -11,11 +11,46 @@ import { detectTools } from "./layer1-discovery/tool-detector.js";
11
11
  import { walkProjectTree } from "./layer1-discovery/file-walker.js";
12
12
  import { evaluateScanStateSnapshots, extractMcpServerSnapshots, loadScanState, saveScanState, } from "./layer2-static/state/scan-state.js";
13
13
  import { applyInlineIgnoreDirectives, collectInlineIgnoreDirectives, } from "./config/inline-ignore.js";
14
+ import { isTrustedDirectory } from "./config/trust.js";
15
+ import { loadKnownBadIndicators } from "./content/known-bad.js";
16
+ import { escalateKnownBadFindings } from "./layer2-static/detectors/known-bad.js";
17
+ import { normalizeForMatching } from "./layer2-static/text/normalize.js";
18
+ import { REMOTE_INSTRUCTION_INDIRECTION_PATTERN } from "./layer2-static/text/threat-patterns.js";
19
+ import { withFindingFingerprint } from "./report/finding-fingerprint.js";
14
20
  import { isGitHubDependabotPath } from "./layer2-static/dependabot/parser.js";
15
21
  const MCP_SERVER_CONTAINER_KEYS = ["mcpServers", "mcp_servers", "context_servers"];
16
22
  const REMOTE_MCP_SERVER_ARRAY_KEYS = ["remoteMCPServers", "remote_mcp_servers"];
17
23
  const USER_SCOPE_WILDCARD_MAX_DEPTH = 6;
18
24
  const USER_SCOPE_WILDCARD_MAX_FILES = 500;
25
+ const SKILL_FILE_NAME = "skill.md";
26
+ const SKILL_SIBLING_MAX_DEPTH = 3;
27
+ const SKILL_SIBLING_MAX_FILES = 200;
28
+ const BINARY_SNIFF_BYTES = 512;
29
+ const SKILL_SIBLING_FORMATS = {
30
+ md: "markdown",
31
+ markdown: "markdown",
32
+ json: "json",
33
+ yaml: "yaml",
34
+ yml: "yaml",
35
+ toml: "toml",
36
+ sh: "text",
37
+ bash: "text",
38
+ zsh: "text",
39
+ ps1: "text",
40
+ py: "text",
41
+ js: "text",
42
+ mjs: "text",
43
+ cjs: "text",
44
+ ts: "text",
45
+ rb: "text",
46
+ txt: "text",
47
+ };
48
+ export const SKILL_BINARY_KIND = {
49
+ Elf: "elf",
50
+ MachO: "mach-o",
51
+ Pe: "pe",
52
+ Unknown: "binary",
53
+ };
19
54
  const INFERRED_ARTIFACT_RULES = [
20
55
  {
21
56
  pattern: /(?:^|\/)\.github\/workflows\/[^/]+\.ya?ml$/iu,
@@ -45,12 +80,19 @@ const INFERRED_ARTIFACT_RULES = [
45
80
  function escapeRegex(value) {
46
81
  return value.replace(/[|\\{}()[\]^$+?.*]/g, "\\$&");
47
82
  }
83
+ const wildcardRegexCache = new Map();
48
84
  function wildcardToRegex(pattern) {
85
+ const cached = wildcardRegexCache.get(pattern);
86
+ if (cached) {
87
+ return cached;
88
+ }
49
89
  let escaped = escapeRegex(pattern);
50
90
  escaped = escaped.replace(/\\\*\\\*\//g, "(?:[^/]+/)*");
51
91
  escaped = escaped.replace(/\\\*\\\*/g, ".*");
52
92
  escaped = escaped.replace(/\\\*/g, "[^/]*");
53
- return new RegExp(`^${escaped}$`, "u");
93
+ const regex = new RegExp(`^${escaped}$`, "u");
94
+ wildcardRegexCache.set(pattern, regex);
95
+ return regex;
54
96
  }
55
97
  function normalizePathForMatch(path) {
56
98
  return path.split(sep).join("/");
@@ -279,15 +321,15 @@ function collectSelectedCandidates(absoluteTarget, walkedFiles, patterns, option
279
321
  relativePath: normalizePathForMatch(relative(absoluteTarget, filePath)),
280
322
  }))
281
323
  .filter((entry) => !entry.relativePath.startsWith(".."));
324
+ const projectPatterns = patterns
325
+ .filter((candidate) => candidate.scope === "project")
326
+ .map((candidate) => ({ candidate, regex: wildcardToRegex(candidate.pattern) }));
282
327
  for (const file of filesByRelativePath) {
283
328
  if (!includeProject) {
284
329
  continue;
285
330
  }
286
- for (const candidate of patterns) {
287
- if (candidate.scope !== "project") {
288
- continue;
289
- }
290
- if (!wildcardToRegex(candidate.pattern).test(file.relativePath)) {
331
+ for (const { candidate, regex } of projectPatterns) {
332
+ if (!regex.test(file.relativePath)) {
291
333
  continue;
292
334
  }
293
335
  if (!matchesCollectionKinds(file.relativePath, options.collectKinds)) {
@@ -418,6 +460,140 @@ function mergeExplicitCandidates(selected, explicitCandidates, collectKinds) {
418
460
  }
419
461
  return Array.from(merged.values());
420
462
  }
463
+ function sniffFileHead(path) {
464
+ const buffer = Buffer.alloc(BINARY_SNIFF_BYTES);
465
+ let bytesRead;
466
+ try {
467
+ const fd = openSync(path, "r");
468
+ try {
469
+ bytesRead = readSync(fd, buffer, 0, BINARY_SNIFF_BYTES, 0);
470
+ }
471
+ finally {
472
+ closeSync(fd);
473
+ }
474
+ }
475
+ catch {
476
+ return { binaryKind: null, hasShebang: false };
477
+ }
478
+ const head = buffer.subarray(0, bytesRead);
479
+ const hasShebang = bytesRead >= 2 && head[0] === 0x23 && head[1] === 0x21;
480
+ if (bytesRead >= 4 &&
481
+ head[0] === 0x7f &&
482
+ head[1] === 0x45 &&
483
+ head[2] === 0x4c &&
484
+ head[3] === 0x46) {
485
+ return { binaryKind: SKILL_BINARY_KIND.Elf, hasShebang: false };
486
+ }
487
+ const magic = bytesRead >= 4 ? head.readUInt32BE(0) : 0;
488
+ if (magic === 0xfeedface ||
489
+ magic === 0xfeedfacf ||
490
+ magic === 0xcefaedfe ||
491
+ magic === 0xcffaedfe ||
492
+ magic === 0xcafebabe) {
493
+ return { binaryKind: SKILL_BINARY_KIND.MachO, hasShebang: false };
494
+ }
495
+ if (bytesRead >= 2 && head[0] === 0x4d && head[1] === 0x5a) {
496
+ return { binaryKind: SKILL_BINARY_KIND.Pe, hasShebang: false };
497
+ }
498
+ if (head.includes(0)) {
499
+ return { binaryKind: SKILL_BINARY_KIND.Unknown, hasShebang: false };
500
+ }
501
+ return { binaryKind: null, hasShebang };
502
+ }
503
+ function isSkillCandidate(candidate) {
504
+ return basename(normalizePathForMatch(candidate.reportPath)).toLowerCase() === SKILL_FILE_NAME;
505
+ }
506
+ function siblingReportPath(candidate, siblingAbsolute) {
507
+ const skillDir = dirname(candidate.absolutePath);
508
+ const relFromSkillDir = normalizePathForMatch(relative(skillDir, siblingAbsolute));
509
+ const normalizedReport = normalizePathForMatch(candidate.reportPath);
510
+ const slashIndex = normalizedReport.lastIndexOf("/");
511
+ const parentReport = slashIndex >= 0 ? normalizedReport.slice(0, slashIndex) : "";
512
+ return parentReport ? `${parentReport}/${relFromSkillDir}` : relFromSkillDir;
513
+ }
514
+ /**
515
+ * Skills ship payloads next to SKILL.md (helper scripts, nested docs,
516
+ * sometimes binaries). Collect text-like siblings as scan candidates so the
517
+ * rule-file detectors see them, and record binary artifacts for reporting.
518
+ */
519
+ function collectSkillSiblings(selected) {
520
+ const known = new Set(selected.map((candidate) => normalizePathForMatch(candidate.reportPath)));
521
+ const candidates = [];
522
+ const binaries = [];
523
+ const visitedDirs = new Set();
524
+ for (const skillCandidate of selected.filter(isSkillCandidate)) {
525
+ const skillDir = dirname(skillCandidate.absolutePath);
526
+ if (visitedDirs.has(skillDir)) {
527
+ continue;
528
+ }
529
+ visitedDirs.add(skillDir);
530
+ let filesSeen = 0;
531
+ const queue = [{ dir: skillDir, depth: 0 }];
532
+ while (queue.length > 0 && filesSeen < SKILL_SIBLING_MAX_FILES) {
533
+ const current = queue.pop();
534
+ if (!current) {
535
+ break;
536
+ }
537
+ let entries;
538
+ try {
539
+ entries = readdirSync(current.dir, { withFileTypes: true });
540
+ }
541
+ catch {
542
+ continue;
543
+ }
544
+ for (const entry of entries) {
545
+ if (filesSeen >= SKILL_SIBLING_MAX_FILES) {
546
+ break;
547
+ }
548
+ if (entry.isSymbolicLink()) {
549
+ continue;
550
+ }
551
+ const absolutePath = join(current.dir, entry.name);
552
+ if (entry.isDirectory()) {
553
+ if (current.depth < SKILL_SIBLING_MAX_DEPTH) {
554
+ queue.push({ dir: absolutePath, depth: current.depth + 1 });
555
+ }
556
+ continue;
557
+ }
558
+ if (!entry.isFile() || absolutePath === skillCandidate.absolutePath) {
559
+ continue;
560
+ }
561
+ filesSeen += 1;
562
+ const reportPath = siblingReportPath(skillCandidate, absolutePath);
563
+ if (known.has(reportPath)) {
564
+ continue;
565
+ }
566
+ const sniffed = sniffFileHead(absolutePath);
567
+ if (sniffed.binaryKind) {
568
+ let executable;
569
+ try {
570
+ executable = (statSync(absolutePath).mode & 0o111) !== 0;
571
+ }
572
+ catch {
573
+ executable = false;
574
+ }
575
+ binaries.push({ reportPath, kind: sniffed.binaryKind, executable });
576
+ continue;
577
+ }
578
+ const extension = entry.name.includes(".")
579
+ ? (entry.name.split(".").pop() ?? "").toLowerCase()
580
+ : "";
581
+ const format = SKILL_SIBLING_FORMATS[extension] ?? (sniffed.hasShebang ? "text" : null);
582
+ if (!format) {
583
+ continue;
584
+ }
585
+ known.add(reportPath);
586
+ candidates.push({
587
+ reportPath,
588
+ absolutePath,
589
+ format,
590
+ tool: skillCandidate.tool,
591
+ });
592
+ }
593
+ }
594
+ }
595
+ return { candidates, binaries };
596
+ }
421
597
  function inferArtifactCandidate(relativePath, absolutePath) {
422
598
  for (const rule of INFERRED_ARTIFACT_RULES) {
423
599
  if (!rule.pattern.test(relativePath)) {
@@ -446,6 +622,57 @@ function ensureParsedCandidates(context) {
446
622
  }
447
623
  return context.parsedCandidates;
448
624
  }
625
+ function makeUntrustedProjectConfigFinding(ignoredSettings) {
626
+ return {
627
+ rule_id: "untrusted-project-config",
628
+ finding_id: "UNTRUSTED_PROJECT_CONFIG-.codegate.json",
629
+ severity: "INFO",
630
+ category: "CONFIG_CHANGE",
631
+ layer: "L1",
632
+ file_path: ".codegate.json",
633
+ location: { field: ignoredSettings.join(", ") },
634
+ description: `Ignored ${ignoredSettings.length} policy setting(s) from untrusted project config: ` +
635
+ `${ignoredSettings.join(", ")}. Project config in untrusted directories may only set ` +
636
+ "presentation options. Run `codegate trust <dir>` to honor its policy settings.",
637
+ affected_tools: [],
638
+ cve: null,
639
+ owasp: [],
640
+ cwe: "CWE-807",
641
+ confidence: "HIGH",
642
+ fixable: false,
643
+ remediation_actions: [],
644
+ suppressed: false,
645
+ };
646
+ }
647
+ function makeSkillBinaryFinding(artifact) {
648
+ const isExecutableFormat = artifact.kind !== SKILL_BINARY_KIND.Unknown;
649
+ const severity = artifact.executable || isExecutableFormat ? "HIGH" : "MEDIUM";
650
+ const kindLabel = isExecutableFormat ? `${artifact.kind} executable` : "binary file";
651
+ return {
652
+ rule_id: "skill-binary-payload",
653
+ finding_id: `SKILL_BINARY-${artifact.reportPath}`,
654
+ severity,
655
+ category: "COMMAND_EXEC",
656
+ layer: "L1",
657
+ file_path: artifact.reportPath,
658
+ location: { field: "content" },
659
+ description: `Skill directory ships a ${kindLabel}${artifact.executable ? " with execute permissions" : ""}. ` +
660
+ "Binary payloads cannot be reviewed as text and have no place in an instruction skill.",
661
+ affected_tools: ["claude-code", "codex-cli", "opencode", "cursor"],
662
+ cve: null,
663
+ owasp: ["ASI02"],
664
+ cwe: "CWE-506",
665
+ confidence: "HIGH",
666
+ fixable: true,
667
+ remediation_actions: ["quarantine_file"],
668
+ metadata: {
669
+ sources: [artifact.reportPath],
670
+ risk_tags: ["skill", "binary-payload"],
671
+ origin: "skill-siblings",
672
+ },
673
+ suppressed: false,
674
+ };
675
+ }
449
676
  function makeParseErrorFinding(filePath, tool, message, strictCollection) {
450
677
  return {
451
678
  rule_id: "parse-error",
@@ -507,7 +734,7 @@ function commandResourceFromTokens(command) {
507
734
  id: `npm:${locator}`,
508
735
  kind: "npm",
509
736
  locator,
510
- preview: `npm view ${locator} --json`,
737
+ preview: `GET https://registry.npmjs.org/${locator} (online mode only; offline records the package name)`,
511
738
  };
512
739
  }
513
740
  if (launcher === "uvx" || launcher === "pipx") {
@@ -519,7 +746,7 @@ function commandResourceFromTokens(command) {
519
746
  id: `pypi:${locator}`,
520
747
  kind: "pypi",
521
748
  locator,
522
- preview: `https://pypi.org/pypi/${locator}/json`,
749
+ preview: `GET https://pypi.org/pypi/${locator}/json (online mode only; offline records the package name)`,
523
750
  };
524
751
  }
525
752
  return null;
@@ -654,7 +881,7 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
654
881
  const collectModes = normalizeCollectionModes(options.collectModes);
655
882
  const collectKinds = normalizeCollectionKinds(options.collectKinds);
656
883
  const explicitOnly = collectModes.size === 1 && collectModes.has("explicit");
657
- const selected = mergeExplicitCandidates(explicitOnly
884
+ const baseSelected = mergeExplicitCandidates(explicitOnly
658
885
  ? []
659
886
  : collectSelectedCandidates(absoluteTarget, walked.files, patterns, {
660
887
  includeUserScope: options.includeUserScope === true,
@@ -662,21 +889,50 @@ export function createScanDiscoveryContext(scanTarget, kbInput, options = {}) {
662
889
  collectModes,
663
890
  collectKinds,
664
891
  }), options.explicitCandidates, collectKinds);
892
+ const skillSiblings = collectSkillSiblings(baseSelected);
893
+ const selected = [...baseSelected, ...skillSiblings.candidates];
665
894
  return {
666
895
  absoluteTarget,
667
896
  kb,
668
897
  walked,
669
898
  selected,
670
899
  parsedCandidates: options.parseSelected ? parseSelectedCandidates(selected) : undefined,
900
+ skillBinaries: skillSiblings.binaries,
671
901
  };
672
902
  }
903
+ function collectIndirectionUrlResources(textContent, filePath, resources) {
904
+ const lines = textContent.split(/\r?\n/u);
905
+ for (const line of lines) {
906
+ const normalized = normalizeForMatching(line);
907
+ if (!REMOTE_INSTRUCTION_INDIRECTION_PATTERN.test(normalized)) {
908
+ continue;
909
+ }
910
+ const urlMatch = line.match(/https?:\/\/[^\s)\]"'`<>]+/iu);
911
+ if (!urlMatch) {
912
+ continue;
913
+ }
914
+ const url = urlMatch[0];
915
+ const kind = inferHttpKind(url);
916
+ const id = `${kind}:${url}`;
917
+ if (resources.has(id)) {
918
+ continue;
919
+ }
920
+ resources.set(id, {
921
+ id,
922
+ request: { id, kind, locator: url },
923
+ commandPreview: `GET ${url} (from ${filePath} -> remote instruction indirection)`,
924
+ });
925
+ }
926
+ }
673
927
  export function discoverDeepScanResourcesFromContext(context) {
674
928
  const discovered = new Map();
675
929
  for (const item of ensureParsedCandidates(context)) {
676
- if (!item.parsed.ok) {
677
- continue;
930
+ if (item.parsed.ok) {
931
+ collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
932
+ }
933
+ if (item.format === "markdown" || item.format === "text") {
934
+ collectIndirectionUrlResources(readCandidateText(item), item.reportPath, discovered);
678
935
  }
679
- collectDeepScanResourcesFromParsed(item.parsed.data, item.reportPath, discovered);
680
936
  }
681
937
  return Array.from(discovered.values()).sort((a, b) => a.id.localeCompare(b.id));
682
938
  }
@@ -712,6 +968,8 @@ export async function runScanEngine(input) {
712
968
  });
713
969
  const absoluteTarget = context.absoluteTarget;
714
970
  const kb = context.kb;
971
+ const scanHomeDir = input.homeDir;
972
+ const knownBadIndicators = loadKnownBadIndicators(scanHomeDir ? { homeDir: () => scanHomeDir } : {});
715
973
  const parseErrors = [];
716
974
  const staticFiles = [];
717
975
  for (const item of ensureParsedCandidates(context)) {
@@ -772,6 +1030,7 @@ export async function runScanEngine(input) {
772
1030
  runtimeMode: input.config.runtime_mode,
773
1031
  workflowAuditsEnabled: input.config.workflow_audits?.enabled === true,
774
1032
  rulePolicies: input.config.rules,
1033
+ knownBadIndicators,
775
1034
  },
776
1035
  });
777
1036
  const snapshots = new Map();
@@ -780,17 +1039,32 @@ export async function runScanEngine(input) {
780
1039
  snapshots.set(snapshot.serverId, snapshot);
781
1040
  }
782
1041
  }
783
- const previousState = loadScanState(input.scanStatePath);
1042
+ const trustedTarget = input.config.project_config_trusted ??
1043
+ isTrustedDirectory(absoluteTarget, input.config.trusted_directories);
1044
+ const previousState = loadScanState(input.scanStatePath, absoluteTarget);
784
1045
  const stateResult = evaluateScanStateSnapshots({
785
1046
  snapshots: Array.from(snapshots.values()),
786
1047
  previousState,
1048
+ trustedTarget,
1049
+ firstScanReview: input.config.first_scan_review,
787
1050
  });
788
- saveScanState(stateResult.nextState, input.scanStatePath);
1051
+ saveScanState(stateResult.nextState, input.scanStatePath, absoluteTarget);
789
1052
  const inlineIgnores = collectInlineIgnoreDirectives(staticFiles.map((file) => ({
790
1053
  filePath: file.filePath,
791
1054
  textContent: file.textContent,
792
1055
  })));
793
- const findings = applyInlineIgnoreDirectives([...report.findings, ...parseErrors, ...stateResult.findings], inlineIgnores);
1056
+ const ignoredProjectSettings = input.config.ignored_project_settings ?? [];
1057
+ const configNoticeFindings = ignoredProjectSettings.length > 0
1058
+ ? [withFindingFingerprint(makeUntrustedProjectConfigFinding(ignoredProjectSettings))]
1059
+ : [];
1060
+ const skillBinaryFindings = (context.skillBinaries ?? []).map((artifact) => withFindingFingerprint(makeSkillBinaryFinding(artifact)));
1061
+ const findings = applyInlineIgnoreDirectives(escalateKnownBadFindings([
1062
+ ...report.findings,
1063
+ ...parseErrors,
1064
+ ...stateResult.findings,
1065
+ ...configNoticeFindings,
1066
+ ...skillBinaryFindings,
1067
+ ], knownBadIndicators), inlineIgnores, { trustedTarget });
794
1068
  return applyReportSummary({
795
1069
  ...report,
796
1070
  findings,
@@ -4,6 +4,19 @@ export declare const FINDING_CATEGORIES: readonly ["ENV_OVERRIDE", "COMMAND_EXEC
4
4
  export type FindingCategory = (typeof FINDING_CATEGORIES)[number];
5
5
  export type FindingLayer = "L1" | "L2" | "L3";
6
6
  export type FindingConfidence = "HIGH" | "MEDIUM" | "LOW";
7
+ /**
8
+ * Where a suppression came from. InlineUntrusted marks inline
9
+ * `codegate: ignore[...]` directives found in content the user has not
10
+ * trusted; such findings stay visibly suppressed in reports but still
11
+ * count toward the exit code so untrusted content cannot self-approve.
12
+ */
13
+ export declare const SUPPRESSION_SOURCE: {
14
+ readonly Inline: "inline";
15
+ readonly InlineUntrusted: "inline-untrusted";
16
+ readonly Config: "config";
17
+ };
18
+ export type SuppressionSource = (typeof SUPPRESSION_SOURCE)[keyof typeof SUPPRESSION_SOURCE];
19
+ export declare function isUntrustedInlineSuppression(finding: Pick<Finding, "suppressed" | "suppression_source">): boolean;
7
20
  export interface FindingLocation {
8
21
  field?: string;
9
22
  line?: number;
@@ -52,4 +65,5 @@ export interface Finding {
52
65
  incident_primary?: boolean | null;
53
66
  source_config?: FindingSourceConfig | null;
54
67
  suppressed: boolean;
68
+ suppression_source?: SuppressionSource | null;
55
69
  }
@@ -18,3 +18,17 @@ export const FINDING_CATEGORIES = [
18
18
  "CI_TEMPLATE_INJECTION",
19
19
  "CI_VULNERABLE_ACTION",
20
20
  ];
21
+ /**
22
+ * Where a suppression came from. InlineUntrusted marks inline
23
+ * `codegate: ignore[...]` directives found in content the user has not
24
+ * trusted; such findings stay visibly suppressed in reports but still
25
+ * count toward the exit code so untrusted content cannot self-approve.
26
+ */
27
+ export const SUPPRESSION_SOURCE = {
28
+ Inline: "inline",
29
+ InlineUntrusted: "inline-untrusted",
30
+ Config: "config",
31
+ };
32
+ export function isUntrustedInlineSuppression(finding) {
33
+ return (finding.suppressed === true && finding.suppression_source === SUPPRESSION_SOURCE.InlineUntrusted);
34
+ }
@@ -4,6 +4,8 @@ export interface ReportSummary {
4
4
  by_severity: Record<string, number>;
5
5
  fixable: number;
6
6
  suppressed: number;
7
+ /** Suppressions requested by untrusted content; these still count toward exit_code. */
8
+ suppressed_untrusted?: number;
7
9
  exit_code: number;
8
10
  }
9
11
  export interface CodeGateReport {