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
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()