techtree 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.
- techtree/__init__.py +35 -0
- techtree/__main__.py +14 -0
- techtree/canonical.py +239 -0
- techtree/catalog/__init__.py +25 -0
- techtree/catalog/repository.py +400 -0
- techtree/catalog/service.py +419 -0
- techtree/cli/__init__.py +1 -0
- techtree/cli/app.py +416 -0
- techtree/cli/commands/__init__.py +1 -0
- techtree/cli/commands/climb.py +1223 -0
- techtree/cli/commands/doctor.py +147 -0
- techtree/cli/commands/engine.py +207 -0
- techtree/cli/commands/proof.py +556 -0
- techtree/cli/commands/publish.py +447 -0
- techtree/cli/commands/release.py +303 -0
- techtree/cli/commands/run.py +1067 -0
- techtree/cli/commands/setup.py +181 -0
- techtree/cli/commands/skill.py +221 -0
- techtree/cli/commands/uplift.py +698 -0
- techtree/cli/commands/withdraw.py +212 -0
- techtree/cli/confirm.py +47 -0
- techtree/cli/context.py +96 -0
- techtree/cli/invoke.py +220 -0
- techtree/cli/output.py +280 -0
- techtree/constants.py +138 -0
- techtree/crypto.py +128 -0
- techtree/doctor/__init__.py +1 -0
- techtree/doctor/checks.py +675 -0
- techtree/doctor/execution_checks.py +435 -0
- techtree/doctor/service.py +326 -0
- techtree/drafts/__init__.py +32 -0
- techtree/drafts/source.py +146 -0
- techtree/drafts/store.py +992 -0
- techtree/engines/__init__.py +1 -0
- techtree/engines/bundle.py +251 -0
- techtree/engines/installer.py +679 -0
- techtree/engines/registry.py +235 -0
- techtree/engines/runner.py +170 -0
- techtree/errors.py +262 -0
- techtree/fs.py +234 -0
- techtree/harness.py +108 -0
- techtree/identity/__init__.py +41 -0
- techtree/identity/models.py +113 -0
- techtree/identity/service.py +199 -0
- techtree/identity/store.py +263 -0
- techtree/ids.py +85 -0
- techtree/manifests/__init__.py +39 -0
- techtree/manifests/builder.py +433 -0
- techtree/manifests/compare.py +376 -0
- techtree/models/__init__.py +282 -0
- techtree/models/base.py +201 -0
- techtree/models/campaign.py +484 -0
- techtree/models/catalog.py +227 -0
- techtree/models/cli.py +151 -0
- techtree/models/climb.py +254 -0
- techtree/models/data_policy.py +130 -0
- techtree/models/engine.py +156 -0
- techtree/models/episode_receipt.py +130 -0
- techtree/models/evaluation_backend.py +113 -0
- techtree/models/experiment.py +154 -0
- techtree/models/run.py +214 -0
- techtree/models/skill.py +156 -0
- techtree/models/uplift_report.py +158 -0
- techtree/models/validation.py +299 -0
- techtree/paths.py +116 -0
- techtree/presentation/__init__.py +31 -0
- techtree/presentation/build.py +1242 -0
- techtree/presentation/compact.py +246 -0
- techtree/presentation/evidence.py +169 -0
- techtree/presentation/models.py +358 -0
- techtree/presentation/rich.py +312 -0
- techtree/presentation/sanitize.py +156 -0
- techtree/publication/__init__.py +44 -0
- techtree/publication/address.py +180 -0
- techtree/publication/coordinates.py +26 -0
- techtree/publication/journal.py +212 -0
- techtree/publication/keccak.py +183 -0
- techtree/publication/models.py +209 -0
- techtree/publication/offer.py +35 -0
- techtree/publication/service.py +618 -0
- techtree/publication/transport.py +296 -0
- techtree/publication/verify.py +242 -0
- techtree/publication/withdraw.py +156 -0
- techtree/py.typed +0 -0
- techtree/receipts/__init__.py +52 -0
- techtree/receipts/bundle.py +578 -0
- techtree/receipts/compare.py +1065 -0
- techtree/receipts/episode.py +672 -0
- techtree/receipts/execution.py +630 -0
- techtree/receipts/observed.py +474 -0
- techtree/receipts/set.py +336 -0
- techtree/receipts/uplift.py +655 -0
- techtree/receipts/verify.py +1055 -0
- techtree/release/__init__.py +9 -0
- techtree/release/bootstrap.py +509 -0
- techtree/release/checks.py +376 -0
- techtree/release/document.py +125 -0
- techtree/release/generate.py +221 -0
- techtree/release/models.py +293 -0
- techtree/release/provenance.py +109 -0
- techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
- techtree/resources/catalog/catalog.json +32 -0
- techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
- techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
- techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
- techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
- techtree/resources/engines/default/engine.json +20 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
- techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
- techtree/resources/engines/default/pyproject.toml +23 -0
- techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
- techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
- techtree/resources/engines/default/tools/normalize_validation.py +222 -0
- techtree/resources/engines/default/uv.lock +1758 -0
- techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
- techtree/resources/release/build-provenance.json +4 -0
- techtree/resources/release/release-core.json +24 -0
- techtree/runs/__init__.py +31 -0
- techtree/runs/artifacts.py +750 -0
- techtree/runs/child_registry.py +228 -0
- techtree/runs/events.py +478 -0
- techtree/runs/executor.py +140 -0
- techtree/runs/fake.py +741 -0
- techtree/runs/launcher.py +253 -0
- techtree/runs/machine.py +489 -0
- techtree/runs/real.py +789 -0
- techtree/runs/service.py +616 -0
- techtree/runs/store.py +555 -0
- techtree/runs/validation.py +259 -0
- techtree/runs/variants.py +684 -0
- techtree/settings.py +143 -0
- techtree/skills/__init__.py +14 -0
- techtree/skills/archive.py +282 -0
- techtree/skills/policy.py +62 -0
- techtree/skills/scanner.py +394 -0
- techtree/skills/service.py +752 -0
- techtree/skills/starter.py +434 -0
- techtree/tasksets/__init__.py +1 -0
- techtree/tasksets/membership.py +269 -0
- techtree/tasksets/provider.py +207 -0
- techtree/tasksets/resolver.py +311 -0
- techtree/tasksets/service.py +484 -0
- techtree/tasksets/verifiers_cli.py +538 -0
- techtree/uplift/__init__.py +20 -0
- techtree/uplift/context.py +544 -0
- techtree/uplift/derive.py +203 -0
- techtree/uplift/public_tasks.py +151 -0
- techtree/uplift/service.py +719 -0
- techtree/uplift/source.py +160 -0
- techtree/verifiers/__init__.py +31 -0
- techtree/verifiers/budget.py +219 -0
- techtree/verifiers/child.py +633 -0
- techtree/verifiers/compiler.py +432 -0
- techtree/verifiers/config.py +365 -0
- techtree/verifiers/credentials.py +321 -0
- techtree/verifiers/image.py +126 -0
- techtree/verifiers/models.py +527 -0
- techtree/verifiers/outputs.py +368 -0
- techtree/verifiers/progress.py +192 -0
- techtree/verifiers/supervisor.py +341 -0
- techtree/verifiers/verify.py +782 -0
- techtree/version.py +39 -0
- techtree/worker/__init__.py +18 -0
- techtree/worker/execute.py +487 -0
- techtree/worker/main.py +57 -0
- techtree-0.1.0.dist-info/METADATA +344 -0
- techtree-0.1.0.dist-info/RECORD +174 -0
- techtree-0.1.0.dist-info/WHEEL +4 -0
- techtree-0.1.0.dist-info/entry_points.txt +3 -0
- techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
techtree/fs.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""Safe filesystem primitives. Spec section 10.9.
|
|
2
|
+
|
|
3
|
+
Two properties matter here and both are about what a reader can observe.
|
|
4
|
+
|
|
5
|
+
*Atomicity.* A reader must never see a half-written file. Writes go to a
|
|
6
|
+
temporary file in the same directory, are flushed and fsynced, and then replace
|
|
7
|
+
the target with ``os.replace``, which is atomic within a filesystem. The
|
|
8
|
+
containing directory is fsynced afterwards so the rename itself survives a
|
|
9
|
+
crash.
|
|
10
|
+
|
|
11
|
+
*Immutability.* Campaign, Climb, DataPolicy, manifest, lock, and receipt
|
|
12
|
+
artifacts are written exactly once. :func:`open_exclusive` creates them with
|
|
13
|
+
``O_EXCL``, so a second attempt to write one is reported as a conflict instead
|
|
14
|
+
of quietly replacing evidence.
|
|
15
|
+
|
|
16
|
+
*Privacy.* Techtree state is single-user: files are ``0600`` and directories
|
|
17
|
+
``0700``. Both helpers that create a directory create *every* directory they
|
|
18
|
+
need at that mode, because a private leaf at the end of a world-readable path
|
|
19
|
+
is not private — a run directory nobody can list is still a run directory
|
|
20
|
+
whose parent tells you it exists.
|
|
21
|
+
|
|
22
|
+
Nothing here follows symlinks into a location the caller did not name.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import shutil
|
|
30
|
+
import tempfile
|
|
31
|
+
from contextlib import suppress
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import BinaryIO, cast
|
|
34
|
+
|
|
35
|
+
from techtree.canonical import to_json_value
|
|
36
|
+
from techtree.errors import ConflictError, NotFoundError, ValidationError
|
|
37
|
+
from techtree.models.base import JsonValue
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
"atomic_write_bytes",
|
|
41
|
+
"atomic_write_json",
|
|
42
|
+
"atomic_write_text",
|
|
43
|
+
"ensure_private_directory",
|
|
44
|
+
"fsync_directory",
|
|
45
|
+
"open_exclusive",
|
|
46
|
+
"read_json",
|
|
47
|
+
"realpath_within",
|
|
48
|
+
"remove_tree",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
#: Owner read and write only. Techtree state is single-user by design.
|
|
52
|
+
_FILE_MODE = 0o600
|
|
53
|
+
#: Owner traversal only, so a run directory is not world-readable.
|
|
54
|
+
_DIRECTORY_MODE = 0o700
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _make_private_directory(path: Path) -> None:
|
|
58
|
+
"""Create a directory and every missing parent, each one private.
|
|
59
|
+
|
|
60
|
+
``mkdir(parents=True)`` creates the intermediate directories under the
|
|
61
|
+
process umask and leaves them there, so hardening only the leaf hardens
|
|
62
|
+
the room and not the corridor. Inside a Techtree home every ancestor
|
|
63
|
+
happens to be ``0700`` already, which made this a latent defect rather
|
|
64
|
+
than a live leak — and the distance between those two is one caller
|
|
65
|
+
passing a path whose parent does not exist yet.
|
|
66
|
+
|
|
67
|
+
The mode is applied at creation rather than after it, so there is no
|
|
68
|
+
instant in which the directory exists and anyone else can read it. A
|
|
69
|
+
umask can only take bits away from that mode, never add them, and the
|
|
70
|
+
``chmod`` afterwards makes the result exact on a machine whose umask is
|
|
71
|
+
unusual.
|
|
72
|
+
|
|
73
|
+
Only directories this call creates are touched. A directory that was
|
|
74
|
+
already there belongs to whoever made it, and quietly tightening
|
|
75
|
+
somebody's home directory to ``0700`` would be a worse bug than the one
|
|
76
|
+
being fixed here.
|
|
77
|
+
"""
|
|
78
|
+
missing: list[Path] = []
|
|
79
|
+
current = path
|
|
80
|
+
while not current.is_dir():
|
|
81
|
+
missing.append(current)
|
|
82
|
+
if current.parent == current:
|
|
83
|
+
break
|
|
84
|
+
current = current.parent
|
|
85
|
+
|
|
86
|
+
for directory in reversed(missing):
|
|
87
|
+
try:
|
|
88
|
+
directory.mkdir(mode=_DIRECTORY_MODE)
|
|
89
|
+
except FileExistsError:
|
|
90
|
+
if directory.is_dir():
|
|
91
|
+
# Another process created it between the walk and here. It is
|
|
92
|
+
# a Techtree directory either way, made by this same code.
|
|
93
|
+
continue
|
|
94
|
+
# The path exists and is not a directory, which is what the
|
|
95
|
+
# caller needs to hear about.
|
|
96
|
+
raise
|
|
97
|
+
# Windows and some network filesystems have no POSIX mode bits. The
|
|
98
|
+
# directory still exists, which is what the caller asked for.
|
|
99
|
+
with suppress(NotImplementedError, OSError):
|
|
100
|
+
os.chmod(directory, _DIRECTORY_MODE)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def atomic_write_bytes(path: Path, data: bytes, *, mode: int = _FILE_MODE) -> None:
|
|
104
|
+
"""Write, fsync, chmod, and atomically replace."""
|
|
105
|
+
directory = path.parent
|
|
106
|
+
_make_private_directory(directory)
|
|
107
|
+
|
|
108
|
+
handle, temporary_name = tempfile.mkstemp(
|
|
109
|
+
dir=directory, prefix=f".{path.name}.", suffix=".tmp"
|
|
110
|
+
)
|
|
111
|
+
temporary = Path(temporary_name)
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(handle, "wb") as stream:
|
|
114
|
+
stream.write(data)
|
|
115
|
+
stream.flush()
|
|
116
|
+
os.fsync(stream.fileno())
|
|
117
|
+
os.chmod(temporary, mode)
|
|
118
|
+
os.replace(temporary, path)
|
|
119
|
+
except BaseException:
|
|
120
|
+
temporary.unlink(missing_ok=True)
|
|
121
|
+
raise
|
|
122
|
+
fsync_directory(directory)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def atomic_write_text(path: Path, text: str, *, mode: int = _FILE_MODE) -> None:
|
|
126
|
+
"""UTF-8 wrapper."""
|
|
127
|
+
atomic_write_bytes(path, text.encode("utf-8"), mode=mode)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def atomic_write_json(path: Path, value: object, *, mode: int = _FILE_MODE) -> None:
|
|
131
|
+
"""Write readable JSON atomically.
|
|
132
|
+
|
|
133
|
+
This is the human-readable spelling, indented and newline-terminated. It is
|
|
134
|
+
not the canonical spelling; anything that is going to be hashed is
|
|
135
|
+
serialized with :func:`techtree.canonical.canonical_json_bytes`.
|
|
136
|
+
"""
|
|
137
|
+
rendered = json.dumps(
|
|
138
|
+
to_json_value(value),
|
|
139
|
+
indent=2,
|
|
140
|
+
sort_keys=True,
|
|
141
|
+
ensure_ascii=False,
|
|
142
|
+
)
|
|
143
|
+
atomic_write_text(path, f"{rendered}\n", mode=mode)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def read_json(path: Path) -> JsonValue:
|
|
147
|
+
"""Read UTF-8 JSON and raise typed malformed-data errors."""
|
|
148
|
+
try:
|
|
149
|
+
raw = path.read_bytes()
|
|
150
|
+
except FileNotFoundError as error:
|
|
151
|
+
raise NotFoundError(
|
|
152
|
+
f"no such file: {path}",
|
|
153
|
+
details={"path": str(path)},
|
|
154
|
+
) from error
|
|
155
|
+
|
|
156
|
+
try:
|
|
157
|
+
text = raw.decode("utf-8")
|
|
158
|
+
except UnicodeDecodeError as error:
|
|
159
|
+
raise ValidationError(
|
|
160
|
+
f"file is not valid UTF-8: {path}",
|
|
161
|
+
details={"path": str(path)},
|
|
162
|
+
) from error
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
return cast(JsonValue, json.loads(text))
|
|
166
|
+
except json.JSONDecodeError as error:
|
|
167
|
+
raise ValidationError(
|
|
168
|
+
f"file is not valid JSON: {path} ({error.msg} at line {error.lineno})",
|
|
169
|
+
details={"path": str(path), "line": error.lineno},
|
|
170
|
+
) from error
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def ensure_private_directory(path: Path) -> None:
|
|
174
|
+
"""Create a directory, and every parent it needs, 0700 where supported."""
|
|
175
|
+
if path.is_symlink():
|
|
176
|
+
raise ValidationError(
|
|
177
|
+
f"refusing to use a symlinked directory: {path}",
|
|
178
|
+
details={"path": str(path)},
|
|
179
|
+
)
|
|
180
|
+
_make_private_directory(path)
|
|
181
|
+
# An existing directory is re-hardened, because this function's promise to
|
|
182
|
+
# its caller is about the directory it returns, not only about the case
|
|
183
|
+
# where it had to make one.
|
|
184
|
+
with suppress(NotImplementedError, OSError):
|
|
185
|
+
os.chmod(path, _DIRECTORY_MODE)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def fsync_directory(path: Path) -> None:
|
|
189
|
+
"""Best-effort directory fsync."""
|
|
190
|
+
try:
|
|
191
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
192
|
+
except OSError:
|
|
193
|
+
return
|
|
194
|
+
try:
|
|
195
|
+
os.fsync(descriptor)
|
|
196
|
+
except OSError:
|
|
197
|
+
# Directory fsync is unsupported on some platforms and filesystems.
|
|
198
|
+
pass
|
|
199
|
+
finally:
|
|
200
|
+
os.close(descriptor)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def remove_tree(path: Path) -> None:
|
|
204
|
+
"""Remove run-owned data without following links."""
|
|
205
|
+
if path.is_symlink():
|
|
206
|
+
# Unlink the link itself; never descend into whatever it points at.
|
|
207
|
+
path.unlink()
|
|
208
|
+
return
|
|
209
|
+
if not path.exists():
|
|
210
|
+
return
|
|
211
|
+
if path.is_dir():
|
|
212
|
+
shutil.rmtree(path)
|
|
213
|
+
return
|
|
214
|
+
path.unlink()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def realpath_within(path: Path, root: Path) -> bool:
|
|
218
|
+
"""Return whether a resolved path is contained in root."""
|
|
219
|
+
return path.resolve().is_relative_to(root.resolve())
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def open_exclusive(path: Path, mode: int = _FILE_MODE) -> BinaryIO:
|
|
223
|
+
"""Create an immutable file with O_EXCL semantics."""
|
|
224
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
225
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
226
|
+
flags |= os.O_NOFOLLOW
|
|
227
|
+
try:
|
|
228
|
+
descriptor = os.open(path, flags, mode)
|
|
229
|
+
except FileExistsError as error:
|
|
230
|
+
raise ConflictError(
|
|
231
|
+
f"refusing to overwrite an immutable artifact: {path}",
|
|
232
|
+
details={"path": str(path)},
|
|
233
|
+
) from error
|
|
234
|
+
return cast(BinaryIO, os.fdopen(descriptor, "wb"))
|
techtree/harness.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""The tool surface a pinned harness offers. Decisions 0007 R9 item 4.
|
|
2
|
+
|
|
3
|
+
Mounting a Skill changes what the subject is offered. Hermes 0.19.0 renders the
|
|
4
|
+
index of visible Skills into the description of its own Skill-management tool,
|
|
5
|
+
so the candidate variant is handed one tool description the baseline was not,
|
|
6
|
+
and a comparison that demanded one identical tool surface would reject every
|
|
7
|
+
correct run.
|
|
8
|
+
|
|
9
|
+
That leaves a question a comparison cannot answer from the run alone: is a
|
|
10
|
+
description difference *the* expected one, or is it evidence that the harness
|
|
11
|
+
pin moved underneath the Campaign? Both look the same from inside a single
|
|
12
|
+
comparison — one description differing on one tool.
|
|
13
|
+
|
|
14
|
+
This module holds the other half of the answer: a conformance fixture recording
|
|
15
|
+
the tool surface the pinned harness offers, tool by tool, schema by schema. The
|
|
16
|
+
ratified rule (decisions document 0007, release ratifications) is an exact
|
|
17
|
+
derived difference — the tool count, the tool names, the parameter schemas and
|
|
18
|
+
the capabilities are unchanged, and only the known skill-aware description field
|
|
19
|
+
differs. Everything in that sentence except the description is pinned here, so a
|
|
20
|
+
future harness that adds a tool, drops one, renames one or reshapes a schema
|
|
21
|
+
fails the comparison instead of being absorbed into "the Skill index changed".
|
|
22
|
+
|
|
23
|
+
Descriptions are deliberately *not* pinned. A description holds the names of the
|
|
24
|
+
Skills currently mounted, so its digest is a property of the Campaign's Skill
|
|
25
|
+
rather than of the harness, and pinning it would make the fixture wrong the
|
|
26
|
+
first time a different Skill was measured.
|
|
27
|
+
|
|
28
|
+
The fixture is recorded from real evaluations, never authored. Each file says
|
|
29
|
+
which ones in its own ``recorded_from`` field.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from functools import cache
|
|
36
|
+
from importlib import resources
|
|
37
|
+
from typing import Final
|
|
38
|
+
|
|
39
|
+
from techtree.models.base import Digest, NonEmptyString, ProtocolModel
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"HARNESS_CONFORMANCE_SCHEMA_VERSION",
|
|
43
|
+
"ConformanceTool",
|
|
44
|
+
"HarnessConformance",
|
|
45
|
+
"harness_conformance",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
#: The schema every conformance fixture declares.
|
|
49
|
+
HARNESS_CONFORMANCE_SCHEMA_VERSION: Final = "techtree.harness-conformance.v1alpha1"
|
|
50
|
+
|
|
51
|
+
_RESOURCE_DIRECTORY: Final = "harness"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ConformanceTool(ProtocolModel):
|
|
55
|
+
"""One tool the pinned harness offers, by name and parameter schema."""
|
|
56
|
+
|
|
57
|
+
name: NonEmptyString
|
|
58
|
+
parameters_digest: Digest
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class HarnessConformance(ProtocolModel):
|
|
62
|
+
"""The tool surface one pinned harness build offers."""
|
|
63
|
+
|
|
64
|
+
schema_version: NonEmptyString
|
|
65
|
+
harness_id: NonEmptyString
|
|
66
|
+
harness_version: NonEmptyString
|
|
67
|
+
skill_index_tool: NonEmptyString
|
|
68
|
+
recorded_from: NonEmptyString
|
|
69
|
+
tools: list[ConformanceTool]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def tool_names(self) -> list[str]:
|
|
73
|
+
"""Every tool name, in the order the fixture records them."""
|
|
74
|
+
return [tool.name for tool in self.tools]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def parameters_by_tool(self) -> dict[str, Digest]:
|
|
78
|
+
"""Each tool's parameter-schema digest, by tool name."""
|
|
79
|
+
return {tool.name: tool.parameters_digest for tool in self.tools}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@cache
|
|
83
|
+
def harness_conformance(harness_id: str, harness_version: str) -> HarnessConformance:
|
|
84
|
+
"""Load the conformance fixture for one pinned harness build.
|
|
85
|
+
|
|
86
|
+
Raises :class:`FileNotFoundError` when no fixture was ever recorded for that
|
|
87
|
+
build, which is the correct outcome: a harness nobody measured has no
|
|
88
|
+
expected tool surface, and inventing one would be the opposite of a
|
|
89
|
+
conformance fixture.
|
|
90
|
+
"""
|
|
91
|
+
resource = (
|
|
92
|
+
resources.files("techtree")
|
|
93
|
+
/ "resources"
|
|
94
|
+
/ _RESOURCE_DIRECTORY
|
|
95
|
+
/ f"{harness_id}-{harness_version}.json"
|
|
96
|
+
)
|
|
97
|
+
if not resource.is_file():
|
|
98
|
+
raise FileNotFoundError(
|
|
99
|
+
f"no tool surface was recorded for {harness_id} {harness_version}"
|
|
100
|
+
)
|
|
101
|
+
document = json.loads(resource.read_text(encoding="utf-8"))
|
|
102
|
+
conformance = HarnessConformance.model_validate(document)
|
|
103
|
+
if conformance.schema_version != HARNESS_CONFORMANCE_SCHEMA_VERSION:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"{resource} declares {conformance.schema_version}, not "
|
|
106
|
+
f"{HARNESS_CONFORMANCE_SCHEMA_VERSION}"
|
|
107
|
+
)
|
|
108
|
+
return conformance
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The one local key that binds a participant to their own receipts.
|
|
2
|
+
|
|
3
|
+
Spec section 7.5. Decisions document 0005, amendment 4.
|
|
4
|
+
|
|
5
|
+
WP0 built the Ed25519 primitives and froze them shut: :mod:`techtree.crypto`
|
|
6
|
+
knows how to make a key and check a signature, and nothing in the package knew
|
|
7
|
+
where a key lived or when to use one. This package is the activation, and it is
|
|
8
|
+
deliberately the *whole* of it — every other module asks this one to sign, so
|
|
9
|
+
there is exactly one place that touches private material.
|
|
10
|
+
|
|
11
|
+
Three rules hold everywhere below.
|
|
12
|
+
|
|
13
|
+
*The private half never leaves the identities directory.* It is written once,
|
|
14
|
+
owner-readable only, and loaded into memory to sign a digest. It is never
|
|
15
|
+
serialized into a document, never logged, never carried in a typed error's
|
|
16
|
+
details, and never written into a proof bundle. Only the public half travels.
|
|
17
|
+
|
|
18
|
+
*The key is self-issued, and the vocabulary says so.* Nothing registers it,
|
|
19
|
+
nothing uploads it, and no authority vouches for it. What a signature over
|
|
20
|
+
these receipts proves is that the participant's own key vouches for bytes that
|
|
21
|
+
verify against each other — which is what ``proof_grade: P1`` is permitted to
|
|
22
|
+
mean and nothing more.
|
|
23
|
+
|
|
24
|
+
*What is signed is the digest string.* :func:`techtree.crypto.sign_digest`
|
|
25
|
+
signs the ASCII ``sha256:...`` spelling of the canonical bytes, so a verifier
|
|
26
|
+
holding the digest can check the signature, and the thing attested to is
|
|
27
|
+
exactly the value that appears in the protocol document.
|
|
28
|
+
|
|
29
|
+
The division of labour:
|
|
30
|
+
|
|
31
|
+
``models``
|
|
32
|
+
The public identity, and the shape a verification verdict is reported in.
|
|
33
|
+
``store``
|
|
34
|
+
Where the key material lives on disk, and how it is created and loaded.
|
|
35
|
+
``service``
|
|
36
|
+
Signing objects into envelopes, and verifying envelopes against a key.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""The local identity, and the shape of a verification verdict.
|
|
2
|
+
|
|
3
|
+
Spec sections 7.5 and 7.12.
|
|
4
|
+
|
|
5
|
+
Two vocabularies live here because they are used together everywhere and
|
|
6
|
+
neither one belongs to the frozen v0.1 protocol.
|
|
7
|
+
|
|
8
|
+
:class:`ExecutorIdentity` is the public half of the participant's key, written
|
|
9
|
+
into the identities directory and copied into every proof bundle. It is a
|
|
10
|
+
:class:`~techtree.models.base.ProtocolModel` so that it is frozen, canonically
|
|
11
|
+
serializable, and digestible like everything else a bundle commits to. The
|
|
12
|
+
private half has no model at all: it is raw bytes on disk and an in-memory key
|
|
13
|
+
object, and giving it a document shape would be the first step towards it
|
|
14
|
+
appearing in one.
|
|
15
|
+
|
|
16
|
+
:class:`VerificationResult` is how every check in this subsystem reports. A
|
|
17
|
+
verifier that raised on the first broken thing would tell a reader which rule
|
|
18
|
+
failed first rather than what is wrong with what they are holding, so checks
|
|
19
|
+
are collected: each one named, each one with its own stable code, and the
|
|
20
|
+
verdict computed from them rather than asserted alongside them.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Final, Literal, Self
|
|
26
|
+
|
|
27
|
+
from pydantic import model_validator
|
|
28
|
+
|
|
29
|
+
from techtree.models.base import (
|
|
30
|
+
Base64String,
|
|
31
|
+
NonEmptyString,
|
|
32
|
+
ProtocolModel,
|
|
33
|
+
UtcDateTime,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"LOCAL_IDENTITY_INVALID",
|
|
38
|
+
"SIGNATURE_VERIFICATION_FAILED",
|
|
39
|
+
"ExecutorIdentity",
|
|
40
|
+
"VerificationMessage",
|
|
41
|
+
"VerificationResult",
|
|
42
|
+
"VerificationStatus",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
type VerificationStatus = Literal["passed", "failed", "warning"]
|
|
46
|
+
"""What one check found. ``warning`` is a claim that holds more weakly than it
|
|
47
|
+
wanted to, never a failure that someone decided to be relaxed about."""
|
|
48
|
+
|
|
49
|
+
#: Stable error codes. Spec section 15.
|
|
50
|
+
LOCAL_IDENTITY_INVALID: Final = "local_identity_invalid"
|
|
51
|
+
SIGNATURE_VERIFICATION_FAILED: Final = "signature_verification_failed"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ExecutorIdentity(ProtocolModel):
|
|
55
|
+
"""The public half of one participant-controlled local signing key.
|
|
56
|
+
|
|
57
|
+
``created_at`` is operational identity metadata: it says when this machine
|
|
58
|
+
made a key, and it is deliberately not part of any scientific comparison.
|
|
59
|
+
Two participants running the same Campaign produce the same measurements
|
|
60
|
+
and different identities.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
kind: Literal["local_ed25519"]
|
|
64
|
+
key_id: NonEmptyString
|
|
65
|
+
algorithm: Literal["ed25519"]
|
|
66
|
+
public_key: Base64String
|
|
67
|
+
created_at: UtcDateTime
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class VerificationMessage(ProtocolModel):
|
|
71
|
+
"""One named check and what it found.
|
|
72
|
+
|
|
73
|
+
``code`` is the spec section 15 error code a failure of this check reports
|
|
74
|
+
under, so a machine reading a failed verification gets the same vocabulary
|
|
75
|
+
whether it read the envelope or the exception.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
id: NonEmptyString
|
|
79
|
+
status: VerificationStatus
|
|
80
|
+
code: NonEmptyString
|
|
81
|
+
detail: NonEmptyString
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class VerificationResult(ProtocolModel):
|
|
85
|
+
"""Every check a verification ran, and whether the thing verified."""
|
|
86
|
+
|
|
87
|
+
verified: bool
|
|
88
|
+
messages: list[VerificationMessage]
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def failures(self) -> list[VerificationMessage]:
|
|
92
|
+
"""Return every check that failed."""
|
|
93
|
+
return [message for message in self.messages if message.status == "failed"]
|
|
94
|
+
|
|
95
|
+
@property
|
|
96
|
+
def warnings(self) -> list[VerificationMessage]:
|
|
97
|
+
"""Return every check that passed with a weaker claim than it wanted."""
|
|
98
|
+
return [message for message in self.messages if message.status == "warning"]
|
|
99
|
+
|
|
100
|
+
@model_validator(mode="after")
|
|
101
|
+
def _check_the_verdict_is_the_one_the_checks_support(self) -> Self:
|
|
102
|
+
"""Reject a result whose verdict disagrees with its own checks."""
|
|
103
|
+
if self.verified and self.failures:
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"a verification that reports success cannot carry a failed check"
|
|
106
|
+
)
|
|
107
|
+
if not self.verified and not self.failures:
|
|
108
|
+
raise ValueError(
|
|
109
|
+
"a verification that reports failure must name the check that failed"
|
|
110
|
+
)
|
|
111
|
+
if not self.messages:
|
|
112
|
+
raise ValueError("a verification result records the checks it ran")
|
|
113
|
+
return self
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Signing objects, and checking signed ones. Spec sections 7.5 and 7.12.
|
|
2
|
+
|
|
3
|
+
Everything that gets signed in Techtree goes through :meth:`IdentityService.
|
|
4
|
+
sign_object`, and everything that gets checked goes through
|
|
5
|
+
:func:`verify_signed_object`. Keeping both on one page is what makes the two
|
|
6
|
+
halves impossible to drift apart: the signature is made over the digest of the
|
|
7
|
+
payload's canonical bytes, and the check recomputes that digest from the
|
|
8
|
+
payload it was handed rather than trusting the one the envelope records.
|
|
9
|
+
|
|
10
|
+
That recomputation is the entire point of the envelope. A stored envelope
|
|
11
|
+
carries ``payload_digest`` and never recalculates it during validation, so an
|
|
12
|
+
edited payload keeps its old digest and looks fine to a parser. Verification is
|
|
13
|
+
where the two are compared, and it is why a receipt that was changed after it
|
|
14
|
+
was written cannot pass.
|
|
15
|
+
|
|
16
|
+
Verification takes the identity it should check against as an argument rather
|
|
17
|
+
than reading the local store. A proof bundle carries the public key that signed
|
|
18
|
+
it, and a bundle is verified against *that* key — offline, on another machine,
|
|
19
|
+
with no Techtree identity of its own. Checking a stranger's bundle against this
|
|
20
|
+
machine's key would fail every bundle for the wrong reason.
|
|
21
|
+
|
|
22
|
+
What a verified signature proves is bounded and worth stating plainly: these
|
|
23
|
+
bytes have not changed since the holder of that private key signed them. The
|
|
24
|
+
key is self-issued and local, so nothing here says who ran the evaluation, and
|
|
25
|
+
nothing here is a third party's word for anything.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from base64 import b64decode
|
|
31
|
+
from typing import Final
|
|
32
|
+
|
|
33
|
+
from pydantic import BaseModel
|
|
34
|
+
|
|
35
|
+
from techtree.canonical import digest_object
|
|
36
|
+
from techtree.crypto import load_public_key, sign_digest, verify_signature
|
|
37
|
+
from techtree.errors import ValidationError
|
|
38
|
+
from techtree.identity.models import (
|
|
39
|
+
LOCAL_IDENTITY_INVALID,
|
|
40
|
+
SIGNATURE_VERIFICATION_FAILED,
|
|
41
|
+
ExecutorIdentity,
|
|
42
|
+
VerificationMessage,
|
|
43
|
+
VerificationResult,
|
|
44
|
+
)
|
|
45
|
+
from techtree.identity.store import IdentityStore
|
|
46
|
+
from techtree.models.base import ObjectEnvelope
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"IdentityService",
|
|
50
|
+
"verify_signed_object",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
_PASSED: Final = "passed"
|
|
54
|
+
_FAILED: Final = "failed"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class IdentityService:
|
|
58
|
+
"""The local identity, ready to sign and to check its own signatures."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, store: IdentityStore) -> None:
|
|
61
|
+
self._store = store
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def store(self) -> IdentityStore:
|
|
65
|
+
"""Return the store this service signs with."""
|
|
66
|
+
return self._store
|
|
67
|
+
|
|
68
|
+
def ensure(self) -> ExecutorIdentity:
|
|
69
|
+
"""Return the existing valid identity, creating one if there is none.
|
|
70
|
+
|
|
71
|
+
Creation happens when a person has asked for something that needs a
|
|
72
|
+
key — ``techtree setup``, or a run they started reaching the point
|
|
73
|
+
where its receipts are sealed. Nothing creates a key at import time and
|
|
74
|
+
nothing creates one on a machine that is only being inspected.
|
|
75
|
+
|
|
76
|
+
An identity whose two halves no longer describe each other is not
|
|
77
|
+
silently replaced. The old key signed real receipts; replacing it would
|
|
78
|
+
make those signatures unverifiable without saying so.
|
|
79
|
+
"""
|
|
80
|
+
if not self._store.exists():
|
|
81
|
+
return self._store.create()
|
|
82
|
+
|
|
83
|
+
identity = self._store.load_public()
|
|
84
|
+
if self._store.verify_pair():
|
|
85
|
+
return identity
|
|
86
|
+
raise ValidationError(
|
|
87
|
+
"the two halves of this machine's local signing identity do not "
|
|
88
|
+
"describe the same key, so nothing may be signed with it",
|
|
89
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
90
|
+
details={"key_id": identity.key_id},
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def sign_object[T: BaseModel](self, value: T) -> ObjectEnvelope[T]:
|
|
94
|
+
"""Canonicalize, digest, and sign one protocol object."""
|
|
95
|
+
identity = self._store.load_public()
|
|
96
|
+
digest = digest_object(value)
|
|
97
|
+
signature = sign_digest(
|
|
98
|
+
self._store.load_private(), digest, key_id=identity.key_id
|
|
99
|
+
)
|
|
100
|
+
return ObjectEnvelope[T](
|
|
101
|
+
payload=value, payload_digest=digest, signature=signature
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def verify_envelope[T: BaseModel](
|
|
105
|
+
self, envelope: ObjectEnvelope[T]
|
|
106
|
+
) -> VerificationResult:
|
|
107
|
+
"""Verify one envelope against this machine's own public identity."""
|
|
108
|
+
return verify_signed_object(
|
|
109
|
+
identity=self._store.load_public(), envelope=envelope
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def verify_signed_object[T: BaseModel](
|
|
114
|
+
*,
|
|
115
|
+
identity: ExecutorIdentity,
|
|
116
|
+
envelope: ObjectEnvelope[T],
|
|
117
|
+
subject: str = "object",
|
|
118
|
+
) -> VerificationResult:
|
|
119
|
+
"""Verify one envelope's digest and signature against a public identity.
|
|
120
|
+
|
|
121
|
+
``subject`` names what is being checked — a bundle path, usually — so that
|
|
122
|
+
a reader of a failed bundle verification learns which of forty receipts is
|
|
123
|
+
the problem rather than that "a signature" failed.
|
|
124
|
+
"""
|
|
125
|
+
messages: list[VerificationMessage] = []
|
|
126
|
+
|
|
127
|
+
computed = digest_object(envelope.payload)
|
|
128
|
+
digest_matches = computed == envelope.payload_digest
|
|
129
|
+
messages.append(
|
|
130
|
+
VerificationMessage(
|
|
131
|
+
id=f"{subject}.payload_digest",
|
|
132
|
+
status=_PASSED if digest_matches else _FAILED,
|
|
133
|
+
code=SIGNATURE_VERIFICATION_FAILED,
|
|
134
|
+
detail=(
|
|
135
|
+
"the payload still matches the digest it was signed under"
|
|
136
|
+
if digest_matches
|
|
137
|
+
else (
|
|
138
|
+
"the payload no longer matches the digest it was signed "
|
|
139
|
+
f"under: sealed {envelope.payload_digest}, computed {computed}"
|
|
140
|
+
)
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
signature = envelope.signature
|
|
146
|
+
if signature is None:
|
|
147
|
+
messages.append(
|
|
148
|
+
VerificationMessage(
|
|
149
|
+
id=f"{subject}.signature_present",
|
|
150
|
+
status=_FAILED,
|
|
151
|
+
code=SIGNATURE_VERIFICATION_FAILED,
|
|
152
|
+
detail="this object carries no signature, so nothing vouches for it",
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return VerificationResult(verified=False, messages=messages)
|
|
156
|
+
|
|
157
|
+
named_key = signature.key_id == identity.key_id
|
|
158
|
+
messages.append(
|
|
159
|
+
VerificationMessage(
|
|
160
|
+
id=f"{subject}.signature_key",
|
|
161
|
+
status=_PASSED if named_key else _FAILED,
|
|
162
|
+
code=LOCAL_IDENTITY_INVALID,
|
|
163
|
+
detail=(
|
|
164
|
+
f"signed by key {identity.key_id}"
|
|
165
|
+
if named_key
|
|
166
|
+
else (
|
|
167
|
+
f"signed by key {signature.key_id}, which is not the key "
|
|
168
|
+
f"{identity.key_id} this proof carries"
|
|
169
|
+
)
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
verified = named_key and digest_matches and _signature_verifies(identity, envelope)
|
|
175
|
+
messages.append(
|
|
176
|
+
VerificationMessage(
|
|
177
|
+
id=f"{subject}.signature",
|
|
178
|
+
status=_PASSED if verified else _FAILED,
|
|
179
|
+
code=SIGNATURE_VERIFICATION_FAILED,
|
|
180
|
+
detail=(
|
|
181
|
+
"the signature verifies against the public key carried with it"
|
|
182
|
+
if verified
|
|
183
|
+
else "the signature does not verify against the public key "
|
|
184
|
+
"carried with it"
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
return VerificationResult(verified=verified, messages=messages)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _signature_verifies[T: BaseModel](
|
|
192
|
+
identity: ExecutorIdentity, envelope: ObjectEnvelope[T]
|
|
193
|
+
) -> bool:
|
|
194
|
+
"""Check the detached signature against the identity's public key."""
|
|
195
|
+
signature = envelope.signature
|
|
196
|
+
if signature is None:
|
|
197
|
+
return False
|
|
198
|
+
public_key = load_public_key(b64decode(identity.public_key, validate=True))
|
|
199
|
+
return verify_signature(public_key, envelope.payload_digest, signature)
|