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
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from importlib import metadata
4
+ from importlib.metadata import Distribution
5
+
6
+ from packaging.utils import canonicalize_name
7
+
8
+ from pkgwhy.core.models import PackageIdentity, PackageMetadata, ProjectUrls
9
+
10
+
11
+ def normalize_package_name(name: str) -> str:
12
+ return canonicalize_name(name)
13
+
14
+
15
+ def list_installed_packages() -> list[PackageMetadata]:
16
+ packages = [metadata_from_distribution(dist) for dist in metadata.distributions()]
17
+ return sorted(packages, key=lambda package: package.identity.normalized_name)
18
+
19
+
20
+ def get_installed_package(name: str) -> PackageMetadata | None:
21
+ dist = find_distribution(name)
22
+ return metadata_from_distribution(dist) if dist is not None else None
23
+
24
+
25
+ def get_distribution(name: str) -> Distribution | None:
26
+ """Return the installed distribution for callers that need file-level inspection."""
27
+ return find_distribution(name)
28
+
29
+
30
+ def find_distribution(name: str) -> Distribution | None:
31
+ wanted = normalize_package_name(name)
32
+ for dist in metadata.distributions():
33
+ dist_name = dist.metadata.get("Name")
34
+ if dist_name and normalize_package_name(dist_name) == wanted:
35
+ return dist
36
+ return None
37
+
38
+
39
+ def metadata_from_distribution(dist: Distribution) -> PackageMetadata:
40
+ meta = dist.metadata
41
+ name = meta.get("Name") or "unknown"
42
+ project_urls = parse_project_urls(meta.get_all("Project-URL") or [])
43
+ entry_points = [
44
+ f"{entry_point.group}:{entry_point.name}={entry_point.value}"
45
+ for entry_point in dist.entry_points
46
+ ]
47
+ return PackageMetadata(
48
+ identity=PackageIdentity(
49
+ name=name,
50
+ normalized_name=normalize_package_name(name),
51
+ version=meta.get("Version"),
52
+ ),
53
+ summary=meta.get("Summary"),
54
+ author=meta.get("Author"),
55
+ maintainer=meta.get("Maintainer"),
56
+ license=meta.get("License"),
57
+ requires=list(meta.get_all("Requires-Dist") or []),
58
+ project_urls=project_urls,
59
+ entry_points=entry_points,
60
+ metadata_available=True,
61
+ )
62
+
63
+
64
+ def parse_project_urls(values: list[str]) -> ProjectUrls:
65
+ raw: dict[str, str] = {}
66
+ homepage: str | None = None
67
+ repository: str | None = None
68
+ documentation: str | None = None
69
+ for value in values:
70
+ if "," not in value:
71
+ continue
72
+ label, url = value.split(",", 1)
73
+ label = label.strip()
74
+ url = url.strip()
75
+ raw[label] = url
76
+ key = label.lower()
77
+ if homepage is None and key in {"homepage", "home-page"}:
78
+ homepage = url
79
+ if repository is None and any(token in key for token in ("source", "repository", "github", "code")):
80
+ repository = url
81
+ if documentation is None and any(token in key for token in ("doc", "documentation")):
82
+ documentation = url
83
+ return ProjectUrls(homepage=homepage, repository=repository, documentation=documentation, raw=raw)
@@ -0,0 +1,199 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from typing import Any
6
+ from urllib import error, request
7
+ from urllib.parse import quote
8
+
9
+ from packaging.version import InvalidVersion, Version
10
+
11
+ from pkgwhy.core.models import Confidence, PackageProvenance
12
+ from pkgwhy.metadata.installed import normalize_package_name
13
+
14
+ PYPI_JSON_URL = "https://pypi.org/pypi/{package}/json"
15
+
16
+
17
+ class PyPIMetadataError(RuntimeError):
18
+ """Raised when optional PyPI metadata lookup fails."""
19
+
20
+
21
+ def fetch_pypi_project(package_name: str, *, timeout_seconds: float = 10.0) -> dict[str, Any]:
22
+ """Fetch PyPI JSON explicitly; callers decide when network access is allowed."""
23
+ url = PYPI_JSON_URL.format(package=quote(package_name, safe=""))
24
+ req = request.Request(url, headers={"Accept": "application/json"}, method="GET")
25
+ try:
26
+ with request.urlopen(req, timeout=timeout_seconds) as response:
27
+ return json.loads(response.read().decode("utf-8"))
28
+ except (OSError, error.HTTPError, json.JSONDecodeError) as exc:
29
+ raise PyPIMetadataError(f"PyPI metadata lookup failed for {package_name}: {exc}") from exc
30
+
31
+
32
+ def provenance_from_pypi_payload(
33
+ package_name: str,
34
+ payload: dict[str, Any],
35
+ *,
36
+ audited_version: str | None = None,
37
+ ) -> PackageProvenance:
38
+ """Build a conservative provenance summary from a PyPI JSON payload."""
39
+ info = payload.get("info")
40
+ info = info if isinstance(info, dict) else {}
41
+ reported_version = audited_version or _string_or_none(info.get("version"))
42
+ project_urls = info.get("project_urls")
43
+ project_urls = project_urls if isinstance(project_urls, dict) else {}
44
+ normalized_urls = {
45
+ str(key): value.strip()
46
+ for key, value in project_urls.items()
47
+ if isinstance(value, str) and value.strip()
48
+ }
49
+ repository_url = _find_url(normalized_urls, ("source", "repository", "github", "code"))
50
+ documentation_url = _find_url(normalized_urls, ("doc", "documentation"))
51
+ homepage_url = _string_or_none(info.get("home_page")) or _find_url(normalized_urls, ("homepage", "home-page"))
52
+ releases = payload.get("releases")
53
+ latest_release_date = _latest_release_date(releases)
54
+ audited_release_date = _release_date(releases, reported_version)
55
+ source_distribution_status = _source_distribution_status(releases, reported_version)
56
+ evidence = ["Read project metadata from PyPI JSON payload."]
57
+ warnings = [
58
+ "Trusted Publishing status is not inferred from PyPI project JSON.",
59
+ "Attestation verification is not implemented.",
60
+ "Source distribution versus wheel comparison is not implemented.",
61
+ ]
62
+
63
+ if repository_url:
64
+ evidence.append(f"Repository URL declared in PyPI metadata: {repository_url}")
65
+ else:
66
+ warnings.append("PyPI metadata does not declare a repository URL.")
67
+
68
+ if audited_version is not None and reported_version:
69
+ if audited_release_date:
70
+ evidence.append(f"Observed PyPI upload time for audited version {reported_version}: {audited_release_date}")
71
+ release_activity_status = f"audited_release_upload:{audited_release_date}"
72
+ else:
73
+ release_activity_status = "unknown"
74
+ warnings.append(f"PyPI metadata did not include release files for audited version {reported_version}.")
75
+ if latest_release_date:
76
+ evidence.append(f"Latest observed PyPI release upload time: {latest_release_date}")
77
+ elif latest_release_date:
78
+ evidence.append(f"Latest observed PyPI release upload time: {latest_release_date}")
79
+ release_activity_status = f"latest_release_upload:{latest_release_date}"
80
+ else:
81
+ release_activity_status = "unknown"
82
+ warnings.append("Could not determine latest release upload time from PyPI metadata.")
83
+
84
+ if source_distribution_status == "present":
85
+ evidence.append("PyPI metadata lists at least one source distribution for the inspected release.")
86
+ elif source_distribution_status == "not_found":
87
+ warnings.append("PyPI metadata did not list a source distribution for the inspected release.")
88
+ else:
89
+ warnings.append("Could not determine source distribution status from PyPI metadata.")
90
+
91
+ return PackageProvenance(
92
+ package=normalize_package_name(package_name),
93
+ version=reported_version,
94
+ repository_url=repository_url,
95
+ documentation_url=documentation_url,
96
+ homepage_url=homepage_url,
97
+ project_urls=normalized_urls,
98
+ metadata_source="pypi_json",
99
+ source_distribution_status=source_distribution_status,
100
+ trusted_publishing_status="unknown",
101
+ attestation_status="not_implemented",
102
+ release_activity_status=release_activity_status,
103
+ confidence=Confidence.MEDIUM if repository_url or latest_release_date else Confidence.LOW,
104
+ warnings=warnings,
105
+ evidence=evidence,
106
+ )
107
+
108
+
109
+ def _find_url(values: dict[str, str], tokens: tuple[str, ...]) -> str | None:
110
+ for key, value in values.items():
111
+ lower = key.lower()
112
+ if any(token in lower for token in tokens):
113
+ return value
114
+ return None
115
+
116
+
117
+ def _latest_release_date(releases: Any) -> str | None:
118
+ if not isinstance(releases, dict):
119
+ return None
120
+ latest: datetime | None = None
121
+ for files in releases.values():
122
+ if not isinstance(files, list):
123
+ continue
124
+ for file_info in files:
125
+ if not isinstance(file_info, dict):
126
+ continue
127
+ uploaded_at = _string_or_none(file_info.get("upload_time_iso_8601")) or _string_or_none(file_info.get("upload_time"))
128
+ if uploaded_at is None:
129
+ continue
130
+ parsed = _parse_datetime(uploaded_at)
131
+ if parsed is not None and (latest is None or parsed > latest):
132
+ latest = parsed
133
+ return latest.date().isoformat() if latest else None
134
+
135
+
136
+ def _release_date(releases: Any, version: str | None) -> str | None:
137
+ files = _release_files(releases, version)
138
+ if files is None:
139
+ return None
140
+ latest: datetime | None = None
141
+ for file_info in files:
142
+ if not isinstance(file_info, dict):
143
+ continue
144
+ uploaded_at = _string_or_none(file_info.get("upload_time_iso_8601")) or _string_or_none(file_info.get("upload_time"))
145
+ if uploaded_at is None:
146
+ continue
147
+ parsed = _parse_datetime(uploaded_at)
148
+ if parsed is not None and (latest is None or parsed > latest):
149
+ latest = parsed
150
+ return latest.date().isoformat() if latest else None
151
+
152
+
153
+ def _source_distribution_status(releases: Any, version: str | None) -> str:
154
+ files = _release_files(releases, version)
155
+ if files is None:
156
+ return "unknown"
157
+ for file_info in files:
158
+ if not isinstance(file_info, dict):
159
+ continue
160
+ package_type = _string_or_none(file_info.get("packagetype"))
161
+ filename = _string_or_none(file_info.get("filename"))
162
+ if package_type == "sdist" or (filename is not None and filename.endswith((".tar.gz", ".zip"))):
163
+ return "present"
164
+ return "not_found"
165
+
166
+
167
+ def _release_files(releases: Any, version: str | None) -> list[Any] | None:
168
+ if not isinstance(releases, dict) or version is None:
169
+ return None
170
+ files = releases.get(version)
171
+ if isinstance(files, list):
172
+ return files
173
+ try:
174
+ target = Version(version)
175
+ except InvalidVersion:
176
+ return None
177
+ for release_version, release_files in releases.items():
178
+ if not isinstance(release_version, str) or not isinstance(release_files, list):
179
+ continue
180
+ try:
181
+ if Version(release_version) == target:
182
+ return release_files
183
+ except InvalidVersion:
184
+ continue
185
+ return None
186
+
187
+
188
+ def _parse_datetime(value: str) -> datetime | None:
189
+ try:
190
+ return datetime.fromisoformat(value)
191
+ except ValueError:
192
+ return None
193
+
194
+
195
+ def _string_or_none(value: Any) -> str | None:
196
+ if not isinstance(value, str):
197
+ return None
198
+ stripped = value.strip()
199
+ return stripped if stripped else None
@@ -0,0 +1 @@
1
+ """Local policy helpers for package and private-tool decisions."""
@@ -0,0 +1,114 @@
1
+ from __future__ import annotations
2
+
3
+ from pkgwhy.core.models import (
4
+ AgentDecision,
5
+ AgentPackagePrecheckResult,
6
+ AgentPolicyConfig,
7
+ PackageJudgement,
8
+ RiskLevel,
9
+ )
10
+
11
+ NON_INTERACTIVE_PACKAGE_ALLOWED_DECISIONS = {AgentDecision.ALLOW, AgentDecision.ALLOW_WITH_CAUTION}
12
+
13
+
14
+ def default_agent_policy() -> AgentPolicyConfig:
15
+ """Return conservative default policy settings for agent package decisions."""
16
+
17
+ return AgentPolicyConfig()
18
+
19
+
20
+ def evaluate_package_policy(
21
+ judgement: PackageJudgement,
22
+ *,
23
+ non_interactive: bool = True,
24
+ policy: AgentPolicyConfig | None = None,
25
+ ) -> AgentPackagePrecheckResult:
26
+ """Apply policy-as-code to a package judgement without changing the judgement itself."""
27
+
28
+ active_policy = policy or default_agent_policy()
29
+ decision = judgement.decision
30
+ reasons: list[str] = []
31
+ warnings = list(judgement.warnings)
32
+ decision_source = "package_judgement"
33
+
34
+ risk_policy_decision = _decision_for_risk(judgement.risk_level, active_policy, non_interactive=non_interactive)
35
+ if risk_policy_decision is not None:
36
+ decision = _stricter_decision(decision, risk_policy_decision)
37
+ decision_source = "agent_policy"
38
+ reasons.append(_risk_policy_reason(judgement.risk_level, non_interactive=non_interactive))
39
+
40
+ if non_interactive and judgement.decision not in NON_INTERACTIVE_PACKAGE_ALLOWED_DECISIONS:
41
+ decision = _stricter_decision(decision, active_policy.non_interactive_default_decision)
42
+ decision_source = "agent_policy"
43
+ reasons.append(f"Non-interactive package use is not allowed for decision: {judgement.decision.value}.")
44
+
45
+ if active_policy.require_pkgwhy_judgement and not judgement.evidence:
46
+ decision = _stricter_decision(decision, AgentDecision.REVIEW_MANUALLY)
47
+ decision_source = "agent_policy"
48
+ reasons.append("Policy requires a pkgwhy judgement with supporting evidence.")
49
+
50
+ recommendation = _recommendation_for_decision(decision, judgement.recommendation, non_interactive=non_interactive)
51
+ return AgentPackagePrecheckResult(
52
+ package=judgement.package,
53
+ version=judgement.version,
54
+ non_interactive=non_interactive,
55
+ decision=decision,
56
+ risk_level=judgement.risk_level,
57
+ confidence=judgement.confidence,
58
+ policy_decision_source=decision_source,
59
+ reasons=reasons,
60
+ warnings=warnings,
61
+ recommendation=recommendation,
62
+ package_judgement=judgement,
63
+ )
64
+
65
+
66
+ def _decision_for_risk(
67
+ risk_level: RiskLevel,
68
+ policy: AgentPolicyConfig,
69
+ *,
70
+ non_interactive: bool,
71
+ ) -> AgentDecision | None:
72
+ if risk_level == RiskLevel.UNKNOWN:
73
+ return (
74
+ policy.non_interactive_unknown_package_decision
75
+ if non_interactive
76
+ else policy.unknown_package_decision
77
+ )
78
+ if risk_level == RiskLevel.HIGH:
79
+ return policy.non_interactive_high_risk_package_decision if non_interactive else policy.high_risk_package_decision
80
+ if risk_level == RiskLevel.CRITICAL:
81
+ return (
82
+ policy.non_interactive_critical_risk_package_decision
83
+ if non_interactive
84
+ else policy.critical_risk_package_decision
85
+ )
86
+ return None
87
+
88
+
89
+ def _risk_policy_reason(risk_level: RiskLevel, *, non_interactive: bool) -> str:
90
+ mode = "non-interactive agent use" if non_interactive else "agent use"
91
+ return f"Policy requires a stricter decision for {risk_level.value}-risk packages in {mode}."
92
+
93
+
94
+ def _stricter_decision(current: AgentDecision, candidate: AgentDecision) -> AgentDecision:
95
+ order = {
96
+ AgentDecision.ALLOW: 0,
97
+ AgentDecision.ALLOW_WITH_CAUTION: 1,
98
+ AgentDecision.REVIEW_MANUALLY: 2,
99
+ AgentDecision.SANDBOX_ONLY: 3,
100
+ AgentDecision.BLOCK: 4,
101
+ }
102
+ return candidate if order[candidate] > order[current] else current
103
+
104
+
105
+ def _recommendation_for_decision(decision: AgentDecision, fallback: str, *, non_interactive: bool) -> str:
106
+ if decision == AgentDecision.BLOCK:
107
+ if non_interactive:
108
+ return "Block non-interactive package use until a human reviews the judgement evidence."
109
+ return "Block package use until a human reviews the evidence."
110
+ if decision == AgentDecision.REVIEW_MANUALLY:
111
+ return "Manual review is required before an agent uses this package."
112
+ if decision == AgentDecision.SANDBOX_ONLY:
113
+ return "Use only in a real sandboxed environment; a Python virtual environment is not a full OS sandbox."
114
+ return fallback
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ from datetime import UTC, datetime
6
+ from pathlib import Path
7
+
8
+ from pkgwhy.core.models import AgentPackagePrecheckResult
9
+ from pkgwhy.registry.local import config_dir
10
+
11
+ AGENT_DECISION_LOG_SCHEMA_VERSION = "pkgwhy.agent_decision_log.v1"
12
+
13
+
14
+ def write_agent_package_decision_log(
15
+ result: AgentPackagePrecheckResult,
16
+ *,
17
+ log_root: Path | None = None,
18
+ ) -> Path | None:
19
+ """Write a compact local audit record for an agent package decision.
20
+
21
+ Audit logging is best-effort: an unwritable local config directory must not
22
+ prevent the policy decision itself from being returned.
23
+ """
24
+
25
+ created_at = datetime.now(tz=UTC)
26
+ root = log_root or config_dir() / "agent-decisions"
27
+ package_dir = root / _safe_path_segment(result.package)
28
+ try:
29
+ package_dir.mkdir(parents=True, exist_ok=True)
30
+ except OSError:
31
+ return None
32
+ log_path = package_dir / f"{created_at.strftime('%Y%m%dT%H%M%S%fZ')}.json"
33
+ payload = {
34
+ "schema_version": AGENT_DECISION_LOG_SCHEMA_VERSION,
35
+ "created_at": created_at.isoformat(),
36
+ "precheck_schema_version": result.schema_version,
37
+ "policy_schema_version": result.policy_schema_version,
38
+ "target_type": result.target_type,
39
+ "package": result.package,
40
+ "version": result.version,
41
+ "non_interactive": result.non_interactive,
42
+ "decision": result.decision.value,
43
+ "risk_level": result.risk_level.value,
44
+ "confidence": result.confidence.value,
45
+ "policy_decision_source": result.policy_decision_source,
46
+ "reason_count": len(result.reasons),
47
+ "warning_count": len(result.warnings),
48
+ "reasons": list(result.reasons),
49
+ "warnings": list(result.warnings),
50
+ }
51
+ try:
52
+ log_path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
53
+ except OSError:
54
+ return None
55
+ return log_path
56
+
57
+
58
+ def _safe_path_segment(value: str) -> str:
59
+ normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip(".-_")
60
+ return normalized or "unknown-package"
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from pkgwhy.core.models import AgentDecision, HashStatus, ToolJudgement
6
+
7
+ SIGNATURE_STATUS_VERIFIED = "verified"
8
+ SIGNATURE_STATUS_NOT_IMPLEMENTED = "not_implemented"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class ToolExecutionPolicy:
13
+ """Conservative local execution policy for private registry tools."""
14
+
15
+ allow_unsigned_tools: bool = False
16
+ allow_unpinned_dependencies: bool = False
17
+ require_hash_verification: bool = True
18
+ require_signature_verification: bool = False
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class ToolExecutionPolicyResult:
23
+ """Policy decision used before local private-tool execution."""
24
+
25
+ allowed: bool
26
+ decision: AgentDecision
27
+ reasons: list[str] = field(default_factory=list)
28
+ warnings: list[str] = field(default_factory=list)
29
+
30
+
31
+ NON_INTERACTIVE_ALLOWED_DECISIONS = {AgentDecision.ALLOW, AgentDecision.ALLOW_WITH_CAUTION}
32
+
33
+
34
+ def evaluate_tool_execution_policy(
35
+ judgement: ToolJudgement,
36
+ *,
37
+ non_interactive: bool = False,
38
+ policy: ToolExecutionPolicy | None = None,
39
+ ) -> ToolExecutionPolicyResult:
40
+ active_policy = policy or ToolExecutionPolicy()
41
+ reasons: list[str] = []
42
+ warnings: list[str] = []
43
+
44
+ if active_policy.require_hash_verification and judgement.hash_status != HashStatus.VERIFIED:
45
+ reasons.append(f"Tool bundle hash is not verified: {judgement.hash_status.value}.")
46
+
47
+ if judgement.decision == AgentDecision.BLOCK:
48
+ reasons.append("Tool judgement blocks execution.")
49
+ elif judgement.decision == AgentDecision.SANDBOX_ONLY:
50
+ reasons.append("Tool requires sandbox-only execution, but the local runner is not a full OS sandbox.")
51
+
52
+ if judgement.signature_status != SIGNATURE_STATUS_VERIFIED:
53
+ message = "Tool signature verification is not implemented; treat this tool as unsigned."
54
+ if active_policy.require_signature_verification:
55
+ reasons.append(message)
56
+ elif not active_policy.allow_unsigned_tools:
57
+ warnings.append(message)
58
+
59
+ if judgement.manifest.dependencies:
60
+ if active_policy.allow_unpinned_dependencies:
61
+ warnings.append("Tool dependencies are declared, but dependency installation is not implemented in the runner.")
62
+ else:
63
+ reasons.append("Dependency installation is not implemented for tools with declared dependencies.")
64
+
65
+ if non_interactive:
66
+ if judgement.decision not in NON_INTERACTIVE_ALLOWED_DECISIONS:
67
+ reasons.append(f"Non-interactive execution is not allowed for judgement decision: {judgement.decision.value}.")
68
+ if judgement.manifest.agent.non_interactive_decision not in NON_INTERACTIVE_ALLOWED_DECISIONS:
69
+ reasons.append(
70
+ "Manifest agent policy does not allow non-interactive execution: "
71
+ f"{judgement.manifest.agent.non_interactive_decision.value}."
72
+ )
73
+
74
+ allowed = not reasons
75
+ decision = AgentDecision.BLOCK if reasons else (AgentDecision.REVIEW_MANUALLY if warnings else judgement.decision)
76
+ return ToolExecutionPolicyResult(allowed=allowed, decision=decision, reasons=reasons, warnings=warnings)
@@ -0,0 +1,2 @@
1
+ """Source-trust and provenance signal helpers."""
2
+
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ from pkgwhy.core.models import Confidence, PackageMetadata, PackageProvenance
4
+
5
+
6
+ def assess_installed_provenance(metadata: PackageMetadata) -> PackageProvenance:
7
+ """Summarize source-trust signals available from installed metadata only."""
8
+ urls = metadata.project_urls
9
+ raw_urls = dict(urls.raw)
10
+ evidence = ["Read project URLs from installed distribution metadata."]
11
+ warnings = [
12
+ "Trusted Publishing status is unknown from installed metadata.",
13
+ "Attestation verification is not implemented.",
14
+ "Source distribution versus wheel comparison is not implemented.",
15
+ "Release activity requires optional online metadata and is not available from installed metadata alone.",
16
+ ]
17
+ confidence = Confidence.LOW
18
+
19
+ if urls.repository:
20
+ evidence.append(f"Repository URL declared in installed metadata: {urls.repository}")
21
+ confidence = Confidence.MEDIUM
22
+ else:
23
+ warnings.append("No repository URL was found in installed metadata.")
24
+
25
+ if urls.documentation:
26
+ evidence.append(f"Documentation URL declared in installed metadata: {urls.documentation}")
27
+
28
+ if urls.homepage:
29
+ evidence.append(f"Homepage URL declared in installed metadata: {urls.homepage}")
30
+
31
+ if not raw_urls:
32
+ warnings.append("Installed metadata does not declare project URLs.")
33
+
34
+ return PackageProvenance(
35
+ package=metadata.identity.normalized_name,
36
+ version=metadata.identity.version,
37
+ repository_url=urls.repository,
38
+ documentation_url=urls.documentation,
39
+ homepage_url=urls.homepage,
40
+ project_urls=raw_urls,
41
+ metadata_source="installed_distribution_metadata",
42
+ confidence=confidence,
43
+ warnings=warnings,
44
+ evidence=evidence,
45
+ )
@@ -0,0 +1,2 @@
1
+ """Local private registry support."""
2
+