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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
"""Validate and scan a candidate skill directory. Spec section 15.2.
|
|
2
|
+
|
|
3
|
+
This module answers one question completely: given a path a participant typed,
|
|
4
|
+
what exactly is going to be snapshotted, and is any of it something we must
|
|
5
|
+
refuse to copy?
|
|
6
|
+
|
|
7
|
+
It refuses rather than repairs. A symlink is not resolved, a hidden file is not
|
|
8
|
+
skipped, and an oversized file is not truncated. Every one of those would
|
|
9
|
+
produce a snapshot that differs from what the participant believes they
|
|
10
|
+
submitted, and the snapshot is the scientific input to an experiment.
|
|
11
|
+
|
|
12
|
+
Every refusal here is about a file's *shape*: how big it is, how many there
|
|
13
|
+
are, whether it is a regular file, whether it decodes as text. None of them
|
|
14
|
+
reads the words. Decision 0036 removed the rules that did — a Skill is prose
|
|
15
|
+
about a procedure, a procedure about security is prose about credentials, and
|
|
16
|
+
refusing one on a regex blocked legitimate work in a product whose premise is
|
|
17
|
+
that people bring their own Skills. What a Skill says is now the participant's
|
|
18
|
+
business.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import stat
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path, PurePosixPath
|
|
27
|
+
from typing import Final
|
|
28
|
+
|
|
29
|
+
from techtree.canonical import sha256_digest_bytes
|
|
30
|
+
from techtree.errors import NotFoundError, ValidationError
|
|
31
|
+
from techtree.fs import realpath_within
|
|
32
|
+
from techtree.models.base import Digest
|
|
33
|
+
from techtree.models.skill import SKILL_ENTRY_FILE
|
|
34
|
+
from techtree.skills.policy import SkillPolicy
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"MEDIA_TYPES",
|
|
38
|
+
"ScannedFile",
|
|
39
|
+
"SkillScanResult",
|
|
40
|
+
"enumerate_files",
|
|
41
|
+
"media_type_for",
|
|
42
|
+
"resolve_skill_root",
|
|
43
|
+
"scan_skill",
|
|
44
|
+
"validate_file",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Structures
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class ScannedFile:
|
|
55
|
+
"""A validated file, and everything the snapshot needs to know about it."""
|
|
56
|
+
|
|
57
|
+
source_path: Path
|
|
58
|
+
relative_path: PurePosixPath
|
|
59
|
+
size: int
|
|
60
|
+
media_type: str
|
|
61
|
+
digest: Digest
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass
|
|
65
|
+
class SkillScanResult:
|
|
66
|
+
"""The complete, ordered description of what would be snapshotted."""
|
|
67
|
+
|
|
68
|
+
root: Path
|
|
69
|
+
files: list[ScannedFile]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Media types
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
#: Suffix to media type. Fixed strings: they travel into the SkillArtifact and
|
|
77
|
+
#: therefore into a digest, so they may never be derived from the host's
|
|
78
|
+
#: ``mimetypes`` database, which differs between machines.
|
|
79
|
+
MEDIA_TYPES: Final[dict[str, str]] = {
|
|
80
|
+
".md": "text/markdown",
|
|
81
|
+
".txt": "text/plain",
|
|
82
|
+
".json": "application/json",
|
|
83
|
+
".yaml": "application/yaml",
|
|
84
|
+
".yml": "application/yaml",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# Root resolution and enumeration
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_skill_root(path: Path) -> Path:
|
|
94
|
+
"""Accept SKILL.md or containing directory.
|
|
95
|
+
|
|
96
|
+
Symlinks are left exactly as they are: whether one is tolerable is the
|
|
97
|
+
policy's decision, and :func:`scan_skill` makes it.
|
|
98
|
+
"""
|
|
99
|
+
if not path.exists() and not path.is_symlink():
|
|
100
|
+
raise NotFoundError(
|
|
101
|
+
f"no such skill path: {path}",
|
|
102
|
+
details={"path": str(path)},
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
if path.is_dir():
|
|
106
|
+
root = path
|
|
107
|
+
elif path.is_file():
|
|
108
|
+
if path.name != SKILL_ENTRY_FILE:
|
|
109
|
+
raise ValidationError(
|
|
110
|
+
"a skill is named by its directory or by its SKILL.md, "
|
|
111
|
+
f"not by one of its other files: {path.name}",
|
|
112
|
+
details={"path": str(path)},
|
|
113
|
+
)
|
|
114
|
+
root = path.parent
|
|
115
|
+
else:
|
|
116
|
+
raise ValidationError(
|
|
117
|
+
f"skill path is neither a directory nor a regular file: {path}",
|
|
118
|
+
details={"path": str(path)},
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
entrypoint = root / SKILL_ENTRY_FILE
|
|
122
|
+
if not entrypoint.is_file():
|
|
123
|
+
raise ValidationError(
|
|
124
|
+
f"skill directory has no {SKILL_ENTRY_FILE} entrypoint: {root}",
|
|
125
|
+
details={"root": str(root), "entrypoint": SKILL_ENTRY_FILE},
|
|
126
|
+
)
|
|
127
|
+
return root
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def enumerate_files(root: Path) -> list[Path]:
|
|
131
|
+
"""Enumerate without following symlinks.
|
|
132
|
+
|
|
133
|
+
Symlinks, sockets, and devices are returned rather than skipped, so that
|
|
134
|
+
validation reports them instead of silently leaving them out of the
|
|
135
|
+
snapshot. Directory symlinks are reported and not descended into.
|
|
136
|
+
"""
|
|
137
|
+
found: list[Path] = []
|
|
138
|
+
_walk(root, found)
|
|
139
|
+
return sorted(found, key=lambda item: _relative_key(item, root))
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _walk(directory: Path, found: list[Path]) -> None:
|
|
143
|
+
with os.scandir(directory) as entries:
|
|
144
|
+
for entry in entries:
|
|
145
|
+
child = Path(entry.path)
|
|
146
|
+
if entry.is_symlink():
|
|
147
|
+
found.append(child)
|
|
148
|
+
elif entry.is_dir(follow_symlinks=False):
|
|
149
|
+
_walk(child, found)
|
|
150
|
+
else:
|
|
151
|
+
found.append(child)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _relative_key(path: Path, root: Path) -> str:
|
|
155
|
+
try:
|
|
156
|
+
return path.relative_to(root).as_posix()
|
|
157
|
+
except ValueError:
|
|
158
|
+
return str(path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _relative_path(path: Path, root: Path) -> PurePosixPath:
|
|
162
|
+
try:
|
|
163
|
+
return PurePosixPath(path.relative_to(root).as_posix())
|
|
164
|
+
except ValueError as error:
|
|
165
|
+
raise ValidationError(
|
|
166
|
+
f"file is outside the skill root: {path}",
|
|
167
|
+
details={"path": str(path), "root": str(root)},
|
|
168
|
+
) from error
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Per-file validation
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def validate_file(
|
|
177
|
+
path: Path,
|
|
178
|
+
root: Path,
|
|
179
|
+
policy: SkillPolicy,
|
|
180
|
+
) -> None:
|
|
181
|
+
"""Validate containment, type, suffix, size, and hidden status."""
|
|
182
|
+
relative = _relative_path(path, root)
|
|
183
|
+
|
|
184
|
+
if not policy.allow_hidden_files:
|
|
185
|
+
hidden = next((part for part in relative.parts if part.startswith(".")), None)
|
|
186
|
+
if hidden is not None:
|
|
187
|
+
raise ValidationError(
|
|
188
|
+
f"skill contains a hidden path, which is never submitted: {relative}",
|
|
189
|
+
details={"path": relative.as_posix(), "hidden_component": hidden},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
if path.is_symlink() and not policy.allow_symlinks:
|
|
193
|
+
raise ValidationError(
|
|
194
|
+
"skill contains a symlink, and a snapshot must mean the same thing "
|
|
195
|
+
f"on every machine: {relative}",
|
|
196
|
+
details={"path": relative.as_posix()},
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
status = _status(path, relative, policy)
|
|
200
|
+
if not stat.S_ISREG(status.st_mode):
|
|
201
|
+
raise ValidationError(
|
|
202
|
+
f"skill contains {_describe_type(status.st_mode)}, which cannot be "
|
|
203
|
+
f"snapshotted: {relative}",
|
|
204
|
+
details={
|
|
205
|
+
"path": relative.as_posix(),
|
|
206
|
+
"kind": _describe_type(status.st_mode),
|
|
207
|
+
},
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
suffix = path.suffix.lower()
|
|
211
|
+
if suffix not in policy.allowed_suffixes:
|
|
212
|
+
raise ValidationError(
|
|
213
|
+
f"skill file has an unsupported suffix: {relative}",
|
|
214
|
+
details={
|
|
215
|
+
"path": relative.as_posix(),
|
|
216
|
+
"suffix": suffix,
|
|
217
|
+
"allowed_suffixes": [*sorted(policy.allowed_suffixes)],
|
|
218
|
+
},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if status.st_size > policy.maximum_file_bytes:
|
|
222
|
+
raise ValidationError(
|
|
223
|
+
f"skill file is larger than {policy.maximum_file_bytes} bytes: {relative}",
|
|
224
|
+
details={
|
|
225
|
+
"path": relative.as_posix(),
|
|
226
|
+
"size": status.st_size,
|
|
227
|
+
"maximum_file_bytes": policy.maximum_file_bytes,
|
|
228
|
+
},
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
if not realpath_within(path, root):
|
|
232
|
+
raise ValidationError(
|
|
233
|
+
f"skill file resolves outside the skill root: {relative}",
|
|
234
|
+
details={"path": relative.as_posix(), "root": str(root)},
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _status(path: Path, relative: PurePosixPath, policy: SkillPolicy) -> os.stat_result:
|
|
239
|
+
"""Stat a candidate file, reporting an unreadable one as a refusal.
|
|
240
|
+
|
|
241
|
+
A file the scanner cannot read — a broken link, a directory it has no
|
|
242
|
+
permission to enter — is not a defect in Techtree, so it is reported the
|
|
243
|
+
same way as anything else the participant needs to fix.
|
|
244
|
+
"""
|
|
245
|
+
try:
|
|
246
|
+
return path.stat(follow_symlinks=policy.allow_symlinks)
|
|
247
|
+
except OSError as error:
|
|
248
|
+
raise ValidationError(
|
|
249
|
+
f"skill file cannot be read: {relative}",
|
|
250
|
+
details={"path": relative.as_posix()},
|
|
251
|
+
) from error
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _describe_type(mode: int) -> str:
|
|
255
|
+
if stat.S_ISDIR(mode):
|
|
256
|
+
return "a directory"
|
|
257
|
+
if stat.S_ISFIFO(mode):
|
|
258
|
+
return "a FIFO"
|
|
259
|
+
if stat.S_ISSOCK(mode):
|
|
260
|
+
return "a socket"
|
|
261
|
+
if stat.S_ISBLK(mode) or stat.S_ISCHR(mode):
|
|
262
|
+
return "a device"
|
|
263
|
+
return "something that is not a regular file"
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def media_type_for(path: Path) -> str:
|
|
267
|
+
"""Map allowed suffix to stable media type."""
|
|
268
|
+
media_type = MEDIA_TYPES.get(path.suffix.lower())
|
|
269
|
+
if media_type is None:
|
|
270
|
+
raise ValidationError(
|
|
271
|
+
f"no media type is defined for this suffix: {path.name}",
|
|
272
|
+
details={"suffix": path.suffix.lower()},
|
|
273
|
+
)
|
|
274
|
+
return media_type
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
# Text decoding
|
|
279
|
+
# ---------------------------------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _decode_text(data: bytes, reported_path: str) -> str:
|
|
283
|
+
"""Read a validated skill file as text, refusing anything unreadable."""
|
|
284
|
+
if b"\x00" in data:
|
|
285
|
+
raise ValidationError(
|
|
286
|
+
f"skill file is binary, and an instruction skill is text: {reported_path}",
|
|
287
|
+
details={"path": reported_path},
|
|
288
|
+
)
|
|
289
|
+
try:
|
|
290
|
+
return data.decode("utf-8")
|
|
291
|
+
except UnicodeDecodeError as error:
|
|
292
|
+
raise ValidationError(
|
|
293
|
+
f"skill file is not valid UTF-8 text: {reported_path}",
|
|
294
|
+
details={"path": reported_path},
|
|
295
|
+
) from error
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
# Whole-skill scan
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def scan_skill(
|
|
304
|
+
path: Path,
|
|
305
|
+
policy: SkillPolicy,
|
|
306
|
+
) -> SkillScanResult:
|
|
307
|
+
"""Perform complete validation and scanning.
|
|
308
|
+
|
|
309
|
+
Raises :class:`~techtree.errors.ValidationError` for anything the policy
|
|
310
|
+
forbids: a shape the snapshot cannot carry, a size over the limit, or a
|
|
311
|
+
file that is not text.
|
|
312
|
+
"""
|
|
313
|
+
root = resolve_skill_root(path)
|
|
314
|
+
if root.is_symlink() and not policy.allow_symlinks:
|
|
315
|
+
raise ValidationError(
|
|
316
|
+
f"skill root is a symlink, which is never snapshotted: {root}",
|
|
317
|
+
details={"root": str(root)},
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
candidates = enumerate_files(root)
|
|
321
|
+
if len(candidates) > policy.maximum_files:
|
|
322
|
+
raise ValidationError(
|
|
323
|
+
f"skill contains {len(candidates)} files, "
|
|
324
|
+
f"more than the {policy.maximum_files} allowed",
|
|
325
|
+
details={"count": len(candidates), "maximum_files": policy.maximum_files},
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
files: list[ScannedFile] = []
|
|
329
|
+
total = 0
|
|
330
|
+
|
|
331
|
+
for candidate in candidates:
|
|
332
|
+
validate_file(candidate, root, policy)
|
|
333
|
+
relative = _relative_path(candidate, root)
|
|
334
|
+
data = candidate.read_bytes()
|
|
335
|
+
total += len(data)
|
|
336
|
+
if total > policy.maximum_total_bytes:
|
|
337
|
+
raise ValidationError(
|
|
338
|
+
"skill is larger than the "
|
|
339
|
+
f"{policy.maximum_total_bytes} byte total limit",
|
|
340
|
+
details={
|
|
341
|
+
"total_bytes": total,
|
|
342
|
+
"maximum_total_bytes": policy.maximum_total_bytes,
|
|
343
|
+
},
|
|
344
|
+
)
|
|
345
|
+
_decode_text(data, relative.as_posix())
|
|
346
|
+
files.append(
|
|
347
|
+
ScannedFile(
|
|
348
|
+
source_path=candidate,
|
|
349
|
+
relative_path=relative,
|
|
350
|
+
size=len(data),
|
|
351
|
+
media_type=media_type_for(candidate),
|
|
352
|
+
digest=sha256_digest_bytes(data),
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
_require_entrypoint(files, root, policy)
|
|
357
|
+
_reject_case_collisions(files)
|
|
358
|
+
|
|
359
|
+
return SkillScanResult(
|
|
360
|
+
root=root,
|
|
361
|
+
files=sorted(files, key=lambda item: item.relative_path.as_posix()),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def _require_entrypoint(
|
|
366
|
+
files: list[ScannedFile], root: Path, policy: SkillPolicy
|
|
367
|
+
) -> None:
|
|
368
|
+
entrypoint = PurePosixPath(policy.required_entrypoint)
|
|
369
|
+
if not any(item.relative_path == entrypoint for item in files):
|
|
370
|
+
raise ValidationError(
|
|
371
|
+
f"skill directory has no {policy.required_entrypoint} entrypoint: {root}",
|
|
372
|
+
details={"root": str(root), "entrypoint": policy.required_entrypoint},
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _reject_case_collisions(files: list[ScannedFile]) -> None:
|
|
377
|
+
"""Refuse paths that differ only by case.
|
|
378
|
+
|
|
379
|
+
Such a pair is two files here and one file on a case-insensitive
|
|
380
|
+
filesystem, so the snapshot would not survive a round trip through one
|
|
381
|
+
machine to another.
|
|
382
|
+
"""
|
|
383
|
+
seen: dict[str, str] = {}
|
|
384
|
+
for item in files:
|
|
385
|
+
posix = item.relative_path.as_posix()
|
|
386
|
+
folded = posix.casefold()
|
|
387
|
+
existing = seen.get(folded)
|
|
388
|
+
if existing is not None:
|
|
389
|
+
raise ValidationError(
|
|
390
|
+
"skill contains paths that differ only by case, which collide "
|
|
391
|
+
f"on a case-insensitive filesystem: {existing} and {posix}",
|
|
392
|
+
details={"paths": [existing, posix]},
|
|
393
|
+
)
|
|
394
|
+
seen[folded] = posix
|