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.
- pkgwhy/__init__.py +3 -0
- pkgwhy/__main__.py +6 -0
- pkgwhy/agent/__init__.py +2 -0
- pkgwhy/agent/judge.py +93 -0
- pkgwhy/cli.py +676 -0
- pkgwhy/core/__init__.py +2 -0
- pkgwhy/core/constants.py +13 -0
- pkgwhy/core/models.py +608 -0
- pkgwhy/dependencies/__init__.py +2 -0
- pkgwhy/dependencies/graph.py +68 -0
- pkgwhy/dependencies/reason.py +79 -0
- pkgwhy/dynamic/__init__.py +2 -0
- pkgwhy/dynamic/analysis.py +156 -0
- pkgwhy/explanations/__init__.py +2 -0
- pkgwhy/explanations/explain.py +47 -0
- pkgwhy/explanations/local_db.py +52 -0
- pkgwhy/imports/__init__.py +2 -0
- pkgwhy/imports/scanner.py +43 -0
- pkgwhy/inspection/__init__.py +2 -0
- pkgwhy/inspection/files.py +540 -0
- pkgwhy/inspection/python_static.py +323 -0
- pkgwhy/inspection/size.py +58 -0
- pkgwhy/inspection/text_patterns.py +135 -0
- pkgwhy/manifests/__init__.py +2 -0
- pkgwhy/manifests/lockfiles.py +51 -0
- pkgwhy/manifests/pyproject.py +37 -0
- pkgwhy/manifests/requirements.py +27 -0
- pkgwhy/metadata/__init__.py +2 -0
- pkgwhy/metadata/installed.py +83 -0
- pkgwhy/metadata/pypi.py +199 -0
- pkgwhy/policy/__init__.py +1 -0
- pkgwhy/policy/agent_policy.py +114 -0
- pkgwhy/policy/audit_log.py +60 -0
- pkgwhy/policy/tool_execution.py +76 -0
- pkgwhy/provenance/__init__.py +2 -0
- pkgwhy/provenance/installed.py +45 -0
- pkgwhy/registry/__init__.py +2 -0
- pkgwhy/registry/local.py +178 -0
- pkgwhy/registry/manifest.py +78 -0
- pkgwhy/registry/publish.py +142 -0
- pkgwhy/registry/run.py +148 -0
- pkgwhy/registry/tools.py +121 -0
- pkgwhy/reports/__init__.py +2 -0
- pkgwhy/reports/audit.py +81 -0
- pkgwhy/risk/__init__.py +5 -0
- pkgwhy/risk/rules.py +372 -0
- pkgwhy/risk/scoring.py +231 -0
- pkgwhy/typosquat/__init__.py +2 -0
- pkgwhy/typosquat/detector.py +182 -0
- pkgwhy/typosquat/popular_packages.py +34 -0
- pkgwhy/vulnerabilities/__init__.py +2 -0
- pkgwhy/vulnerabilities/matching.py +122 -0
- pkgwhy/vulnerabilities/osv.py +330 -0
- pkgwhy-1.0.0.dist-info/METADATA +688 -0
- pkgwhy-1.0.0.dist-info/RECORD +58 -0
- pkgwhy-1.0.0.dist-info/WHEEL +4 -0
- pkgwhy-1.0.0.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|
pkgwhy/core/__init__.py
ADDED