cuff-cli 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.
- cuff/__init__.py +4 -0
- cuff/__main__.py +4 -0
- cuff/cli.py +230 -0
- cuff/errors.py +19 -0
- cuff/gate.py +206 -0
- cuff/git.py +113 -0
- cuff/ledger.py +640 -0
- cuff/subject.py +180 -0
- cuff/workspace.py +204 -0
- cuff_cli-0.1.0.dist-info/METADATA +162 -0
- cuff_cli-0.1.0.dist-info/RECORD +15 -0
- cuff_cli-0.1.0.dist-info/WHEEL +5 -0
- cuff_cli-0.1.0.dist-info/entry_points.txt +2 -0
- cuff_cli-0.1.0.dist-info/licenses/LICENSE +202 -0
- cuff_cli-0.1.0.dist-info/top_level.txt +1 -0
cuff/ledger.py
ADDED
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
"""Closed claim/evidence ledgers and atomic command observations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import uuid
|
|
14
|
+
from contextlib import contextmanager
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Iterator
|
|
19
|
+
|
|
20
|
+
from .errors import CuffError
|
|
21
|
+
from .subject import SHA256_RE, current_subject, validate_subject
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
RECORDS_DIR = ".cuff/records"
|
|
25
|
+
WORK_ITEM_RE = re.compile(r"^[a-z0-9_.-]{1,120}$")
|
|
26
|
+
RECORD_ID_RE = re.compile(r"^rec_[0-9a-f]{32}$")
|
|
27
|
+
GIT_OID_RE = re.compile(r"^[0-9a-f]{40}(?:[0-9a-f]{24})?$")
|
|
28
|
+
CLAIM_FIELDS = {"v", "id", "type", "work_item", "created_at", "actor", "summary", "subject"}
|
|
29
|
+
EVIDENCE_FIELDS = {
|
|
30
|
+
"v", "id", "type", "work_item", "created_at", "actor", "claim",
|
|
31
|
+
"subject_digest", "command_digest", "exit_code", "output_digest", "provenance",
|
|
32
|
+
}
|
|
33
|
+
MAX_ACTOR_BYTES = 256
|
|
34
|
+
MAX_SUMMARY_BYTES = 4096
|
|
35
|
+
MAX_COMMAND_ARGUMENTS = 256
|
|
36
|
+
MAX_COMMAND_BYTES = 64 * 1024
|
|
37
|
+
MAX_RETAINED_OUTPUT = 1024 * 1024
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class LedgerBaseline:
|
|
42
|
+
length: int
|
|
43
|
+
digest: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Observation:
|
|
48
|
+
stdout: bytes
|
|
49
|
+
stderr: bytes
|
|
50
|
+
output_digest: str
|
|
51
|
+
exit_code: int
|
|
52
|
+
timed_out: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Verification:
|
|
57
|
+
record: dict[str, Any]
|
|
58
|
+
stdout: bytes
|
|
59
|
+
stderr: bytes
|
|
60
|
+
path: Path
|
|
61
|
+
line: int
|
|
62
|
+
timed_out: bool = False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class Sealing:
|
|
67
|
+
claim: dict[str, Any]
|
|
68
|
+
evidence: dict[str, Any]
|
|
69
|
+
stdout: bytes
|
|
70
|
+
stderr: bytes
|
|
71
|
+
path: Path
|
|
72
|
+
lines: list[int]
|
|
73
|
+
timed_out: bool = False
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def normalize_work_item(value: str | None) -> str:
|
|
77
|
+
if value is None:
|
|
78
|
+
raise CuffError("CUFF_WORK_ITEM_REQUIRED", "A work item is required")
|
|
79
|
+
if not isinstance(value, str):
|
|
80
|
+
raise CuffError("CUFF_WORK_ITEM_INVALID", "Work item must be a short workspace-safe name")
|
|
81
|
+
normalized = re.sub(r"[^a-z0-9_.-]+", "-", value.strip().lower()).strip("-")
|
|
82
|
+
if not normalized or normalized in {".", ".."} or WORK_ITEM_RE.fullmatch(normalized) is None:
|
|
83
|
+
raise CuffError("CUFF_WORK_ITEM_INVALID", "Work item must be a short workspace-safe name")
|
|
84
|
+
return normalized
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def resolve_actor(explicit: str | None) -> str:
|
|
88
|
+
actor = explicit if explicit is not None else os.environ.get("CUFF_ACTOR")
|
|
89
|
+
return _bounded_text(actor if actor is not None else "human:unknown", "actor", MAX_ACTOR_BYTES)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def init(root: Path) -> Path:
|
|
93
|
+
path = root / RECORDS_DIR
|
|
94
|
+
if (root / ".cuff").is_symlink() or path.is_symlink():
|
|
95
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff workspace directories must not be symlinks")
|
|
96
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
read_all(root)
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def record_path(root: Path, work_item: str) -> Path:
|
|
102
|
+
return root / RECORDS_DIR / f"{normalize_work_item(work_item)}.jsonl"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def baseline(root: Path, work_item: str) -> LedgerBaseline:
|
|
106
|
+
path = record_path(root, work_item)
|
|
107
|
+
content = path.read_bytes() if path.exists() else b""
|
|
108
|
+
return _content_baseline(content)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def create_claim(
|
|
112
|
+
root: Path,
|
|
113
|
+
work_item: str,
|
|
114
|
+
summary: str,
|
|
115
|
+
subject: dict[str, str],
|
|
116
|
+
actor: str | None = None,
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
selected_actor = resolve_actor(actor)
|
|
119
|
+
record = {
|
|
120
|
+
**_base_record("claim", work_item, selected_actor),
|
|
121
|
+
"summary": _bounded_text(summary.strip(), "summary", MAX_SUMMARY_BYTES),
|
|
122
|
+
"subject": validate_subject(subject),
|
|
123
|
+
}
|
|
124
|
+
validate_record(record)
|
|
125
|
+
return record
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def verify(
|
|
129
|
+
root: Path,
|
|
130
|
+
work_item: str,
|
|
131
|
+
claim_id: str,
|
|
132
|
+
command: list[str],
|
|
133
|
+
*,
|
|
134
|
+
timeout: float = 300,
|
|
135
|
+
actor: str | None = None,
|
|
136
|
+
) -> Verification:
|
|
137
|
+
normalized = normalize_work_item(work_item)
|
|
138
|
+
records = read(root, normalized)
|
|
139
|
+
claim = next(
|
|
140
|
+
(record for record in records if record["type"] == "claim" and record["id"] == claim_id),
|
|
141
|
+
None,
|
|
142
|
+
)
|
|
143
|
+
if claim is None:
|
|
144
|
+
raise CuffError("CUFF_CLAIM_UNKNOWN", "Evidence must link to a claim in the same work item")
|
|
145
|
+
expected = baseline(root, normalized)
|
|
146
|
+
subject = _capture_subject(root, claim["subject"])
|
|
147
|
+
git_state = _capture_git(root)
|
|
148
|
+
observation = observe_command(root, command, timeout=timeout)
|
|
149
|
+
_require_unchanged_subject(root, subject)
|
|
150
|
+
evidence_provenance = _finish_provenance(root, git_state)
|
|
151
|
+
evidence = _create_evidence(
|
|
152
|
+
normalized,
|
|
153
|
+
claim,
|
|
154
|
+
command,
|
|
155
|
+
observation,
|
|
156
|
+
evidence_provenance,
|
|
157
|
+
resolve_actor(actor),
|
|
158
|
+
)
|
|
159
|
+
path, lines = append_many(root, [evidence], expected=expected)
|
|
160
|
+
return Verification(
|
|
161
|
+
evidence,
|
|
162
|
+
observation.stdout,
|
|
163
|
+
observation.stderr,
|
|
164
|
+
path,
|
|
165
|
+
lines[0],
|
|
166
|
+
observation.timed_out,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def seal(
|
|
171
|
+
root: Path,
|
|
172
|
+
work_item: str,
|
|
173
|
+
summary: str,
|
|
174
|
+
subject: dict[str, str],
|
|
175
|
+
command: list[str],
|
|
176
|
+
*,
|
|
177
|
+
timeout: float = 300,
|
|
178
|
+
actor: str | None = None,
|
|
179
|
+
) -> Sealing:
|
|
180
|
+
normalized = normalize_work_item(work_item)
|
|
181
|
+
read(root, normalized)
|
|
182
|
+
expected = baseline(root, normalized)
|
|
183
|
+
captured_subject = _capture_subject(root, subject)
|
|
184
|
+
selected_actor = resolve_actor(actor)
|
|
185
|
+
claim = create_claim(root, normalized, summary, captured_subject, selected_actor)
|
|
186
|
+
git_state = _capture_git(root)
|
|
187
|
+
observation = observe_command(root, command, timeout=timeout)
|
|
188
|
+
_require_unchanged_subject(root, captured_subject)
|
|
189
|
+
evidence_provenance = _finish_provenance(root, git_state)
|
|
190
|
+
evidence = _create_evidence(
|
|
191
|
+
normalized,
|
|
192
|
+
claim,
|
|
193
|
+
command,
|
|
194
|
+
observation,
|
|
195
|
+
evidence_provenance,
|
|
196
|
+
selected_actor,
|
|
197
|
+
)
|
|
198
|
+
path, lines = append_many(root, [claim, evidence], expected=expected)
|
|
199
|
+
return Sealing(
|
|
200
|
+
claim,
|
|
201
|
+
evidence,
|
|
202
|
+
observation.stdout,
|
|
203
|
+
observation.stderr,
|
|
204
|
+
path,
|
|
205
|
+
lines,
|
|
206
|
+
observation.timed_out,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def observe_command(root: Path, command: list[str], *, timeout: float = 300) -> Observation:
|
|
211
|
+
_validate_command(command, timeout)
|
|
212
|
+
timed_out = False
|
|
213
|
+
with tempfile.TemporaryFile() as stdout_file, tempfile.TemporaryFile() as stderr_file:
|
|
214
|
+
try:
|
|
215
|
+
process = subprocess.Popen(
|
|
216
|
+
command,
|
|
217
|
+
cwd=root,
|
|
218
|
+
stdout=stdout_file,
|
|
219
|
+
stderr=stderr_file,
|
|
220
|
+
)
|
|
221
|
+
except OSError as exc:
|
|
222
|
+
raise CuffError("CUFF_COMMAND_FAILED", f"Verification command could not start: {exc}") from exc
|
|
223
|
+
try:
|
|
224
|
+
exit_code = process.wait(timeout=timeout)
|
|
225
|
+
except subprocess.TimeoutExpired:
|
|
226
|
+
timed_out = True
|
|
227
|
+
process.kill()
|
|
228
|
+
process.wait()
|
|
229
|
+
exit_code = 124
|
|
230
|
+
except KeyboardInterrupt:
|
|
231
|
+
process.kill()
|
|
232
|
+
process.wait()
|
|
233
|
+
raise
|
|
234
|
+
digest = _output_digest(stdout_file, stderr_file)
|
|
235
|
+
stdout, stderr = _retained_output(stdout_file, stderr_file)
|
|
236
|
+
return Observation(stdout, stderr, digest, exit_code, timed_out)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def append(root: Path, record: dict[str, Any]) -> tuple[Path, int]:
|
|
240
|
+
path, lines = append_many(root, [record])
|
|
241
|
+
return path, lines[0]
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def append_many(
|
|
245
|
+
root: Path,
|
|
246
|
+
records: list[dict[str, Any]],
|
|
247
|
+
*,
|
|
248
|
+
expected: LedgerBaseline | None = None,
|
|
249
|
+
) -> tuple[Path, list[int]]:
|
|
250
|
+
if not records:
|
|
251
|
+
raise CuffError("CUFF_LEDGER_INVALID", "At least one record is required")
|
|
252
|
+
for record in records:
|
|
253
|
+
validate_record(record)
|
|
254
|
+
work_items = {record["work_item"] for record in records}
|
|
255
|
+
if len(work_items) != 1:
|
|
256
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Atomic records must share one work item")
|
|
257
|
+
work_item = next(iter(work_items))
|
|
258
|
+
path = record_path(root, work_item)
|
|
259
|
+
directory = root / RECORDS_DIR
|
|
260
|
+
if not directory.is_dir():
|
|
261
|
+
raise CuffError("CUFF_NOT_INITIALIZED", "Run cuff init first")
|
|
262
|
+
if directory.is_symlink() or path.is_symlink():
|
|
263
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks")
|
|
264
|
+
with _lock(path):
|
|
265
|
+
existing = path.read_bytes() if path.exists() else b""
|
|
266
|
+
if expected is not None and _content_baseline(existing) != expected:
|
|
267
|
+
raise CuffError(
|
|
268
|
+
"CUFF_CONCURRENT_UPDATE",
|
|
269
|
+
"The ledger changed while verification was running",
|
|
270
|
+
)
|
|
271
|
+
prior = _parse(existing, path)
|
|
272
|
+
_require_ledger_work_item(prior, work_item, path)
|
|
273
|
+
content = existing + b"".join((_canonical(record) + "\n").encode() for record in records)
|
|
274
|
+
_require_ledger_work_item(_parse(content, path), work_item, path)
|
|
275
|
+
first_line = len(prior) + 1
|
|
276
|
+
_replace(path, content)
|
|
277
|
+
return path, list(range(first_line, first_line + len(records)))
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def read(root: Path, work_item: str) -> list[dict[str, Any]]:
|
|
281
|
+
normalized = normalize_work_item(work_item)
|
|
282
|
+
directory = root / RECORDS_DIR
|
|
283
|
+
if directory.is_symlink():
|
|
284
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff records directory must not be a symlink")
|
|
285
|
+
path = record_path(root, normalized)
|
|
286
|
+
if not path.exists():
|
|
287
|
+
return []
|
|
288
|
+
if path.is_symlink():
|
|
289
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks")
|
|
290
|
+
records = _parse(path.read_bytes(), path)
|
|
291
|
+
_require_ledger_work_item(records, normalized, path)
|
|
292
|
+
return records
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def read_all(root: Path) -> dict[str, list[dict[str, Any]]]:
|
|
296
|
+
directory = root / RECORDS_DIR
|
|
297
|
+
if not directory.is_dir():
|
|
298
|
+
raise CuffError("CUFF_NOT_INITIALIZED", "Run cuff init first")
|
|
299
|
+
if directory.is_symlink():
|
|
300
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff records directory must not be a symlink")
|
|
301
|
+
ledgers: dict[str, list[dict[str, Any]]] = {}
|
|
302
|
+
for path in sorted(directory.glob("*.jsonl")):
|
|
303
|
+
if path.is_symlink():
|
|
304
|
+
raise CuffError("CUFF_PATH_INVALID", "Cuff ledgers must not be symlinks", {"path": str(path)})
|
|
305
|
+
work_item = normalize_work_item(path.stem)
|
|
306
|
+
records = _parse(path.read_bytes(), path)
|
|
307
|
+
_require_ledger_work_item(records, work_item, path)
|
|
308
|
+
ledgers[work_item] = records
|
|
309
|
+
return ledgers
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def validate_record(record: Any) -> None:
|
|
313
|
+
if not isinstance(record, dict):
|
|
314
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Each JSONL line must be an object")
|
|
315
|
+
if "git_ref" in record:
|
|
316
|
+
raise CuffError(
|
|
317
|
+
"CUFF_LEDGER_INVALID",
|
|
318
|
+
"Ledger schema is incompatible; archive or remove .cuff and run cuff init",
|
|
319
|
+
)
|
|
320
|
+
record_type = record.get("type")
|
|
321
|
+
allowed = (
|
|
322
|
+
CLAIM_FIELDS
|
|
323
|
+
if record_type == "claim"
|
|
324
|
+
else EVIDENCE_FIELDS
|
|
325
|
+
if record_type == "evidence"
|
|
326
|
+
else set()
|
|
327
|
+
)
|
|
328
|
+
if not allowed or set(record) != allowed:
|
|
329
|
+
raise CuffError(
|
|
330
|
+
"CUFF_LEDGER_INVALID",
|
|
331
|
+
"Record fields or type are invalid",
|
|
332
|
+
{"record_id": record.get("id")},
|
|
333
|
+
)
|
|
334
|
+
if (
|
|
335
|
+
record.get("v") != 1
|
|
336
|
+
or not isinstance(record.get("id"), str)
|
|
337
|
+
or RECORD_ID_RE.fullmatch(record["id"]) is None
|
|
338
|
+
):
|
|
339
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Record version or id is invalid")
|
|
340
|
+
if normalize_work_item(record.get("work_item")) != record["work_item"]:
|
|
341
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Work item is not canonical")
|
|
342
|
+
_validate_timestamp(record.get("created_at"))
|
|
343
|
+
_bounded_text(record.get("actor"), "actor", MAX_ACTOR_BYTES)
|
|
344
|
+
if record_type == "claim":
|
|
345
|
+
_bounded_text(record.get("summary"), "summary", MAX_SUMMARY_BYTES)
|
|
346
|
+
validate_subject(record.get("subject"))
|
|
347
|
+
return
|
|
348
|
+
if not isinstance(record["claim"], str) or RECORD_ID_RE.fullmatch(record["claim"]) is None:
|
|
349
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Evidence claim link is invalid")
|
|
350
|
+
if type(record["exit_code"]) is not int:
|
|
351
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Evidence exit_code must be an integer")
|
|
352
|
+
for field in ("subject_digest", "command_digest", "output_digest"):
|
|
353
|
+
if not isinstance(record[field], str) or SHA256_RE.fullmatch(record[field]) is None:
|
|
354
|
+
raise CuffError("CUFF_LEDGER_INVALID", f"Evidence {field} is invalid")
|
|
355
|
+
_validate_provenance(record["provenance"])
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _create_evidence(
|
|
359
|
+
work_item: str,
|
|
360
|
+
claim: dict[str, Any],
|
|
361
|
+
command: list[str],
|
|
362
|
+
observation: Observation,
|
|
363
|
+
provenance: dict[str, str],
|
|
364
|
+
actor: str,
|
|
365
|
+
) -> dict[str, Any]:
|
|
366
|
+
record = {
|
|
367
|
+
**_base_record("evidence", work_item, actor),
|
|
368
|
+
"claim": claim["id"],
|
|
369
|
+
"subject_digest": claim["subject"]["digest"],
|
|
370
|
+
"command_digest": "sha256:" + hashlib.sha256(_canonical(command).encode()).hexdigest(),
|
|
371
|
+
"exit_code": observation.exit_code,
|
|
372
|
+
"output_digest": observation.output_digest,
|
|
373
|
+
"provenance": provenance,
|
|
374
|
+
}
|
|
375
|
+
validate_record(record)
|
|
376
|
+
return record
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _base_record(record_type: str, work_item: str, actor: str) -> dict[str, Any]:
|
|
380
|
+
return {
|
|
381
|
+
"v": 1,
|
|
382
|
+
"id": "rec_" + uuid.uuid4().hex,
|
|
383
|
+
"type": record_type,
|
|
384
|
+
"work_item": normalize_work_item(work_item),
|
|
385
|
+
"created_at": datetime.now(UTC).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
|
386
|
+
"actor": _bounded_text(actor, "actor", MAX_ACTOR_BYTES),
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _parse(content: bytes, path: Path) -> list[dict[str, Any]]:
|
|
391
|
+
if not content:
|
|
392
|
+
return []
|
|
393
|
+
if not content.endswith(b"\n"):
|
|
394
|
+
raise CuffError("CUFF_LEDGER_INVALID", "JSONL ledger must end with a newline", {"path": str(path)})
|
|
395
|
+
records: list[dict[str, Any]] = []
|
|
396
|
+
for line_number, raw in enumerate(content.splitlines(), 1):
|
|
397
|
+
try:
|
|
398
|
+
record = json.loads(raw, object_pairs_hook=_no_duplicate_keys)
|
|
399
|
+
validate_record(record)
|
|
400
|
+
except (UnicodeError, json.JSONDecodeError, ValueError) as exc:
|
|
401
|
+
raise CuffError(
|
|
402
|
+
"CUFF_LEDGER_INVALID", "Ledger contains invalid JSON", {"path": str(path), "line": line_number}
|
|
403
|
+
) from exc
|
|
404
|
+
except CuffError as exc:
|
|
405
|
+
raise CuffError(
|
|
406
|
+
exc.code,
|
|
407
|
+
exc.message,
|
|
408
|
+
{**exc.context, "path": str(path), "line": line_number},
|
|
409
|
+
) from exc
|
|
410
|
+
records.append(record)
|
|
411
|
+
ids = [record["id"] for record in records]
|
|
412
|
+
if len(ids) != len(set(ids)):
|
|
413
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Ledger contains duplicate record ids", {"path": str(path)})
|
|
414
|
+
claims: dict[str, dict[str, Any]] = {}
|
|
415
|
+
for record in records:
|
|
416
|
+
if record["type"] == "claim":
|
|
417
|
+
claims[record["id"]] = record
|
|
418
|
+
continue
|
|
419
|
+
claim = claims.get(record["claim"])
|
|
420
|
+
if claim is None:
|
|
421
|
+
raise CuffError(
|
|
422
|
+
"CUFF_LEDGER_INVALID",
|
|
423
|
+
"Evidence links to an unknown claim",
|
|
424
|
+
{"record_id": record["id"]},
|
|
425
|
+
)
|
|
426
|
+
if record["work_item"] != claim["work_item"]:
|
|
427
|
+
raise CuffError(
|
|
428
|
+
"CUFF_LEDGER_INVALID",
|
|
429
|
+
"Evidence work item differs from its claim",
|
|
430
|
+
{"record_id": record["id"]},
|
|
431
|
+
)
|
|
432
|
+
if record["subject_digest"] != claim["subject"]["digest"]:
|
|
433
|
+
raise CuffError(
|
|
434
|
+
"CUFF_LEDGER_INVALID",
|
|
435
|
+
"Evidence subject digest differs from its claim",
|
|
436
|
+
{"record_id": record["id"]},
|
|
437
|
+
)
|
|
438
|
+
return records
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _require_ledger_work_item(
|
|
442
|
+
records: list[dict[str, Any]],
|
|
443
|
+
work_item: str,
|
|
444
|
+
path: Path,
|
|
445
|
+
) -> None:
|
|
446
|
+
if any(record["work_item"] != work_item for record in records):
|
|
447
|
+
raise CuffError(
|
|
448
|
+
"CUFF_LEDGER_INVALID",
|
|
449
|
+
"Record work item does not match its ledger",
|
|
450
|
+
{"path": str(path)},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _capture_subject(root: Path, subject: dict[str, str]) -> dict[str, str]:
|
|
455
|
+
declared = validate_subject(subject)
|
|
456
|
+
observed = current_subject(root, declared)
|
|
457
|
+
if observed["digest"] != declared["digest"]:
|
|
458
|
+
raise CuffError("CUFF_SUBJECT_CHANGED", "Filesystem subject does not match its declared digest")
|
|
459
|
+
return declared
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _require_unchanged_subject(root: Path, subject: dict[str, str]) -> None:
|
|
463
|
+
try:
|
|
464
|
+
observed = current_subject(root, subject)
|
|
465
|
+
except CuffError as exc:
|
|
466
|
+
raise CuffError(
|
|
467
|
+
"CUFF_SUBJECT_CHANGED",
|
|
468
|
+
"Subject changed during verification; no evidence was recorded",
|
|
469
|
+
) from exc
|
|
470
|
+
if observed != subject:
|
|
471
|
+
raise CuffError(
|
|
472
|
+
"CUFF_SUBJECT_CHANGED",
|
|
473
|
+
"Subject changed during verification; no evidence was recorded",
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _capture_git(root: Path) -> tuple[Path, str]:
|
|
478
|
+
from . import git
|
|
479
|
+
|
|
480
|
+
workspace = root.resolve()
|
|
481
|
+
repository = git.repo_root(workspace)
|
|
482
|
+
if repository != workspace:
|
|
483
|
+
raise CuffError(
|
|
484
|
+
"CUFF_WORKSPACE_NOT_ROOT",
|
|
485
|
+
"Cuff workspace must be the Git worktree root",
|
|
486
|
+
{"workspace": str(workspace), "repository": str(repository)},
|
|
487
|
+
)
|
|
488
|
+
dirty = [path for path in git.dirty_paths(repository) if not _is_record_path(repository, root, path)]
|
|
489
|
+
if dirty:
|
|
490
|
+
raise CuffError(
|
|
491
|
+
"CUFF_REPOSITORY_DIRTY",
|
|
492
|
+
"Commit or remove non-ledger changes before Git-provenance verification",
|
|
493
|
+
{"paths": dirty},
|
|
494
|
+
)
|
|
495
|
+
return repository, git.head(repository)
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _finish_provenance(root: Path, state: tuple[Path, str]) -> dict[str, str]:
|
|
499
|
+
repository, commit = state
|
|
500
|
+
from . import git
|
|
501
|
+
|
|
502
|
+
current_repository = git.repo_root(root)
|
|
503
|
+
current_commit = git.head(current_repository)
|
|
504
|
+
if current_repository != repository or current_commit != commit:
|
|
505
|
+
raise CuffError(
|
|
506
|
+
"CUFF_REPOSITORY_CHANGED",
|
|
507
|
+
"Verification command changed Git HEAD; no evidence was recorded",
|
|
508
|
+
)
|
|
509
|
+
dirty = [path for path in git.dirty_paths(repository) if not _is_record_path(repository, root, path)]
|
|
510
|
+
if dirty:
|
|
511
|
+
raise CuffError(
|
|
512
|
+
"CUFF_REPOSITORY_DIRTY",
|
|
513
|
+
"Verification command left non-ledger Git changes; no evidence was recorded",
|
|
514
|
+
{"paths": dirty},
|
|
515
|
+
)
|
|
516
|
+
return {"kind": "git", "commit": commit}
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def _is_record_path(repository: Path, root: Path, path: str) -> bool:
|
|
520
|
+
try:
|
|
521
|
+
prefix = (root / RECORDS_DIR).resolve().relative_to(repository.resolve()).as_posix()
|
|
522
|
+
except ValueError:
|
|
523
|
+
return False
|
|
524
|
+
normalized = path.replace("\\", "/")
|
|
525
|
+
return normalized == prefix or normalized.startswith(prefix + "/")
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _validate_provenance(value: Any) -> dict[str, str]:
|
|
529
|
+
if not isinstance(value, dict):
|
|
530
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Evidence provenance must be an object")
|
|
531
|
+
if set(value) == {"kind", "commit"} and value.get("kind") == "git":
|
|
532
|
+
commit = value.get("commit")
|
|
533
|
+
if isinstance(commit, str) and GIT_OID_RE.fullmatch(commit) is not None:
|
|
534
|
+
return value
|
|
535
|
+
raise CuffError("CUFF_LEDGER_INVALID", "Evidence provenance is invalid")
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _validate_command(command: list[str], timeout: float) -> None:
|
|
539
|
+
if not command:
|
|
540
|
+
raise CuffError("CUFF_COMMAND_REQUIRED", "Pass a verification command after --")
|
|
541
|
+
if (
|
|
542
|
+
len(command) > MAX_COMMAND_ARGUMENTS
|
|
543
|
+
or any(not isinstance(argument, str) or not argument or "\0" in argument for argument in command)
|
|
544
|
+
or sum(len(argument.encode()) for argument in command) > MAX_COMMAND_BYTES
|
|
545
|
+
):
|
|
546
|
+
raise CuffError("CUFF_COMMAND_INVALID", "Verification command exceeds its argument bounds")
|
|
547
|
+
if not math.isfinite(timeout) or timeout <= 0:
|
|
548
|
+
raise CuffError("CUFF_TIMEOUT_INVALID", "Verification timeout must be a positive number")
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _validate_timestamp(value: Any) -> None:
|
|
552
|
+
if not isinstance(value, str) or not value.endswith("Z"):
|
|
553
|
+
raise CuffError("CUFF_LEDGER_INVALID", "created_at must be a UTC timestamp ending in Z")
|
|
554
|
+
try:
|
|
555
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
556
|
+
except ValueError as exc:
|
|
557
|
+
raise CuffError("CUFF_LEDGER_INVALID", "created_at must be an ISO timestamp") from exc
|
|
558
|
+
if parsed.utcoffset() != UTC.utcoffset(parsed):
|
|
559
|
+
raise CuffError("CUFF_LEDGER_INVALID", "created_at must be UTC")
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _bounded_text(value: Any, field: str, maximum: int) -> str:
|
|
563
|
+
if not isinstance(value, str) or not value or len(value.encode("utf-8")) > maximum:
|
|
564
|
+
raise CuffError("CUFF_LEDGER_INVALID", f"Record {field} must be bounded nonempty text")
|
|
565
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in value):
|
|
566
|
+
raise CuffError("CUFF_LEDGER_INVALID", f"Record {field} must not contain controls")
|
|
567
|
+
return value
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _output_digest(stdout: Any, stderr: Any) -> str:
|
|
571
|
+
digest = hashlib.sha256(b"cuff-output-1\0")
|
|
572
|
+
for handle in (stdout, stderr):
|
|
573
|
+
length = handle.seek(0, os.SEEK_END)
|
|
574
|
+
digest.update(length.to_bytes(8, "big"))
|
|
575
|
+
handle.seek(0)
|
|
576
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
577
|
+
digest.update(chunk)
|
|
578
|
+
return "sha256:" + digest.hexdigest()
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def _retained_output(stdout: Any, stderr: Any) -> tuple[bytes, bytes]:
|
|
582
|
+
stdout.seek(0)
|
|
583
|
+
retained_stdout = stdout.read(MAX_RETAINED_OUTPUT)
|
|
584
|
+
stderr.seek(0)
|
|
585
|
+
retained_stderr = stderr.read(MAX_RETAINED_OUTPUT - len(retained_stdout))
|
|
586
|
+
return retained_stdout, retained_stderr
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _content_baseline(content: bytes) -> LedgerBaseline:
|
|
590
|
+
return LedgerBaseline(len(content), "sha256:" + hashlib.sha256(content).hexdigest())
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
def _replace(path: Path, content: bytes) -> None:
|
|
594
|
+
temporary: Path | None = None
|
|
595
|
+
try:
|
|
596
|
+
with tempfile.NamedTemporaryFile("wb", dir=path.parent, prefix=f".{path.stem}.", delete=False) as handle:
|
|
597
|
+
temporary = Path(handle.name)
|
|
598
|
+
handle.write(content)
|
|
599
|
+
handle.flush()
|
|
600
|
+
os.fsync(handle.fileno())
|
|
601
|
+
os.replace(temporary, path)
|
|
602
|
+
descriptor = os.open(path.parent, os.O_RDONLY)
|
|
603
|
+
try:
|
|
604
|
+
os.fsync(descriptor)
|
|
605
|
+
finally:
|
|
606
|
+
os.close(descriptor)
|
|
607
|
+
finally:
|
|
608
|
+
if temporary is not None and temporary.exists():
|
|
609
|
+
temporary.unlink()
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _no_duplicate_keys(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
613
|
+
data: dict[str, Any] = {}
|
|
614
|
+
for key, value in pairs:
|
|
615
|
+
if key in data:
|
|
616
|
+
raise ValueError(f"duplicate JSON key: {key}")
|
|
617
|
+
data[key] = value
|
|
618
|
+
return data
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _canonical(value: Any) -> str:
|
|
622
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, allow_nan=False)
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
@contextmanager
|
|
626
|
+
def _lock(path: Path) -> Iterator[None]:
|
|
627
|
+
lock = path.with_suffix(".lock")
|
|
628
|
+
deadline = time.monotonic() + 5
|
|
629
|
+
while True:
|
|
630
|
+
try:
|
|
631
|
+
lock.mkdir()
|
|
632
|
+
break
|
|
633
|
+
except FileExistsError:
|
|
634
|
+
if time.monotonic() >= deadline:
|
|
635
|
+
raise CuffError("CUFF_LEDGER_BUSY", "Another writer holds the ledger lock")
|
|
636
|
+
time.sleep(0.05)
|
|
637
|
+
try:
|
|
638
|
+
yield
|
|
639
|
+
finally:
|
|
640
|
+
lock.rmdir()
|