installgate 0.1.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. contracts/README.md +11 -0
  2. contracts/__init__.py +31 -0
  3. contracts/contracts.py +260 -0
  4. contracts/contracts.ts +151 -0
  5. engine/README.md +36 -0
  6. engine/__init__.py +1 -0
  7. engine/cache.py +64 -0
  8. engine/checks.py +695 -0
  9. engine/clients/__init__.py +64 -0
  10. engine/clients/huggingface.py +69 -0
  11. engine/clients/npm.py +91 -0
  12. engine/clients/osv.py +91 -0
  13. engine/clients/pypi.py +197 -0
  14. engine/data/pypi-top5000-2026-10-27.txt +5011 -0
  15. engine/evidence.py +55 -0
  16. engine/parsers.py +216 -0
  17. engine/policy.py +153 -0
  18. engine/policy.yaml +92 -0
  19. engine/runtime_floors.yaml +67 -0
  20. engine/stage1.py +137 -0
  21. installgate-0.1.0.dist-info/METADATA +439 -0
  22. installgate-0.1.0.dist-info/RECORD +58 -0
  23. installgate-0.1.0.dist-info/WHEEL +4 -0
  24. installgate-0.1.0.dist-info/entry_points.txt +3 -0
  25. installgate-0.1.0.dist-info/licenses/LICENSE +202 -0
  26. mcp_server/README.md +19 -0
  27. mcp_server/__init__.py +1 -0
  28. mcp_server/agent.py +61 -0
  29. mcp_server/api.py +141 -0
  30. mcp_server/check.py +412 -0
  31. mcp_server/cli.py +270 -0
  32. mcp_server/limit.py +74 -0
  33. mcp_server/render.py +241 -0
  34. mcp_server/samples.py +53 -0
  35. mcp_server/server.py +27 -0
  36. mcp_server/spend.py +90 -0
  37. remediate/README.md +18 -0
  38. remediate/__init__.py +1 -0
  39. remediate/candidates.py +118 -0
  40. remediate/diff.py +13 -0
  41. remediate/pinning.py +27 -0
  42. remediate/sandbox.py +1 -0
  43. router/README.md +60 -0
  44. router/__init__.py +1 -0
  45. router/compare.py +572 -0
  46. router/prompts/explain.txt +8 -0
  47. router/prompts/extract.txt +3 -0
  48. router/prompts/rank_substitute.txt +1 -0
  49. router/receipt.py +41 -0
  50. router/smoke.py +49 -0
  51. router/tavily.py +129 -0
  52. router/tiers.py +321 -0
  53. router/token_factory.py +267 -0
  54. validator/README.md +11 -0
  55. validator/__init__.py +1 -0
  56. validator/citations.py +64 -0
  57. validator/failclosed.py +53 -0
  58. validator/lattice.py +52 -0
@@ -0,0 +1,64 @@
1
+ """Read-only metadata clients. Allowlisted hosts, timeouts, no code execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+
9
+ ALLOWED_HOSTS = frozenset(
10
+ {
11
+ "pypi.org",
12
+ "registry.npmjs.org",
13
+ "api.osv.dev",
14
+ "huggingface.co",
15
+ }
16
+ )
17
+
18
+ TIMEOUT = httpx.Timeout(connect=3.0, read=5.0, write=3.0, pool=3.0)
19
+ HEADERS = {"User-Agent": "InstallGate/0.1 (metadata-only pre-install check)"}
20
+
21
+
22
+ class UpstreamError(RuntimeError):
23
+ """A registry call failed. The caller must fail closed to REVIEW."""
24
+
25
+
26
+ def _assert_allowed(url: str) -> None:
27
+ host = httpx.URL(url).host
28
+ if host not in ALLOWED_HOSTS:
29
+ raise UpstreamError(f"host not allowlisted: {host}")
30
+
31
+
32
+ async def get_json(
33
+ client: httpx.AsyncClient, url: str, *, allow_404: bool = False
34
+ ) -> dict[str, Any] | None:
35
+ """GET and parse JSON. Returns None on 404 when allow_404 (used for existence checks)."""
36
+ _assert_allowed(url)
37
+ try:
38
+ response = await client.get(url, timeout=TIMEOUT, headers=HEADERS)
39
+ except httpx.HTTPError as exc:
40
+ raise UpstreamError(f"GET {url} failed: {exc}") from exc
41
+ if response.status_code == 404 and allow_404:
42
+ return None
43
+ if response.status_code >= 400:
44
+ raise UpstreamError(f"GET {url} returned {response.status_code}")
45
+ try:
46
+ body: dict[str, Any] = response.json()
47
+ except ValueError as exc:
48
+ raise UpstreamError(f"GET {url} returned non-JSON") from exc
49
+ return body
50
+
51
+
52
+ async def post_json(client: httpx.AsyncClient, url: str, payload: dict[str, Any]) -> dict[str, Any]:
53
+ _assert_allowed(url)
54
+ try:
55
+ response = await client.post(url, json=payload, timeout=TIMEOUT, headers=HEADERS)
56
+ except httpx.HTTPError as exc:
57
+ raise UpstreamError(f"POST {url} failed: {exc}") from exc
58
+ if response.status_code >= 400:
59
+ raise UpstreamError(f"POST {url} returned {response.status_code}")
60
+ try:
61
+ body: dict[str, Any] = response.json()
62
+ except ValueError as exc:
63
+ raise UpstreamError(f"POST {url} returned non-JSON") from exc
64
+ return body
@@ -0,0 +1,69 @@
1
+ """Hugging Face Hub model metadata. We read the file list; we never load weights."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC, datetime
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from contracts.contracts import TargetRef
10
+
11
+ from engine.cache import Cache
12
+ from engine.clients import get_json
13
+
14
+ BASE = "https://huggingface.co/api/models"
15
+
16
+ PICKLE_SUFFIXES = (".bin", ".pt", ".pth", ".pkl", ".ckpt")
17
+ SAFE_SUFFIXES = (".safetensors",)
18
+
19
+
20
+ def model_url(repo_id: str) -> str:
21
+ return f"{BASE}/{repo_id}"
22
+
23
+
24
+ def summarise(body: dict[str, Any] | None) -> dict[str, Any]:
25
+ if body is None:
26
+ return {"exists": False}
27
+
28
+ filenames = [
29
+ str(s.get("rfilename"))
30
+ for s in (body.get("siblings") or [])
31
+ if isinstance(s, dict) and s.get("rfilename")
32
+ ]
33
+ pickle_files = [f for f in filenames if f.lower().endswith(PICKLE_SUFFIXES)]
34
+ safetensors_files = [f for f in filenames if f.lower().endswith(SAFE_SUFFIXES)]
35
+
36
+ created_raw = str(body.get("createdAt") or "")
37
+ modified_raw = str(body.get("lastModified") or "")
38
+
39
+ def _parse(raw: str) -> str | None:
40
+ try:
41
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC).isoformat()
42
+ except ValueError:
43
+ return None
44
+
45
+ return {
46
+ "exists": True,
47
+ "files": filenames,
48
+ "pickle_files": pickle_files,
49
+ "safetensors_files": safetensors_files,
50
+ "has_pickle": bool(pickle_files),
51
+ "has_safetensors": bool(safetensors_files),
52
+ "first_upload": _parse(created_raw),
53
+ "latest_upload": _parse(modified_raw),
54
+ "sha": body.get("sha"),
55
+ }
56
+
57
+
58
+ async def fetch(
59
+ client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
60
+ ) -> dict[str, Any]:
61
+ if cache is not None:
62
+ cached = cache.get("huggingface", target.name)
63
+ if cached is not None:
64
+ return dict(cached)
65
+ body = await get_json(client, model_url(target.name), allow_404=True)
66
+ facts = summarise(body)
67
+ if cache is not None:
68
+ cache.set("huggingface", target.name, facts)
69
+ return facts
engine/clients/npm.py ADDED
@@ -0,0 +1,91 @@
1
+ """npm registry. Existence, `time` map, maintainers, dist integrity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+ from contracts.contracts import TargetRef
12
+
13
+ from engine.cache import Cache
14
+ from engine.clients import get_json
15
+
16
+ BASE = "https://registry.npmjs.org"
17
+
18
+
19
+ def project_url(name: str) -> str:
20
+ # A scoped name (@scope/pkg) must have its slash encoded.
21
+ return f"{BASE}/{quote(name, safe='@')}"
22
+
23
+
24
+ def _parse(raw: str) -> datetime | None:
25
+ try:
26
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC)
27
+ except ValueError:
28
+ return None
29
+
30
+
31
+ def _pin_digest(integrity: object, shasum: object, name: str, version: str) -> str:
32
+ """Stable 64-hex pin from registry metadata. Not the tarball bytes (we never fetch them)."""
33
+ raw = str(integrity or shasum or f"{name}@{version}")
34
+ return hashlib.sha256(raw.encode()).hexdigest()
35
+
36
+
37
+ def summarise(body: dict[str, Any] | None, *, version: str | None = None) -> dict[str, Any]:
38
+ if body is None:
39
+ return {"exists": False}
40
+
41
+ times: dict[str, Any] = body.get("time") or {}
42
+ created = _parse(str(times.get("created") or ""))
43
+ modified = _parse(str(times.get("modified") or ""))
44
+
45
+ latest_version = ((body.get("dist-tags") or {}).get("latest")) or None
46
+ versions: dict[str, Any] = body.get("versions") or {}
47
+ pin_version = version if version and str(version) in versions else latest_version
48
+ pin_meta = versions.get(str(pin_version)) or {}
49
+ dist = pin_meta.get("dist") or {}
50
+ integrity = dist.get("integrity")
51
+ shasum = dist.get("shasum")
52
+ name = str(body.get("name") or pin_meta.get("name") or "")
53
+
54
+ maintainers = sorted(
55
+ {
56
+ str(m.get("name") or m.get("email"))
57
+ for m in (body.get("maintainers") or [])
58
+ if isinstance(m, dict) and (m.get("name") or m.get("email"))
59
+ }
60
+ )
61
+
62
+ pin_sha = _pin_digest(integrity, shasum, name, str(pin_version)) if pin_version else None
63
+
64
+ return {
65
+ "exists": True,
66
+ "latest_version": latest_version,
67
+ "first_upload": created.isoformat() if created else None,
68
+ "latest_upload": modified.isoformat() if modified else None,
69
+ "maintainers": maintainers,
70
+ "dist_integrity": integrity,
71
+ "dist_shasum": shasum,
72
+ "pin_version": pin_version,
73
+ "pin_sha256": pin_sha,
74
+ "latest_sha256": pin_sha,
75
+ "release_count": len(versions),
76
+ }
77
+
78
+
79
+ async def fetch(
80
+ client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
81
+ ) -> dict[str, Any]:
82
+ key = f"{target.name}@{target.version or 'latest'}"
83
+ if cache is not None:
84
+ cached = cache.get("npm", key)
85
+ if cached is not None:
86
+ return dict(cached)
87
+ body = await get_json(client, project_url(target.name), allow_404=True)
88
+ facts = summarise(body, version=target.version)
89
+ if cache is not None:
90
+ cache.set("npm", key, facts)
91
+ return facts
engine/clients/osv.py ADDED
@@ -0,0 +1,91 @@
1
+ """OSV.dev query. Only MAL- records make a package malicious."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ from contracts.contracts import Ecosystem, TargetRef
9
+
10
+ from engine.cache import Cache
11
+ from engine.clients import post_json
12
+
13
+ QUERY_URL = "https://api.osv.dev/v1/query"
14
+
15
+ OSV_ECOSYSTEM = {
16
+ Ecosystem.PYPI: "PyPI",
17
+ Ecosystem.NPM: "npm",
18
+ }
19
+
20
+
21
+ def affected_spec(record: dict[str, Any], name: str | None = None) -> dict[str, Any]:
22
+ """What an advisory says about *which versions* it covers.
23
+
24
+ A MAL- record usually lists exact versions. Some cover the whole project and
25
+ name none. The difference decides whether a clean release of a package that
26
+ was once compromised may still be installed, so it has to survive into the
27
+ evidence rather than collapsing to a boolean.
28
+ """
29
+ versions: list[str] = []
30
+ ranges: list[dict[str, Any]] = []
31
+ for affected in record.get("affected") or []:
32
+ package = (affected or {}).get("package") or {}
33
+ if name and package.get("name") and str(package["name"]).lower() != name.lower():
34
+ continue
35
+ versions.extend(str(v) for v in (affected.get("versions") or []))
36
+ ranges.extend(r for r in (affected.get("ranges") or []) if isinstance(r, dict))
37
+
38
+ return {
39
+ "id": str(record.get("id", "")),
40
+ "versions": sorted(set(versions)),
41
+ "ranges": ranges,
42
+ # No version information at all means the project as a whole is the
43
+ # finding: every release is covered.
44
+ "whole_package": not versions and not ranges,
45
+ }
46
+
47
+
48
+ def summarise(body: dict[str, Any], name: str | None = None) -> dict[str, Any]:
49
+ vulns = [v for v in (body.get("vulns") or []) if isinstance(v, dict)]
50
+ mal = [v for v in vulns if str(v.get("id", "")).startswith("MAL-")]
51
+ other_ids = sorted({str(v["id"]) for v in vulns if not str(v.get("id", "")).startswith("MAL-")})
52
+ records = [affected_spec(v, name) for v in mal]
53
+ return {
54
+ # True when a MAL- record exists for the *name*. Whether it covers the
55
+ # version in hand is a separate question, answered by `mal_records`.
56
+ "malicious": bool(mal),
57
+ "mal_ids": sorted({r["id"] for r in records}),
58
+ "mal_records": records,
59
+ "other_ids": other_ids,
60
+ }
61
+
62
+
63
+ async def fetch(
64
+ client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
65
+ ) -> dict[str, Any]:
66
+ ecosystem = OSV_ECOSYSTEM.get(target.ecosystem)
67
+ if ecosystem is None:
68
+ return {
69
+ "malicious": False,
70
+ "mal_ids": [],
71
+ "mal_records": [],
72
+ "other_ids": [],
73
+ "queried": False,
74
+ }
75
+
76
+ # Always query by name, never by version. Asking OSV to filter would hide
77
+ # which versions a record covers, and the check needs that to tell a
78
+ # compromised release from a clean one of the same project.
79
+ key = f"{ecosystem}:{target.name}"
80
+ if cache is not None:
81
+ cached = cache.get("osv", key)
82
+ if cached is not None:
83
+ return dict(cached)
84
+
85
+ payload: dict[str, Any] = {"package": {"name": target.name, "ecosystem": ecosystem}}
86
+
87
+ facts = summarise(await post_json(client, QUERY_URL, payload), name=target.name)
88
+ facts["queried"] = True
89
+ if cache is not None:
90
+ cache.set("osv", key, facts)
91
+ return facts
engine/clients/pypi.py ADDED
@@ -0,0 +1,197 @@
1
+ """PyPI JSON API. Existence, release dates, maintainers, file hashes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ import httpx
10
+ from contracts.contracts import TargetRef
11
+ from packaging.specifiers import InvalidSpecifier, SpecifierSet
12
+ from packaging.tags import Tag, sys_tags
13
+ from packaging.utils import InvalidWheelFilename, parse_wheel_filename
14
+ from packaging.version import InvalidVersion, Version
15
+
16
+ from engine.cache import Cache
17
+ from engine.clients import get_json
18
+
19
+ BASE = "https://pypi.org/pypi"
20
+
21
+
22
+ def project_url(name: str) -> str:
23
+ return f"{BASE}/{name}/json"
24
+
25
+
26
+ def cache_key(name: str, python: tuple[int, int] | None = None) -> str:
27
+ major, minor = python or sys.version_info[:2]
28
+ return f"{name}:cp{major}{minor}"
29
+
30
+
31
+ def _parse_upload_time(raw: str) -> datetime | None:
32
+ try:
33
+ return datetime.fromisoformat(raw.replace("Z", "+00:00")).astimezone(UTC)
34
+ except ValueError:
35
+ return None
36
+
37
+
38
+ def _python_ok(spec: object, python: str) -> bool:
39
+ if not spec:
40
+ return True
41
+ try:
42
+ return python in SpecifierSet(str(spec))
43
+ except InvalidSpecifier:
44
+ return False
45
+
46
+
47
+ def _usable_files(
48
+ files: list[dict[str, Any]], python: str, tags: list[Tag]
49
+ ) -> list[dict[str, Any]]:
50
+ usable: list[dict[str, Any]] = []
51
+ for item in files:
52
+ if item.get("yanked"):
53
+ continue
54
+ if not _python_ok(item.get("requires_python"), python):
55
+ continue
56
+ usable.append(item)
57
+ if not usable:
58
+ return []
59
+ if _file_sha(usable, tags) is None:
60
+ return []
61
+ return usable
62
+
63
+
64
+ def _file_sha(files: list[dict[str, Any]], tags: list[Tag]) -> str | None:
65
+ wheels: list[tuple[frozenset[Tag], str]] = []
66
+ sdist: str | None = None
67
+ for item in files:
68
+ digest = (item.get("digests") or {}).get("sha256")
69
+ if not digest:
70
+ continue
71
+ name = str(item.get("filename") or "")
72
+ if name.endswith(".whl"):
73
+ try:
74
+ _, _, _, wheel_tags = parse_wheel_filename(name)
75
+ except InvalidWheelFilename:
76
+ continue
77
+ wheels.append((wheel_tags, str(digest)))
78
+ continue
79
+ if sdist is None:
80
+ sdist = str(digest)
81
+ for tag in tags:
82
+ for wheel_tags, digest in wheels:
83
+ if tag in wheel_tags:
84
+ return digest
85
+ return sdist
86
+
87
+
88
+ def _newest_installable(
89
+ releases: dict[str, list[dict[str, Any]]], python: str, tags: list[Tag]
90
+ ) -> tuple[str | None, list[dict[str, Any]]]:
91
+ ranked: list[tuple[Version, str]] = []
92
+ for version in releases:
93
+ try:
94
+ ranked.append((Version(version), version))
95
+ except InvalidVersion:
96
+ continue
97
+ ranked.sort(reverse=True)
98
+ for _, version in ranked:
99
+ files = _usable_files(releases.get(version) or [], python, tags)
100
+ if files:
101
+ return version, files
102
+ return None, []
103
+
104
+
105
+ def summarise(
106
+ body: dict[str, Any] | None,
107
+ *,
108
+ python: tuple[int, int] | None = None,
109
+ tags: list[Tag] | None = None,
110
+ ) -> dict[str, Any]:
111
+ """Flatten the PyPI payload into the facts the checks need.
112
+
113
+ `latest_version` is what PyPI reports. `pin_version` is the newest release
114
+ this interpreter can actually install (requires_python + a matching wheel
115
+ or sdist). Unpinned checks pin `pin_version`, not a 3.12-only latest on 3.11.
116
+ """
117
+ if body is None:
118
+ return {"exists": False}
119
+
120
+ info = body.get("info") or {}
121
+ releases: dict[str, list[dict[str, Any]]] = body.get("releases") or {}
122
+ if not releases and body.get("urls"):
123
+ # A release document (/pypi/{name}/{version}/json) has no `releases` map;
124
+ # its files are under `urls` and describe that one version. Without this
125
+ # the summary comes back with no dates and no hash, which reads as a
126
+ # package nobody ever uploaded.
127
+ releases = {str(info.get("version")): list(body["urls"])}
128
+
129
+ upload_times: list[datetime] = []
130
+ for files in releases.values():
131
+ for item in files:
132
+ parsed = _parse_upload_time(str(item.get("upload_time_iso_8601") or ""))
133
+ if parsed:
134
+ upload_times.append(parsed)
135
+
136
+ major, minor = python or sys.version_info[:2]
137
+ python_s = f"{major}.{minor}"
138
+ tag_list = tags if tags is not None else list(sys_tags())
139
+
140
+ latest_version = info.get("version")
141
+ latest_files = releases.get(str(latest_version), []) if latest_version else []
142
+ latest_upload = max(
143
+ (
144
+ t
145
+ for t in (
146
+ _parse_upload_time(str(f.get("upload_time_iso_8601") or "")) for f in latest_files
147
+ )
148
+ if t
149
+ ),
150
+ default=None,
151
+ )
152
+ latest_sha = _file_sha(latest_files, tag_list) or next(
153
+ (
154
+ str((f.get("digests") or {}).get("sha256"))
155
+ for f in latest_files
156
+ if (f.get("digests") or {}).get("sha256")
157
+ ),
158
+ None,
159
+ )
160
+
161
+ pin_version, pin_files = _newest_installable(releases, python_s, tag_list)
162
+ pin_sha = _file_sha(pin_files, tag_list) if pin_files else None
163
+
164
+ maintainers = sorted(
165
+ {
166
+ str(value)
167
+ for key in ("author", "maintainer", "author_email", "maintainer_email")
168
+ if (value := info.get(key))
169
+ }
170
+ )
171
+
172
+ return {
173
+ "exists": True,
174
+ "latest_version": latest_version,
175
+ "first_upload": min(upload_times).isoformat() if upload_times else None,
176
+ "latest_upload": latest_upload.isoformat() if latest_upload else None,
177
+ "maintainers": maintainers,
178
+ "latest_sha256": latest_sha,
179
+ "pin_version": pin_version,
180
+ "pin_sha256": pin_sha,
181
+ "release_count": len(releases),
182
+ }
183
+
184
+
185
+ async def fetch(
186
+ client: httpx.AsyncClient, target: TargetRef, cache: Cache | None = None
187
+ ) -> dict[str, Any]:
188
+ key = cache_key(target.name)
189
+ if cache is not None:
190
+ cached = cache.get("pypi", key)
191
+ if cached is not None:
192
+ return dict(cached)
193
+ body = await get_json(client, project_url(target.name), allow_404=True)
194
+ facts = summarise(body)
195
+ if cache is not None:
196
+ cache.set("pypi", key, facts)
197
+ return facts