@tangle-network/agent-bench 0.1.0 → 0.3.6

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 (317) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/HARNESS.md +302 -0
  3. package/README.md +39 -2
  4. package/dist/adapters.d.ts +15 -0
  5. package/dist/adapters.js +43 -0
  6. package/dist/adapters.js.map +1 -0
  7. package/dist/benchmarks/_harness.d.ts +125 -0
  8. package/dist/benchmarks/_harness.js +33 -0
  9. package/dist/benchmarks/_harness.js.map +1 -0
  10. package/dist/benchmarks/aec-bench.d.ts +27 -0
  11. package/dist/benchmarks/aec-bench.js +8 -0
  12. package/dist/benchmarks/aec-bench.js.map +1 -0
  13. package/dist/benchmarks/agentbench.d.ts +16 -0
  14. package/dist/benchmarks/agentbench.js +10 -0
  15. package/dist/benchmarks/agentbench.js.map +1 -0
  16. package/dist/benchmarks/appworld.d.ts +37 -0
  17. package/dist/benchmarks/appworld.js +14 -0
  18. package/dist/benchmarks/appworld.js.map +1 -0
  19. package/dist/benchmarks/bfcl.d.ts +18 -0
  20. package/dist/benchmarks/bfcl.js +10 -0
  21. package/dist/benchmarks/bfcl.js.map +1 -0
  22. package/dist/benchmarks/cad-design.d.ts +45 -0
  23. package/dist/benchmarks/cad-design.js +7 -0
  24. package/dist/benchmarks/cad-design.js.map +1 -0
  25. package/dist/benchmarks/cadbench.d.ts +19 -0
  26. package/dist/benchmarks/cadbench.js +8 -0
  27. package/dist/benchmarks/cadbench.js.map +1 -0
  28. package/dist/benchmarks/cadgenbench.d.ts +22 -0
  29. package/dist/benchmarks/cadgenbench.js +8 -0
  30. package/dist/benchmarks/cadgenbench.js.map +1 -0
  31. package/dist/benchmarks/commit0.d.ts +31 -0
  32. package/dist/benchmarks/commit0.js +10 -0
  33. package/dist/benchmarks/commit0.js.map +1 -0
  34. package/dist/benchmarks/crag.d.ts +14 -0
  35. package/dist/benchmarks/crag.js +9 -0
  36. package/dist/benchmarks/crag.js.map +1 -0
  37. package/dist/benchmarks/dabstep.d.ts +18 -0
  38. package/dist/benchmarks/dabstep.js +10 -0
  39. package/dist/benchmarks/dabstep.js.map +1 -0
  40. package/dist/benchmarks/enterpriseops-gym.d.ts +38 -0
  41. package/dist/benchmarks/enterpriseops-gym.js +10 -0
  42. package/dist/benchmarks/enterpriseops-gym.js.map +1 -0
  43. package/dist/benchmarks/finresearchbench.d.ts +15 -0
  44. package/dist/benchmarks/finresearchbench.js +8 -0
  45. package/dist/benchmarks/finresearchbench.js.map +1 -0
  46. package/dist/benchmarks/finsearchcomp.d.ts +49 -0
  47. package/dist/benchmarks/finsearchcomp.js +7 -0
  48. package/dist/benchmarks/finsearchcomp.js.map +1 -0
  49. package/dist/benchmarks/frames.d.ts +59 -0
  50. package/dist/benchmarks/frames.js +13 -0
  51. package/dist/benchmarks/frames.js.map +1 -0
  52. package/dist/benchmarks/hotpotqa.d.ts +48 -0
  53. package/dist/benchmarks/hotpotqa.js +15 -0
  54. package/dist/benchmarks/hotpotqa.js.map +1 -0
  55. package/dist/benchmarks/humaneval.d.ts +53 -0
  56. package/dist/benchmarks/humaneval.js +15 -0
  57. package/dist/benchmarks/humaneval.js.map +1 -0
  58. package/dist/benchmarks/mind2web.d.ts +41 -0
  59. package/dist/benchmarks/mind2web.js +9 -0
  60. package/dist/benchmarks/mind2web.js.map +1 -0
  61. package/dist/benchmarks/nomiracl.d.ts +15 -0
  62. package/dist/benchmarks/nomiracl.js +9 -0
  63. package/dist/benchmarks/nomiracl.js.map +1 -0
  64. package/dist/benchmarks/open-rag-bench.d.ts +14 -0
  65. package/dist/benchmarks/open-rag-bench.js +9 -0
  66. package/dist/benchmarks/open-rag-bench.js.map +1 -0
  67. package/dist/benchmarks/programbench.d.ts +38 -0
  68. package/dist/benchmarks/programbench.js +10 -0
  69. package/dist/benchmarks/programbench.js.map +1 -0
  70. package/dist/benchmarks/rag-shared.d.ts +42 -0
  71. package/dist/benchmarks/rag-shared.js +39 -0
  72. package/dist/benchmarks/rag-shared.js.map +1 -0
  73. package/dist/benchmarks/ragbench.d.ts +16 -0
  74. package/dist/benchmarks/ragbench.js +9 -0
  75. package/dist/benchmarks/ragbench.js.map +1 -0
  76. package/dist/benchmarks/simpleqa.d.ts +64 -0
  77. package/dist/benchmarks/simpleqa.js +11 -0
  78. package/dist/benchmarks/simpleqa.js.map +1 -0
  79. package/dist/benchmarks/swe-bench.d.ts +56 -0
  80. package/dist/benchmarks/swe-bench.js +14 -0
  81. package/dist/benchmarks/swe-bench.js.map +1 -0
  82. package/dist/benchmarks/t2-ragbench.d.ts +14 -0
  83. package/dist/benchmarks/t2-ragbench.js +9 -0
  84. package/dist/benchmarks/t2-ragbench.js.map +1 -0
  85. package/dist/benchmarks/tau-bench-shared.d.ts +26 -0
  86. package/dist/benchmarks/tau-bench-shared.js +10 -0
  87. package/dist/benchmarks/tau-bench-shared.js.map +1 -0
  88. package/dist/benchmarks/tau2-bench.d.ts +7 -0
  89. package/dist/benchmarks/tau2-bench.js +11 -0
  90. package/dist/benchmarks/tau2-bench.js.map +1 -0
  91. package/dist/benchmarks/tau3-banking.d.ts +15 -0
  92. package/dist/benchmarks/tau3-banking.js +9 -0
  93. package/dist/benchmarks/tau3-banking.js.map +1 -0
  94. package/dist/benchmarks/terminal-bench.d.ts +24 -0
  95. package/dist/benchmarks/terminal-bench.js +8 -0
  96. package/dist/benchmarks/terminal-bench.js.map +1 -0
  97. package/dist/benchmarks/toollm.d.ts +16 -0
  98. package/dist/benchmarks/toollm.js +10 -0
  99. package/dist/benchmarks/toollm.js.map +1 -0
  100. package/dist/benchmarks/trata-hedge.d.ts +32 -0
  101. package/dist/benchmarks/trata-hedge.js +7 -0
  102. package/dist/benchmarks/trata-hedge.js.map +1 -0
  103. package/dist/benchmarks/types.d.ts +107 -0
  104. package/dist/benchmarks/types.js +1 -0
  105. package/dist/benchmarks/types.js.map +1 -0
  106. package/dist/benchmarks/webarena-verified.d.ts +16 -0
  107. package/dist/benchmarks/webarena-verified.js +10 -0
  108. package/dist/benchmarks/webarena-verified.js.map +1 -0
  109. package/dist/chunk-2PVVP7GN.js +197 -0
  110. package/dist/chunk-2PVVP7GN.js.map +1 -0
  111. package/dist/chunk-2XU6OGEN.js +170 -0
  112. package/dist/chunk-2XU6OGEN.js.map +1 -0
  113. package/dist/chunk-53UPUNBZ.js +325 -0
  114. package/dist/chunk-53UPUNBZ.js.map +1 -0
  115. package/dist/chunk-5SBJCB6W.js +144 -0
  116. package/dist/chunk-5SBJCB6W.js.map +1 -0
  117. package/dist/chunk-7WSD27QQ.js +118 -0
  118. package/dist/chunk-7WSD27QQ.js.map +1 -0
  119. package/dist/chunk-C7T7WEK2.js +103 -0
  120. package/dist/chunk-C7T7WEK2.js.map +1 -0
  121. package/dist/chunk-CKUVRZ2T.js +251 -0
  122. package/dist/chunk-CKUVRZ2T.js.map +1 -0
  123. package/dist/chunk-HBSWHQNJ.js +30 -0
  124. package/dist/chunk-HBSWHQNJ.js.map +1 -0
  125. package/dist/chunk-HHXFIHXC.js +116 -0
  126. package/dist/chunk-HHXFIHXC.js.map +1 -0
  127. package/dist/chunk-IFAV6KEM.js +276 -0
  128. package/dist/chunk-IFAV6KEM.js.map +1 -0
  129. package/dist/chunk-INNOYXCP.js +387 -0
  130. package/dist/chunk-INNOYXCP.js.map +1 -0
  131. package/dist/chunk-J3KDJNX2.js +182 -0
  132. package/dist/chunk-J3KDJNX2.js.map +1 -0
  133. package/dist/chunk-JRWWGMK7.js +148 -0
  134. package/dist/chunk-JRWWGMK7.js.map +1 -0
  135. package/dist/chunk-JTHWEDEW.js +32 -0
  136. package/dist/chunk-JTHWEDEW.js.map +1 -0
  137. package/dist/chunk-KDIKRJGB.js +120 -0
  138. package/dist/chunk-KDIKRJGB.js.map +1 -0
  139. package/dist/chunk-LRRD7NAG.js +301 -0
  140. package/dist/chunk-LRRD7NAG.js.map +1 -0
  141. package/dist/chunk-ODT47UAY.js +221 -0
  142. package/dist/chunk-ODT47UAY.js.map +1 -0
  143. package/dist/chunk-PA2ZKHJC.js +230 -0
  144. package/dist/chunk-PA2ZKHJC.js.map +1 -0
  145. package/dist/chunk-PPYSEKFM.js +182 -0
  146. package/dist/chunk-PPYSEKFM.js.map +1 -0
  147. package/dist/chunk-PUIRNYI7.js +189 -0
  148. package/dist/chunk-PUIRNYI7.js.map +1 -0
  149. package/dist/chunk-R36V2VP7.js +169 -0
  150. package/dist/chunk-R36V2VP7.js.map +1 -0
  151. package/dist/chunk-R67DFVLO.js +142 -0
  152. package/dist/chunk-R67DFVLO.js.map +1 -0
  153. package/dist/chunk-SEVJPLZC.js +260 -0
  154. package/dist/chunk-SEVJPLZC.js.map +1 -0
  155. package/dist/chunk-SYDW647C.js +318 -0
  156. package/dist/chunk-SYDW647C.js.map +1 -0
  157. package/dist/chunk-TBKU5XQI.js +228 -0
  158. package/dist/chunk-TBKU5XQI.js.map +1 -0
  159. package/dist/chunk-TSWPNOYM.js +147 -0
  160. package/dist/chunk-TSWPNOYM.js.map +1 -0
  161. package/dist/chunk-UAIOHCUK.js +27 -0
  162. package/dist/chunk-UAIOHCUK.js.map +1 -0
  163. package/dist/chunk-UPAMRDX4.js +233 -0
  164. package/dist/chunk-UPAMRDX4.js.map +1 -0
  165. package/dist/chunk-VQRS7VUC.js +342 -0
  166. package/dist/chunk-VQRS7VUC.js.map +1 -0
  167. package/dist/chunk-X3BTXCJ4.js +262 -0
  168. package/dist/chunk-X3BTXCJ4.js.map +1 -0
  169. package/dist/chunk-X5YKXC6V.js +211 -0
  170. package/dist/chunk-X5YKXC6V.js.map +1 -0
  171. package/dist/chunk-Y6O2OCUO.js +130 -0
  172. package/dist/chunk-Y6O2OCUO.js.map +1 -0
  173. package/dist/chunk-YCGY7UIZ.js +208 -0
  174. package/dist/chunk-YCGY7UIZ.js.map +1 -0
  175. package/dist/chunk-Z7ML6L77.js +162 -0
  176. package/dist/chunk-Z7ML6L77.js.map +1 -0
  177. package/dist/chunk-ZEWMTR5M.js +136 -0
  178. package/dist/chunk-ZEWMTR5M.js.map +1 -0
  179. package/dist/index.d.ts +355 -0
  180. package/dist/index.js +1908 -0
  181. package/dist/index.js.map +1 -0
  182. package/fixtures/aec-bench.json +18 -0
  183. package/fixtures/agentbench-dbbench.json +22 -0
  184. package/fixtures/bfcl.json +45 -0
  185. package/fixtures/commit0.json +72 -0
  186. package/fixtures/crag.json +10 -0
  187. package/fixtures/dabstep.json +22 -0
  188. package/fixtures/enterpriseops-gym.json +103 -0
  189. package/fixtures/finresearchbench.json +21 -0
  190. package/fixtures/finsearchcomp.json +66 -0
  191. package/fixtures/frames.json +26 -0
  192. package/fixtures/hotpotqa.json +182 -0
  193. package/fixtures/nomiracl.json +26 -0
  194. package/fixtures/open-rag-bench.json +16 -0
  195. package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
  196. package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
  197. package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
  198. package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
  199. package/fixtures/pier-agent/no-model-task/task.toml +35 -0
  200. package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
  201. package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
  202. package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
  203. package/fixtures/programbench.json +17 -0
  204. package/fixtures/ragbench.json +21 -0
  205. package/fixtures/simpleqa.json +121 -0
  206. package/fixtures/t2-ragbench.json +13 -0
  207. package/fixtures/tau2-bench.json +16 -0
  208. package/fixtures/tau3-banking.json +16 -0
  209. package/fixtures/toollm.json +28 -0
  210. package/fixtures/webarena-verified.json +20 -0
  211. package/package.json +60 -20
  212. package/pier_agents/__init__.py +18 -0
  213. package/pier_agents/candidate_contract.py +755 -0
  214. package/pier_agents/process_boundary.py +321 -0
  215. package/pier_agents/tangle_candidate.py +907 -0
  216. package/pier_agents/workspace_boundary.py +368 -0
  217. package/scripts/appworld_driver.py +359 -0
  218. package/scripts/cadbench_prepare.py +22 -0
  219. package/scripts/cadgenbench_hard_parts.py +48 -0
  220. package/scripts/clbench_codebase_judge.py +73 -0
  221. package/scripts/commit0_judge.py +170 -0
  222. package/scripts/dabstep_judge.py +42 -0
  223. package/scripts/enterpriseops_gym_judge.py +281 -0
  224. package/scripts/programbench_judge.py +120 -0
  225. package/scripts/render-gate-chart.mjs +176 -0
  226. package/scripts/run-package-tests.mjs +56 -0
  227. package/scripts/terminate-pier-trial.mts +66 -0
  228. package/scripts/trata-hedge/README.md +56 -0
  229. package/scripts/trata-hedge/run.sh +60 -0
  230. package/scripts/trata-hedge/solve.py +83 -0
  231. package/scripts/verify-packed-consumer.mjs +235 -0
  232. package/scripts/verify-pier-agent.mts +715 -0
  233. package/scripts/verify-pier-pair.mts +74 -0
  234. package/scripts/verify-pier-recovery.mts +139 -0
  235. package/src/adapters.ts +26 -0
  236. package/src/benchmarks/_harness.test.mts +178 -0
  237. package/src/benchmarks/_harness.ts +239 -16
  238. package/src/benchmarks/agentbench.ts +163 -0
  239. package/src/benchmarks/appworld.test.mts +15 -9
  240. package/src/benchmarks/bfcl.ts +346 -0
  241. package/src/benchmarks/crag.ts +137 -0
  242. package/src/benchmarks/dabstep.test.mts +70 -0
  243. package/src/benchmarks/dabstep.ts +212 -0
  244. package/src/benchmarks/external-adapters.test.mts +150 -0
  245. package/src/benchmarks/finresearchbench.ts +269 -0
  246. package/src/benchmarks/humaneval.ts +20 -8
  247. package/src/benchmarks/nomiracl.ts +180 -0
  248. package/src/benchmarks/open-rag-bench.ts +153 -0
  249. package/src/benchmarks/rag-benchmarks.test.mts +138 -0
  250. package/src/benchmarks/rag-shared.ts +327 -0
  251. package/src/benchmarks/ragbench.ts +171 -0
  252. package/src/benchmarks/swe-bench.test.mts +61 -0
  253. package/src/benchmarks/swe-bench.ts +201 -19
  254. package/src/benchmarks/t2-ragbench.ts +166 -0
  255. package/src/benchmarks/tau-bench-shared.ts +214 -0
  256. package/src/benchmarks/tau2-bench.ts +30 -0
  257. package/src/benchmarks/tau3-banking.ts +29 -0
  258. package/src/benchmarks/terminal-bench.test.mts +33 -0
  259. package/src/benchmarks/terminal-bench.ts +23 -8
  260. package/src/benchmarks/toollm.ts +254 -0
  261. package/src/benchmarks/types.ts +42 -0
  262. package/src/benchmarks/webarena-verified.ts +200 -0
  263. package/src/commit0-prereqs.sh +0 -0
  264. package/src/coordination-mcp-container-reach.mts +181 -0
  265. package/src/decoder-live.mts +1 -1
  266. package/src/examples/README.md +103 -39
  267. package/src/examples/benchmark-matrix.mts +101 -0
  268. package/src/examples/lean-proof-gate.README.md +77 -0
  269. package/src/examples/lean-proof-gate.mts +162 -0
  270. package/src/examples/lean-verify.ts +95 -0
  271. package/src/examples/lean.Dockerfile +12 -0
  272. package/src/examples/math-demo.mts +9 -7
  273. package/src/examples/strategy-demo.mts +10 -12
  274. package/src/gate.ts +3 -2
  275. package/src/hev-eval.mts +69 -0
  276. package/src/hev-improve.mts +169 -0
  277. package/src/hev-structural.mts +688 -0
  278. package/src/index.ts +73 -0
  279. package/src/mbpp-structural.mts +662 -0
  280. package/src/pier-agent.test-fixtures.mts +19 -0
  281. package/src/pier-agent.test.mts +363 -0
  282. package/src/pier-agent.ts +657 -0
  283. package/src/pier-result-grader.mjs +30 -0
  284. package/src/pier-result-grader.test.mts +62 -0
  285. package/src/pier-result-grader.ts +108 -0
  286. package/src/pier-task-outcome.test.mts +117 -0
  287. package/src/pier-task-outcome.ts +240 -0
  288. package/src/pier-trial-controller.test.mts +412 -0
  289. package/src/pier-trial-controller.ts +858 -0
  290. package/src/pier-trial-supervisor.mjs +352 -0
  291. package/src/resolve-client.ts +25 -2
  292. package/src/run-benchmarks-cli.mts +72 -0
  293. package/src/run-benchmarks-report.ts +66 -0
  294. package/src/run-benchmarks.test.mts +231 -0
  295. package/src/run-benchmarks.ts +589 -0
  296. package/src/smoke-structural-rollout.mts +393 -0
  297. package/src/swe-bench-env.test.ts +207 -0
  298. package/src/swe-bench-env.ts +554 -0
  299. package/src/swe-jail.ts +293 -0
  300. package/src/swe-self-improve.mts +84 -0
  301. package/src/swe-structural-judge-policy.test.ts +117 -0
  302. package/src/swe-structural-judge-policy.ts +133 -0
  303. package/src/swe-structural-policy.test.ts +124 -0
  304. package/src/swe-structural-policy.ts +132 -0
  305. package/src/swe-structural-provenance.test.ts +93 -0
  306. package/src/swe-structural-provenance.ts +138 -0
  307. package/src/swe-structural.mts +1260 -0
  308. package/src/swe-temp.ts +14 -0
  309. package/src/tb-container-executor.mts +234 -0
  310. package/src/tb-container-executor.test.mts +99 -0
  311. package/src/tb-supervisor-sidecar.mts +222 -0
  312. package/src/trata-gepa.mts +1 -1
  313. package/steerers/eops-itsm-population.json +1 -0
  314. package/tb_agents/opencode_refine_agent.py +117 -0
  315. package/tb_agents/opencode_router_agent.py +406 -0
  316. package/tb_agents/opencode_supervisor_agent.py +239 -0
  317. package/tb_agents/script_agent.py +66 -0
@@ -0,0 +1,755 @@
1
+ """Strict reader for runtime-prepared candidate execution artifacts.
2
+
3
+ The TypeScript runtime is the authority that verifies bundles and creates the
4
+ canonical execution plan and materialization receipt. This module performs the
5
+ small, security-critical replay checks needed before Pier copies those prepared
6
+ bytes into a task container. It deliberately does not define another plan.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import base64
12
+ import hashlib
13
+ import json
14
+ import math
15
+ import re
16
+ import stat
17
+ from dataclasses import dataclass
18
+ from pathlib import Path, PurePosixPath
19
+ from typing import Any
20
+
21
+
22
+ _SHA256_RE = re.compile(r"^sha256:[a-f0-9]{64}$")
23
+ _GIT_OBJECT_RE = re.compile(r"^(?:[a-f0-9]{40}|[a-f0-9]{64})$")
24
+ _ENV_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
25
+ _EXECUTABLE_RE = re.compile(r"^[A-Za-z0-9._+/-]+$")
26
+ _DNS_LABEL_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
27
+ _SHELLS = {"sh", "bash", "zsh", "fish", "cmd", "cmd.exe", "powershell", "pwsh"}
28
+ _BLOCKED_GATEWAY_DOMAINS = {
29
+ "localhost",
30
+ "metadata.google.internal",
31
+ "metadata.google",
32
+ }
33
+ _RESERVED_PATH_PARTS = {".git", ".sidecar"}
34
+ _PLAN_KIND = "agent-candidate-execution-plan-material"
35
+ _RECEIPT_KIND = "agent-candidate-materialization"
36
+ _PROFILE_PLAN_KIND = "agent-profile-workspace-plan"
37
+ _EXECUTION_EVIDENCE_KIND = "agent-candidate-execution-plan"
38
+ _STDIN_TASK_PATH = "/tangle/input/stdin.txt"
39
+
40
+
41
+ class CandidateContractError(ValueError):
42
+ """Prepared execution bytes are missing, malformed, or inconsistent."""
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class InstructionEvidence:
47
+ sha256: str
48
+ byte_length: int
49
+ delivery_kind: str
50
+ delivery_env: str | None = None
51
+ delivery_path: str | None = None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class WorkspaceFile:
56
+ path: str
57
+ mode: int
58
+ sha256: str
59
+ byte_length: int
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class ProfileFile:
64
+ path: str
65
+ mode: int
66
+ sha256: str
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class PreparedCandidateContract:
71
+ plan: dict[str, Any]
72
+ plan_raw: bytes
73
+ receipt: dict[str, Any]
74
+ receipt_raw: bytes
75
+ bundle_digest: str
76
+ execution_id: str
77
+ execution_plan_digest: str
78
+ materialization_receipt_digest: str
79
+ instruction: InstructionEvidence
80
+ repository_base_commit: str
81
+ repository_base_tree: str
82
+ task_root: str
83
+ candidate_root: str | None
84
+ task_files: tuple[WorkspaceFile, ...]
85
+ candidate_files: tuple[WorkspaceFile, ...]
86
+ profile_target: str
87
+ profile_files: tuple[ProfileFile, ...]
88
+ executable: str
89
+ args: tuple[str, ...]
90
+ env: dict[str, str]
91
+ cwd_root: str
92
+ cwd_path: str
93
+ timeout_ms: int
94
+ requested_model: str
95
+ resolved_model: dict[str, Any]
96
+ model_network_domains: tuple[str, ...]
97
+ container: dict[str, Any]
98
+
99
+
100
+ def sha256_bytes(data: bytes) -> str:
101
+ return f"sha256:{hashlib.sha256(data).hexdigest()}"
102
+
103
+
104
+ def _object(value: Any, label: str) -> dict[str, Any]:
105
+ if not isinstance(value, dict):
106
+ raise CandidateContractError(f"{label} must be a JSON object")
107
+ return value
108
+
109
+
110
+ def _contract_object(
111
+ value: Any, label: str, *, kind: str | None = None
112
+ ) -> dict[str, Any]:
113
+ obj = _object(value, label)
114
+ if "schemaVersion" in obj or "version" in obj:
115
+ raise CandidateContractError(f"{label} contains obsolete version fields")
116
+ if kind is not None and obj.get("kind") != kind:
117
+ raise CandidateContractError(f"{label} has the wrong kind")
118
+ return obj
119
+
120
+
121
+ def _string(value: Any, label: str) -> str:
122
+ if not isinstance(value, str) or not value or "\0" in value:
123
+ raise CandidateContractError(f"{label} must be a non-empty string without NUL")
124
+ return value
125
+
126
+
127
+ def _integer(value: Any, label: str, *, minimum: int = 0) -> int:
128
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
129
+ raise CandidateContractError(f"{label} must be an integer >= {minimum}")
130
+ return value
131
+
132
+
133
+ def _number(value: Any, label: str, *, minimum: float = 0) -> float:
134
+ if (
135
+ isinstance(value, bool)
136
+ or not isinstance(value, (int, float))
137
+ or not math.isfinite(value)
138
+ or value < minimum
139
+ ):
140
+ raise CandidateContractError(f"{label} must be a finite number >= {minimum}")
141
+ return float(value)
142
+
143
+
144
+ def _digest(value: Any, label: str) -> str:
145
+ digest = _string(value, label)
146
+ if not _SHA256_RE.fullmatch(digest):
147
+ raise CandidateContractError(f"{label} must be sha256:<64 lowercase hex>")
148
+ return digest
149
+
150
+
151
+ def _git_object(value: Any, label: str) -> str:
152
+ object_id = _string(value, label)
153
+ if not _GIT_OBJECT_RE.fullmatch(object_id):
154
+ raise CandidateContractError(f"{label} must be a full Git object id")
155
+ return object_id
156
+
157
+
158
+ def _safe_relative(value: Any, label: str, *, allow_dot: bool = False) -> str:
159
+ path = _string(value, label)
160
+ if any(ord(char) < 32 or ord(char) == 127 for char in path) or "\\" in path:
161
+ raise CandidateContractError(f"{label} contains a forbidden character")
162
+ parsed = PurePosixPath(path)
163
+ if parsed.is_absolute():
164
+ raise CandidateContractError(f"{label} must be relative")
165
+ if path == ".":
166
+ if allow_dot:
167
+ return path
168
+ raise CandidateContractError(f"{label} must identify a child path")
169
+ parts = path.split("/")
170
+ if any(
171
+ not part or part in {".", ".."} or part in _RESERVED_PATH_PARTS
172
+ for part in parts
173
+ ):
174
+ raise CandidateContractError(f"{label} must be a canonical safe relative path")
175
+ return path
176
+
177
+
178
+ def _absolute(value: Any, label: str) -> str:
179
+ path = _string(value, label)
180
+ if any(ord(char) < 32 or ord(char) == 127 for char in path) or "\\" in path:
181
+ raise CandidateContractError(f"{label} contains a forbidden character")
182
+ parsed = PurePosixPath(path)
183
+ if (
184
+ not parsed.is_absolute()
185
+ or path == "/"
186
+ or ".." in parsed.parts
187
+ or str(parsed) != path
188
+ ):
189
+ raise CandidateContractError(f"{label} must be a canonical absolute POSIX path")
190
+ return path
191
+
192
+
193
+ def _public(value: Any, label: str) -> str:
194
+ obj = _object(value, label)
195
+ if set(obj) != {"kind", "value"} or obj.get("kind") != "public":
196
+ raise CandidateContractError(f"{label} must be one explicit public value")
197
+ return _string(obj.get("value"), f"{label}.value")
198
+
199
+
200
+ def _gateway_domain(value: Any, label: str) -> str:
201
+ domain = _string(value, label)
202
+ labels = domain.split(".")
203
+ if (
204
+ len(domain) > 253
205
+ or domain != domain.lower()
206
+ or domain.endswith(".")
207
+ or ":" in domain
208
+ or domain in _BLOCKED_GATEWAY_DOMAINS
209
+ or domain.endswith(".localhost")
210
+ or len(labels) < 2
211
+ or not all(1 <= len(part) <= 63 and _DNS_LABEL_RE.fullmatch(part) for part in labels)
212
+ or not re.search(r"[a-z]", labels[-1])
213
+ ):
214
+ raise CandidateContractError(
215
+ f"{label} must be an exact lowercase public DNS name"
216
+ )
217
+ return domain
218
+
219
+
220
+ def _read_json(path: Path, label: str) -> tuple[dict[str, Any], bytes]:
221
+ try:
222
+ raw = path.read_bytes()
223
+ except OSError as exc:
224
+ raise CandidateContractError(f"cannot read {label} {path}: {exc}") from exc
225
+ try:
226
+ value = json.loads(raw)
227
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
228
+ raise CandidateContractError(f"{label} is not valid UTF-8 JSON: {exc}") from exc
229
+ return _object(value, label), raw
230
+
231
+
232
+ def _embedded_bytes(value: Any, label: str) -> bytes:
233
+ artifact = _object(value, label)
234
+ if artifact.get("encoding") != "base64" or not isinstance(
235
+ artifact.get("content"), str
236
+ ):
237
+ raise CandidateContractError(f"{label} must embed exact base64 bytes")
238
+ try:
239
+ raw = base64.b64decode(artifact["content"], validate=True)
240
+ except ValueError as exc:
241
+ raise CandidateContractError(f"{label} contains invalid base64") from exc
242
+ expected_digest = _digest(artifact.get("sha256"), f"{label}.sha256")
243
+ expected_length = _integer(artifact.get("byteLength"), f"{label}.byteLength")
244
+ if len(raw) != expected_length or sha256_bytes(raw) != expected_digest:
245
+ raise CandidateContractError(f"{label} bytes do not match their identity")
246
+ return raw
247
+
248
+
249
+ def _workspace_files(value: Any, label: str) -> tuple[WorkspaceFile, ...]:
250
+ snapshot = _contract_object(
251
+ value, label, kind="agent-candidate-workspace-snapshot"
252
+ )
253
+ material = _contract_object(
254
+ snapshot.get("material"),
255
+ f"{label}.material",
256
+ kind="agent-candidate-workspace-manifest",
257
+ )
258
+ values = material.get("files")
259
+ if not isinstance(values, list):
260
+ raise CandidateContractError(f"{label}.material.files must be an array")
261
+ files: list[WorkspaceFile] = []
262
+ for index, value in enumerate(values):
263
+ obj = _object(value, f"{label}.material.files[{index}]")
264
+ mode = _integer(obj.get("mode"), f"{label}.material.files[{index}].mode")
265
+ if mode > 0o777:
266
+ raise CandidateContractError(
267
+ f"{label} contains unsupported file mode {mode:o}"
268
+ )
269
+ files.append(
270
+ WorkspaceFile(
271
+ path=_safe_relative(
272
+ obj.get("path"), f"{label}.material.files[{index}].path"
273
+ ),
274
+ mode=mode,
275
+ sha256=_digest(
276
+ obj.get("sha256"), f"{label}.material.files[{index}].sha256"
277
+ ),
278
+ byte_length=_integer(
279
+ obj.get("byteLength"), f"{label}.material.files[{index}].byteLength"
280
+ ),
281
+ )
282
+ )
283
+ paths = [file.path for file in files]
284
+ if paths != sorted(set(paths)):
285
+ raise CandidateContractError(f"{label} file paths must be unique and sorted")
286
+ return tuple(files)
287
+
288
+
289
+ def _profile_files(value: Any) -> tuple[ProfileFile, ...]:
290
+ values = value.get("files") if isinstance(value, dict) else None
291
+ if not isinstance(values, list):
292
+ raise CandidateContractError("profilePlan.material.files must be an array")
293
+ files: list[ProfileFile] = []
294
+ for index, value in enumerate(values):
295
+ obj = _object(value, f"profilePlan.material.files[{index}]")
296
+ mode = _integer(obj.get("mode"), f"profilePlan.material.files[{index}].mode")
297
+ if mode > 0o777:
298
+ raise CandidateContractError(
299
+ f"profile plan contains unsupported mode {mode:o}"
300
+ )
301
+ files.append(
302
+ ProfileFile(
303
+ path=_safe_relative(
304
+ obj.get("relPath"), f"profilePlan.material.files[{index}].relPath"
305
+ ),
306
+ mode=mode,
307
+ sha256=_digest(
308
+ obj.get("contentSha256"),
309
+ f"profilePlan.material.files[{index}].contentSha256",
310
+ ),
311
+ )
312
+ )
313
+ paths = [file.path for file in files]
314
+ if paths != sorted(set(paths)):
315
+ raise CandidateContractError(
316
+ "profile plan file paths must be unique and sorted"
317
+ )
318
+ return tuple(files)
319
+
320
+
321
+ def _instruction(value: Any) -> InstructionEvidence:
322
+ obj = _object(value, "task.instruction")
323
+ if obj.get("encoding") != "utf8":
324
+ raise CandidateContractError("task.instruction.encoding must equal utf8")
325
+ delivery = _object(obj.get("delivery"), "task.instruction.delivery")
326
+ kind = delivery.get("kind")
327
+ if kind == "argv-append" or kind == "stdin-utf8":
328
+ if set(delivery) != {"kind"}:
329
+ raise CandidateContractError(f"{kind} delivery has unexpected fields")
330
+ env = path = None
331
+ elif kind == "utf8-file":
332
+ if (
333
+ set(delivery) != {"kind", "env", "path"}
334
+ or delivery.get("env") != "TANGLE_CANDIDATE_TASK_PATH"
335
+ or delivery.get("path") != "/tangle/input/task.txt"
336
+ ):
337
+ raise CandidateContractError(
338
+ "utf8-file delivery has unexpected fields or does not use the fixed env and path"
339
+ )
340
+ env = "TANGLE_CANDIDATE_TASK_PATH"
341
+ path = "/tangle/input/task.txt"
342
+ else:
343
+ raise CandidateContractError("unsupported task instruction delivery")
344
+ return InstructionEvidence(
345
+ sha256=_digest(obj.get("sha256"), "task.instruction.sha256"),
346
+ byte_length=_integer(
347
+ obj.get("byteLength"), "task.instruction.byteLength", minimum=1
348
+ ),
349
+ delivery_kind=kind,
350
+ delivery_env=env,
351
+ delivery_path=path,
352
+ )
353
+
354
+
355
+ def load_prepared_candidate_contract(
356
+ plan_path: Path,
357
+ receipt_path: Path,
358
+ expected_receipt_digest: str,
359
+ ) -> PreparedCandidateContract:
360
+ """Load exact runtime bytes and prove the receipt binds the plan."""
361
+
362
+ plan, plan_raw = _read_json(plan_path, "execution plan")
363
+ receipt, receipt_raw = _read_json(receipt_path, "materialization receipt")
364
+ receipt_digest = _digest(expected_receipt_digest, "expected receipt digest")
365
+ if sha256_bytes(receipt_raw) != receipt_digest:
366
+ raise CandidateContractError(
367
+ "materialization receipt bytes do not match the expected digest"
368
+ )
369
+ plan = _contract_object(plan, "execution plan", kind=_PLAN_KIND)
370
+ receipt = _contract_object(
371
+ receipt, "materialization receipt", kind=_RECEIPT_KIND
372
+ )
373
+ if receipt.get("digestAlgorithm") != "rfc8785-sha256" or "digest" in receipt:
374
+ raise CandidateContractError(
375
+ "materialization receipt must be exact digest-free canonical material"
376
+ )
377
+
378
+ execution_evidence = _contract_object(
379
+ receipt.get("executionPlan"),
380
+ "receipt.executionPlan",
381
+ kind=_EXECUTION_EVIDENCE_KIND,
382
+ )
383
+ plan_digest = _digest(
384
+ execution_evidence.get("digest"), "receipt.executionPlan.digest"
385
+ )
386
+ if (
387
+ sha256_bytes(plan_raw) != plan_digest
388
+ or execution_evidence.get("material") != plan
389
+ ):
390
+ raise CandidateContractError(
391
+ "receipt does not bind the exact execution plan material"
392
+ )
393
+ if (
394
+ _embedded_bytes(
395
+ execution_evidence.get("artifact"), "receipt.executionPlan.artifact"
396
+ )
397
+ != plan_raw
398
+ ):
399
+ raise CandidateContractError(
400
+ "receipt execution-plan artifact differs from the executed bytes"
401
+ )
402
+
403
+ bundle_digest = _digest(plan.get("bundleDigest"), "plan.bundleDigest")
404
+ if receipt.get("bundleDigest") != bundle_digest:
405
+ raise CandidateContractError("receipt and plan bundle digests differ")
406
+ execution_id = _string(plan.get("executionId"), "plan.executionId")
407
+
408
+ task = _object(plan.get("task"), "plan.task")
409
+ outcome = _object(task.get("outcome"), "plan.task.outcome")
410
+ if outcome.get("kind") != "workspace":
411
+ raise CandidateContractError("Pier requires a workspace task outcome")
412
+ repository = _object(task.get("repository"), "plan.task.repository")
413
+ base_commit = _git_object(
414
+ repository.get("baseCommit"), "plan.task.repository.baseCommit"
415
+ )
416
+ base_tree = _git_object(
417
+ repository.get("baseTree"), "plan.task.repository.baseTree"
418
+ )
419
+ if len(base_commit) != len(base_tree):
420
+ raise CandidateContractError("task Git objects use different hash formats")
421
+ instruction = _instruction(task.get("instruction"))
422
+ task_files = _workspace_files(task.get("workspace"), "plan.task.workspace")
423
+ if not task_files:
424
+ raise CandidateContractError("task workspace cannot be empty")
425
+
426
+ roots = _object(plan.get("workspaces"), "plan.workspaces")
427
+ task_root = _absolute(roots.get("taskRoot"), "plan.workspaces.taskRoot")
428
+ candidate_root_value = roots.get("candidateRoot")
429
+ candidate_root = (
430
+ _absolute(candidate_root_value, "plan.workspaces.candidateRoot")
431
+ if candidate_root_value is not None
432
+ else None
433
+ )
434
+ if candidate_root is not None and (
435
+ candidate_root == task_root
436
+ or candidate_root.startswith(f"{task_root}/")
437
+ or task_root.startswith(f"{candidate_root}/")
438
+ ):
439
+ raise CandidateContractError("task and candidate workspace roots overlap")
440
+ protected_instruction_path = (
441
+ instruction.delivery_path
442
+ if instruction.delivery_path is not None
443
+ else _STDIN_TASK_PATH
444
+ if instruction.delivery_kind == "stdin-utf8"
445
+ else None
446
+ )
447
+ if protected_instruction_path is not None and (
448
+ protected_instruction_path == task_root
449
+ or protected_instruction_path.startswith(f"{task_root}/")
450
+ or task_root.startswith(f"{protected_instruction_path}/")
451
+ or (
452
+ candidate_root is not None
453
+ and (
454
+ protected_instruction_path == candidate_root
455
+ or protected_instruction_path.startswith(f"{candidate_root}/")
456
+ or candidate_root.startswith(f"{protected_instruction_path}/")
457
+ )
458
+ )
459
+ ):
460
+ raise CandidateContractError("instruction path overlaps an execution workspace")
461
+
462
+ code_kind = plan.get("codeKind")
463
+ candidate_snapshot = plan.get("candidateWorkspace")
464
+ active_code = code_kind in {"no-op", "git-patch"}
465
+ if code_kind not in {"disabled", "no-op", "git-patch"}:
466
+ raise CandidateContractError("plan.codeKind is unsupported")
467
+ if active_code != (candidate_snapshot is not None) or active_code != (
468
+ candidate_root is not None
469
+ ):
470
+ raise CandidateContractError(
471
+ "active code and candidate workspace presence disagree"
472
+ )
473
+ candidate_files = (
474
+ _workspace_files(candidate_snapshot, "plan.candidateWorkspace")
475
+ if candidate_snapshot is not None
476
+ else ()
477
+ )
478
+ if active_code and not candidate_files:
479
+ raise CandidateContractError("active candidate workspace cannot be empty")
480
+ if receipt.get("candidateWorkspace") != candidate_snapshot:
481
+ raise CandidateContractError("receipt and plan candidate workspaces differ")
482
+
483
+ profile_evidence = _contract_object(
484
+ receipt.get("profilePlan"),
485
+ "receipt.profilePlan",
486
+ kind=_PROFILE_PLAN_KIND,
487
+ )
488
+ profile_digest = _digest(
489
+ profile_evidence.get("digest"), "receipt.profilePlan.digest"
490
+ )
491
+ profile_raw = _embedded_bytes(
492
+ profile_evidence.get("artifact"), "receipt.profilePlan.artifact"
493
+ )
494
+ if sha256_bytes(profile_raw) != profile_digest:
495
+ raise CandidateContractError("profile artifact does not match its digest")
496
+ try:
497
+ profile_artifact_material = json.loads(profile_raw)
498
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
499
+ raise CandidateContractError("profile artifact is not UTF-8 JSON") from exc
500
+ profile_material = _contract_object(
501
+ profile_evidence.get("material"), "receipt.profilePlan.material"
502
+ )
503
+ if profile_artifact_material != profile_material:
504
+ raise CandidateContractError("profile artifact and material differ")
505
+ profile_files = _profile_files(profile_material)
506
+ profile_application = _object(plan.get("profile"), "plan.profile")
507
+ if profile_application.get("planDigest") != profile_digest:
508
+ raise CandidateContractError("plan does not bind the profile digest")
509
+ profile_target = profile_application.get("targetWorkspace")
510
+ if profile_target not in {"task", "candidate"}:
511
+ raise CandidateContractError("profile target workspace is unsupported")
512
+ if profile_target == "candidate" and candidate_root is None:
513
+ raise CandidateContractError(
514
+ "candidate-targeted profile lacks a candidate workspace"
515
+ )
516
+ if profile_application.get("mountPaths") != [file.path for file in profile_files]:
517
+ raise CandidateContractError(
518
+ "profile mount paths differ from the exact profile files"
519
+ )
520
+
521
+ launch = _object(plan.get("launch"), "plan.launch")
522
+ executable = _string(launch.get("executable"), "plan.launch.executable")
523
+ executable_parts = executable.split("/")
524
+ if executable.startswith("/"):
525
+ executable_parts = executable_parts[1:]
526
+ if (
527
+ not _EXECUTABLE_RE.fullmatch(executable)
528
+ or not executable_parts
529
+ or any(part in {"", ".", ".."} for part in executable_parts)
530
+ or executable_parts[-1].lower() in _SHELLS
531
+ ):
532
+ raise CandidateContractError("plan launch executable is unsafe")
533
+ args_value = launch.get("args")
534
+ if not isinstance(args_value, list):
535
+ raise CandidateContractError("plan.launch.args must be an array")
536
+ args = tuple(
537
+ _public(value, f"plan.launch.args[{index}]")
538
+ for index, value in enumerate(args_value)
539
+ )
540
+ env_value = _object(launch.get("env"), "plan.launch.env")
541
+ env: dict[str, str] = {}
542
+ for name, value in env_value.items():
543
+ if not _ENV_RE.fullmatch(name):
544
+ raise CandidateContractError(f"invalid launch environment name: {name}")
545
+ env[name] = _public(value, f"plan.launch.env.{name}")
546
+ if instruction.delivery_kind == "utf8-file":
547
+ if env.get("TANGLE_CANDIDATE_TASK_PATH") != instruction.delivery_path:
548
+ raise CandidateContractError(
549
+ "launch env does not bind the fixed instruction path"
550
+ )
551
+ elif "TANGLE_CANDIDATE_TASK_PATH" in env:
552
+ raise CandidateContractError(
553
+ "non-file instruction delivery exposes a task path"
554
+ )
555
+
556
+ cwd = _object(launch.get("cwd"), "plan.launch.cwd")
557
+ cwd_workspace = cwd.get("workspace")
558
+ if cwd_workspace == "task":
559
+ cwd_root = task_root
560
+ elif cwd_workspace == "candidate" and candidate_root is not None:
561
+ cwd_root = candidate_root
562
+ else:
563
+ raise CandidateContractError("launch cwd names an unavailable workspace")
564
+ cwd_path = _safe_relative(cwd.get("path"), "plan.launch.cwd.path", allow_dot=True)
565
+ limits = _object(plan.get("limits"), "plan.limits")
566
+ timeout_ms = _integer(limits.get("timeoutMs"), "plan.limits.timeoutMs", minimum=1)
567
+ _integer(limits.get("maxSteps"), "plan.limits.maxSteps", minimum=1)
568
+ max_model_calls = _integer(
569
+ limits.get("maxModelCalls"), "plan.limits.maxModelCalls"
570
+ )
571
+ _integer(limits.get("maxInputTokens"), "plan.limits.maxInputTokens")
572
+ _integer(limits.get("maxOutputTokens"), "plan.limits.maxOutputTokens")
573
+ _number(limits.get("maxCostUsd"), "plan.limits.maxCostUsd")
574
+ attempt = _object(plan.get("attempt"), "plan.attempt")
575
+ _integer(attempt.get("number"), "plan.attempt.number", minimum=1)
576
+ _integer(attempt.get("maxAttempts"), "plan.attempt.maxAttempts", minimum=1)
577
+ if attempt.get("retryPolicy") != "none":
578
+ raise CandidateContractError("Pier execution requires runtime-owned retries")
579
+ container = _object(plan.get("container"), "plan.container")
580
+ if receipt.get("container") != container:
581
+ raise CandidateContractError("receipt and plan container identities differ")
582
+ _digest(container.get("indexDigest"), "plan.container.indexDigest")
583
+ _digest(container.get("manifestDigest"), "plan.container.manifestDigest")
584
+ image = _string(container.get("image"), "plan.container.image")
585
+ if "@" in image or "://" in image or any(char.isspace() for char in image):
586
+ raise CandidateContractError(
587
+ "container image must be an unpinned OCI reference without credentials"
588
+ )
589
+ if container.get("source") not in {
590
+ "pinned-container",
591
+ "evaluator-task-container",
592
+ }:
593
+ raise CandidateContractError("plan container source is unsupported")
594
+ platform = _object(container.get("platform"), "plan.container.platform")
595
+ if platform.get("os") != "linux" or platform.get("architecture") not in {
596
+ "amd64",
597
+ "arm64",
598
+ }:
599
+ raise CandidateContractError("Pier adapter requires a supported Linux platform")
600
+ model = _object(plan.get("model"), "plan.model")
601
+ resolved_model = _object(model.get("resolved"), "plan.model.resolved")
602
+ requested_model = _string(
603
+ resolved_model.get("requested"), "plan.model.resolved.requested"
604
+ )
605
+ _string(resolved_model.get("provider"), "plan.model.resolved.provider")
606
+ _string(resolved_model.get("model"), "plan.model.resolved.model")
607
+ _string(resolved_model.get("snapshot"), "plan.model.resolved.snapshot")
608
+ if receipt.get("resolvedModel") != resolved_model:
609
+ raise CandidateContractError("receipt and plan resolved models differ")
610
+ model_access = _object(model.get("access"), "plan.model.access")
611
+ if set(model_access) != {"kind", "grantDigest", "network"}:
612
+ raise CandidateContractError("plan.model.access has unexpected fields")
613
+ if model_access.get("kind") != "evaluator-mediated":
614
+ raise CandidateContractError(
615
+ "plan.model.access.kind must equal evaluator-mediated"
616
+ )
617
+ _digest(model_access.get("grantDigest"), "plan.model.access.grantDigest")
618
+ model_network = _object(
619
+ model_access.get("network"), "plan.model.access.network"
620
+ )
621
+ if max_model_calls == 0:
622
+ if model_network != {"mode": "disabled"}:
623
+ raise CandidateContractError(
624
+ "zero-call plans cannot expose a model gateway"
625
+ )
626
+ model_network_domains: tuple[str, ...] = ()
627
+ else:
628
+ if set(model_network) != {"mode", "domains"} or model_network.get(
629
+ "mode"
630
+ ) != "gateway-only":
631
+ raise CandidateContractError(
632
+ "model-calling plans require one frozen gateway allowlist"
633
+ )
634
+ domain_values = model_network.get("domains")
635
+ if not isinstance(domain_values, list) or not domain_values:
636
+ raise CandidateContractError(
637
+ "plan.model.access.network.domains must be a non-empty array"
638
+ )
639
+ model_network_domains = tuple(
640
+ _gateway_domain(
641
+ value, f"plan.model.access.network.domains[{index}]"
642
+ )
643
+ for index, value in enumerate(domain_values)
644
+ )
645
+ if list(model_network_domains) != sorted(set(model_network_domains)):
646
+ raise CandidateContractError(
647
+ "model gateway domains must be unique and sorted"
648
+ )
649
+ if receipt.get("harness") != plan.get("harness") or receipt.get(
650
+ "harnessVersion"
651
+ ) != plan.get("harnessVersion"):
652
+ raise CandidateContractError("receipt and plan harness identities differ")
653
+ if receipt.get("codeKind") != code_kind:
654
+ raise CandidateContractError("receipt and plan code kinds differ")
655
+ memory = _object(plan.get("memory"), "plan.memory")
656
+ if memory != {"mode": "disabled"}:
657
+ raise CandidateContractError(
658
+ "Pier adapter does not yet support isolated candidate memory"
659
+ )
660
+ if plan.get("knowledgeManifestDigest") is not None:
661
+ raise CandidateContractError(
662
+ "Pier adapter does not yet support candidate knowledge snapshots"
663
+ )
664
+ network = _object(plan.get("network"), "plan.network")
665
+ if network != {"mode": "disabled"}:
666
+ raise CandidateContractError(
667
+ "Pier candidate execution requires disabled network"
668
+ )
669
+
670
+ return PreparedCandidateContract(
671
+ plan=plan,
672
+ plan_raw=plan_raw,
673
+ receipt=receipt,
674
+ receipt_raw=receipt_raw,
675
+ bundle_digest=bundle_digest,
676
+ execution_id=execution_id,
677
+ execution_plan_digest=plan_digest,
678
+ materialization_receipt_digest=receipt_digest,
679
+ instruction=instruction,
680
+ repository_base_commit=base_commit,
681
+ repository_base_tree=base_tree,
682
+ task_root=task_root,
683
+ candidate_root=candidate_root,
684
+ task_files=task_files,
685
+ candidate_files=candidate_files,
686
+ profile_target=profile_target,
687
+ profile_files=profile_files,
688
+ executable=executable,
689
+ args=args,
690
+ env=env,
691
+ cwd_root=cwd_root,
692
+ cwd_path=cwd_path,
693
+ timeout_ms=timeout_ms,
694
+ requested_model=requested_model,
695
+ resolved_model=resolved_model,
696
+ model_network_domains=model_network_domains,
697
+ container=container,
698
+ )
699
+
700
+
701
+ def verify_local_files(
702
+ root: Path,
703
+ expected: tuple[WorkspaceFile, ...] | tuple[ProfileFile, ...],
704
+ label: str,
705
+ ) -> None:
706
+ """Require exact regular files, modes, and bytes below one staging root."""
707
+
708
+ if root.is_symlink() or not root.is_dir():
709
+ raise CandidateContractError(f"{label} must be a real directory: {root}")
710
+ if root.resolve(strict=True) != root.absolute():
711
+ raise CandidateContractError(f"{label} has a symlinked path component: {root}")
712
+ observed: dict[str, tuple[int, str, int]] = {}
713
+ for entry in root.rglob("*"):
714
+ relative = entry.relative_to(root).as_posix()
715
+ info = entry.lstat()
716
+ if stat.S_ISLNK(info.st_mode):
717
+ raise CandidateContractError(f"{label} contains a symlink: {relative}")
718
+ if stat.S_ISDIR(info.st_mode):
719
+ continue
720
+ if not stat.S_ISREG(info.st_mode) or info.st_nlink != 1:
721
+ raise CandidateContractError(
722
+ f"{label} contains a non-regular or hard-linked file: {relative}"
723
+ )
724
+ raw = entry.read_bytes()
725
+ observed[relative] = (stat.S_IMODE(info.st_mode), sha256_bytes(raw), len(raw))
726
+ expected_map = {file.path: file for file in expected}
727
+ if set(observed) != set(expected_map):
728
+ raise CandidateContractError(
729
+ f"{label} file set differs from the signed manifest"
730
+ )
731
+ for path, expected_file in expected_map.items():
732
+ actual = observed[path]
733
+ if isinstance(expected_file, ProfileFile):
734
+ if actual[:2] != (expected_file.mode, expected_file.sha256):
735
+ raise CandidateContractError(f"{label} bytes or mode differ: {path}")
736
+ elif actual != (
737
+ expected_file.mode,
738
+ expected_file.sha256,
739
+ expected_file.byte_length,
740
+ ):
741
+ raise CandidateContractError(
742
+ f"{label} bytes, length, or mode differ: {path}"
743
+ )
744
+
745
+
746
+ def immutable_snapshot(
747
+ source: Path, destination: Path, expected: tuple[Any, ...], label: str
748
+ ) -> None:
749
+ """Copy a verified staging tree and prove it did not change during capture."""
750
+
751
+ import shutil
752
+
753
+ verify_local_files(source, expected, label)
754
+ shutil.copytree(source, destination, symlinks=True, copy_function=shutil.copy2)
755
+ verify_local_files(destination, expected, f"{label} snapshot")