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.
- contracts/README.md +11 -0
- contracts/__init__.py +31 -0
- contracts/contracts.py +260 -0
- contracts/contracts.ts +151 -0
- engine/README.md +36 -0
- engine/__init__.py +1 -0
- engine/cache.py +64 -0
- engine/checks.py +695 -0
- engine/clients/__init__.py +64 -0
- engine/clients/huggingface.py +69 -0
- engine/clients/npm.py +91 -0
- engine/clients/osv.py +91 -0
- engine/clients/pypi.py +197 -0
- engine/data/pypi-top5000-2026-10-27.txt +5011 -0
- engine/evidence.py +55 -0
- engine/parsers.py +216 -0
- engine/policy.py +153 -0
- engine/policy.yaml +92 -0
- engine/runtime_floors.yaml +67 -0
- engine/stage1.py +137 -0
- installgate-0.1.0.dist-info/METADATA +439 -0
- installgate-0.1.0.dist-info/RECORD +58 -0
- installgate-0.1.0.dist-info/WHEEL +4 -0
- installgate-0.1.0.dist-info/entry_points.txt +3 -0
- installgate-0.1.0.dist-info/licenses/LICENSE +202 -0
- mcp_server/README.md +19 -0
- mcp_server/__init__.py +1 -0
- mcp_server/agent.py +61 -0
- mcp_server/api.py +141 -0
- mcp_server/check.py +412 -0
- mcp_server/cli.py +270 -0
- mcp_server/limit.py +74 -0
- mcp_server/render.py +241 -0
- mcp_server/samples.py +53 -0
- mcp_server/server.py +27 -0
- mcp_server/spend.py +90 -0
- remediate/README.md +18 -0
- remediate/__init__.py +1 -0
- remediate/candidates.py +118 -0
- remediate/diff.py +13 -0
- remediate/pinning.py +27 -0
- remediate/sandbox.py +1 -0
- router/README.md +60 -0
- router/__init__.py +1 -0
- router/compare.py +572 -0
- router/prompts/explain.txt +8 -0
- router/prompts/extract.txt +3 -0
- router/prompts/rank_substitute.txt +1 -0
- router/receipt.py +41 -0
- router/smoke.py +49 -0
- router/tavily.py +129 -0
- router/tiers.py +321 -0
- router/token_factory.py +267 -0
- validator/README.md +11 -0
- validator/__init__.py +1 -0
- validator/citations.py +64 -0
- validator/failclosed.py +53 -0
- validator/lattice.py +52 -0
contracts/README.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# contracts/ — both engineers
|
|
2
|
+
|
|
3
|
+
`contracts.py` and `contracts.ts` are the shared API shape.
|
|
4
|
+
|
|
5
|
+
**FROZEN as of gate G1 (2026-10-26).** Field names, types, and enum values in `contracts.py` are final. Kate and Jenny both build against this shape starting now — the API, CLI, terminal output, and UI must all read the same field names, or the same package install will show different results depending on which surface you look at.
|
|
6
|
+
|
|
7
|
+
Rules from here on:
|
|
8
|
+
|
|
9
|
+
- No renaming, adding, or removing a field, enum value, or model on a feature branch without saying so in the PR title (e.g. `fix(contracts): ...`).
|
|
10
|
+
- Any such change needs review from **both** engineers before merge — see `.github/CODEOWNERS`.
|
|
11
|
+
- If a field turns out to be wrong, open an issue and discuss before touching the file. Do not silently rename and let the other person find out from a failing build.
|
contracts/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Frozen data contracts. Changes after gate G1 need both engineers."""
|
|
2
|
+
|
|
3
|
+
from contracts.contracts import (
|
|
4
|
+
Ecosystem,
|
|
5
|
+
Evidence,
|
|
6
|
+
EvidenceKind,
|
|
7
|
+
ExplanationSentence,
|
|
8
|
+
GateVerdict,
|
|
9
|
+
Outcome,
|
|
10
|
+
RouteCall,
|
|
11
|
+
RouterReceipt,
|
|
12
|
+
SafeRemediation,
|
|
13
|
+
SandboxResult,
|
|
14
|
+
TargetRef,
|
|
15
|
+
Verdict,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Ecosystem",
|
|
20
|
+
"Evidence",
|
|
21
|
+
"EvidenceKind",
|
|
22
|
+
"ExplanationSentence",
|
|
23
|
+
"GateVerdict",
|
|
24
|
+
"Outcome",
|
|
25
|
+
"RouteCall",
|
|
26
|
+
"RouterReceipt",
|
|
27
|
+
"SafeRemediation",
|
|
28
|
+
"SandboxResult",
|
|
29
|
+
"TargetRef",
|
|
30
|
+
"Verdict",
|
|
31
|
+
]
|
contracts/contracts.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""InstallGate data contracts (Pydantic v2).
|
|
2
|
+
|
|
3
|
+
FROZEN as of gate G1 (2026-10-26). Field names, types, and enum values below
|
|
4
|
+
are final. Renaming, adding, or removing any of them requires both engineers
|
|
5
|
+
on the PR (see contracts/README.md and AGENTS.md section 9) — the API, CLI,
|
|
6
|
+
and UI all read these field names and must stay in sync.
|
|
7
|
+
|
|
8
|
+
Design rules enforced in code, not in prose:
|
|
9
|
+
* Evidence is frozen and content-hashed (immutable once emitted).
|
|
10
|
+
* final_verdict == max(policy_verdict, llm_verdict) on the ALLOW<REVIEW<BLOCK lattice.
|
|
11
|
+
* The LLM may only raise ALLOW -> REVIEW. BLOCK requires hard, deterministic evidence.
|
|
12
|
+
* Every explanation sentence must cite at least one existing Evidence id.
|
|
13
|
+
* VERIFIED_SUBSTITUTE is an *outcome*, never a loosened verdict: the original target stays BLOCK/REVIEW.
|
|
14
|
+
* A substitute must come from a closed candidate set and be pinned (version + sha256).
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
from datetime import datetime
|
|
21
|
+
from enum import Enum
|
|
22
|
+
from typing import Any, Literal, Optional
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
25
|
+
|
|
26
|
+
EV_ID = r"^EV-[0-9]{1,4}$"
|
|
27
|
+
SHA256 = r"^[0-9a-f]{64}$"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Ecosystem(str, Enum):
|
|
31
|
+
PYPI = "pypi"
|
|
32
|
+
NPM = "npm"
|
|
33
|
+
HUGGINGFACE = "huggingface"
|
|
34
|
+
DOCKER = "docker"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Verdict(str, Enum):
|
|
38
|
+
ALLOW = "ALLOW"
|
|
39
|
+
REVIEW = "REVIEW"
|
|
40
|
+
BLOCK = "BLOCK"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def rank(self) -> int:
|
|
44
|
+
return {"ALLOW": 0, "REVIEW": 1, "BLOCK": 2}[self.value]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Outcome(str, Enum):
|
|
48
|
+
ALLOW = "ALLOW"
|
|
49
|
+
REVIEW = "REVIEW"
|
|
50
|
+
BLOCK = "BLOCK"
|
|
51
|
+
VERIFIED_SUBSTITUTE = "VERIFIED_SUBSTITUTE" # original stays blocked; a pinned alternative passed Stage 4
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class Frozen(BaseModel):
|
|
55
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TargetRef(Frozen):
|
|
59
|
+
ecosystem: Ecosystem
|
|
60
|
+
name: str = Field(min_length=1, max_length=214, pattern=r"^[A-Za-z0-9@/_.\-:]+$") # validated BEFORE any network call
|
|
61
|
+
version: Optional[str] = Field(default=None, max_length=64)
|
|
62
|
+
artifact_sha256: Optional[str] = Field(default=None, pattern=SHA256)
|
|
63
|
+
revision: Optional[str] = Field(default=None, pattern=r"^[0-9a-f]{40}$") # git commit; for Hugging Face, pinned = revision is set
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
CHECK_UNAVAILABLE_REASONS = frozenset({
|
|
67
|
+
"no_floor_for_branch", "floating_tag", "digest_only", "image_not_in_table", "typosquat_unsupported_ecosystem",
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EvidenceKind(str, Enum):
|
|
72
|
+
NOT_FOUND = "not_found"
|
|
73
|
+
NEW_PROJECT = "new_project" # first upload is recent
|
|
74
|
+
NEW_RELEASE = "new_release" # latest version is recent (soft by itself)
|
|
75
|
+
MAINTAINER_CHANGE = "maintainer_change"
|
|
76
|
+
OSV_MALICIOUS = "osv_malicious" # MAL-* record
|
|
77
|
+
TYPOSQUAT = "typosquat"
|
|
78
|
+
TYPOSQUAT_NEW_PROJECT = "typosquat_new_project" # derived: typosquat + first upload < 7 days (rule P4, hard)
|
|
79
|
+
HF_PICKLE = "hf_pickle"
|
|
80
|
+
HF_SAFETENSORS_SIBLING = "hf_safetensors_sibling"
|
|
81
|
+
RUNTIME_BELOW_FLOOR = "runtime_below_floor"
|
|
82
|
+
WEB_REPORT = "web_report" # Tavily; untrusted, soft only
|
|
83
|
+
RECHECK_PASSED = "recheck_passed"
|
|
84
|
+
SANDBOX_IMPORT = "sandbox_import"
|
|
85
|
+
CHECK_UNAVAILABLE = "check_unavailable" # a check could not run; observed["reason"] says why; never hard
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Evidence(Frozen):
|
|
89
|
+
id: str = Field(pattern=EV_ID)
|
|
90
|
+
kind: EvidenceKind
|
|
91
|
+
source: Literal["pypi", "npm", "osv", "huggingface", "docker_registry", "policy_yaml", "tavily", "sandbox"]
|
|
92
|
+
trust: Literal["deterministic", "retrieved_untrusted"]
|
|
93
|
+
severity: Literal["info", "soft", "hard"]
|
|
94
|
+
target: TargetRef
|
|
95
|
+
observed: dict[str, Any] = Field(default_factory=dict)
|
|
96
|
+
source_url: Optional[str] = Field(default=None, max_length=2048)
|
|
97
|
+
fetched_at: datetime
|
|
98
|
+
content_hash: str = Field(pattern=SHA256)
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def compute_hash(kind: str, target: TargetRef, observed: dict[str, Any], source_url: Optional[str]) -> str:
|
|
102
|
+
t = target.model_dump(mode="json")
|
|
103
|
+
if t.get("revision") is None:
|
|
104
|
+
t.pop("revision", None) # keeps hashes of evidence made before the revision field unchanged
|
|
105
|
+
blob = json.dumps(
|
|
106
|
+
{"k": kind, "t": t, "o": observed, "u": source_url},
|
|
107
|
+
sort_keys=True, separators=(",", ":"), default=str,
|
|
108
|
+
)
|
|
109
|
+
return hashlib.sha256(blob.encode()).hexdigest()
|
|
110
|
+
|
|
111
|
+
@model_validator(mode="after")
|
|
112
|
+
def _invariants(self) -> "Evidence":
|
|
113
|
+
if self.trust == "retrieved_untrusted" and self.severity == "hard":
|
|
114
|
+
raise ValueError("untrusted (web) evidence can never be 'hard'")
|
|
115
|
+
if self.kind == EvidenceKind.CHECK_UNAVAILABLE and self.severity == "hard":
|
|
116
|
+
raise ValueError("check_unavailable can never be 'hard'")
|
|
117
|
+
if self.kind == EvidenceKind.CHECK_UNAVAILABLE and self.observed.get("reason") not in CHECK_UNAVAILABLE_REASONS:
|
|
118
|
+
raise ValueError(f"check_unavailable needs observed['reason'] in {sorted(CHECK_UNAVAILABLE_REASONS)}")
|
|
119
|
+
if self.content_hash != self.compute_hash(self.kind.value, self.target, self.observed, self.source_url):
|
|
120
|
+
raise ValueError("content_hash mismatch: evidence was mutated")
|
|
121
|
+
return self
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class ExplanationSentence(Frozen):
|
|
125
|
+
text: str = Field(min_length=1, max_length=400)
|
|
126
|
+
evidence_ids: list[str] = Field(min_length=1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------- Router ----------
|
|
130
|
+
class ModelTier(str, Enum):
|
|
131
|
+
NANO = "nano"
|
|
132
|
+
SUPER = "super"
|
|
133
|
+
ULTRA = "ultra"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class RouteCall(Frozen):
|
|
137
|
+
stage: Literal["extract", "explain", "rank_substitute", "escalate"]
|
|
138
|
+
tier: ModelTier
|
|
139
|
+
model_id: str # resolved from GET /v1/models at startup, never hard-coded
|
|
140
|
+
prompt_tokens: int = Field(ge=0)
|
|
141
|
+
completion_tokens: int = Field(ge=0)
|
|
142
|
+
cost_usd: float = Field(ge=0)
|
|
143
|
+
latency_ms: int = Field(ge=0)
|
|
144
|
+
escalation_reason: Optional[str] = None
|
|
145
|
+
schema_valid: bool = True # structured output parsed on first try?
|
|
146
|
+
retries: int = Field(default=0, ge=0)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class RouterReceipt(Frozen):
|
|
150
|
+
calls: list[RouteCall]
|
|
151
|
+
total_cost_usd: float = Field(ge=0)
|
|
152
|
+
counterfactual_all_ultra_usd: float = Field(ge=0)
|
|
153
|
+
savings_pct: float
|
|
154
|
+
price_table_version: str # e.g. "2026-10-25 token-factory-catalog"
|
|
155
|
+
tavily_calls: int = Field(default=0, ge=0)
|
|
156
|
+
deterministic_only_ms: int = Field(ge=0) # Stage 1 latency, shown to prove the LLM is not on the hot path
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------- Remediation ----------
|
|
160
|
+
class SandboxResult(Frozen):
|
|
161
|
+
provider: Literal["nebius_token_factory_sandboxes"]
|
|
162
|
+
sandbox_id: str
|
|
163
|
+
image_digest: str
|
|
164
|
+
prepare_phase_network: Literal["registry_only", "on"]
|
|
165
|
+
verify_phase_network: Literal["off"] # verification MUST run offline
|
|
166
|
+
egress_denial_confirmed: bool
|
|
167
|
+
exit_code: int
|
|
168
|
+
import_ok: bool
|
|
169
|
+
probe_symbols_ok: list[str] = Field(default_factory=list) # AST-validated import/hasattr probes only
|
|
170
|
+
duration_ms: int = Field(ge=0)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class SafeRemediation(Frozen):
|
|
174
|
+
status: Literal["VERIFIED_SUBSTITUTE", "NO_SAFE_SUBSTITUTE", "VERIFICATION_UNAVAILABLE", "VERIFICATION_FAILED"]
|
|
175
|
+
original: TargetRef
|
|
176
|
+
candidate: Optional[TargetRef] = None
|
|
177
|
+
candidate_source: Optional[Literal["alias_map", "top_n_neighbor", "hf_sibling", "docker_patched_floor"]] = None
|
|
178
|
+
candidate_set_ids: list[str] = Field(default_factory=list) # closed set the LLM ranked over, by id
|
|
179
|
+
selected_by: Optional[Literal["rule", "llm_rank"]] = None
|
|
180
|
+
verification_level: Optional[Literal["sandbox_import", "static_registry"]] = None
|
|
181
|
+
recheck_evidence_ids: list[str] = Field(default_factory=list)
|
|
182
|
+
approved_requirement: Optional[str] = None # e.g. "requests-oauthlib==2.0.0 --hash=sha256:..."
|
|
183
|
+
sandbox: Optional[SandboxResult] = None
|
|
184
|
+
equivalence_note: str = "import-compatible only; behavioural equivalence is not guaranteed"
|
|
185
|
+
|
|
186
|
+
@model_validator(mode="after")
|
|
187
|
+
def _verified_requires_proof(self) -> "SafeRemediation":
|
|
188
|
+
if self.status == "VERIFIED_SUBSTITUTE":
|
|
189
|
+
c = self.candidate
|
|
190
|
+
if c is None or not c.version or not c.artifact_sha256:
|
|
191
|
+
raise ValueError("verified substitute must be pinned (version + sha256)")
|
|
192
|
+
if self.candidate_source is None or not self.candidate_set_ids:
|
|
193
|
+
raise ValueError("substitute must come from a closed candidate set")
|
|
194
|
+
if not self.recheck_evidence_ids:
|
|
195
|
+
raise ValueError("Stage 1 must be re-run on the substitute")
|
|
196
|
+
if self.verification_level == "sandbox_import":
|
|
197
|
+
s = self.sandbox
|
|
198
|
+
if s is None or not (s.import_ok and s.egress_denial_confirmed and s.exit_code == 0):
|
|
199
|
+
raise ValueError("sandbox verification incomplete")
|
|
200
|
+
elif self.verification_level != "static_registry":
|
|
201
|
+
raise ValueError("verification_level required")
|
|
202
|
+
return self
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ---------- Verdict ----------
|
|
206
|
+
class GateVerdict(Frozen):
|
|
207
|
+
request_id: str
|
|
208
|
+
target: TargetRef
|
|
209
|
+
policy_version: str
|
|
210
|
+
policy_verdict: Verdict
|
|
211
|
+
llm_verdict: Optional[Verdict] = None
|
|
212
|
+
final_verdict: Verdict
|
|
213
|
+
outcome: Outcome
|
|
214
|
+
risk_score: float = Field(ge=0, le=1)
|
|
215
|
+
failed_closed: bool = False
|
|
216
|
+
evidence: list[Evidence]
|
|
217
|
+
explanation: list[ExplanationSentence] = Field(default_factory=list)
|
|
218
|
+
dropped_sentences: int = Field(default=0, ge=0)
|
|
219
|
+
next_action: Literal["proceed", "use_substitute", "ask_human", "abort"]
|
|
220
|
+
remediation: Optional[SafeRemediation] = None
|
|
221
|
+
receipt: Optional[RouterReceipt] = None
|
|
222
|
+
|
|
223
|
+
@model_validator(mode="after")
|
|
224
|
+
def _invariants(self) -> "GateVerdict":
|
|
225
|
+
ids = {e.id for e in self.evidence}
|
|
226
|
+
if len(ids) != len(self.evidence):
|
|
227
|
+
raise ValueError("duplicate evidence ids")
|
|
228
|
+
for s in self.explanation:
|
|
229
|
+
if not set(s.evidence_ids) <= ids:
|
|
230
|
+
raise ValueError("explanation cites unknown evidence id")
|
|
231
|
+
# lattice: LLM can only tighten
|
|
232
|
+
llm = self.llm_verdict or Verdict.ALLOW
|
|
233
|
+
expected = self.policy_verdict if self.policy_verdict.rank >= llm.rank else llm
|
|
234
|
+
if self.final_verdict != expected:
|
|
235
|
+
raise ValueError("final_verdict must equal max(policy, llm)")
|
|
236
|
+
# LLM may raise ALLOW->REVIEW only; BLOCK needs hard deterministic evidence
|
|
237
|
+
if self.final_verdict == Verdict.BLOCK and self.policy_verdict != Verdict.BLOCK:
|
|
238
|
+
raise ValueError("BLOCK must come from deterministic policy, not the LLM")
|
|
239
|
+
if self.policy_verdict == Verdict.BLOCK and not any(
|
|
240
|
+
e.severity == "hard" and e.trust == "deterministic" for e in self.evidence
|
|
241
|
+
):
|
|
242
|
+
raise ValueError("policy BLOCK requires hard deterministic evidence")
|
|
243
|
+
if self.failed_closed and self.final_verdict == Verdict.ALLOW:
|
|
244
|
+
raise ValueError("fail-closed can never yield ALLOW")
|
|
245
|
+
# outcome consistency
|
|
246
|
+
if self.outcome == Outcome.VERIFIED_SUBSTITUTE:
|
|
247
|
+
if self.final_verdict == Verdict.ALLOW:
|
|
248
|
+
raise ValueError("nothing to substitute for an ALLOW")
|
|
249
|
+
if not self.remediation or self.remediation.status != "VERIFIED_SUBSTITUTE":
|
|
250
|
+
raise ValueError("VERIFIED_SUBSTITUTE outcome requires verified remediation")
|
|
251
|
+
if self.next_action != "use_substitute":
|
|
252
|
+
raise ValueError("next_action must be use_substitute")
|
|
253
|
+
elif self.outcome.value != self.final_verdict.value:
|
|
254
|
+
raise ValueError("outcome must mirror final_verdict unless VERIFIED_SUBSTITUTE")
|
|
255
|
+
pinned = bool(self.target.version and self.target.artifact_sha256) or (
|
|
256
|
+
self.target.ecosystem == Ecosystem.HUGGINGFACE and self.target.revision is not None
|
|
257
|
+
)
|
|
258
|
+
if self.final_verdict == Verdict.ALLOW and self.outcome == Outcome.ALLOW and not pinned:
|
|
259
|
+
raise ValueError("ALLOW must be pinned (version + sha256, or a Hugging Face revision) to be meaningful (TOCTOU)")
|
|
260
|
+
return self
|
contracts/contracts.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// InstallGate data contracts (TypeScript, zod v3/v4). Mirror of contracts.py.
|
|
2
|
+
// Generate once from the Python models (pydantic -> JSON Schema -> json-schema-to-zod) in CI so the two cannot drift.
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
|
|
5
|
+
export const Ecosystem = z.enum(["pypi", "npm", "huggingface", "docker"]);
|
|
6
|
+
export const Verdict = z.enum(["ALLOW", "REVIEW", "BLOCK"]);
|
|
7
|
+
export const Outcome = z.enum(["ALLOW", "REVIEW", "BLOCK", "VERIFIED_SUBSTITUTE"]);
|
|
8
|
+
export type Verdict = z.infer<typeof Verdict>;
|
|
9
|
+
|
|
10
|
+
const rank: Record<Verdict, number> = { ALLOW: 0, REVIEW: 1, BLOCK: 2 };
|
|
11
|
+
export const maxVerdict = (a: Verdict, b: Verdict): Verdict => (rank[a] >= rank[b] ? a : b);
|
|
12
|
+
|
|
13
|
+
const sha256 = z.string().regex(/^[0-9a-f]{64}$/);
|
|
14
|
+
const evId = z.string().regex(/^EV-[0-9]{1,4}$/);
|
|
15
|
+
|
|
16
|
+
export const TargetRef = z.object({
|
|
17
|
+
ecosystem: Ecosystem,
|
|
18
|
+
name: z.string().min(1).max(214).regex(/^[A-Za-z0-9@/_.\-:]+$/),
|
|
19
|
+
version: z.string().max(64).optional(),
|
|
20
|
+
artifact_sha256: sha256.optional(),
|
|
21
|
+
revision: z.string().regex(/^[0-9a-f]{40}$/).optional(),
|
|
22
|
+
}).strict();
|
|
23
|
+
|
|
24
|
+
export const EvidenceKind = z.enum([
|
|
25
|
+
"not_found", "new_project", "new_release", "maintainer_change", "osv_malicious",
|
|
26
|
+
"typosquat", "typosquat_new_project", "hf_pickle", "hf_safetensors_sibling", "runtime_below_floor",
|
|
27
|
+
"web_report", "recheck_passed", "sandbox_import", "check_unavailable",
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const CHECK_UNAVAILABLE_REASONS = [
|
|
31
|
+
"no_floor_for_branch", "floating_tag", "digest_only", "image_not_in_table", "typosquat_unsupported_ecosystem",
|
|
32
|
+
] as const;
|
|
33
|
+
|
|
34
|
+
export const Evidence = z.object({
|
|
35
|
+
id: evId,
|
|
36
|
+
kind: EvidenceKind,
|
|
37
|
+
source: z.enum(["pypi", "npm", "osv", "huggingface", "docker_registry", "policy_yaml", "tavily", "sandbox"]),
|
|
38
|
+
trust: z.enum(["deterministic", "retrieved_untrusted"]),
|
|
39
|
+
severity: z.enum(["info", "soft", "hard"]),
|
|
40
|
+
target: TargetRef,
|
|
41
|
+
observed: z.record(z.string(), z.unknown()),
|
|
42
|
+
source_url: z.string().max(2048).optional(),
|
|
43
|
+
fetched_at: z.string().datetime(),
|
|
44
|
+
content_hash: sha256,
|
|
45
|
+
}).strict().refine(e => !(e.trust === "retrieved_untrusted" && e.severity === "hard"),
|
|
46
|
+
{ message: "untrusted (web) evidence can never be 'hard'" })
|
|
47
|
+
.refine(e => !(e.kind === "check_unavailable" && e.severity === "hard"),
|
|
48
|
+
{ message: "check_unavailable can never be 'hard'" })
|
|
49
|
+
.refine(e => e.kind !== "check_unavailable" ||
|
|
50
|
+
(CHECK_UNAVAILABLE_REASONS as readonly unknown[]).includes(e.observed["reason"]),
|
|
51
|
+
{ message: "check_unavailable needs observed['reason'] from CHECK_UNAVAILABLE_REASONS" });
|
|
52
|
+
|
|
53
|
+
export const ExplanationSentence = z.object({
|
|
54
|
+
text: z.string().min(1).max(400),
|
|
55
|
+
evidence_ids: z.array(evId).min(1),
|
|
56
|
+
}).strict();
|
|
57
|
+
|
|
58
|
+
export const ModelTier = z.enum(["nano", "super", "ultra"]);
|
|
59
|
+
|
|
60
|
+
export const RouteCall = z.object({
|
|
61
|
+
stage: z.enum(["extract", "explain", "rank_substitute", "escalate"]),
|
|
62
|
+
tier: ModelTier,
|
|
63
|
+
model_id: z.string(),
|
|
64
|
+
prompt_tokens: z.number().int().nonnegative(),
|
|
65
|
+
completion_tokens: z.number().int().nonnegative(),
|
|
66
|
+
cost_usd: z.number().nonnegative(),
|
|
67
|
+
latency_ms: z.number().int().nonnegative(),
|
|
68
|
+
escalation_reason: z.string().optional(),
|
|
69
|
+
schema_valid: z.boolean().default(true),
|
|
70
|
+
retries: z.number().int().nonnegative().default(0),
|
|
71
|
+
}).strict();
|
|
72
|
+
|
|
73
|
+
export const RouterReceipt = z.object({
|
|
74
|
+
calls: z.array(RouteCall),
|
|
75
|
+
total_cost_usd: z.number().nonnegative(),
|
|
76
|
+
counterfactual_all_ultra_usd: z.number().nonnegative(),
|
|
77
|
+
savings_pct: z.number(),
|
|
78
|
+
price_table_version: z.string(),
|
|
79
|
+
tavily_calls: z.number().int().nonnegative().default(0),
|
|
80
|
+
deterministic_only_ms: z.number().int().nonnegative(),
|
|
81
|
+
}).strict();
|
|
82
|
+
|
|
83
|
+
export const SandboxResult = z.object({
|
|
84
|
+
provider: z.literal("nebius_token_factory_sandboxes"),
|
|
85
|
+
sandbox_id: z.string(),
|
|
86
|
+
image_digest: z.string(),
|
|
87
|
+
prepare_phase_network: z.enum(["registry_only", "on"]),
|
|
88
|
+
verify_phase_network: z.literal("off"),
|
|
89
|
+
egress_denial_confirmed: z.boolean(),
|
|
90
|
+
exit_code: z.number().int(),
|
|
91
|
+
import_ok: z.boolean(),
|
|
92
|
+
probe_symbols_ok: z.array(z.string()).default([]),
|
|
93
|
+
duration_ms: z.number().int().nonnegative(),
|
|
94
|
+
}).strict();
|
|
95
|
+
|
|
96
|
+
export const SafeRemediation = z.object({
|
|
97
|
+
status: z.enum(["VERIFIED_SUBSTITUTE", "NO_SAFE_SUBSTITUTE", "VERIFICATION_UNAVAILABLE", "VERIFICATION_FAILED"]),
|
|
98
|
+
original: TargetRef,
|
|
99
|
+
candidate: TargetRef.optional(),
|
|
100
|
+
candidate_source: z.enum(["alias_map", "top_n_neighbor", "hf_sibling", "docker_patched_floor"]).optional(),
|
|
101
|
+
candidate_set_ids: z.array(z.string()).default([]),
|
|
102
|
+
selected_by: z.enum(["rule", "llm_rank"]).optional(),
|
|
103
|
+
verification_level: z.enum(["sandbox_import", "static_registry"]).optional(),
|
|
104
|
+
recheck_evidence_ids: z.array(evId).default([]),
|
|
105
|
+
approved_requirement: z.string().optional(),
|
|
106
|
+
sandbox: SandboxResult.optional(),
|
|
107
|
+
equivalence_note: z.string().default("import-compatible only; behavioural equivalence is not guaranteed"),
|
|
108
|
+
}).strict().refine(r => r.status !== "VERIFIED_SUBSTITUTE" || (
|
|
109
|
+
!!r.candidate?.version && !!r.candidate?.artifact_sha256 &&
|
|
110
|
+
!!r.candidate_source && r.candidate_set_ids.length > 0 && r.recheck_evidence_ids.length > 0
|
|
111
|
+
), { message: "verified substitute must be pinned, from a closed set, and re-checked" });
|
|
112
|
+
|
|
113
|
+
export const GateVerdict = z.object({
|
|
114
|
+
request_id: z.string(),
|
|
115
|
+
target: TargetRef,
|
|
116
|
+
policy_version: z.string(),
|
|
117
|
+
policy_verdict: Verdict,
|
|
118
|
+
llm_verdict: Verdict.optional(),
|
|
119
|
+
final_verdict: Verdict,
|
|
120
|
+
outcome: Outcome,
|
|
121
|
+
risk_score: z.number().min(0).max(1),
|
|
122
|
+
failed_closed: z.boolean().default(false),
|
|
123
|
+
evidence: z.array(Evidence),
|
|
124
|
+
explanation: z.array(ExplanationSentence).default([]),
|
|
125
|
+
dropped_sentences: z.number().int().nonnegative().default(0),
|
|
126
|
+
next_action: z.enum(["proceed", "use_substitute", "ask_human", "abort"]),
|
|
127
|
+
remediation: SafeRemediation.optional(),
|
|
128
|
+
receipt: RouterReceipt.optional(),
|
|
129
|
+
}).strict().superRefine((v, ctx) => {
|
|
130
|
+
const bad = (message: string) => ctx.addIssue({ code: "custom", message });
|
|
131
|
+
const ids = new Set(v.evidence.map(e => e.id));
|
|
132
|
+
if (ids.size !== v.evidence.length) bad("duplicate evidence ids");
|
|
133
|
+
if (v.explanation.some(s => s.evidence_ids.some(i => !ids.has(i)))) bad("explanation cites unknown evidence id");
|
|
134
|
+
if (v.final_verdict !== maxVerdict(v.policy_verdict, v.llm_verdict ?? "ALLOW")) bad("final_verdict must equal max(policy, llm)");
|
|
135
|
+
if (v.final_verdict === "BLOCK" && v.policy_verdict !== "BLOCK") bad("BLOCK must come from deterministic policy");
|
|
136
|
+
if (v.policy_verdict === "BLOCK" && !v.evidence.some(e => e.severity === "hard" && e.trust === "deterministic")) bad("policy BLOCK requires hard deterministic evidence");
|
|
137
|
+
if (v.failed_closed && v.final_verdict === "ALLOW") bad("fail-closed can never yield ALLOW");
|
|
138
|
+
if (v.outcome === "VERIFIED_SUBSTITUTE") {
|
|
139
|
+
if (v.final_verdict === "ALLOW") bad("nothing to substitute for an ALLOW");
|
|
140
|
+
if (v.remediation?.status !== "VERIFIED_SUBSTITUTE") bad("VERIFIED_SUBSTITUTE outcome requires verified remediation");
|
|
141
|
+
if (v.next_action !== "use_substitute") bad("next_action must be use_substitute");
|
|
142
|
+
} else if (v.outcome !== v.final_verdict) bad("outcome must mirror final_verdict");
|
|
143
|
+
const pinned = !!(v.target.version && v.target.artifact_sha256) ||
|
|
144
|
+
(v.target.ecosystem === "huggingface" && v.target.revision !== undefined);
|
|
145
|
+
if (v.outcome === "ALLOW" && !pinned) bad("ALLOW must be pinned (version + sha256, or a Hugging Face revision) (TOCTOU)");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
export type GateVerdict = z.infer<typeof GateVerdict>;
|
|
149
|
+
export type Evidence = z.infer<typeof Evidence>;
|
|
150
|
+
export type SafeRemediation = z.infer<typeof SafeRemediation>;
|
|
151
|
+
export type RouterReceipt = z.infer<typeof RouterReceipt>;
|
engine/README.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# engine/ — Kate
|
|
2
|
+
|
|
3
|
+
Stage 1. Deterministic. Zero model calls. Metadata only — do not install or import the package.
|
|
4
|
+
|
|
5
|
+
| File | Build this |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `parsers.py` | Parse pip / npm / HF repo id / Dockerfile FROM |
|
|
8
|
+
| `clients/pypi.py` | GET pypi.org JSON |
|
|
9
|
+
| `clients/npm.py` | GET registry.npmjs.org |
|
|
10
|
+
| `clients/osv.py` | POST api.osv.dev (MAL- records) |
|
|
11
|
+
| `clients/huggingface.py` | GET huggingface.co model file list |
|
|
12
|
+
| `checks.py` | exists, age, maintainers, osv_mal, typosquat, hf_format, runtime_floor |
|
|
13
|
+
| `evidence.py` | Hashed Evidence with ids `EV-1`, `EV-2`, … |
|
|
14
|
+
| `policy.yaml` + `policy.py` | Rules P1–P10. Strictest match wins |
|
|
15
|
+
| `runtime_floors.yaml` | Patched version floors (e.g. php) |
|
|
16
|
+
| `cache.py` | SQLite with TTL |
|
|
17
|
+
| `data/` | Dated top-5k names for typosquat |
|
|
18
|
+
|
|
19
|
+
## P10: a check that could not run
|
|
20
|
+
|
|
21
|
+
A check that cannot run does not stay silent. It emits a soft `check_unavailable`
|
|
22
|
+
evidence record with the cause in `observed["reason"]`, and rule **P10** turns any
|
|
23
|
+
such record into REVIEW. P9 (ALLOW) needs zero evidence, so a gap can never read as
|
|
24
|
+
a clean result, and being soft it can never produce a BLOCK (FR-09).
|
|
25
|
+
|
|
26
|
+
| `observed["reason"]` | When |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| `no_floor_for_branch` | The tag is on a release branch with no recorded floor |
|
|
29
|
+
| `floating_tag` | `latest`, `bookworm`, or `8.3` / `8.3-apache` with no patch release |
|
|
30
|
+
| `digest_only` | A Dockerfile reference with no tag |
|
|
31
|
+
| `image_not_in_table` | No floors recorded for this image |
|
|
32
|
+
| `typosquat_unsupported_ecosystem` | A registry listed for typosquat has no snapshot. npm is not listed; exists/pin still ALLOW. |
|
|
33
|
+
|
|
34
|
+
Tasks: T03, T04, T05, T06, T22. Tests first. Mock HTTP with respx. No live network in unit tests.
|
|
35
|
+
|
|
36
|
+
Timeouts and host allowlists on every client. Fail closed to REVIEW on errors.
|
engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Kate. See README.md.
|
engine/cache.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""SQLite cache with a per-source TTL. Stores raw registry JSON, never secrets."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
DEFAULT_PATH = Path(os.environ.get("CACHE_PATH", "./.cache/installgate.sqlite"))
|
|
13
|
+
|
|
14
|
+
TTL_SECONDS: dict[str, int] = {
|
|
15
|
+
"pypi": 3600,
|
|
16
|
+
"npm": 3600,
|
|
17
|
+
"osv": 900,
|
|
18
|
+
"huggingface": 3600,
|
|
19
|
+
"docker_registry": 3600,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_SCHEMA = """
|
|
23
|
+
CREATE TABLE IF NOT EXISTS entries (
|
|
24
|
+
source TEXT NOT NULL,
|
|
25
|
+
key TEXT NOT NULL,
|
|
26
|
+
body TEXT NOT NULL,
|
|
27
|
+
stored_at REAL NOT NULL,
|
|
28
|
+
PRIMARY KEY (source, key)
|
|
29
|
+
)
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class Cache:
|
|
34
|
+
def __init__(self, path: Path | str = DEFAULT_PATH) -> None:
|
|
35
|
+
self.path = Path(path)
|
|
36
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
self._conn = sqlite3.connect(self.path, check_same_thread=False)
|
|
38
|
+
self._conn.execute(_SCHEMA)
|
|
39
|
+
self._conn.commit()
|
|
40
|
+
|
|
41
|
+
def get(self, source: str, key: str) -> Any | None:
|
|
42
|
+
ttl = TTL_SECONDS.get(source, 900)
|
|
43
|
+
row = self._conn.execute(
|
|
44
|
+
"SELECT body, stored_at FROM entries WHERE source = ? AND key = ?",
|
|
45
|
+
(source, key),
|
|
46
|
+
).fetchone()
|
|
47
|
+
if row is None:
|
|
48
|
+
return None
|
|
49
|
+
body, stored_at = row
|
|
50
|
+
if time.time() - stored_at > ttl:
|
|
51
|
+
self._conn.execute("DELETE FROM entries WHERE source = ? AND key = ?", (source, key))
|
|
52
|
+
self._conn.commit()
|
|
53
|
+
return None
|
|
54
|
+
return json.loads(body)
|
|
55
|
+
|
|
56
|
+
def set(self, source: str, key: str, value: Any) -> None:
|
|
57
|
+
self._conn.execute(
|
|
58
|
+
"INSERT OR REPLACE INTO entries (source, key, body, stored_at) VALUES (?, ?, ?, ?)",
|
|
59
|
+
(source, key, json.dumps(value), time.time()),
|
|
60
|
+
)
|
|
61
|
+
self._conn.commit()
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
self._conn.close()
|