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.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
techtree/settings.py ADDED
@@ -0,0 +1,143 @@
1
+ """Local Techtree settings. Spec section 10.11.
2
+
3
+ Settings are user preferences plus one pointer: which managed engine is active.
4
+ They live in ``config.toml`` under the Techtree root and are read with the
5
+ standard library, written atomically, and then overlaid with any supported
6
+ ``TECHTREE_*`` environment variable.
7
+
8
+ No provider secret belongs in this model. A subject's credential is named by
9
+ an environment variable in the Campaign, and the value is read from the
10
+ environment at execution time; it is never copied into settings, into a run
11
+ directory, or into any protocol document.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import tomllib
18
+ from typing import Final, Literal
19
+
20
+ import tomli_w
21
+ from pydantic import ValidationError as PydanticValidationError
22
+
23
+ from techtree.errors import ValidationError
24
+ from techtree.fs import atomic_write_text
25
+ from techtree.models.base import Digest, StateModel
26
+ from techtree.models.cli import NextAction
27
+ from techtree.paths import TechtreePaths
28
+
29
+ __all__ = [
30
+ "ENVIRONMENT_OVERRIDES",
31
+ "Settings",
32
+ "load_settings",
33
+ "resolved_settings",
34
+ "save_settings",
35
+ "settings_from_environment",
36
+ ]
37
+
38
+ OutputMode = Literal["human", "json"]
39
+
40
+
41
+ class Settings(StateModel):
42
+ """Local preferences and the active engine pointer."""
43
+
44
+ active_engine_digest: Digest | None = None
45
+ log_level: str = "INFO"
46
+ output_mode: OutputMode = "human"
47
+ #: A development override for where ``techtree publish`` and ``techtree
48
+ #: withdraw`` send a request. Decisions 0038's founder ruling of 2026-08-27
49
+ #: made the real address a release coordinate, pinned in the ReleaseCore the
50
+ #: wheel carries, so that a stable release publishes with nothing
51
+ #: configured. This stays for the case it is actually for: pointing the CLI
52
+ #: at a throwaway local instance while the run log is being built. It moves
53
+ #: the address and nothing else — the network key a receipt is checked
54
+ #: against is never overridable, because a key a person can point elsewhere
55
+ #: is the removal of the only thing that makes a receipt mean anything.
56
+ publication_endpoint: str | None = None
57
+
58
+
59
+ #: Environment variable to settings field. Only these four are supported; an
60
+ #: unrecognized ``TECHTREE_*`` variable is ignored rather than guessed at.
61
+ ENVIRONMENT_OVERRIDES: Final[dict[str, str]] = {
62
+ "TECHTREE_ACTIVE_ENGINE_DIGEST": "active_engine_digest",
63
+ "TECHTREE_LOG_LEVEL": "log_level",
64
+ "TECHTREE_OUTPUT_MODE": "output_mode",
65
+ "TECHTREE_PUBLICATION_ENDPOINT": "publication_endpoint",
66
+ }
67
+
68
+
69
+ def load_settings(paths: TechtreePaths) -> Settings:
70
+ """Load TOML or return defaults."""
71
+ try:
72
+ raw = paths.config_file.read_bytes()
73
+ except FileNotFoundError:
74
+ return Settings()
75
+
76
+ try:
77
+ document = tomllib.loads(raw.decode("utf-8"))
78
+ except (UnicodeDecodeError, tomllib.TOMLDecodeError) as error:
79
+ raise ValidationError(
80
+ f"settings file is not valid TOML: {paths.config_file}",
81
+ details={"path": str(paths.config_file)},
82
+ next_actions=[_repair_settings_action(str(paths.config_file))],
83
+ ) from error
84
+
85
+ return _validate(document, source=str(paths.config_file))
86
+
87
+
88
+ def save_settings(paths: TechtreePaths, settings: Settings) -> None:
89
+ """Atomically write TOML."""
90
+ document = {
91
+ name: value
92
+ for name, value in settings.model_dump().items()
93
+ if value is not None
94
+ }
95
+ atomic_write_text(paths.config_file, tomli_w.dumps(document))
96
+
97
+
98
+ def settings_from_environment(base: Settings) -> Settings:
99
+ """Apply supported ``TECHTREE_*`` environment overrides."""
100
+ document = base.model_dump()
101
+ applied = False
102
+ for variable, field in ENVIRONMENT_OVERRIDES.items():
103
+ value = os.environ.get(variable)
104
+ if value is None:
105
+ continue
106
+ document[field] = value
107
+ applied = True
108
+
109
+ if not applied:
110
+ return base
111
+ return _validate(document, source="environment")
112
+
113
+
114
+ def resolved_settings(paths: TechtreePaths) -> Settings:
115
+ """Load file settings and apply environment values."""
116
+ return settings_from_environment(load_settings(paths))
117
+
118
+
119
+ def _validate(document: object, *, source: str) -> Settings:
120
+ try:
121
+ return Settings.model_validate(document)
122
+ except PydanticValidationError as error:
123
+ raise ValidationError(
124
+ f"invalid Techtree settings from {source}: {error.errors()[0]['msg']}",
125
+ details={"source": source},
126
+ next_actions=[_repair_settings_action(source)],
127
+ ) from error
128
+
129
+
130
+ def _repair_settings_action(source: str) -> NextAction:
131
+ """Point at the file to fix; deleting it restores working defaults."""
132
+ return NextAction(
133
+ id="repair_settings",
134
+ label="Fix or remove the settings file",
135
+ reason=(
136
+ f"the settings file at {source} cannot be read; deleting it "
137
+ "restores the defaults and techtree recreates it on demand"
138
+ ),
139
+ cli=["rm", source],
140
+ hermes_tool=None,
141
+ hermes_args=None,
142
+ requires_user_confirmation=True,
143
+ )
@@ -0,0 +1,14 @@
1
+ """Candidate skill validation, scanning, and snapshot archiving.
2
+
3
+ Spec section 15.
4
+
5
+ A source skill is never evaluated where the participant wrote it. It is
6
+ validated against a policy, scanned for secrets, and copied into an immutable
7
+ snapshot whose archive bytes are fully determined by the file contents. This
8
+ package holds the three pieces that make that possible: the policy, the
9
+ scanner, and the deterministic archive.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ __all__: list[str] = []
@@ -0,0 +1,282 @@
1
+ """The deterministic snapshot archive. Spec section 15.3.
2
+
3
+ A skill archive is content-addressed, so two people who submit the same files
4
+ must produce the same bytes. Ordinary tar does not do that: it records the
5
+ mtime, owner, group, and permission bits it happens to find, none of which are
6
+ part of what the participant wrote. Every one of those is normalized here —
7
+ mtime 0, uid and gid 0, empty owner names, mode 0644, members in lexicographic
8
+ order — until the only thing left that can change the archive digest is the
9
+ content of the files.
10
+
11
+ The archive is uncompressed in WP2. Compression would add a second thing to
12
+ keep deterministic (the compressor's version and settings) in exchange for
13
+ saving a few kilobytes on a two-megabyte cap.
14
+
15
+ Extraction is the other half. :func:`safe_extract_archive` never uses
16
+ ``extractall``: it reads each member itself and refuses links, devices,
17
+ absolute names, and traversal, because an archive is untrusted input the moment
18
+ it comes back off disk.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import io
24
+ import tarfile
25
+ from collections.abc import Sequence
26
+ from pathlib import Path, PurePosixPath
27
+
28
+ from techtree.canonical import sha256_digest_bytes
29
+ from techtree.errors import NotFoundError, ValidationError, VerificationError
30
+ from techtree.fs import atomic_write_bytes, ensure_private_directory
31
+ from techtree.models.base import Digest
32
+ from techtree.models.skill import SkillArtifact
33
+ from techtree.skills.scanner import ScannedFile
34
+
35
+ __all__ = [
36
+ "build_deterministic_tar",
37
+ "normalized_tar_info",
38
+ "safe_extract_archive",
39
+ "verify_archive",
40
+ ]
41
+
42
+ #: The single mode every member carries. Skill files are data a harness reads;
43
+ #: none of them is executable, and preserving the source machine's bits would
44
+ #: make the digest depend on a participant's umask.
45
+ _MEMBER_MODE = 0o644
46
+ #: The format is pinned rather than left to the tarfile default, which has
47
+ #: changed between Python versions. PAX spells long names and non-ASCII names
48
+ #: in exactly one way.
49
+ _TAR_FORMAT = tarfile.PAX_FORMAT
50
+
51
+
52
+ def normalized_tar_info(
53
+ relative_path: PurePosixPath,
54
+ size: int,
55
+ ) -> tarfile.TarInfo:
56
+ """Return uid/gid 0, empty names, mtime 0, mode 0644."""
57
+ _require_safe_member_name(relative_path.as_posix())
58
+ if size < 0:
59
+ raise ValidationError(
60
+ f"archive member size cannot be negative: {relative_path}",
61
+ details={"path": relative_path.as_posix(), "size": size},
62
+ )
63
+
64
+ info = tarfile.TarInfo(name=relative_path.as_posix())
65
+ info.size = size
66
+ info.mtime = 0
67
+ info.mode = _MEMBER_MODE
68
+ info.type = tarfile.REGTYPE
69
+ info.uid = 0
70
+ info.gid = 0
71
+ info.uname = ""
72
+ info.gname = ""
73
+ return info
74
+
75
+
76
+ def build_deterministic_tar(
77
+ files: Sequence[ScannedFile],
78
+ output_path: Path,
79
+ ) -> Digest:
80
+ """Write lexicographically and return archive digest.
81
+
82
+ The caller's ordering is irrelevant: members are sorted by their POSIX
83
+ relative path. Each file is re-read and re-digested here, so a source file
84
+ edited between the scan and the snapshot is reported rather than quietly
85
+ archived.
86
+ """
87
+ ordered = sorted(files, key=lambda item: item.relative_path.as_posix())
88
+ _reject_duplicate_members(ordered)
89
+
90
+ buffer = io.BytesIO()
91
+ with tarfile.open(fileobj=buffer, mode="w", format=_TAR_FORMAT) as archive:
92
+ for item in ordered:
93
+ data = _read_unchanged(item)
94
+ archive.addfile(
95
+ normalized_tar_info(item.relative_path, len(data)),
96
+ io.BytesIO(data),
97
+ )
98
+
99
+ payload = buffer.getvalue()
100
+ atomic_write_bytes(output_path, payload)
101
+ return sha256_digest_bytes(payload)
102
+
103
+
104
+ def _reject_duplicate_members(files: Sequence[ScannedFile]) -> None:
105
+ seen: set[str] = set()
106
+ for item in files:
107
+ posix = item.relative_path.as_posix()
108
+ if posix in seen:
109
+ raise ValidationError(
110
+ f"archive would contain the same path twice: {posix}",
111
+ details={"path": posix},
112
+ )
113
+ seen.add(posix)
114
+
115
+
116
+ def _read_unchanged(item: ScannedFile) -> bytes:
117
+ """Read a scanned file and confirm it is still what was scanned."""
118
+ try:
119
+ data = item.source_path.read_bytes()
120
+ except FileNotFoundError as error:
121
+ raise VerificationError(
122
+ "a scanned skill file disappeared before it could be archived: "
123
+ f"{item.relative_path}",
124
+ details={"path": item.relative_path.as_posix()},
125
+ ) from error
126
+
127
+ if len(data) != item.size or sha256_digest_bytes(data) != item.digest:
128
+ raise VerificationError(
129
+ "a skill file changed after it was scanned, so the snapshot was "
130
+ f"abandoned: {item.relative_path}",
131
+ details={
132
+ "path": item.relative_path.as_posix(),
133
+ "scanned_size": item.size,
134
+ "observed_size": len(data),
135
+ },
136
+ )
137
+ return data
138
+
139
+
140
+ def verify_archive(
141
+ archive_path: Path,
142
+ artifact: SkillArtifact,
143
+ ) -> bool:
144
+ """Verify archive and member manifest.
145
+
146
+ The artifact is the manifest and the archive is checked against it: same
147
+ members, same order, same sizes, same content digests, and the normalized
148
+ metadata this module writes. Returns ``False`` for any disagreement.
149
+ """
150
+ payload = _read_archive(archive_path)
151
+ if sha256_digest_bytes(payload) != artifact.archive_digest:
152
+ return False
153
+
154
+ expected = [(entry.path, entry.size, entry.digest) for entry in artifact.files]
155
+
156
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:") as archive:
157
+ members = archive.getmembers()
158
+ if len(members) != len(expected):
159
+ return False
160
+ for member, (path, size, digest) in zip(members, expected, strict=True):
161
+ if not _member_is_normalized(member, path, size):
162
+ return False
163
+ stream = archive.extractfile(member)
164
+ if stream is None:
165
+ return False
166
+ if sha256_digest_bytes(stream.read()) != digest:
167
+ return False
168
+ return True
169
+
170
+
171
+ def _member_is_normalized(member: tarfile.TarInfo, path: str, size: int) -> bool:
172
+ return (
173
+ member.isreg()
174
+ and member.name == path
175
+ and member.size == size
176
+ and member.mtime == 0
177
+ and member.mode == _MEMBER_MODE
178
+ and member.uid == 0
179
+ and member.gid == 0
180
+ and member.uname == ""
181
+ and member.gname == ""
182
+ )
183
+
184
+
185
+ def safe_extract_archive(
186
+ archive_path: Path,
187
+ destination: Path,
188
+ ) -> None:
189
+ """Reject links, devices, absolute paths, and traversal.
190
+
191
+ Only regular-file members are accepted, which is everything
192
+ :func:`build_deterministic_tar` writes. Parent directories are created by
193
+ this function, so a hostile archive cannot use a directory member to place
194
+ anything of its own choosing.
195
+ """
196
+ payload = _read_archive(archive_path)
197
+ ensure_private_directory(destination)
198
+ root = destination.resolve()
199
+
200
+ with tarfile.open(fileobj=io.BytesIO(payload), mode="r:") as archive:
201
+ for member in archive.getmembers():
202
+ _require_extractable(member)
203
+ target = _resolve_member_target(member.name, root)
204
+ stream = archive.extractfile(member)
205
+ if stream is None:
206
+ raise ValidationError(
207
+ f"archive member has no readable content: {member.name}",
208
+ details={"member": member.name},
209
+ )
210
+ ensure_private_directory(target.parent)
211
+ atomic_write_bytes(target, stream.read())
212
+
213
+
214
+ def _require_extractable(member: tarfile.TarInfo) -> None:
215
+ if member.issym() or member.islnk():
216
+ raise ValidationError(
217
+ f"archive contains a link, which is never extracted: {member.name}",
218
+ details={"member": member.name, "kind": "link"},
219
+ )
220
+ if member.ischr() or member.isblk() or member.isfifo() or member.isdev():
221
+ raise ValidationError(
222
+ f"archive contains a device or FIFO entry: {member.name}",
223
+ details={"member": member.name, "kind": "device"},
224
+ )
225
+ if not member.isreg():
226
+ raise ValidationError(
227
+ f"archive contains an entry that is not a regular file: {member.name}",
228
+ details={"member": member.name, "kind": "not_regular"},
229
+ )
230
+ _require_safe_member_name(member.name)
231
+
232
+
233
+ def _require_safe_member_name(name: str) -> None:
234
+ if not name or name in {".", ".."}:
235
+ raise ValidationError(
236
+ "archive member name is empty or refers to a directory itself",
237
+ details={"member": name},
238
+ )
239
+ if (
240
+ name.startswith("/")
241
+ or name.startswith("\\")
242
+ or PurePosixPath(name).is_absolute()
243
+ ):
244
+ raise ValidationError(
245
+ f"archive member name is absolute: {name}",
246
+ details={"member": name},
247
+ )
248
+ if "\\" in name:
249
+ raise ValidationError(
250
+ f"archive member name contains a backslash: {name}",
251
+ details={"member": name},
252
+ )
253
+ if ".." in PurePosixPath(name).parts:
254
+ raise ValidationError(
255
+ f"archive member name escapes the destination: {name}",
256
+ details={"member": name},
257
+ )
258
+ if name.endswith("/"):
259
+ raise ValidationError(
260
+ f"archive member name is a directory: {name}",
261
+ details={"member": name},
262
+ )
263
+
264
+
265
+ def _resolve_member_target(name: str, root: Path) -> Path:
266
+ target = (root / PurePosixPath(name)).resolve()
267
+ if not target.is_relative_to(root):
268
+ raise ValidationError(
269
+ f"archive member would be written outside the destination: {name}",
270
+ details={"member": name, "destination": str(root)},
271
+ )
272
+ return target
273
+
274
+
275
+ def _read_archive(archive_path: Path) -> bytes:
276
+ try:
277
+ return archive_path.read_bytes()
278
+ except FileNotFoundError as error:
279
+ raise NotFoundError(
280
+ f"no such archive: {archive_path}",
281
+ details={"path": str(archive_path)},
282
+ ) from error
@@ -0,0 +1,62 @@
1
+ """What a candidate skill is allowed to be. Spec section 15.1.
2
+
3
+ The policy is the whole answer to "may this directory be submitted?", stated as
4
+ data rather than as conditionals spread through the scanner. It is frozen, so a
5
+ scan cannot widen the rules it was handed partway through.
6
+
7
+ Every limit here exists for a reason a participant can be told:
8
+
9
+ * An instruction skill is text a model reads. Only text suffixes are allowed,
10
+ which is also what makes secret scanning meaningful — bytes nobody can read
11
+ cannot be checked.
12
+ * ``SKILL.md`` must exist, because that is the entrypoint the harness loads.
13
+ * Hidden files are excluded rather than filtered. ``.env``, ``.git``, and
14
+ editor state are the usual accidental contents of a skill directory, and
15
+ quietly dropping them would make the snapshot disagree with what the
16
+ participant sees on disk.
17
+ * Symlinks are excluded because a link's meaning depends on the machine it is
18
+ read on, and a snapshot must mean the same thing everywhere.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+
25
+ from techtree.constants import (
26
+ ALLOWED_SKILL_SUFFIXES,
27
+ MAX_SKILL_FILE_BYTES,
28
+ MAX_SKILL_FILES,
29
+ MAX_SKILL_TOTAL_BYTES,
30
+ )
31
+ from techtree.models.skill import SKILL_ENTRY_FILE
32
+
33
+ __all__ = [
34
+ "SkillPolicy",
35
+ "default_instruction_skill_policy",
36
+ ]
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class SkillPolicy:
41
+ """The limits a candidate skill directory is validated against."""
42
+
43
+ allowed_suffixes: frozenset[str] = field(default=ALLOWED_SKILL_SUFFIXES)
44
+ maximum_files: int = MAX_SKILL_FILES
45
+ maximum_file_bytes: int = MAX_SKILL_FILE_BYTES
46
+ maximum_total_bytes: int = MAX_SKILL_TOTAL_BYTES
47
+ required_entrypoint: str = SKILL_ENTRY_FILE
48
+ allow_symlinks: bool = False
49
+ allow_hidden_files: bool = False
50
+
51
+
52
+ def default_instruction_skill_policy() -> SkillPolicy:
53
+ """Return v0.1 Markdown instruction-skill policy."""
54
+ return SkillPolicy(
55
+ allowed_suffixes=ALLOWED_SKILL_SUFFIXES,
56
+ maximum_files=MAX_SKILL_FILES,
57
+ maximum_file_bytes=MAX_SKILL_FILE_BYTES,
58
+ maximum_total_bytes=MAX_SKILL_TOTAL_BYTES,
59
+ required_entrypoint=SKILL_ENTRY_FILE,
60
+ allow_symlinks=False,
61
+ allow_hidden_files=False,
62
+ )