pkgwhy 1.0.0__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 (58) hide show
  1. pkgwhy/__init__.py +3 -0
  2. pkgwhy/__main__.py +6 -0
  3. pkgwhy/agent/__init__.py +2 -0
  4. pkgwhy/agent/judge.py +93 -0
  5. pkgwhy/cli.py +676 -0
  6. pkgwhy/core/__init__.py +2 -0
  7. pkgwhy/core/constants.py +13 -0
  8. pkgwhy/core/models.py +608 -0
  9. pkgwhy/dependencies/__init__.py +2 -0
  10. pkgwhy/dependencies/graph.py +68 -0
  11. pkgwhy/dependencies/reason.py +79 -0
  12. pkgwhy/dynamic/__init__.py +2 -0
  13. pkgwhy/dynamic/analysis.py +156 -0
  14. pkgwhy/explanations/__init__.py +2 -0
  15. pkgwhy/explanations/explain.py +47 -0
  16. pkgwhy/explanations/local_db.py +52 -0
  17. pkgwhy/imports/__init__.py +2 -0
  18. pkgwhy/imports/scanner.py +43 -0
  19. pkgwhy/inspection/__init__.py +2 -0
  20. pkgwhy/inspection/files.py +540 -0
  21. pkgwhy/inspection/python_static.py +323 -0
  22. pkgwhy/inspection/size.py +58 -0
  23. pkgwhy/inspection/text_patterns.py +135 -0
  24. pkgwhy/manifests/__init__.py +2 -0
  25. pkgwhy/manifests/lockfiles.py +51 -0
  26. pkgwhy/manifests/pyproject.py +37 -0
  27. pkgwhy/manifests/requirements.py +27 -0
  28. pkgwhy/metadata/__init__.py +2 -0
  29. pkgwhy/metadata/installed.py +83 -0
  30. pkgwhy/metadata/pypi.py +199 -0
  31. pkgwhy/policy/__init__.py +1 -0
  32. pkgwhy/policy/agent_policy.py +114 -0
  33. pkgwhy/policy/audit_log.py +60 -0
  34. pkgwhy/policy/tool_execution.py +76 -0
  35. pkgwhy/provenance/__init__.py +2 -0
  36. pkgwhy/provenance/installed.py +45 -0
  37. pkgwhy/registry/__init__.py +2 -0
  38. pkgwhy/registry/local.py +178 -0
  39. pkgwhy/registry/manifest.py +78 -0
  40. pkgwhy/registry/publish.py +142 -0
  41. pkgwhy/registry/run.py +148 -0
  42. pkgwhy/registry/tools.py +121 -0
  43. pkgwhy/reports/__init__.py +2 -0
  44. pkgwhy/reports/audit.py +81 -0
  45. pkgwhy/risk/__init__.py +5 -0
  46. pkgwhy/risk/rules.py +372 -0
  47. pkgwhy/risk/scoring.py +231 -0
  48. pkgwhy/typosquat/__init__.py +2 -0
  49. pkgwhy/typosquat/detector.py +182 -0
  50. pkgwhy/typosquat/popular_packages.py +34 -0
  51. pkgwhy/vulnerabilities/__init__.py +2 -0
  52. pkgwhy/vulnerabilities/matching.py +122 -0
  53. pkgwhy/vulnerabilities/osv.py +330 -0
  54. pkgwhy-1.0.0.dist-info/METADATA +688 -0
  55. pkgwhy-1.0.0.dist-info/RECORD +58 -0
  56. pkgwhy-1.0.0.dist-info/WHEEL +4 -0
  57. pkgwhy-1.0.0.dist-info/entry_points.txt +2 -0
  58. pkgwhy-1.0.0.dist-info/licenses/LICENSE +22 -0
pkgwhy/cli.py ADDED
@@ -0,0 +1,676 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from pkgwhy.agent.judge import inspect_installed_package, judge_installed_package
13
+ from pkgwhy.core.constants import CAPABILITY_EXPOSURE_NOTE
14
+ from pkgwhy.core.models import AgentPackagePrecheckResult, PackageMetadata, RiskRuleEvidence, VulnerabilityMatch
15
+ from pkgwhy.dependencies.reason import explain_dependency_reason
16
+ from pkgwhy.dynamic.analysis import build_unavailable_dynamic_result
17
+ from pkgwhy.explanations.explain import explain_package
18
+ from pkgwhy.imports.scanner import scan_project_imports
19
+ from pkgwhy.metadata.pypi import PyPIMetadataError, fetch_pypi_project, provenance_from_pypi_payload
20
+ from pkgwhy.metadata.installed import get_installed_package, list_installed_packages
21
+ from pkgwhy.policy.audit_log import write_agent_package_decision_log
22
+ from pkgwhy.policy.agent_policy import default_agent_policy, evaluate_package_policy
23
+ from pkgwhy.registry.local import add_registry, init_local_registry, list_registries, use_registry
24
+ from pkgwhy.registry.publish import publish_local_tool
25
+ from pkgwhy.registry.run import RUNNER_ISOLATION_WARNING, run_local_tool
26
+ from pkgwhy.registry.tools import judge_tool
27
+ from pkgwhy.reports.audit import build_audit_report, render_audit_markdown
28
+ from pkgwhy.typosquat.detector import detect_typosquats
29
+ from pkgwhy.vulnerabilities.matching import match_vulnerabilities
30
+ from pkgwhy.vulnerabilities.osv import load_osv_records, query_osv_cached
31
+
32
+ app = typer.Typer(no_args_is_help=True, help="Explain, inspect, judge packages, and run local private tools.")
33
+ registry_app = typer.Typer(no_args_is_help=True, help="Manage local private registries.")
34
+ tool_app = typer.Typer(no_args_is_help=True, help="Inspect and judge local private tools.")
35
+ dynamic_app = typer.Typer(
36
+ no_args_is_help=True,
37
+ help="Experimental dynamic analysis; not part of the stable security decision surface in this release.",
38
+ )
39
+ agent_app = typer.Typer(no_args_is_help=True, help="Agent-facing policy and package precheck commands.")
40
+ app.add_typer(registry_app, name="registry")
41
+ app.add_typer(tool_app, name="tool")
42
+ app.add_typer(dynamic_app, name="dynamic")
43
+ app.add_typer(agent_app, name="agent")
44
+ console = Console()
45
+
46
+
47
+ @app.command()
48
+ def scan(limit: Annotated[int, typer.Option(help="Maximum packages to display.")] = 25) -> None:
49
+ """Scan installed packages in the active Python environment."""
50
+ if limit <= 0:
51
+ raise typer.BadParameter("limit must be greater than zero")
52
+ packages = list_installed_packages()[:limit]
53
+ table = Table(title="Installed packages")
54
+ table.add_column("Package")
55
+ table.add_column("Version")
56
+ table.add_column("Summary")
57
+ for package in packages:
58
+ table.add_row(package.identity.name, package.identity.version or "unknown", package.summary or "")
59
+ console.print(table)
60
+
61
+
62
+ @app.command()
63
+ def explain(package: str, project_root: Annotated[Path, typer.Option(help="Project root for dependency context.")] = Path(".")) -> None:
64
+ """Explain what an installed package appears to do."""
65
+ metadata = get_installed_package(package)
66
+ dependency_status = dependency_status_for(package, project_root, metadata=metadata)
67
+ explanation = explain_package(metadata, package, dependency_status)
68
+ console.print(f"[bold]{explanation.package}[/bold]")
69
+ if explanation.version:
70
+ console.print(f"Version: {explanation.version}")
71
+ console.print(f"Summary: {explanation.summary}")
72
+ console.print(f"Dependency status: {explanation.dependency_status}")
73
+ console.print(f"Confidence: {explanation.confidence.value}")
74
+ if explanation.common_use_cases:
75
+ console.print("Common use cases:")
76
+ for item in explanation.common_use_cases:
77
+ console.print(f" - {item}")
78
+ if explanation.common_imports:
79
+ console.print("Common imports:")
80
+ for item in explanation.common_imports:
81
+ console.print(f" - {item}")
82
+ if explanation.minimal_usage_example:
83
+ console.print("Minimal usage example:")
84
+ console.print(explanation.minimal_usage_example)
85
+ console.print(f"Sources used: {', '.join(explanation.sources_used) or 'none'}")
86
+
87
+
88
+ @app.command()
89
+ def why(package: str, project_root: Annotated[Path, typer.Option(help="Project root to inspect.")] = Path(".")) -> None:
90
+ """Explain why a package may be installed in the current project."""
91
+ imports = scan_project_imports(project_root)
92
+ reason = explain_dependency_reason(package, project_root, imports)
93
+ console.print(f"[bold]{package}[/bold]")
94
+ console.print(f"Dependency status: {reason.status.value}")
95
+ if reason.declared_in:
96
+ console.print(f"Declared in: {', '.join(reason.declared_in)}")
97
+ if reason.transitive_via:
98
+ console.print(f"Transitive parent signal: {', '.join(reason.transitive_via)}")
99
+ if reason.lockfiles:
100
+ console.print(f"Lockfile signal: {', '.join(reason.lockfiles)}")
101
+ if reason.imported_by_project:
102
+ console.print("Local import signal: imported by project source.")
103
+ else:
104
+ console.print("Local import signal: no matching top-level import found.")
105
+ console.print("Evidence:")
106
+ for item in reason.evidence:
107
+ console.print(f" - {item}")
108
+
109
+
110
+ @app.command()
111
+ def inspect(package: str) -> None:
112
+ """Inspect installed package metadata and files without importing package code."""
113
+ inspection = inspect_installed_package(package)
114
+ if inspection is None:
115
+ _package_not_found(package)
116
+ raise typer.Exit(1)
117
+
118
+ metadata = inspection.metadata
119
+ console.print(f"[bold]{metadata.identity.name}[/bold] {metadata.identity.version or 'unknown'}")
120
+ console.print(f"Summary: {metadata.summary or 'Unavailable from installed metadata.'}")
121
+ console.print(f"License: {metadata.license or 'Unknown from installed metadata.'}")
122
+ console.print(f"Source availability: {inspection.source_availability.value}")
123
+ console.print(f"Readability: {inspection.readability.value}")
124
+ console.print(f"Installed size: {inspection.size.total_bytes} bytes across {inspection.size.file_count} files")
125
+ console.print("Runtime capability exposure:")
126
+ console.print(f" {CAPABILITY_EXPOSURE_NOTE}")
127
+ if inspection.detected_capabilities:
128
+ console.print("Detected capability signals:")
129
+ for capability in inspection.detected_capabilities:
130
+ console.print(f" - {capability}")
131
+ if inspection.warnings:
132
+ console.print("Warnings:")
133
+ for warning in inspection.warnings:
134
+ console.print(f" - {warning}")
135
+ _print_rule_evidence(inspection.rule_evidence)
136
+ if inspection.size.largest_files:
137
+ console.print("Largest files:")
138
+ for item in inspection.size.largest_files:
139
+ console.print(f" - {item.path}: {item.size_bytes} bytes")
140
+
141
+
142
+ @app.command()
143
+ def judge(package: str, as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON for agents.")] = False) -> None:
144
+ """Produce a conservative package judgement."""
145
+ judgement = judge_installed_package(package)
146
+ if as_json:
147
+ print(json.dumps(judgement.model_dump(mode="json"), indent=2, sort_keys=True))
148
+ return
149
+ console.print(f"[bold]{judgement.package}[/bold]")
150
+ console.print(f"Decision: {judgement.decision.value}")
151
+ console.print(f"Risk level: {judgement.risk_level.value}")
152
+ console.print(f"Confidence: {judgement.confidence.value}")
153
+ console.print(f"Recommendation: {judgement.recommendation}")
154
+ if judgement.warnings:
155
+ console.print("Warnings:")
156
+ for warning in judgement.warnings:
157
+ console.print(f" - {warning}")
158
+ _print_rule_evidence(judgement.risk_rules)
159
+
160
+
161
+ @app.command()
162
+ def risk(package: str, as_json: Annotated[bool, typer.Option("--json", help="Emit JSON risk report.")] = False) -> None:
163
+ """Show conservative risk judgement for a package."""
164
+ judgement = judge_installed_package(package)
165
+ if as_json:
166
+ print(json.dumps(judgement.model_dump(mode="json"), indent=2, sort_keys=True))
167
+ return
168
+
169
+ console.print(f"[bold]{judgement.package}[/bold]")
170
+ console.print(f"Risk level: {judgement.risk_level.value}")
171
+ console.print(f"Decision: {judgement.decision.value}")
172
+ console.print(f"Confidence: {judgement.confidence.value}")
173
+ console.print(f"Source availability: {judgement.source_availability.value}")
174
+ console.print(f"Installed size: {judgement.installed_size_bytes} bytes")
175
+ console.print(f"Recommendation: {judgement.recommendation}")
176
+ if judgement.detected_capabilities:
177
+ console.print("Detected capability signals:")
178
+ for capability in judgement.detected_capabilities:
179
+ console.print(f" - {capability}")
180
+ if judgement.warnings:
181
+ console.print("Warnings:")
182
+ for warning in judgement.warnings:
183
+ console.print(f" - {warning}")
184
+ _print_rule_evidence(judgement.risk_rules)
185
+ if judgement.evidence:
186
+ if len(judgement.evidence) > 10:
187
+ console.print(f"Evidence (showing first 10 of {len(judgement.evidence)}):")
188
+ else:
189
+ console.print("Evidence:")
190
+ for item in judgement.evidence[:10]:
191
+ console.print(f" - {item}")
192
+
193
+
194
+ @app.command()
195
+ def audit(
196
+ limit: Annotated[int, typer.Option(help="Maximum installed packages to audit.")] = 25,
197
+ as_json: Annotated[bool, typer.Option("--json", help="Emit schema-versioned JSON audit report.")] = False,
198
+ markdown: Annotated[bool, typer.Option("--markdown", help="Emit Markdown audit report.")] = False,
199
+ output: Annotated[Path | None, typer.Option(help="Optional output path for JSON or Markdown reports.")] = None,
200
+ vulnerability_file: Annotated[
201
+ Path | None,
202
+ typer.Option(help="Optional local OSV-like JSON file with vulnerability data. No network is used."),
203
+ ] = None,
204
+ osv: Annotated[
205
+ bool,
206
+ typer.Option("--osv", help="Query OSV.dev for known vulnerabilities. Network is never used unless this is set."),
207
+ ] = False,
208
+ osv_cache_dir: Annotated[
209
+ Path | None,
210
+ typer.Option(help="Optional OSV.dev cache directory. Defaults to the pkgwhy user cache."),
211
+ ] = None,
212
+ pypi: Annotated[
213
+ bool,
214
+ typer.Option("--pypi", help="Query PyPI JSON for provenance metadata. Network is never used unless this is set."),
215
+ ] = False,
216
+ ) -> None:
217
+ """Audit installed packages with conservative static judgements."""
218
+ if limit <= 0:
219
+ raise typer.BadParameter("limit must be greater than zero")
220
+ if as_json and markdown:
221
+ raise typer.BadParameter("Choose either --json or --markdown, not both")
222
+ if output is not None and not (as_json or markdown):
223
+ raise typer.BadParameter("--output requires --json or --markdown")
224
+
225
+ packages = list_installed_packages()[:limit]
226
+ vulnerability_records = []
227
+ audit_warnings: list[str] = []
228
+ if vulnerability_file is not None:
229
+ try:
230
+ vulnerability_records = load_osv_records(vulnerability_file)
231
+ except ValueError as exc:
232
+ raise typer.BadParameter(str(exc)) from exc
233
+
234
+ judgements = []
235
+ for package in packages:
236
+ package_name = package.identity.name
237
+ package_version = package.identity.version
238
+ matches = match_vulnerabilities(package_name, package_version, vulnerability_records)
239
+ if osv:
240
+ lookup = query_osv_cached(package_name, package_version, cache_dir=osv_cache_dir)
241
+ audit_warnings.extend(lookup.warnings)
242
+ if lookup.cache_status == "stale_cache":
243
+ audit_warnings.append(f"OSV.dev lookup used stale cached data for {package_name}.")
244
+ elif lookup.cache_status == "unavailable":
245
+ audit_warnings.append(f"OSV.dev lookup unavailable for {package_name}; no vulnerability result was inferred.")
246
+ matches.extend(match_vulnerabilities(package_name, package_version, lookup.records))
247
+ matches = _dedupe_vulnerability_matches(matches)
248
+ provenance = None
249
+ if pypi:
250
+ try:
251
+ provenance = provenance_from_pypi_payload(
252
+ package_name,
253
+ fetch_pypi_project(package_name),
254
+ audited_version=package_version,
255
+ )
256
+ except PyPIMetadataError as exc:
257
+ audit_warnings.append(
258
+ f"PyPI provenance lookup unavailable for {package_name}: {exc}. "
259
+ "Provenance fields fall back to installed metadata where available."
260
+ )
261
+ judgements.append(
262
+ judge_installed_package(package_name, known_vulnerabilities=matches, provenance=provenance)
263
+ )
264
+
265
+ report = build_audit_report(judgements, warnings=audit_warnings)
266
+
267
+ if as_json:
268
+ rendered = json.dumps(report, indent=2, sort_keys=True)
269
+ _emit_or_write(rendered, output)
270
+ return
271
+ if markdown:
272
+ rendered = render_audit_markdown(judgements, warnings=audit_warnings)
273
+ _emit_or_write(rendered, output)
274
+ return
275
+
276
+ table = Table(title="Package audit")
277
+ table.add_column("Package")
278
+ table.add_column("Version")
279
+ table.add_column("Risk")
280
+ table.add_column("Decision")
281
+ table.add_column("Vulns")
282
+ table.add_column("Warnings")
283
+ for judgement in judgements:
284
+ table.add_row(
285
+ judgement.package,
286
+ judgement.version or "unknown",
287
+ judgement.risk_level.value,
288
+ judgement.decision.value,
289
+ str(len(judgement.known_vulnerabilities)),
290
+ str(len(judgement.warnings)),
291
+ )
292
+ console.print(table)
293
+ for warning in audit_warnings:
294
+ console.print(f"Warning: {warning}")
295
+
296
+
297
+ @app.command()
298
+ def typos(packages: Annotated[list[str] | None, typer.Argument(help="Package names to check. Omit to scan installed packages.")] = None) -> None:
299
+ """Detect conservative typosquatting similarity signals."""
300
+ names = packages if packages else [package.identity.name for package in list_installed_packages()]
301
+ candidates = detect_typosquats(names)
302
+ if not candidates:
303
+ console.print("No possible typosquatting signals found.")
304
+ return
305
+
306
+ table = Table(title="Possible typosquatting signals")
307
+ table.add_column("Package")
308
+ table.add_column("Possible target")
309
+ table.add_column("Similarity")
310
+ table.add_column("Signals")
311
+ table.add_column("Recommendation")
312
+ for candidate in candidates:
313
+ table.add_row(
314
+ candidate.package,
315
+ candidate.possible_target,
316
+ f"{candidate.similarity:.3f}",
317
+ ", ".join(candidate.signals),
318
+ candidate.recommendation,
319
+ )
320
+ console.print(table)
321
+
322
+
323
+ @app.command()
324
+ def publish(path: Annotated[Path, typer.Argument(help="Local .py file or folder with pkgwhy.toml to publish.")]) -> None:
325
+ """Publish a local private tool into the current local registry."""
326
+ try:
327
+ result = publish_local_tool(path)
328
+ except ValueError as exc:
329
+ console.print(str(exc))
330
+ raise typer.Exit(1) from exc
331
+
332
+ manifest = result.manifest
333
+ console.print(f"Published {manifest.owner}/{manifest.name} {manifest.version} to registry '{result.registry_name}'")
334
+ console.print(f"Bundle: {result.bundle_path}")
335
+ console.print(f"SHA-256: {result.sha256}")
336
+ console.print("Signature status: not_implemented")
337
+
338
+
339
+ @app.command()
340
+ def run(
341
+ reference: Annotated[str, typer.Argument(help="Tool name or owner/name reference from the local registry.")],
342
+ non_interactive: Annotated[
343
+ bool,
344
+ typer.Option("--non-interactive", help="Apply conservative non-interactive tool execution policy."),
345
+ ] = False,
346
+ ) -> None:
347
+ """Run a hash-verified local private tool from the current local registry."""
348
+ print(RUNNER_ISOLATION_WARNING, file=sys.stderr)
349
+ try:
350
+ result = run_local_tool(reference, non_interactive=non_interactive)
351
+ except ValueError as exc:
352
+ console.print(str(exc))
353
+ raise typer.Exit(1) from exc
354
+
355
+ if result.stdout:
356
+ print(result.stdout, end="" if result.stdout.endswith("\n") else "\n")
357
+ if result.stderr:
358
+ print(result.stderr, end="" if result.stderr.endswith("\n") else "\n")
359
+ console.print(f"Execution log: {result.log_path}")
360
+ raise typer.Exit(result.exit_code)
361
+
362
+
363
+ @agent_app.command("policy")
364
+ def agent_policy(as_json: Annotated[bool, typer.Option("--json", help="Emit schema-versioned agent policy JSON.")] = False) -> None:
365
+ """Show conservative default policy for agent package and tool decisions."""
366
+ policy = default_agent_policy()
367
+ if as_json:
368
+ print(json.dumps(policy.model_dump(mode="json"), indent=2, sort_keys=True))
369
+ return
370
+ console.print("[bold]Agent policy[/bold]")
371
+ console.print(f"Schema: {policy.schema_version}")
372
+ console.print(f"Public PyPI allowed by default: {policy.allow_public_pypi}")
373
+ console.print(f"Unpinned dependencies allowed by default: {policy.allow_unpinned_dependencies}")
374
+ console.print(f"Unsigned tools allowed by default: {policy.allow_unsigned_tools}")
375
+ console.print(f"Non-interactive default decision: {policy.non_interactive_default_decision.value}")
376
+ console.print(f"Unknown package decision: {policy.unknown_package_decision.value}")
377
+ console.print(f"Non-interactive unknown package decision: {policy.non_interactive_unknown_package_decision.value}")
378
+ console.print(f"High-risk package decision: {policy.high_risk_package_decision.value}")
379
+ console.print(f"Non-interactive high-risk package decision: {policy.non_interactive_high_risk_package_decision.value}")
380
+ console.print("Policy is decision support; it does not install, import, or execute packages.")
381
+
382
+
383
+ @agent_app.command("precheck")
384
+ def agent_precheck(
385
+ package: Annotated[str, typer.Argument(help="Installed package name to judge against agent policy.")],
386
+ as_json: Annotated[bool, typer.Option("--json", help="Emit schema-versioned agent precheck JSON.")] = False,
387
+ non_interactive: Annotated[
388
+ bool,
389
+ typer.Option(
390
+ "--non-interactive/--interactive",
391
+ help="Apply conservative non-interactive agent defaults.",
392
+ ),
393
+ ] = True,
394
+ ) -> None:
395
+ """Apply conservative agent policy to an installed package judgement."""
396
+ result = _build_agent_package_precheck(package, non_interactive=non_interactive)
397
+ log_path = write_agent_package_decision_log(result)
398
+ _emit_agent_package_precheck(result, as_json=as_json, log_path=log_path)
399
+
400
+
401
+ @agent_app.command("judge")
402
+ def agent_judge(
403
+ package: Annotated[str, typer.Argument(help="Installed package name to judge against agent policy.")],
404
+ as_json: Annotated[bool, typer.Option("--json", help="Emit schema-versioned agent judgement JSON.")] = False,
405
+ non_interactive: Annotated[
406
+ bool,
407
+ typer.Option(
408
+ "--non-interactive/--interactive",
409
+ help="Apply conservative non-interactive agent defaults.",
410
+ ),
411
+ ] = True,
412
+ ) -> None:
413
+ """Alias for package precheck until tool-specific agent judgement is expanded."""
414
+ result = _build_agent_package_precheck(package, non_interactive=non_interactive)
415
+ log_path = write_agent_package_decision_log(result)
416
+ _emit_agent_package_precheck(result, as_json=as_json, log_path=log_path)
417
+
418
+
419
+ @dynamic_app.command("inspect")
420
+ def dynamic_inspect(
421
+ target: Annotated[str, typer.Argument(help="Target package or artifact reference to analyze dynamically.")],
422
+ container: Annotated[
423
+ bool,
424
+ typer.Option("--container", help="Require a disposable container backend. Host execution is never used."),
425
+ ] = False,
426
+ network: Annotated[
427
+ str,
428
+ typer.Option("--network", help="Network mode for a future sandbox backend. Only 'off' is accepted currently."),
429
+ ] = "off",
430
+ as_json: Annotated[bool, typer.Option("--json", help="Emit schema-versioned dynamic analysis JSON.")] = False,
431
+ ) -> None:
432
+ """Experimental dynamic analysis skeleton that fails safely without a backend."""
433
+ result = build_unavailable_dynamic_result(target, container=container, network=network)
434
+ if as_json:
435
+ print(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True))
436
+ raise typer.Exit(1)
437
+
438
+ for warning in result.warnings:
439
+ console.print(warning)
440
+ if result.limitations:
441
+ console.print("Limitations:")
442
+ for limitation in result.limitations:
443
+ console.print(f" - {limitation}")
444
+ console.print(f"Target was not executed: {result.target}")
445
+ raise typer.Exit(1)
446
+
447
+
448
+ @tool_app.command("inspect")
449
+ def tool_inspect(reference: Annotated[str, typer.Argument(help="Tool name or owner/name reference.")]) -> None:
450
+ """Inspect a locally published private tool."""
451
+ try:
452
+ judgement = judge_tool(reference)
453
+ except ValueError as exc:
454
+ console.print(str(exc))
455
+ raise typer.Exit(1) from exc
456
+
457
+ console.print(f"[bold]{judgement.tool}[/bold] {judgement.version}")
458
+ console.print(f"Description: {judgement.manifest.description}")
459
+ console.print(f"Artifact type: {judgement.manifest.artifact_type.value}")
460
+ console.print(f"Entrypoint: {judgement.manifest.entrypoint}")
461
+ console.print(f"Hash status: {judgement.hash_status.value}")
462
+ console.print(f"Signature status: {judgement.signature_status}")
463
+ console.print(f"Risk level: {judgement.risk_level.value}")
464
+ console.print(f"Decision: {judgement.decision.value}")
465
+ if judgement.declared_permissions:
466
+ console.print("Declared permissions:")
467
+ for permission in judgement.declared_permissions:
468
+ console.print(f" - {permission}")
469
+ if judgement.warnings:
470
+ console.print("Warnings:")
471
+ for warning in judgement.warnings:
472
+ console.print(f" - {warning}")
473
+
474
+
475
+ @tool_app.command("judge")
476
+ def tool_judge(
477
+ reference: Annotated[str, typer.Argument(help="Tool name or owner/name reference.")],
478
+ as_json: Annotated[bool, typer.Option("--json", help="Emit stable JSON for agents.")] = False,
479
+ ) -> None:
480
+ """Produce a conservative private tool judgement."""
481
+ try:
482
+ judgement = judge_tool(reference)
483
+ except ValueError as exc:
484
+ console.print(str(exc))
485
+ raise typer.Exit(1) from exc
486
+
487
+ if as_json:
488
+ print(json.dumps(judgement.model_dump(mode="json"), indent=2, sort_keys=True))
489
+ return
490
+ console.print(f"[bold]{judgement.tool}[/bold]")
491
+ console.print(f"Decision: {judgement.decision.value}")
492
+ console.print(f"Risk level: {judgement.risk_level.value}")
493
+ console.print(f"Hash status: {judgement.hash_status.value}")
494
+ console.print(f"Recommendation: {judgement.recommendation}")
495
+
496
+
497
+ @registry_app.command("init")
498
+ def registry_init(
499
+ path: Annotated[Path, typer.Argument(help="Local registry directory to create or initialize.")],
500
+ name: Annotated[str, typer.Option(help="Registry name to store in local config.")] = "local",
501
+ ) -> None:
502
+ """Initialize a local private registry directory and select it."""
503
+ entry = init_local_registry(path, name=name)
504
+ console.print(f"Initialized registry '{entry.name}' at {entry.path}")
505
+ console.print(f"Current registry: {entry.name}")
506
+
507
+
508
+ @registry_app.command("add")
509
+ def registry_add(
510
+ name: Annotated[str, typer.Argument(help="Registry name to store in local config.")],
511
+ path: Annotated[Path, typer.Argument(help="Existing local registry directory.")],
512
+ ) -> None:
513
+ """Add an existing local registry directory to config."""
514
+ try:
515
+ entry = add_registry(name, path)
516
+ except ValueError as exc:
517
+ console.print(str(exc))
518
+ raise typer.Exit(1) from exc
519
+ console.print(f"Added registry '{entry.name}' at {entry.path}")
520
+ if not entry.index_exists:
521
+ console.print("Warning: registry index not found. Verify the directory contains 'pkgwhy-registry.json'.")
522
+
523
+
524
+ @registry_app.command("use")
525
+ def registry_use(name: Annotated[str, typer.Argument(help="Configured registry name to select.")]) -> None:
526
+ """Select the active local registry."""
527
+ try:
528
+ entry = use_registry(name)
529
+ except ValueError as exc:
530
+ console.print(str(exc))
531
+ raise typer.Exit(1) from exc
532
+ console.print(f"Current registry: {entry.name}")
533
+ console.print(f"Path: {entry.path}")
534
+
535
+
536
+ @registry_app.command("list")
537
+ def registry_list() -> None:
538
+ """List configured local registries."""
539
+ entries = list_registries()
540
+ if not entries:
541
+ console.print("No registries configured.")
542
+ return
543
+
544
+ table = Table(title="Configured registries")
545
+ table.add_column("Current")
546
+ table.add_column("Name")
547
+ table.add_column("Path")
548
+ table.add_column("Index")
549
+ for entry in entries:
550
+ table.add_row(
551
+ "*" if entry.is_current else "",
552
+ entry.name,
553
+ str(entry.path),
554
+ "present" if entry.index_exists else "missing",
555
+ )
556
+ console.print(table)
557
+
558
+
559
+ def dependency_status_for(
560
+ package: str,
561
+ project_root: Path,
562
+ project_imports: set[str] | None = None,
563
+ metadata: PackageMetadata | None = None,
564
+ ) -> str:
565
+ reason = explain_dependency_reason(package, project_root, project_imports, metadata)
566
+ return reason.status.value
567
+
568
+
569
+ def _package_not_found(package: str) -> None:
570
+ console.print(f"Package '{package}' is not installed in the active Python environment.")
571
+
572
+
573
+ def _emit_or_write(rendered: str, output: Path | None) -> None:
574
+ if output is None:
575
+ print(rendered, end="" if rendered.endswith("\n") else "\n")
576
+ return
577
+ try:
578
+ output.write_text(rendered, encoding="utf-8")
579
+ except OSError as exc:
580
+ console.print(f"Could not write report to {output}: {exc}")
581
+ raise typer.Exit(1) from exc
582
+ console.print(f"Wrote report to {output}")
583
+
584
+
585
+ def _print_rule_evidence(rules: list[RiskRuleEvidence], limit: int = 8) -> None:
586
+ if not rules:
587
+ return
588
+ selected_rules = sorted(rules, key=_rule_sort_key)[:limit]
589
+ heading = f"Rule evidence (showing first {limit} of {len(rules)}):" if len(rules) > limit else "Rule evidence:"
590
+ console.print(heading)
591
+ for rule in selected_rules:
592
+ location = _format_rule_location(rule)
593
+ location_text = f" at {location}" if location else ""
594
+ console.print(
595
+ f" - {rule.rule_id} ({rule.severity.value}/{rule.confidence.value}) {rule.name}{location_text}: {rule.message}"
596
+ )
597
+
598
+
599
+ def _rule_sort_key(rule: RiskRuleEvidence) -> tuple[int, str, str]:
600
+ severity_order = {
601
+ "critical": 0,
602
+ "high": 1,
603
+ "medium": 2,
604
+ "low": 3,
605
+ "info": 4,
606
+ }
607
+ return severity_order.get(rule.severity.value, 5), rule.rule_id, rule.file_path or ""
608
+
609
+
610
+ def _format_rule_location(rule: RiskRuleEvidence) -> str:
611
+ if rule.file_path and rule.line_number:
612
+ return f"{rule.file_path}:{rule.line_number}"
613
+ if rule.file_path:
614
+ return rule.file_path
615
+ if rule.symbol:
616
+ return rule.symbol
617
+ return ""
618
+
619
+
620
+ def _build_agent_package_precheck(package: str, *, non_interactive: bool) -> AgentPackagePrecheckResult:
621
+ judgement = judge_installed_package(package)
622
+ return evaluate_package_policy(judgement, non_interactive=non_interactive)
623
+
624
+
625
+ def _emit_agent_package_precheck(
626
+ result: AgentPackagePrecheckResult,
627
+ *,
628
+ as_json: bool,
629
+ log_path: Path | None = None,
630
+ ) -> None:
631
+ if as_json:
632
+ print(json.dumps(result.model_dump(mode="json"), indent=2, sort_keys=True))
633
+ return
634
+ console.print(f"[bold]{result.package}[/bold]")
635
+ console.print(f"Decision: {result.decision.value}")
636
+ console.print(f"Risk level: {result.risk_level.value}")
637
+ console.print(f"Confidence: {result.confidence.value}")
638
+ console.print(f"Policy source: {result.policy_decision_source}")
639
+ console.print(f"Recommendation: {result.recommendation}")
640
+ if log_path is not None:
641
+ console.print(f"Decision log: {log_path}")
642
+ if result.reasons:
643
+ console.print("Policy reasons:")
644
+ for reason in result.reasons:
645
+ console.print(f" - {reason}")
646
+ if result.warnings:
647
+ console.print("Warnings:")
648
+ for warning in result.warnings:
649
+ console.print(f" - {warning}")
650
+
651
+
652
+ def _dedupe_vulnerability_matches(matches: list[VulnerabilityMatch]) -> list[VulnerabilityMatch]:
653
+ deduped: dict[str, VulnerabilityMatch] = {}
654
+ for match in matches:
655
+ existing = deduped.get(match.vulnerability_id)
656
+ if existing is None or _vulnerability_match_rank(match) > _vulnerability_match_rank(existing):
657
+ deduped[match.vulnerability_id] = match
658
+ return sorted(deduped.values(), key=lambda item: item.vulnerability_id)
659
+
660
+
661
+ def _vulnerability_match_rank(match: VulnerabilityMatch) -> tuple[int, bool, int, bool, int]:
662
+ severity_order = {
663
+ "CRITICAL": 5,
664
+ "HIGH": 4,
665
+ "MODERATE": 3,
666
+ "MEDIUM": 3,
667
+ "LOW": 2,
668
+ }
669
+ strongest_severity = max((severity_order.get(value.upper(), 1) for value in match.severity), default=0)
670
+ return (
671
+ strongest_severity,
672
+ bool(match.fixed_versions),
673
+ len(match.evidence),
674
+ bool(match.source_url),
675
+ len(match.references),
676
+ )
@@ -0,0 +1,2 @@
1
+ """Core pkgwhy models and shared helpers."""
2
+