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
|
@@ -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)
|
pkgwhy/metadata/pypi.py
ADDED
|
@@ -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,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
|
+
)
|