@tangle-network/agent-bench 0.1.0 → 0.3.5
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.
- package/CHANGELOG.md +17 -0
- package/HARNESS.md +302 -0
- package/README.md +26 -1
- package/fixtures/aec-bench.json +18 -0
- package/fixtures/agentbench-dbbench.json +22 -0
- package/fixtures/bfcl.json +45 -0
- package/fixtures/commit0.json +72 -0
- package/fixtures/crag.json +10 -0
- package/fixtures/dabstep.json +22 -0
- package/fixtures/enterpriseops-gym.json +103 -0
- package/fixtures/finresearchbench.json +21 -0
- package/fixtures/finsearchcomp.json +66 -0
- package/fixtures/frames.json +26 -0
- package/fixtures/hotpotqa.json +182 -0
- package/fixtures/nomiracl.json +26 -0
- package/fixtures/open-rag-bench.json +16 -0
- package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
- package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
- package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
- package/fixtures/pier-agent/no-model-task/task.toml +35 -0
- package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
- package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
- package/fixtures/programbench.json +17 -0
- package/fixtures/ragbench.json +21 -0
- package/fixtures/simpleqa.json +121 -0
- package/fixtures/t2-ragbench.json +13 -0
- package/fixtures/tau2-bench.json +16 -0
- package/fixtures/tau3-banking.json +16 -0
- package/fixtures/toollm.json +28 -0
- package/fixtures/webarena-verified.json +20 -0
- package/package.json +39 -15
- package/pier_agents/__init__.py +18 -0
- package/pier_agents/candidate_contract.py +755 -0
- package/pier_agents/process_boundary.py +321 -0
- package/pier_agents/tangle_candidate.py +907 -0
- package/pier_agents/workspace_boundary.py +368 -0
- package/scripts/appworld_driver.py +359 -0
- package/scripts/cadbench_prepare.py +22 -0
- package/scripts/cadgenbench_hard_parts.py +48 -0
- package/scripts/clbench_codebase_judge.py +73 -0
- package/scripts/commit0_judge.py +170 -0
- package/scripts/dabstep_judge.py +42 -0
- package/scripts/enterpriseops_gym_judge.py +281 -0
- package/scripts/programbench_judge.py +120 -0
- package/scripts/render-gate-chart.mjs +176 -0
- package/scripts/run-package-tests.mjs +56 -0
- package/scripts/terminate-pier-trial.mts +66 -0
- package/scripts/trata-hedge/README.md +56 -0
- package/scripts/trata-hedge/run.sh +60 -0
- package/scripts/trata-hedge/solve.py +83 -0
- package/scripts/verify-packed-consumer.mjs +224 -0
- package/scripts/verify-pier-agent.mts +715 -0
- package/scripts/verify-pier-pair.mts +74 -0
- package/scripts/verify-pier-recovery.mts +139 -0
- package/src/adapters.ts +26 -0
- package/src/benchmarks/_harness.test.mts +178 -0
- package/src/benchmarks/_harness.ts +239 -16
- package/src/benchmarks/agentbench.ts +163 -0
- package/src/benchmarks/appworld.test.mts +15 -9
- package/src/benchmarks/bfcl.ts +346 -0
- package/src/benchmarks/crag.ts +137 -0
- package/src/benchmarks/dabstep.test.mts +70 -0
- package/src/benchmarks/dabstep.ts +212 -0
- package/src/benchmarks/external-adapters.test.mts +150 -0
- package/src/benchmarks/finresearchbench.ts +269 -0
- package/src/benchmarks/humaneval.ts +20 -8
- package/src/benchmarks/nomiracl.ts +180 -0
- package/src/benchmarks/open-rag-bench.ts +153 -0
- package/src/benchmarks/rag-benchmarks.test.mts +138 -0
- package/src/benchmarks/rag-shared.ts +327 -0
- package/src/benchmarks/ragbench.ts +171 -0
- package/src/benchmarks/swe-bench.test.mts +61 -0
- package/src/benchmarks/swe-bench.ts +201 -19
- package/src/benchmarks/t2-ragbench.ts +166 -0
- package/src/benchmarks/tau-bench-shared.ts +214 -0
- package/src/benchmarks/tau2-bench.ts +30 -0
- package/src/benchmarks/tau3-banking.ts +29 -0
- package/src/benchmarks/terminal-bench.test.mts +33 -0
- package/src/benchmarks/terminal-bench.ts +23 -8
- package/src/benchmarks/toollm.ts +254 -0
- package/src/benchmarks/types.ts +42 -0
- package/src/benchmarks/webarena-verified.ts +200 -0
- package/src/commit0-prereqs.sh +0 -0
- package/src/coordination-mcp-container-reach.mts +181 -0
- package/src/decoder-live.mts +1 -1
- package/src/examples/README.md +103 -39
- package/src/examples/benchmark-matrix.mts +101 -0
- package/src/examples/lean-proof-gate.README.md +77 -0
- package/src/examples/lean-proof-gate.mts +162 -0
- package/src/examples/lean-verify.ts +95 -0
- package/src/examples/lean.Dockerfile +12 -0
- package/src/examples/math-demo.mts +9 -7
- package/src/examples/strategy-demo.mts +10 -12
- package/src/gate.ts +3 -2
- package/src/hev-eval.mts +69 -0
- package/src/hev-improve.mts +169 -0
- package/src/hev-structural.mts +688 -0
- package/src/index.ts +73 -0
- package/src/mbpp-structural.mts +662 -0
- package/src/pier-agent.test-fixtures.mts +19 -0
- package/src/pier-agent.test.mts +363 -0
- package/src/pier-agent.ts +657 -0
- package/src/pier-result-grader.mjs +30 -0
- package/src/pier-result-grader.test.mts +62 -0
- package/src/pier-result-grader.ts +108 -0
- package/src/pier-task-outcome.test.mts +117 -0
- package/src/pier-task-outcome.ts +240 -0
- package/src/pier-trial-controller.test.mts +412 -0
- package/src/pier-trial-controller.ts +858 -0
- package/src/pier-trial-supervisor.mjs +352 -0
- package/src/resolve-client.ts +25 -2
- package/src/run-benchmarks-cli.mts +72 -0
- package/src/run-benchmarks-report.ts +66 -0
- package/src/run-benchmarks.test.mts +231 -0
- package/src/run-benchmarks.ts +589 -0
- package/src/smoke-structural-rollout.mts +393 -0
- package/src/swe-bench-env.test.ts +207 -0
- package/src/swe-bench-env.ts +554 -0
- package/src/swe-jail.ts +293 -0
- package/src/swe-self-improve.mts +84 -0
- package/src/swe-structural-judge-policy.test.ts +117 -0
- package/src/swe-structural-judge-policy.ts +133 -0
- package/src/swe-structural-policy.test.ts +124 -0
- package/src/swe-structural-policy.ts +132 -0
- package/src/swe-structural-provenance.test.ts +93 -0
- package/src/swe-structural-provenance.ts +138 -0
- package/src/swe-structural.mts +1260 -0
- package/src/swe-temp.ts +14 -0
- package/src/tb-container-executor.mts +234 -0
- package/src/tb-container-executor.test.mts +99 -0
- package/src/tb-supervisor-sidecar.mts +222 -0
- package/src/trata-gepa.mts +1 -1
- package/steerers/eops-itsm-population.json +1 -0
- package/tb_agents/opencode_refine_agent.py +117 -0
- package/tb_agents/opencode_router_agent.py +406 -0
- package/tb_agents/opencode_supervisor_agent.py +239 -0
- 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")
|