plugin-scanner 1.4.15__py3-none-any.whl

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 (57) hide show
  1. codex_plugin_scanner/__init__.py +29 -0
  2. codex_plugin_scanner/action_runner.py +470 -0
  3. codex_plugin_scanner/checks/__init__.py +0 -0
  4. codex_plugin_scanner/checks/best_practices.py +238 -0
  5. codex_plugin_scanner/checks/claude.py +285 -0
  6. codex_plugin_scanner/checks/code_quality.py +115 -0
  7. codex_plugin_scanner/checks/ecosystem_common.py +34 -0
  8. codex_plugin_scanner/checks/gemini.py +196 -0
  9. codex_plugin_scanner/checks/manifest.py +501 -0
  10. codex_plugin_scanner/checks/manifest_support.py +61 -0
  11. codex_plugin_scanner/checks/marketplace.py +334 -0
  12. codex_plugin_scanner/checks/opencode.py +223 -0
  13. codex_plugin_scanner/checks/operational_security.py +346 -0
  14. codex_plugin_scanner/checks/security.py +447 -0
  15. codex_plugin_scanner/checks/skill_security.py +241 -0
  16. codex_plugin_scanner/cli.py +467 -0
  17. codex_plugin_scanner/config.py +76 -0
  18. codex_plugin_scanner/ecosystems/__init__.py +15 -0
  19. codex_plugin_scanner/ecosystems/base.py +20 -0
  20. codex_plugin_scanner/ecosystems/claude.py +112 -0
  21. codex_plugin_scanner/ecosystems/codex.py +94 -0
  22. codex_plugin_scanner/ecosystems/detect.py +46 -0
  23. codex_plugin_scanner/ecosystems/gemini.py +80 -0
  24. codex_plugin_scanner/ecosystems/opencode.py +184 -0
  25. codex_plugin_scanner/ecosystems/registry.py +41 -0
  26. codex_plugin_scanner/ecosystems/types.py +45 -0
  27. codex_plugin_scanner/integrations/__init__.py +5 -0
  28. codex_plugin_scanner/integrations/cisco_skill_scanner.py +200 -0
  29. codex_plugin_scanner/lint_fixes.py +105 -0
  30. codex_plugin_scanner/marketplace_support.py +100 -0
  31. codex_plugin_scanner/models.py +177 -0
  32. codex_plugin_scanner/path_support.py +46 -0
  33. codex_plugin_scanner/policy.py +140 -0
  34. codex_plugin_scanner/quality_artifact.py +91 -0
  35. codex_plugin_scanner/repo_detect.py +137 -0
  36. codex_plugin_scanner/reporting.py +376 -0
  37. codex_plugin_scanner/rules/__init__.py +6 -0
  38. codex_plugin_scanner/rules/registry.py +101 -0
  39. codex_plugin_scanner/rules/specs.py +26 -0
  40. codex_plugin_scanner/scanner.py +557 -0
  41. codex_plugin_scanner/submission.py +284 -0
  42. codex_plugin_scanner/suppressions.py +87 -0
  43. codex_plugin_scanner/trust_domain_scoring.py +22 -0
  44. codex_plugin_scanner/trust_helpers.py +207 -0
  45. codex_plugin_scanner/trust_mcp_scoring.py +116 -0
  46. codex_plugin_scanner/trust_models.py +85 -0
  47. codex_plugin_scanner/trust_plugin_scoring.py +180 -0
  48. codex_plugin_scanner/trust_scoring.py +52 -0
  49. codex_plugin_scanner/trust_skill_scoring.py +296 -0
  50. codex_plugin_scanner/trust_specs.py +286 -0
  51. codex_plugin_scanner/verification.py +964 -0
  52. codex_plugin_scanner/version.py +3 -0
  53. plugin_scanner-1.4.15.dist-info/METADATA +596 -0
  54. plugin_scanner-1.4.15.dist-info/RECORD +57 -0
  55. plugin_scanner-1.4.15.dist-info/WHEEL +4 -0
  56. plugin_scanner-1.4.15.dist-info/entry_points.txt +4 -0
  57. plugin_scanner-1.4.15.dist-info/licenses/LICENSE +120 -0
@@ -0,0 +1,557 @@
1
+ """Codex Plugin Scanner - core scanning engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ from datetime import datetime, timezone
7
+ from pathlib import Path
8
+
9
+ from .checks.best_practices import run_best_practice_checks
10
+ from .checks.claude import run_claude_checks
11
+ from .checks.code_quality import run_code_quality_checks
12
+ from .checks.gemini import run_gemini_checks
13
+ from .checks.manifest import run_manifest_checks
14
+ from .checks.marketplace import run_marketplace_checks
15
+ from .checks.opencode import run_opencode_checks
16
+ from .checks.operational_security import run_operational_security_checks
17
+ from .checks.security import run_security_checks
18
+ from .checks.skill_security import resolve_skill_security_context, run_skill_security_checks
19
+ from .ecosystems.detect import detect_packages
20
+ from .ecosystems.registry import get_default_adapters, resolve_ecosystem
21
+ from .ecosystems.types import Ecosystem, NormalizedPackage, PackageCandidate
22
+ from .integrations.cisco_skill_scanner import CiscoIntegrationStatus
23
+ from .models import (
24
+ CategoryResult,
25
+ CheckResult,
26
+ Finding,
27
+ IntegrationResult,
28
+ PackageSummary,
29
+ ScanOptions,
30
+ ScanResult,
31
+ build_severity_counts,
32
+ get_grade,
33
+ )
34
+ from .repo_detect import LocalPluginTarget, discover_scan_targets
35
+ from .trust_scoring import build_plugin_trust_report, build_repository_trust_report
36
+
37
+
38
+ def _build_integration_results(skill_security_context, package_label: str = "") -> tuple[IntegrationResult, ...]:
39
+ integration_name = "cisco-skill-scanner" if not package_label else f"cisco-skill-scanner[{package_label}]"
40
+ if skill_security_context.skip_message:
41
+ return (
42
+ IntegrationResult(
43
+ name=integration_name,
44
+ status=CiscoIntegrationStatus.SKIPPED,
45
+ message=skill_security_context.skip_message,
46
+ ),
47
+ )
48
+
49
+ summary = skill_security_context.summary
50
+ if summary is None:
51
+ return (
52
+ IntegrationResult(
53
+ name=integration_name,
54
+ status=CiscoIntegrationStatus.SKIPPED,
55
+ message="Cisco scan context unavailable.",
56
+ ),
57
+ )
58
+
59
+ metadata = {"policy": summary.policy_name}
60
+ if summary.analyzers_used:
61
+ metadata["analyzers"] = ",".join(summary.analyzers_used)
62
+ return (
63
+ IntegrationResult(
64
+ name=integration_name,
65
+ status=summary.status,
66
+ message=summary.message,
67
+ findings_count=summary.total_findings,
68
+ metadata=metadata,
69
+ ),
70
+ )
71
+
72
+
73
+ def _score_categories(categories: tuple[CategoryResult, ...]) -> int:
74
+ earned_points = sum(check.points for category in categories for check in category.checks)
75
+ max_points = sum(check.max_points for category in categories for check in category.checks)
76
+ return 100 if max_points == 0 else round((earned_points / max_points) * 100)
77
+
78
+
79
+ def _build_adapter_map() -> dict[Ecosystem, object]:
80
+ adapters = get_default_adapters()
81
+ return {adapter.ecosystem_id: adapter for adapter in adapters}
82
+
83
+
84
+ def _build_candidate_fallback(resolved: Path, requested: Ecosystem | None) -> list[PackageCandidate]:
85
+ if requested is not None:
86
+ return [
87
+ PackageCandidate(
88
+ ecosystem=requested,
89
+ package_kind="workspace-bundle",
90
+ root_path=resolved,
91
+ manifest_path=None,
92
+ detection_reason="fallback to requested ecosystem at scan root",
93
+ )
94
+ ]
95
+ return [
96
+ PackageCandidate(
97
+ ecosystem=Ecosystem.CODEX,
98
+ package_kind="single-plugin",
99
+ root_path=resolved,
100
+ manifest_path=resolved / ".codex-plugin" / "plugin.json",
101
+ detection_reason="legacy codex fallback at scan root",
102
+ )
103
+ ]
104
+
105
+
106
+ def _category_prefix(scan_root: Path, package: NormalizedPackage, package_count: int) -> str:
107
+ if package_count == 1 and package.root_path.resolve() == scan_root.resolve():
108
+ return ""
109
+ try:
110
+ relative_root = package.root_path.resolve().relative_to(scan_root.resolve())
111
+ relative_label = "." if str(relative_root) == "." else str(relative_root)
112
+ except ValueError:
113
+ relative_label = str(package.root_path)
114
+ return f"[{package.ecosystem.value}:{relative_label}] "
115
+
116
+
117
+ def _is_path_within(path: Path, root: Path) -> bool:
118
+ try:
119
+ path.relative_to(root)
120
+ return True
121
+ except ValueError:
122
+ return False
123
+
124
+
125
+ def _summarize_package(package: NormalizedPackage) -> PackageSummary:
126
+ return PackageSummary(
127
+ ecosystem=package.ecosystem.value,
128
+ package_kind=package.package_kind,
129
+ root_path=str(package.root_path),
130
+ manifest_path=str(package.manifest_path) if package.manifest_path is not None else None,
131
+ name=package.name,
132
+ version=package.version,
133
+ )
134
+
135
+
136
+ def _rebase_finding(finding: Finding, plugin_dir: Path, repo_root: Path) -> Finding:
137
+ if not finding.file_path:
138
+ return finding
139
+ file_path = Path(finding.file_path)
140
+ resolved_path = file_path.resolve() if file_path.is_absolute() else (plugin_dir / file_path).resolve()
141
+ try:
142
+ rebased_path = resolved_path.relative_to(repo_root.resolve()).as_posix()
143
+ except ValueError:
144
+ return finding
145
+ return replace(finding, file_path=rebased_path)
146
+
147
+
148
+ def _rebase_check_result(check: CheckResult, plugin_dir: Path, repo_root: Path) -> CheckResult:
149
+ return replace(
150
+ check,
151
+ findings=tuple(_rebase_finding(finding, plugin_dir, repo_root) for finding in check.findings),
152
+ )
153
+
154
+
155
+ def _maybe_rebase_checks(
156
+ checks: tuple[CheckResult, ...],
157
+ plugin_dir: Path,
158
+ repo_root: Path,
159
+ enabled: bool,
160
+ ) -> tuple[CheckResult, ...]:
161
+ if not enabled:
162
+ return checks
163
+ return tuple(_rebase_check_result(check, plugin_dir, repo_root) for check in checks)
164
+
165
+
166
+ def _rebase_plugin_result(plugin_result: ScanResult, plugin_target: LocalPluginTarget, repo_root: Path) -> ScanResult:
167
+ rebased_categories = tuple(
168
+ CategoryResult(
169
+ name=category.name,
170
+ checks=tuple(_rebase_check_result(check, plugin_target.plugin_dir, repo_root) for check in category.checks),
171
+ )
172
+ for category in plugin_result.categories
173
+ )
174
+ rebased_integrations = tuple(
175
+ replace(integration, name=f"{plugin_target.name} / {integration.name}")
176
+ for integration in plugin_result.integrations
177
+ )
178
+ rebased_findings = tuple(
179
+ _rebase_finding(finding, plugin_target.plugin_dir, repo_root) for finding in plugin_result.findings
180
+ )
181
+ return replace(
182
+ plugin_result,
183
+ categories=rebased_categories,
184
+ findings=rebased_findings,
185
+ severity_counts=build_severity_counts(rebased_findings),
186
+ integrations=rebased_integrations,
187
+ plugin_name=plugin_target.name,
188
+ )
189
+
190
+
191
+ def _scan_single_plugin(plugin_dir: Path, options: ScanOptions) -> ScanResult:
192
+ skill_security_context = resolve_skill_security_context(plugin_dir, options)
193
+ categories: list[CategoryResult] = [
194
+ CategoryResult(name="Manifest Validation", checks=run_manifest_checks(plugin_dir)),
195
+ CategoryResult(name="Security", checks=run_security_checks(plugin_dir)),
196
+ CategoryResult(name="Operational Security", checks=run_operational_security_checks(plugin_dir)),
197
+ CategoryResult(name="Best Practices", checks=run_best_practice_checks(plugin_dir)),
198
+ CategoryResult(name="Marketplace", checks=run_marketplace_checks(plugin_dir)),
199
+ CategoryResult(
200
+ name="Skill Security",
201
+ checks=run_skill_security_checks(plugin_dir, options, skill_security_context),
202
+ ),
203
+ CategoryResult(name="Code Quality", checks=run_code_quality_checks(plugin_dir)),
204
+ ]
205
+
206
+ score = _score_categories(tuple(categories))
207
+ findings = tuple(finding for category in categories for check in category.checks for finding in check.findings)
208
+ trust_report = build_plugin_trust_report(plugin_dir, tuple(categories), skill_security_context)
209
+ return ScanResult(
210
+ score=score,
211
+ grade=get_grade(score),
212
+ categories=tuple(categories),
213
+ timestamp=datetime.now(timezone.utc).isoformat(),
214
+ plugin_dir=str(plugin_dir),
215
+ findings=findings,
216
+ severity_counts=build_severity_counts(findings),
217
+ integrations=_build_integration_results(skill_security_context),
218
+ scope="plugin",
219
+ trust_report=trust_report,
220
+ ecosystems=("codex",),
221
+ packages=(
222
+ PackageSummary(
223
+ ecosystem="codex",
224
+ package_kind="single-plugin",
225
+ root_path=str(plugin_dir),
226
+ manifest_path=str(plugin_dir / ".codex-plugin" / "plugin.json"),
227
+ name=None,
228
+ version=None,
229
+ ),
230
+ ),
231
+ )
232
+
233
+
234
+ def _build_repository_categories(
235
+ repo_root: Path,
236
+ plugin_results: tuple[ScanResult, ...],
237
+ ) -> tuple[CategoryResult, ...]:
238
+ categories: list[CategoryResult] = [
239
+ CategoryResult(name="Repository Marketplace", checks=run_marketplace_checks(repo_root)),
240
+ CategoryResult(name="Repository Operational Security", checks=run_operational_security_checks(repo_root)),
241
+ ]
242
+ for plugin_result in plugin_results:
243
+ for category in plugin_result.categories:
244
+ if category.name in {"Marketplace", "Operational Security"}:
245
+ continue
246
+ plugin_name = plugin_result.plugin_name or Path(plugin_result.plugin_dir).name
247
+ categories.append(CategoryResult(name=f"{plugin_name} · {category.name}", checks=category.checks))
248
+ return tuple(categories)
249
+
250
+
251
+ def _scan_repository(repo_root: Path, options: ScanOptions) -> ScanResult:
252
+ discovery = discover_scan_targets(repo_root)
253
+ plugin_results = tuple(
254
+ _rebase_plugin_result(_scan_single_plugin(target.plugin_dir, options), target, repo_root)
255
+ for target in discovery.local_plugins
256
+ )
257
+ categories = _build_repository_categories(repo_root, plugin_results)
258
+ findings = tuple(finding for category in categories for check in category.checks for finding in check.findings)
259
+ repo_scores = [plugin.score for plugin in plugin_results]
260
+ repo_category_score = _score_categories(categories[:2]) if categories[:2] else 100
261
+ if categories[:2]:
262
+ repo_scores.append(repo_category_score)
263
+ score = min(repo_scores) if repo_scores else 0
264
+ trust_report = build_repository_trust_report(
265
+ tuple(plugin.trust_report for plugin in plugin_results if plugin.trust_report is not None)
266
+ )
267
+ ecosystems = tuple(sorted({ecosystem for plugin in plugin_results for ecosystem in plugin.ecosystems})) or (
268
+ "codex",
269
+ )
270
+ packages = tuple(summary for plugin in plugin_results for summary in plugin.packages)
271
+ return ScanResult(
272
+ score=score,
273
+ grade=get_grade(score),
274
+ categories=categories,
275
+ timestamp=datetime.now(timezone.utc).isoformat(),
276
+ plugin_dir=str(repo_root),
277
+ findings=findings,
278
+ severity_counts=build_severity_counts(findings),
279
+ integrations=tuple(integration for plugin in plugin_results for integration in plugin.integrations),
280
+ scope="repository",
281
+ plugin_results=plugin_results,
282
+ skipped_targets=discovery.skipped_targets,
283
+ marketplace_file=str(discovery.marketplace_file) if discovery.marketplace_file else None,
284
+ trust_report=trust_report,
285
+ ecosystems=ecosystems,
286
+ packages=packages,
287
+ )
288
+
289
+
290
+ def _scan_mixed_packages(scan_root: Path, packages: list[NormalizedPackage], options: ScanOptions) -> ScanResult:
291
+ package_count = len(packages)
292
+ scan_root_resolved = scan_root.resolve()
293
+ categories: list[CategoryResult] = []
294
+ integrations: list[IntegrationResult] = []
295
+ plugin_results: list[ScanResult] = []
296
+ skipped_targets = []
297
+ codex_trust_reports = []
298
+ processed_packages: list[NormalizedPackage] = []
299
+ codex_marketplace_roots = tuple(
300
+ package.root_path.resolve()
301
+ for package in packages
302
+ if package.ecosystem == Ecosystem.CODEX and package.package_kind == "marketplace"
303
+ )
304
+
305
+ for package in packages:
306
+ package_root = package.root_path.resolve()
307
+ if (
308
+ package.ecosystem == Ecosystem.CODEX
309
+ and package.package_kind == "single-plugin"
310
+ and any(
311
+ package_root != marketplace_root and _is_path_within(package_root, marketplace_root)
312
+ for marketplace_root in codex_marketplace_roots
313
+ )
314
+ ):
315
+ continue
316
+ needs_rebase = package_root != scan_root_resolved
317
+ prefix = _category_prefix(scan_root, package, package_count)
318
+
319
+ if package.ecosystem == Ecosystem.CODEX:
320
+ if package.package_kind == "marketplace":
321
+ codex_repo_result = _scan_repository(package_root, options)
322
+ codex_categories = list(codex_repo_result.categories)
323
+ codex_integrations = list(codex_repo_result.integrations)
324
+ if prefix:
325
+ codex_categories = [
326
+ CategoryResult(name=f"{prefix}{category.name}", checks=category.checks)
327
+ for category in codex_categories
328
+ ]
329
+ codex_integrations = [
330
+ replace(
331
+ integration,
332
+ name=f"{package.ecosystem.value}:{package_root.name} / {integration.name}",
333
+ )
334
+ for integration in codex_integrations
335
+ ]
336
+ categories.extend(codex_categories)
337
+ integrations.extend(codex_integrations)
338
+ if codex_repo_result.trust_report is not None:
339
+ codex_trust_reports.append(codex_repo_result.trust_report)
340
+ plugin_results.extend(codex_repo_result.plugin_results)
341
+ skipped_targets.extend(codex_repo_result.skipped_targets)
342
+ processed_packages.append(package)
343
+ continue
344
+
345
+ codex_result = _scan_single_plugin(package_root, options)
346
+ package_name = package.name or package_root.name
347
+ if needs_rebase:
348
+ codex_plugin_result = _rebase_plugin_result(
349
+ codex_result,
350
+ LocalPluginTarget(name=package_name, plugin_dir=package_root, source_path="./"),
351
+ scan_root_resolved,
352
+ )
353
+ else:
354
+ codex_plugin_result = replace(codex_result, plugin_name=package_name)
355
+ codex_categories = [
356
+ CategoryResult(
357
+ name=category.name,
358
+ checks=_maybe_rebase_checks(category.checks, package_root, scan_root_resolved, needs_rebase),
359
+ )
360
+ for category in codex_result.categories
361
+ ]
362
+ codex_integrations = list(codex_result.integrations)
363
+ if prefix:
364
+ codex_categories = [
365
+ CategoryResult(name=f"{prefix}{category.name}", checks=category.checks)
366
+ for category in codex_categories
367
+ ]
368
+ codex_integrations = [
369
+ replace(
370
+ integration,
371
+ name=f"{package.ecosystem.value}:{package_root.name} / {integration.name}",
372
+ )
373
+ for integration in codex_integrations
374
+ ]
375
+ categories.extend(codex_categories)
376
+ integrations.extend(codex_integrations)
377
+ if codex_result.trust_report is not None:
378
+ codex_trust_reports.append(codex_result.trust_report)
379
+ plugin_results.append(codex_plugin_result)
380
+ processed_packages.append(package)
381
+ continue
382
+
383
+ if package.ecosystem == Ecosystem.CLAUDE:
384
+ claude_checks = _maybe_rebase_checks(
385
+ run_claude_checks(package),
386
+ package_root,
387
+ scan_root_resolved,
388
+ needs_rebase,
389
+ )
390
+ security_checks = _maybe_rebase_checks(
391
+ run_security_checks(package_root),
392
+ package_root,
393
+ scan_root_resolved,
394
+ needs_rebase,
395
+ )
396
+ operational_checks = _maybe_rebase_checks(
397
+ run_operational_security_checks(package_root),
398
+ package_root,
399
+ scan_root_resolved,
400
+ needs_rebase,
401
+ )
402
+ quality_checks = _maybe_rebase_checks(
403
+ run_code_quality_checks(package_root),
404
+ package_root,
405
+ scan_root_resolved,
406
+ needs_rebase,
407
+ )
408
+ categories.extend(
409
+ (
410
+ CategoryResult(name=f"{prefix}Claude Plugin", checks=claude_checks),
411
+ CategoryResult(name=f"{prefix}Security", checks=security_checks),
412
+ CategoryResult(name=f"{prefix}Operational Security", checks=operational_checks),
413
+ CategoryResult(name=f"{prefix}Code Quality", checks=quality_checks),
414
+ )
415
+ )
416
+ processed_packages.append(package)
417
+ continue
418
+
419
+ if package.ecosystem == Ecosystem.GEMINI:
420
+ gemini_checks = _maybe_rebase_checks(
421
+ run_gemini_checks(package),
422
+ package_root,
423
+ scan_root_resolved,
424
+ needs_rebase,
425
+ )
426
+ security_checks = _maybe_rebase_checks(
427
+ run_security_checks(package_root),
428
+ package_root,
429
+ scan_root_resolved,
430
+ needs_rebase,
431
+ )
432
+ operational_checks = _maybe_rebase_checks(
433
+ run_operational_security_checks(package_root),
434
+ package_root,
435
+ scan_root_resolved,
436
+ needs_rebase,
437
+ )
438
+ quality_checks = _maybe_rebase_checks(
439
+ run_code_quality_checks(package_root),
440
+ package_root,
441
+ scan_root_resolved,
442
+ needs_rebase,
443
+ )
444
+ categories.extend(
445
+ (
446
+ CategoryResult(name=f"{prefix}Gemini Extension", checks=gemini_checks),
447
+ CategoryResult(name=f"{prefix}Security", checks=security_checks),
448
+ CategoryResult(name=f"{prefix}Operational Security", checks=operational_checks),
449
+ CategoryResult(name=f"{prefix}Code Quality", checks=quality_checks),
450
+ )
451
+ )
452
+ processed_packages.append(package)
453
+ continue
454
+
455
+ if package.ecosystem == Ecosystem.OPENCODE:
456
+ opencode_checks = _maybe_rebase_checks(
457
+ run_opencode_checks(package),
458
+ package_root,
459
+ scan_root_resolved,
460
+ needs_rebase,
461
+ )
462
+ security_checks = _maybe_rebase_checks(
463
+ run_security_checks(package_root),
464
+ package_root,
465
+ scan_root_resolved,
466
+ needs_rebase,
467
+ )
468
+ operational_checks = _maybe_rebase_checks(
469
+ run_operational_security_checks(package_root),
470
+ package_root,
471
+ scan_root_resolved,
472
+ needs_rebase,
473
+ )
474
+ quality_checks = _maybe_rebase_checks(
475
+ run_code_quality_checks(package_root),
476
+ package_root,
477
+ scan_root_resolved,
478
+ needs_rebase,
479
+ )
480
+ categories.extend(
481
+ (
482
+ CategoryResult(name=f"{prefix}OpenCode Plugin", checks=opencode_checks),
483
+ CategoryResult(name=f"{prefix}Security", checks=security_checks),
484
+ CategoryResult(name=f"{prefix}Operational Security", checks=operational_checks),
485
+ CategoryResult(name=f"{prefix}Code Quality", checks=quality_checks),
486
+ )
487
+ )
488
+ processed_packages.append(package)
489
+
490
+ findings = tuple(finding for category in categories for check in category.checks for finding in check.findings)
491
+ score = _score_categories(tuple(categories))
492
+ trust_report = build_repository_trust_report(tuple(codex_trust_reports)) if codex_trust_reports else None
493
+ reported_packages = tuple(processed_packages) if processed_packages else tuple(packages)
494
+ marketplace_candidates = tuple(
495
+ package
496
+ for package in reported_packages
497
+ if package.ecosystem == Ecosystem.CODEX and package.package_kind == "marketplace"
498
+ )
499
+ scope = "repository" if marketplace_candidates or len(reported_packages) > 1 else "plugin"
500
+ marketplace_file = str(marketplace_candidates[0].manifest_path) if len(marketplace_candidates) == 1 else None
501
+ return ScanResult(
502
+ score=score,
503
+ grade=get_grade(score),
504
+ categories=tuple(categories),
505
+ timestamp=datetime.now(timezone.utc).isoformat(),
506
+ plugin_dir=str(scan_root),
507
+ findings=findings,
508
+ severity_counts=build_severity_counts(findings),
509
+ integrations=tuple(integrations),
510
+ scope=scope,
511
+ plugin_results=tuple(plugin_results),
512
+ skipped_targets=tuple(skipped_targets),
513
+ marketplace_file=marketplace_file,
514
+ trust_report=trust_report,
515
+ ecosystems=tuple(sorted({package.ecosystem.value for package in reported_packages})),
516
+ packages=tuple(_summarize_package(package) for package in reported_packages),
517
+ )
518
+
519
+
520
+ def _scan_non_repository_target(target_dir: Path, options: ScanOptions) -> ScanResult:
521
+ requested_ecosystem = resolve_ecosystem(options.ecosystem)
522
+ candidates = detect_packages(target_dir, requested_ecosystem)
523
+ if not candidates:
524
+ candidates = _build_candidate_fallback(target_dir, requested_ecosystem)
525
+
526
+ adapter_map = _build_adapter_map()
527
+ packages = [adapter_map[candidate.ecosystem].parse(candidate) for candidate in candidates]
528
+
529
+ if (
530
+ len(packages) == 1
531
+ and packages[0].ecosystem == Ecosystem.CODEX
532
+ and packages[0].root_path.resolve() == target_dir.resolve()
533
+ ):
534
+ codex_result = _scan_single_plugin(target_dir, options)
535
+ summary = _summarize_package(packages[0])
536
+ return replace(codex_result, ecosystems=("codex",), packages=(summary,))
537
+
538
+ return _scan_mixed_packages(target_dir, packages, options)
539
+
540
+
541
+ def scan_plugin(plugin_dir: str | Path, options: ScanOptions | None = None) -> ScanResult:
542
+ """Scan a plugin directory or repository root."""
543
+
544
+ resolved = Path(plugin_dir).resolve()
545
+ scan_options = options or ScanOptions()
546
+ requested_ecosystem = resolve_ecosystem(scan_options.ecosystem)
547
+ discovery = discover_scan_targets(resolved)
548
+ if discovery.scope == "repository":
549
+ if requested_ecosystem == Ecosystem.CODEX:
550
+ return _scan_repository(resolved, scan_options)
551
+ if requested_ecosystem is None:
552
+ detected_candidates = detect_packages(resolved)
553
+ if not detected_candidates or all(
554
+ candidate.ecosystem == Ecosystem.CODEX for candidate in detected_candidates
555
+ ):
556
+ return _scan_repository(resolved, scan_options)
557
+ return _scan_non_repository_target(resolved, scan_options)