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/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
|
+
)
|