From 1c6b68e09ee2ca583573154d7df58b0c8b527d88 Mon Sep 17 00:00:00 2001 From: Ivan Oparin Date: Mon, 24 Aug 2026 19:15:20 +0400 Subject: [PATCH] feat: implement manifest schema 8, module roots, and install marker v4 Adopt the schema-8 additions of Curator Protocol core sections 4.1.1, 4.2.3, and 10 against curator-spec candidate/schema-8-rc.9. Manifest schema 8 admits two optional fields. A local go-v1 build command may declare `modules`, the first-party Go modules of the same package that its build root replaces. A script command may select the enforced execution policy with the co-required `execution_policy` and `interpreter` pair, whose value spaces are closed. Schemas 1 through 7 reject all three fields at the top level and on every command; schema 1 rejects them through its reserved-field check because it keeps its deployed extension behavior. Module roots follow the manager profile's fixed order. The declaration is validated against the frozen snapshot alone before the fixed `go list`: portable relative path other than ".", unique, real link-free directory, `go.mod` directly inside, pairwise disjoint from every other declared module, every build root, and every runtime root under exact and platform-path comparison. After `go list` returns and before `go build`, the effective replace set is read only from `/vendor/modules.txt`, selection annotations are reconciled against their unversioned-left directives, every versioned side and every module-to-module redirect is rejected, and the one-to-one correspondence with the declaration is checked in both directions. A package resolving through a replacement is then admitted exactly on the bijected set, the scan surface extends over the declared directories and their vendor copies, and the audited-vendor allowance is withheld from a module carrying a replacement. No build input, cache key, receipt, or artifact path changes. csk implements no script execution policy, so a command selecting `script-worker-v1` is refused with `script_execution_policy_unsupported` at `csk check` and at install, and the single shim publication point refuses it however it is reached. Downgrading such a command to a declared-only shim would run package code the manifest says is contained. Install marker v4 carries marker-v3 meaning over a schema-8 manifest: same object shape, same build-entry semantics, same top-level build_source and build_roots rules. A schema-8 mutation writes v4, schema 7 still writes v3, schemas 1 through 6 still write v2, and markers 1 through 4 are read. --- ARCHITECTURE.md | 13 + CHANGELOG.md | 32 ++ LOGBOOK.md | 44 ++ docs/reference.md | 18 + docs/skill-authoring.md | 67 +++ src/csk/builds/go_v1.py | 239 +++++++++- src/csk/builds/module_roots.py | 346 ++++++++++++++ src/csk/builds/planner.py | 12 +- src/csk/gc.py | 2 +- src/csk/install_marker.py | 110 ++++- src/csk/installer.py | 55 ++- src/csk/skillcheck.py | 10 + src/csk/skillspec.py | 165 ++++++- src/csk/status.py | 18 +- tests/test_install_marker_v4.py | 199 ++++++++ tests/test_install_schema8.py | 282 +++++++++++ tests/test_module_roots.py | 796 ++++++++++++++++++++++++++++++++ tests/test_skillspec.py | 2 +- tests/test_skillspec_schema8.py | 424 +++++++++++++++++ 19 files changed, 2782 insertions(+), 52 deletions(-) create mode 100644 src/csk/builds/module_roots.py create mode 100644 tests/test_install_marker_v4.py create mode 100644 tests/test_install_schema8.py create mode 100644 tests/test_module_roots.py create mode 100644 tests/test_skillspec_schema8.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index dce2162..a1d58f3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -246,6 +246,19 @@ cannot insert a hook or script between them. See external-build boundary is qualified only for macOS and Windows; this document makes no Linux support claim. +Schema 8 keeps that session byte-identical and adds one declaration to the +local `go-v1` command: `modules`, the first-party Go modules of the same package +that the build root replaces. The package states a claim and the manager checks +it against the frozen snapshot before the fixed `go list`, then reconciles it +against the effective replace set read only from `/vendor/modules.txt` +after `go list` and before `go build`. Nothing else moves: the logical build +input, the cache key, the receipt, and the artifact-relative path are unchanged, +and a schema-8 installation is recorded by install marker v4, which carries +marker-v3 meaning over a schema-8 manifest. `script-worker-v1` is a separate +schema-8 addition this manager does not implement; a command selecting it is +rejected with `script_execution_policy_unsupported` rather than installed +declared-only. + This architecture boundary follows the accepted [rc.5 protocol core](https://github.com/relux-works/curator-spec/blob/v1.0.0-rc.5/protocol/core.md). Later protocol revisions are outside this document's scope. diff --git a/CHANGELOG.md b/CHANGELOG.md index d061289..4fd4db4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Добавлено - Аутентификация приватных HTTPS build-репозиториев через manager credential broker. Скоупы `build_https` в глобальном конфиге хранят источник токена (`git-credentials`, `keyring`, `token_env`), никогда сам токен; та же longest-prefix грамматика канонической идентичности, что у `build_ssh`, применение пер-репо. Сабкоманды `csk config build-https add/login/list/remove`, интерактивный precheck с обнаруженными кандидатами перед первым fetch, run-wide override `CSK_BUILD_HTTPS_TOKEN` (с необязательными `CSK_BUILD_HTTPS_USERNAME` и пином хоста `CSK_BUILD_HTTPS_HOST`; без пина токен уходит каждому HTTPS-хосту замыкания). Креды читает менеджер до fetch через `git credential fill/approve/reject` при отключённых интерактивных запросах; брокер отвечает только на два промпта Git и только для запиненного хоста, любой другой ввод завершается fail-closed. Helper, который молча ничего не сохранил, обнаруживается обратным чтением. Анонимный HTTPS остаётся рабочим транспортом, когда ни один скоуп не совпал. +- Authoring-контракт `agent-skill.json` schema 8 (`csk-skill.json` тоже): опциональный + список `modules` на локальной команде `go-v1` и опциональная пара + `execution_policy` + `interpreter` на script-команде. Схемы 1-7 отклоняют оба + поля и на верхнем уровне, и на каждой команде. +- Declared first-party module roots для `go-v1`: менеджер проверяет объявленные + каталоги модулей по замороженному снапшоту до фиксированного `go list` + (portable relative path, не `.`, уникальность, реальный link-free каталог, + `go.mod` прямо внутри, попарная непересекаемость с другими объявленными + модулями, build roots и runtime roots под точным и platform-path сравнением), + затем после `go list` и до `go build` читает эффективный replace-set только из + `/vendor/modules.txt`, сверяет selection-аннотации с + unversioned-left директивами, отклоняет версию с любой стороны и + module-to-module redirect и проверяет биекцию в обе стороны. Пакеты + заменённого модуля допускаются ровно на этом множестве, поверхность сканирования + расширена на объявленные каталоги и их vendor-копии, а послабление для + аудированного стороннего vendor-кода к заменённым модулям не применяется. + Диагностики: `build_module_root_declaration_invalid`, + `build_module_root_containment_invalid`, + `build_module_root_directive_form_unsupported`, + `build_module_root_directive_undeclared`, + `build_module_root_declaration_unused`. +- Install marker schema 4: та же форма и та же семантика записей сборки, что и у + marker v3, но описывает установку schema-8. Мутация установки schema 8 + записывает marker v4, schema 7 по-прежнему marker v3, схемы 1-6 по-прежнему + marker v2. Читаются marker-схемы 1, 2, 3 и 4. + +### Исправлено + +- Fail-closed отказ `script_execution_policy_unsupported`: csk не реализует + `script-worker-v1`, поэтому script-команда, выбравшая эту политику, + отклоняется на установке и в `csk check` и никогда не публикуется как + declared-only shim. ### Исправлено diff --git a/LOGBOOK.md b/LOGBOOK.md index 77dabf7..357d85d 100644 --- a/LOGBOOK.md +++ b/LOGBOOK.md @@ -1,5 +1,49 @@ # Logbook +## 2026-08-24 - TASK-260824-2i5yqw: marker v4 inherits four pre-existing marker-v3 conformance gaps + +Marker v4 is marker v3 with a different manifest band, so `InstallMarkerV3` and the new +`InstallMarkerV4` were folded onto one `_InstallMarkerExternalCapable` body in +`src/csk/install_marker.py`. Running the candidate `install-marker-v4` schema cases through +`install_marker.read_install_marker` showed 4 of 27 disagreeing, and running the +`install-marker-v3` cases from both the candidate root and the ref CI pins today +(curator-spec `0c81c1f`) showed the identical 4 of 27. The gaps are pre-existing schema-7 +external-repository substitution semantics, not something schema 8 introduced, and marker v4 +inherits them exactly because it inherits v3's body. + +- `valid-network-substitution-branch`: csk rejects a substitution ref of kind `branch` with + `repository substitution ref kind must be tag or revision`; the corpus expects it valid. +- `invalid-marker-local-identity-kind-mismatch`: a `local-path` substitution against a + `network-git` effective identity is accepted; the corpus expects it rejected. +- `invalid-marker-sha1-effective-revision-width` and its sha256 twin: a substitution revision + whose hex width contradicts `object_format` is accepted; the corpus expects both rejected. + +CI does not surface this today. `tests/test_protocol_conformance.py` drives schema cases only for +the names in `IN_SCOPE_SCHEMA_NAMES` (agent-skill-v6, build-receipt-v1, conformance-claim-v1..v3, +csk-skill-v6, install-marker-v2), so no install-marker-v3 case is executed at all. The full suite +with `CURATOR_CONFORMANCE_ROOT` set to the pinned root is green: 2735 passed, 84 skipped, exit 0. + +Deliberately left out of the schema-8 branch. Fixing them means changing schema-7 substitution +validation, a different manifest band with its own corpus, and the correct expectations live in the +rc.9 candidate suite that TASK-260824-31y75t consumes. Recorded here so the qualification task picks +it up instead of rediscovering it. + +## 2026-08-24 - TASK-260824-2i5yqw: `go list -mod=vendor` reports a replaced module through `Module.Replace` only + +Probed a real two-module vendored tree before touching `src/csk/builds/go_v1.py`. For +`replace example.com/board => ../../pkg/board`, `go mod vendor` writes both +`# example.com/board v0.0.0 => ../../pkg/board` (selection) and +`# example.com/board => ../../pkg/board` (the effective directive), which is exactly the +reconciliation Protocol Core 4.2.3 relies on to reject a versioned left side without parsing +`go.mod`. + +The `go list` package entry for the replaced module carries `Module.Path`, `Module.Version`, and a +`Module.Replace` object with `Path`, `Dir`, `GoMod`, `GoVersion`, but no top-level `Module.Dir` or +`Module.GoMod`. `Module.Replace.Dir` points outside the build root, into the snapshot. That is why +`_validate_module` keys admission on `Module.Path` against the bijected set rather than on any path +in the stream: the spec forbids treating `Replace.Dir` or `Replace.GoMod` as evidence a path exists, +and under `-mod=vendor` Go does not stat them. + ## 2026-08-24 - TASK-260824-2h0vjy EPERM is not a verdict about a process group `Merge Go E2E / Python 3.14 on macos-latest` rejected an install with diff --git a/docs/reference.md b/docs/reference.md index 5fec81c..d34b0f8 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -141,6 +141,7 @@ python -m pip install --user cocoaskills * Схема v5: добавляет секцию требований к MCP-серверам. * Схема v6: добавляет компилируемые команды и исключение файлов сборки через `build_roots`. * Схема v7: добавляет зафиксированные внешние git-репозитории сборки через `go-repository-v1`. +* Схема v8: добавляет объявленные корни first-party модулей `modules` на команде `go-v1` и пару `execution_policy` + `interpreter` на script-команде. ### Пример смешанного манифеста @@ -192,6 +193,23 @@ python -m pip install --user cocoaskills Драйвер `go-v1` использует вендоренные зависимости и отключает сетевой доступ во время сборки. Валидация пакета отвергает смену инструментария, cgo, PGO, кодогенераторы, тесты, ассемблерные файлы и внешнюю линковку. +### Корни first-party модулей схемы v8 + +Команда `go-v1` схемы v8 объявляет `modules`: каталоги first-party Go-модулей +того же пакета, которые корень сборки заменяет через `replace`. Менеджер +проверяет объявление по снапшоту до фиксированного `go list`, читает +эффективный набор `replace` только из `<корень сборки>/vendor/modules.txt` +после `go list` и до `go build`, допускает лишь форму "каталог" без версий с +обеих сторон и требует взаимно однозначного соответствия объявленных каталогов +и эффективных директив. Объявленные каталоги входят в поверхность сканирования +вместе со своими vendor-копиями; послабление для аудированного стороннего +vendor-кода к заменённым модулям не применяется. Установка схемы v8 записывает +install marker schema 4. + +Политику выполнения `script-worker-v1` csk не реализует: script-команда, которая +её выбирает, отклоняется с `script_execution_policy_unsupported` на установке и +в `csk check` и никогда не публикуется как declared-only shim. + Процесс сборки выполняется через изолированный воркер `manager-worker-v1`. Менеджер проверяет идентичность воркера перед выдачей авторизации на сборку. Скомпилированные артефакты сохраняются в защищенном кэше `/builds/go-v1/` и исполняются через сгенерированные shims. Полный контракт сборки, структуру хранения кэша, протокол передачи воркера и границы безопасности см. в разделе [Schema-6 build contract](../ARCHITECTURE.md#schema-6-build-contract). ## SSH-креды внешних репозиториев сборки diff --git a/docs/skill-authoring.md b/docs/skill-authoring.md index c861a94..1f9c691 100644 --- a/docs/skill-authoring.md +++ b/docs/skill-authoring.md @@ -330,6 +330,73 @@ build/vendor/ checked-in modules when non-standard packages a конфиге, описан в `docs/external-build-repositories.md`. `go-repository-v1` поддерживается только на macOS и Windows. +### Схема v8: корни first-party модулей и политика выполнения script-команд + +Схема v8 добавляет два необязательных поля. + +Локальная команда `go-v1` объявляет `modules`: список каталогов first-party +Go-модулей того же пакета, которые корень сборки заменяет через `replace`. +Отсутствующий или пустой список сохраняет смысл схем v6 и v7, то есть корень +сборки из одного модуля. + +```json +{ + "schema_version": 8, + "capabilities": {}, + "build_roots": ["tools/cli"], + "commands": { + "cli": { + "type": "build", + "driver": "go-v1", + "source_dir": "tools/cli/cmd/cli", + "modules": ["pkg/board", "pkg/remoteconfig"] + } + } +} +``` + +Пакет заявляет, менеджер проверяет. Каждый объявленный каталог обязан быть +портируемым относительным путём, отличным от `.`, реальным каталогом без +ссылок внутри снапшота, содержать `go.mod` прямо внутри и не пересекаться ни с +другим объявленным модулем, ни с корнем сборки, ни с runtime-корнем. Проверка +идёт до фиксированного `go list`. + +Эффективный набор `replace` менеджер читает только из +`<корень сборки>/vendor/modules.txt` после `go list` и до `go build`. +Допускается лишь форма "каталог": по одному токену с каждой стороны. +Версия слева, версия справа и redirect с модуля на модуль отклоняются. +Объявленные каталоги и эффективные директивы обязаны находиться во взаимно +однозначном соответствии: директива без объявления отклоняется как +`build_module_root_directive_undeclared`, объявление без директивы как +`build_module_root_declaration_unused`. + +Объявленные каталоги входят в поверхность сканирования вместе со своими +vendor-копиями, а послабление для аудированного стороннего vendor-кода на +заменённые модули не распространяется. + +Script-команда может выбрать закрытую политику выполнения парой +`execution_policy` + `interpreter`: + +```json +{ + "type": "script", + "unix_path": "scripts/tool", + "execution_policy": "script-worker-v1", + "interpreter": "python3-v1" +} +``` + +Поля обязательны друг для друга; единственное допустимое значение +`execution_policy` это `script-worker-v1`, допустимые интерпретаторы это +`python3-v1` и `node-v1`. csk не реализует `script-worker-v1`, поэтому +отклоняет такую команду с `script_execution_policy_unsupported` на установке и +в `csk check`. Команда не понижается до declared-only shim: опубликованный +лаунчер выполнял бы код пакета, который манифест объявляет изолированным. + +Установка схемы v8 записывает install marker schema 4. Форма и семантика +записей сборки те же, что у marker v3. + + ## 4. Runtime-корни Секция `runtime_roots` перечисляет каталоги только для runtime. CocoaSkills копирует их в глобальное хранилище runtime и исключает из установленного контекста промпта. diff --git a/src/csk/builds/go_v1.py b/src/csk/builds/go_v1.py index 436498d..9ba1907 100644 --- a/src/csk/builds/go_v1.py +++ b/src/csk/builds/go_v1.py @@ -42,12 +42,12 @@ import time from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field, replace -from pathlib import Path +from pathlib import Path, PurePosixPath from types import MappingProxyType from typing import Any, BinaryIO, Final, Protocol, cast from ..identifiers import is_valid_identifier, is_valid_portable_path -from . import source, toolchain +from . import module_roots, source, toolchain # Stable execution-boundary diagnostics from Protocol Core 4.2.1. @@ -346,6 +346,12 @@ class BuildRequest: source_dir: str command: str limits: ResourceLimits = field(default_factory=ResourceLimits) + # Schema-8 declaration surface. ``modules`` names the first-party Go + # modules this build root replaces; the two root lists exist only so the + # driver can repeat the containment comparison the manifest already made. + modules: tuple[str, ...] = () + build_roots: tuple[str, ...] = () + runtime_roots: tuple[str, ...] = () @dataclass(frozen=True) @@ -628,8 +634,15 @@ def validate_package_graph( build_root: Path, source_dir: Path, goroot: Path, + replaced_modules: frozenset[str] = frozenset(), ) -> None: - """Parse the complete ``go list`` stream and constrain every active input.""" + """Parse the complete ``go list`` stream and constrain every active input. + + ``replaced_modules`` names the module paths the schema-8 bijection admitted. + A vendored package whose module carries a replacement is first-party code, + so it is admitted here but loses every allowance the profile grants to + audited third-party vendored dependencies. + """ try: _validate_package_graph( @@ -637,6 +650,7 @@ def validate_package_graph( build_root=build_root, source_dir=source_dir, goroot=goroot, + replaced_modules=replaced_modules, ) except GoV1Error as exc: if exc.code != CODE_WORKER_PROTOCOL_INVALID: @@ -653,6 +667,7 @@ def _validate_package_graph( build_root: Path, source_dir: Path, goroot: Path, + replaced_modules: frozenset[str] = frozenset(), ) -> None: packages = _decode_json_stream(payload) if not packages: @@ -715,6 +730,7 @@ def _validate_package_graph( item, build_root=build_root, goroot=goroot, + replaced_modules=replaced_modules, ) if has_vendored_module: @@ -770,6 +786,7 @@ def _validate_package_inputs( *, build_root: Path, goroot: Path, + replaced_modules: frozenset[str] = frozenset(), ) -> None: import_path = _optional_string(item.get("ImportPath")) trusted_standard = ( @@ -796,7 +813,7 @@ def _validate_package_inputs( f"standard package {import_path!r} has an unexpected Root", ) else: - _validate_module(item, module, build_root) + _validate_module(item, module, build_root, replaced_modules) item_root = _optional_string(item.get("Root")) if item_root: try: @@ -831,7 +848,12 @@ def _validate_package_inputs( # The single vendored-exception predicate of decision 0005: a package is # audited-by-vendoring only when it lives below the checked-in vendor tree. - vendored = _strictly_below(package_dir, build_root / "vendor") + # Protocol Core 4.2.3 withholds that allowance from a module carrying a + # replacement, because a replaced module is the package's own first-party + # code that merely compiles from its vendor copy. + vendored = _strictly_below(package_dir, build_root / "vendor") and not _carries_replacement( + module, replaced_modules + ) if _string_list(item.get("SysoFiles"), "SysoFiles"): raise GoV1Error( @@ -967,17 +989,37 @@ def _validate_package_inputs( ) +def _carries_replacement( + module: Mapping[str, object] | None, + replaced_modules: frozenset[str], +) -> bool: + """Return whether this package resolves through an admitted replacement.""" + if module is None or module.get("Replace") is None: + return False + return _optional_string(module.get("Path")) in replaced_modules + + def _validate_module( item: Mapping[str, object], module: Mapping[str, object] | None, build_root: Path, + replaced_modules: frozenset[str] = frozenset(), ) -> None: import_path = _optional_string(item.get("ImportPath")) if ( module is None or not _optional_string(module.get("Path")) or module.get("Error") is not None - or module.get("Replace") is not None + ): + raise GoV1Error( + "vendor_metadata_inconsistent", + f"non-standard package {import_path!r} has invalid module metadata", + ) + # A replacement is admitted only on the exact set the bijection proved + # against vendor/modules.txt. Module.Replace is never the source of that + # set, and Module.Replace.Dir and .GoMod are never evidence a path exists. + if module.get("Replace") is not None and not _carries_replacement( + module, replaced_modules ): raise GoV1Error( "vendor_metadata_inconsistent", @@ -6744,6 +6786,9 @@ def build( request.build_root, request.source_dir, ) + # Protocol Core 4.2.3 step 1: the declaration is validated against the + # frozen snapshot alone, before the fixed go list starts a single process. + _validate_declared_module_roots(request, source_root) _emit_state(_state_observer, SESSION_STATES[0]) platform, probes = probe_native_controls( @@ -6847,12 +6892,18 @@ def build( request.toolchain_session.operation_root, limits.disk_bytes, ) + # Protocol Core 4.2.3 step 3: the effective replace set is read from + # vendor/modules.txt only, reconciled, and checked against the + # declaration in both directions before go build. + replaced_modules = _resolve_module_root_bijection(request, build_root) validate_package_graph( list_result.stdout, build_root=build_root, source_dir=source_dir, goroot=request.toolchain_session.goroot, + replaced_modules=replaced_modules, ) + _scan_declared_module_roots(request, source_root) _emit_state(_state_observer, SESSION_STATES[7]) _emit_state(_state_observer, SESSION_STATES[8]) @@ -6946,7 +6997,10 @@ def _validate_package_command_surface(request: BuildRequest) -> None: "package build command has a non-string field", ) keys.append(key) - extra = sorted(set(keys) - {"type", "driver", "source_dir"}) + closed_surface = {"type", "driver", "source_dir"} + if request.modules: + closed_surface.add("modules") + extra = sorted(set(keys) - closed_surface) if extra: surfaces = { "executable": "worker, Go launcher, or GOROOT tool program", @@ -6971,7 +7025,7 @@ def _validate_package_command_surface(request: BuildRequest) -> None: CODE_PACKAGE_INFLUENCE_FORBIDDEN, f"package field {extra[0]!r} selects the {surface}", ) - if set(keys) != {"type", "driver", "source_dir"}: + if set(keys) != closed_surface: raise GoV1Error( CODE_PACKAGE_INFLUENCE_FORBIDDEN, "package build command is not the exact closed surface", @@ -6985,6 +7039,11 @@ def _validate_package_command_surface(request: BuildRequest) -> None: CODE_PACKAGE_INFLUENCE_FORBIDDEN, "package build command differs from the validated go-v1 command", ) + if request.modules and list(request.modules) != request.command_object.get("modules"): + raise GoV1Error( + CODE_PACKAGE_INFLUENCE_FORBIDDEN, + "package build command differs from the validated go-v1 command", + ) if not is_valid_identifier(request.command): raise GoV1Error( "invalid_build_request", @@ -7055,6 +7114,170 @@ def _normalize_limits(limits: ResourceLimits) -> ResourceLimits: return values +# Go's own package loader ignores these directory names outright, so a file +# below one of them can never be an active input of a declared module. +_IGNORED_MODULE_SUBTREES: Final[frozenset[str]] = frozenset({"testdata", "vendor"}) + +# Every native-input extension Go reports as CgoFiles, CFiles, CXXFiles, +# MFiles, HFiles, FFiles, SwigFiles, SwigCXXFiles, SFiles, or SysoFiles. A +# declared module directory is first-party code and carries none of them. +_FORBIDDEN_MODULE_INPUT_SUFFIXES: Final[tuple[str, ...]] = ( + ".c", + ".cc", + ".cpp", + ".cxx", + ".f", + ".for", + ".f90", + ".h", + ".hh", + ".hpp", + ".hxx", + ".m", + ".mm", + ".s", + ".swig", + ".swigcxx", + ".syso", +) + + +def _module_root_failure(exc: module_roots.ModuleRootError) -> GoV1Error: + return GoV1Error(exc.code, exc.detail) + + +def _validate_declared_module_roots( + request: BuildRequest, + source_root: Path, +) -> None: + """Validate the schema-8 declaration before the fixed ``go list`` runs.""" + if not request.modules: + return + build_roots = request.build_roots or (request.build_root,) + try: + module_roots.validate_declaration( + source_root, + request.modules, + build_root=request.build_root, + build_roots=build_roots, + runtime_roots=request.runtime_roots, + label=f"commands.{request.command}", + ) + except module_roots.ModuleRootError as exc: + raise _module_root_failure(exc) from exc + + +def _resolve_module_root_bijection( + request: BuildRequest, + build_root: Path, +) -> frozenset[str]: + """Check the declaration against the effective replace set both ways.""" + try: + replacements = module_roots.parse_effective_replacements( + module_roots.read_vendor_modules_text(build_root) + ) + return module_roots.resolve_bijection( + request.modules, + replacements, + build_root=request.build_root, + ) + except module_roots.ModuleRootError as exc: + raise _module_root_failure(exc) from exc + + +def _scan_declared_module_roots( + request: BuildRequest, + source_root: Path, +) -> None: + """Extend the scan surface of section 4.2 over every declared module. + + ``go list`` never reports the declared directory, only its vendor copy, so + the manager walks the directory itself. The walk is deliberately a superset + of Go's active-input set: it can reject a first-party module, never admit + one the fixed ``go list`` would have rejected. + """ + + for relative in request.modules: + root = source_root.joinpath(*PurePosixPath(relative).parts) + _reject_module_root_toolchain_directive(root / "go.mod", relative) + for directory, names, files in os.walk(root, topdown=True, followlinks=False): + names[:] = sorted( + name + for name in names + if not name.startswith((".", "_")) and name not in _IGNORED_MODULE_SUBTREES + ) + if "go.work" in files: + raise GoV1Error( + "workspace_dependency_forbidden", + f"declared module {relative!r} contains a forbidden go.work", + ) + for name in sorted(files): + if name.startswith((".", "_")): + continue + path = Path(directory) / name + lowered = name.lower() + if lowered.endswith(_FORBIDDEN_MODULE_INPUT_SUFFIXES): + code = ( + "go_syso_forbidden" + if lowered.endswith(".syso") + else "go_assembly_forbidden" + if lowered.endswith(".s") + else "go_native_input_forbidden" + ) + raise GoV1Error( + code, + f"declared module {relative!r} contains the native input " + f"{name!r}", + ) + if lowered.endswith(".go"): + _scan_module_root_source(path, relative) + + +def _reject_module_root_toolchain_directive(go_mod: Path, relative: str) -> None: + """Keep a declared module from selecting a toolchain the manager did not pin.""" + try: + payload = go_mod.read_bytes() + except OSError as exc: + raise GoV1Error( + "build_module_missing", + f"declared module {relative!r} go.mod is unreadable", + ) from exc + for line in payload.splitlines(): + fields = line.split() + if fields and fields[0] == b"toolchain": + raise GoV1Error( + "toolchain_switch_forbidden", + f"declared module {relative!r} go.mod contains a " + "package-selected toolchain directive", + ) + + +def _scan_module_root_source(path: Path, relative: str) -> None: + """Apply the exact-bytes ``//go:cgo_import_dynamic`` scan to one file.""" + try: + with path.open("rb", buffering=0) as handle: + carry = b"" + while True: + chunk = handle.read(64 * 1024) + if not chunk: + return + window = carry + chunk + if b"//go:cgo_import_dynamic" in window: + raise GoV1Error( + "go_forbidden_compiler_directive", + f"declared module {relative!r} contains " + "//go:cgo_import_dynamic", + ) + carry = window[-31:] + except GoV1Error: + raise + except (OSError, ValueError) as exc: + raise GoV1Error( + "go_source_unreadable", + f"cannot read an active Go file in declared module {relative!r}", + ) from exc + + def _canonical_build_directories( snapshot: source.FrozenSnapshot, build_root_value: str, diff --git a/src/csk/builds/module_roots.py b/src/csk/builds/module_roots.py new file mode 100644 index 0000000..4dbc77f --- /dev/null +++ b/src/csk/builds/module_roots.py @@ -0,0 +1,346 @@ +"""Schema-8 first-party module roots for the local ``go-v1`` driver. + +Protocol Core section 4.2.3 lets one schema-8 build command declare the +first-party Go modules its build root replaces. The package states a claim and +the manager checks it: nothing here reads a replacement as an instruction, +discovers a directory, or lets package data select which paths are trusted. + +The module owns three separable steps so that the manager profile's fixed order +survives refactoring: + +1. :func:`validate_declaration` runs against the frozen snapshot alone and + completes before the fixed ``go list``. +2. :func:`parse_effective_replacements` reads ``/vendor/modules.txt`` + after ``go list`` returns. That file is the only surface the effective + replace set is read from. +3. :func:`resolve_bijection` checks the one-to-one correspondence between the + declared directories and the effective directives, and returns the module + paths whose vendor copies may carry a replacement. +""" + +from __future__ import annotations + +import stat +import unicodedata +from dataclasses import dataclass +from pathlib import Path, PurePosixPath +from typing import Final + +from ..identifiers import is_valid_portable_path + + +CODE_DECLARATION_INVALID: Final = "build_module_root_declaration_invalid" +CODE_CONTAINMENT_INVALID: Final = "build_module_root_containment_invalid" +CODE_DIRECTIVE_FORM_UNSUPPORTED: Final = "build_module_root_directive_form_unsupported" +CODE_DIRECTIVE_UNDECLARED: Final = "build_module_root_directive_undeclared" +CODE_DECLARATION_UNUSED: Final = "build_module_root_declaration_unused" + +MODULE_ROOT_DIAGNOSTICS: Final[frozenset[str]] = frozenset( + { + CODE_DECLARATION_INVALID, + CODE_CONTAINMENT_INVALID, + CODE_DIRECTIVE_FORM_UNSUPPORTED, + CODE_DIRECTIVE_UNDECLARED, + CODE_DECLARATION_UNUSED, + } +) + +_ANNOTATION_PREFIX: Final = "# " +_ANNOTATION_ARROW: Final = " => " +_MODULES_TXT: Final = "vendor/modules.txt" + + +class ModuleRootError(Exception): + """One stable ``phase: preflight`` module-root diagnostic.""" + + def __init__(self, code: str, detail: str) -> None: + super().__init__(f"{code}: {detail}") + self.code = code + self.detail = detail + + +@dataclass(frozen=True) +class Replacement: + """One effective, unversioned-left replacement directive.""" + + module_path: str + target: str + + +def platform_path_key(value: str) -> str: + """Fold one protocol path the way a case-insensitive host would map it.""" + return unicodedata.normalize("NFD", unicodedata.normalize("NFD", value).casefold()) + + +def validate_declaration( + snapshot: Path, + modules: tuple[str, ...], + *, + build_root: str, + build_roots: tuple[str, ...], + runtime_roots: tuple[str, ...], + label: str, +) -> None: + """Validate a declared module list against the frozen snapshot alone. + + ``Module.Replace.Dir`` and ``Module.Replace.GoMod`` are never evidence that + a path exists, so this step never looks at a ``go list`` stream. + """ + + seen: set[str] = set() + for index, value in enumerate(modules): + field = f"{label}.modules[{index}]" + if not isinstance(value, str) or not value: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} must be a non-empty portable relative path", + ) + if value == "." or not is_valid_portable_path(value): + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} must be a portable relative path other than '.': {value!r}", + ) + if PurePosixPath(value).as_posix() != value or ".." in PurePosixPath(value).parts: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} must be a normalized relative path: {value!r}", + ) + if value in seen: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} declares a duplicate module directory: {value!r}", + ) + seen.add(value) + _require_link_free_directory(snapshot, value, field=field) + _require_direct_go_mod(snapshot, value, field=field) + + _reject_overlaps(modules, build_root, build_roots, runtime_roots) + + +def _require_link_free_directory(snapshot: Path, relative: str, *, field: str) -> None: + current = snapshot + for component in PurePosixPath(relative).parts: + current = current / component + try: + info = current.lstat() + except FileNotFoundError as exc: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} module directory does not exist: {relative}", + ) from exc + except OSError as exc: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} cannot inspect module directory {relative}: {exc}", + ) from exc + if stat.S_ISLNK(info.st_mode): + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} module directory must be link-free: {relative}", + ) + if not stat.S_ISDIR(info.st_mode): + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} module directory must be a directory: {relative}", + ) + + +def _require_direct_go_mod(snapshot: Path, relative: str, *, field: str) -> None: + go_mod = snapshot.joinpath(*PurePosixPath(relative).parts, "go.mod") + try: + info = go_mod.lstat() + except FileNotFoundError as exc: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} module directory has no go.mod directly inside it: {relative}", + ) from exc + except OSError as exc: + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} cannot inspect {relative}/go.mod: {exc}", + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise ModuleRootError( + CODE_DECLARATION_INVALID, + f"{field} module go.mod must be a real regular file: {relative}/go.mod", + ) + + +def _reject_overlaps( + modules: tuple[str, ...], + build_root: str, + build_roots: tuple[str, ...], + runtime_roots: tuple[str, ...], +) -> None: + others: list[tuple[str, str]] = [("build root", root) for root in dict.fromkeys((build_root, *build_roots))] + others.extend(("runtime root", root) for root in runtime_roots) + ordered = sorted(modules) + for index, left in enumerate(ordered): + for right in ordered[index + 1 :]: + _reject_pair("declared module directory", left, "declared module directory", right) + for noun, root in others: + _reject_pair("declared module directory", left, noun, root) + + +def _reject_pair(left_noun: str, left: str, right_noun: str, right: str) -> None: + if not _overlaps(left, right): + return + raise ModuleRootError( + CODE_CONTAINMENT_INVALID, + f"{left_noun} {left!r} overlaps {right_noun} {right!r}", + ) + + +def _overlaps(left: str, right: str) -> bool: + return _contains(left, right) or _contains(right, left) + + +def _contains(root: str, path: str) -> bool: + """Return containment under exact and platform-path comparison.""" + root_parts = PurePosixPath(root).parts + path_parts = PurePosixPath(path).parts + if len(path_parts) < len(root_parts): + return False + prefix = path_parts[: len(root_parts)] + if prefix == root_parts: + return True + return tuple(platform_path_key(part) for part in prefix) == tuple( + platform_path_key(part) for part in root_parts + ) + + +def read_vendor_modules_text(build_root: Path) -> str: + """Read ``/vendor/modules.txt`` as the single effective surface.""" + path = build_root.joinpath("vendor", "modules.txt") + try: + info = path.lstat() + except FileNotFoundError: + return "" + except OSError as exc: + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + f"cannot inspect {_MODULES_TXT}: {exc}", + ) from exc + if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode): + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + f"{_MODULES_TXT} must be a real regular file below the build root", + ) + try: + return path.read_bytes().decode("utf-8", errors="strict") + except (OSError, UnicodeDecodeError) as exc: + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + f"{_MODULES_TXT} is unreadable: {exc}", + ) from exc + + +def parse_effective_replacements(text: str) -> tuple[Replacement, ...]: + """Extract the effective replace set from ``vendor/modules.txt`` bytes. + + Only a line whose first two bytes are exactly ``"# "`` and which contains + the exact bytes ``" => "`` is a replacement annotation. A one-token-left + annotation is the effective directive; a two-token-left annotation is + selection metadata that MUST reconcile against an identical one-token-left + directive, which is what rejects a versioned left side without parsing + ``go.mod``. + """ + + directives: list[Replacement] = [] + selections: list[tuple[str, tuple[str, ...]]] = [] + directive_keys: set[tuple[str, tuple[str, ...]]] = set() + for raw_line in text.splitlines(): + if not raw_line.startswith(_ANNOTATION_PREFIX) or _ANNOTATION_ARROW not in raw_line: + continue + head, _, tail = raw_line.partition(_ANNOTATION_ARROW) + left_tokens = tuple(head[len(_ANNOTATION_PREFIX) :].split()) + right_tokens = tuple(tail.split()) + if not 1 <= len(left_tokens) <= 2 or not 1 <= len(right_tokens) <= 2: + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + f"{_MODULES_TXT} carries an unreadable replacement annotation: {raw_line!r}", + ) + if len(left_tokens) == 2: + selections.append((left_tokens[0], right_tokens)) + continue + if len(right_tokens) != 1: + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + "a module-to-module redirect is not a directory replacement: " + f"{raw_line!r}", + ) + directive_keys.add((left_tokens[0], right_tokens)) + directives.append(Replacement(module_path=left_tokens[0], target=right_tokens[0])) + + for module_path, right_tokens in selections: + if (module_path, right_tokens) not in directive_keys: + raise ModuleRootError( + CODE_DIRECTIVE_FORM_UNSUPPORTED, + "a versioned replacement side has no matching unversioned " + f"directive: {module_path!r}", + ) + return tuple(directives) + + +def resolve_bijection( + modules: tuple[str, ...], + replacements: tuple[Replacement, ...], + *, + build_root: str, +) -> frozenset[str]: + """Check the declaration against the effective replace set both ways. + + Returns the module paths whose vendored packages are permitted to carry a + ``Module.Replace`` record. + """ + + declared = set(modules) + claimed: dict[str, str] = {} + for replacement in replacements: + resolved = _resolve_target(replacement.target, build_root=build_root) + if resolved is None or resolved not in declared: + raise ModuleRootError( + CODE_DIRECTIVE_UNDECLARED, + f"replacement {replacement.module_path!r} => {replacement.target!r} " + "names no declared module directory", + ) + previous = claimed.get(resolved) + if previous is not None: + raise ModuleRootError( + CODE_DIRECTIVE_UNDECLARED, + f"declared module directory {resolved!r} is named by both " + f"{previous!r} and {replacement.module_path!r}", + ) + claimed[resolved] = replacement.module_path + + unused = sorted(declared - set(claimed)) + if unused: + raise ModuleRootError( + CODE_DECLARATION_UNUSED, + f"declared module directory {unused[0]!r} is named by no replacement", + ) + return frozenset(claimed.values()) + + +def _resolve_target(target: str, *, build_root: str) -> str | None: + """Resolve one right-hand directory token against the build root.""" + if not target or target.startswith("/") or "\\" in target: + return None + if PurePosixPath(target).is_absolute(): + return None + parts: list[str] = list(PurePosixPath(build_root).parts) + for component in PurePosixPath(target).parts: + if component == ".": + continue + if component == "..": + if not parts: + return None + parts.pop() + continue + parts.append(component) + if not parts: + return None + resolved = PurePosixPath(*parts).as_posix() + if not is_valid_portable_path(resolved): + return None + return resolved diff --git a/src/csk/builds/planner.py b/src/csk/builds/planner.py index b38cb0a..14780da 100644 --- a/src/csk/builds/planner.py +++ b/src/csk/builds/planner.py @@ -41,6 +41,7 @@ class BuildCommand: driver: str build_root: str source_dir: str + modules: tuple[str, ...] = () def __post_init__(self) -> None: if not self.name: @@ -67,6 +68,8 @@ class BuildProvider: name: str snapshot: source.FrozenSnapshot commands: tuple[BuildCommand, ...] + build_roots: tuple[str, ...] = () + runtime_roots: tuple[str, ...] = () def __post_init__(self) -> None: if not self.name: @@ -239,9 +242,16 @@ def provider_from_spec( driver=command.driver, build_root=containing[0], source_dir=command.source_dir, + modules=command.modules, ) ) - return BuildProvider(name=name, snapshot=snapshot, commands=tuple(commands)) + return BuildProvider( + name=name, + snapshot=snapshot, + commands=tuple(commands), + build_roots=spec.build_roots, + runtime_roots=spec.runtime_roots, + ) def detect_command_collisions( diff --git a/src/csk/gc.py b/src/csk/gc.py index 8bcea2e..5cecf6f 100644 --- a/src/csk/gc.py +++ b/src/csk/gc.py @@ -361,7 +361,7 @@ def _collect_marker_directory( references.snapshots.add((marker.source, marker.commit)) if isinstance(marker, install_marker.InstallMarkerV2): references.builds.update(build.cache_key for build in marker.builds.values()) - elif isinstance(marker, install_marker.InstallMarkerV3): + elif isinstance(marker, (install_marker.InstallMarkerV3, install_marker.InstallMarkerV4)): for build in marker.builds.values(): if build.driver == "go-v1": references.builds.add(build.cache_key) diff --git a/src/csk/install_marker.py b/src/csk/install_marker.py index 40452da..65ff025 100644 --- a/src/csk/install_marker.py +++ b/src/csk/install_marker.py @@ -1,4 +1,4 @@ -"""Typed install-marker schemas 1, 2, and 3. +"""Typed install-marker schemas 1, 2, 3, and 4. The marker records portable installation state only. Physical build-cache, receipt, lock, quarantine, and manager-home paths are deliberately absent from @@ -26,11 +26,13 @@ INSTALL_MARKER_V1_SCHEMA_VERSION: Final = 1 INSTALL_MARKER_V2_SCHEMA_VERSION: Final = 2 INSTALL_MARKER_V3_SCHEMA_VERSION: Final = 3 +INSTALL_MARKER_V4_SCHEMA_VERSION: Final = 4 SUPPORTED_INSTALL_MARKER_SCHEMA_VERSIONS: Final[frozenset[int]] = frozenset( { INSTALL_MARKER_V1_SCHEMA_VERSION, INSTALL_MARKER_V2_SCHEMA_VERSION, INSTALL_MARKER_V3_SCHEMA_VERSION, + INSTALL_MARKER_V4_SCHEMA_VERSION, } ) @@ -83,6 +85,11 @@ ) _V3_REQUIRED_MEMBERS: Final[frozenset[str]] = _V2_REQUIRED_MEMBERS _V3_MEMBERS: Final[frozenset[str]] = _V2_MEMBERS +# Marker v4 carries marker-v3 meaning unchanged and only widens the manifest +# band it may describe: schema 8 changes which manifests a marker may describe, +# not what a marker records. +_V4_REQUIRED_MEMBERS: Final[frozenset[str]] = _V3_REQUIRED_MEMBERS +_V4_MEMBERS: Final[frozenset[str]] = _V3_MEMBERS _BUILD_SOURCE_MEMBERS: Final[frozenset[str]] = frozenset({"algorithm", "content_sha256"}) _BUILD_RECORD_MEMBERS: Final[frozenset[str]] = frozenset( {"driver", "cache_key", "receipt_sha256", "artifact_sha256", "artifact_path"} @@ -636,20 +643,27 @@ def to_json(self) -> dict[str, Any]: @dataclass(frozen=True, kw_only=True) -class InstallMarkerV3(_InstallMarkerCommon): - """Schema-7 marker supporting local and external compiled commands.""" +class _InstallMarkerExternalCapable(_InstallMarkerCommon): + """Shared marker body for local, external, and mixed command sets.""" + schema_version: int build_roots: tuple[str, ...] builds: Mapping[str, InstallMarkerBuildV3] build_source: BuildSourceIdentity | None = None - schema_version: Literal[3] = INSTALL_MARKER_V3_SCHEMA_VERSION - def __post_init__(self) -> None: - if self.schema_version != INSTALL_MARKER_V3_SCHEMA_VERSION: - raise InstallMarkerError("unsupported_install_marker_schema", f"marker v3 schema_version must be 3, got {self.schema_version!r}") - self._validate_common(maximum_skill_schema=7) - if self.skill_schema_version != 7: - raise InstallMarkerError("install_marker_invalid", "marker v3 requires skill_schema_version 7") + def _validate_external_capable(self, *, marker_version: int, skill_schema: int) -> None: + if self.schema_version != marker_version: + raise InstallMarkerError( + "unsupported_install_marker_schema", + f"marker v{marker_version} schema_version must be {marker_version}, " + f"got {self.schema_version!r}", + ) + self._validate_common(maximum_skill_schema=skill_schema) + if self.skill_schema_version != skill_schema: + raise InstallMarkerError( + "install_marker_invalid", + f"marker v{marker_version} requires skill_schema_version {skill_schema}", + ) object.__setattr__(self, "build_roots", _freeze_path_set(self.build_roots, "build_roots")) object.__setattr__(self, "builds", _freeze_builds_v3(self.builds)) local_builds = [build for build in self.builds.values() if build.driver == GO_V1_DRIVER] @@ -659,14 +673,17 @@ def __post_init__(self) -> None: if self.build_source is not None: _validate_build_source(self.build_source) if local_builds and not self.build_roots: - raise InstallMarkerError("install_marker_invalid", "local marker-v3 builds require build_roots") + raise InstallMarkerError( + "install_marker_invalid", + f"local marker-v{marker_version} builds require build_roots", + ) for name, build in self.builds.items(): if name not in self.commands: raise InstallMarkerError("install_marker_invalid", f"build {name!r} is not present in commands") _validate_build_artifact_path(name, build.artifact_path) - def to_json(self) -> dict[str, Any]: - result = self._common_json(INSTALL_MARKER_V3_SCHEMA_VERSION) + def _external_capable_json(self, marker_version: int) -> dict[str, Any]: + result = self._common_json(marker_version) result["build_roots"] = list(self.build_roots) result["builds"] = {name: build.to_json() for name, build in self.builds.items()} if self.build_source is not None: @@ -677,8 +694,47 @@ def to_json(self) -> dict[str, Any]: return result +@dataclass(frozen=True, kw_only=True) +class InstallMarkerV3(_InstallMarkerExternalCapable): + """Schema-7 marker supporting local and external compiled commands.""" + + schema_version: Literal[3] = INSTALL_MARKER_V3_SCHEMA_VERSION + + def __post_init__(self) -> None: + self._validate_external_capable( + marker_version=INSTALL_MARKER_V3_SCHEMA_VERSION, + skill_schema=7, + ) + + def to_json(self) -> dict[str, Any]: + return self._external_capable_json(INSTALL_MARKER_V3_SCHEMA_VERSION) + + +@dataclass(frozen=True, kw_only=True) +class InstallMarkerV4(_InstallMarkerExternalCapable): + """Schema-8 marker: marker-v3 meaning over a schema-8 manifest. + + An enforced ``script-worker-v1`` command produces no build entry and adds + no marker member, so the object shape, the build-entry semantics, and the + top-level ``build_source`` and ``build_roots`` rules are marker-v3's. + """ + + schema_version: Literal[4] = INSTALL_MARKER_V4_SCHEMA_VERSION + + def __post_init__(self) -> None: + self._validate_external_capable( + marker_version=INSTALL_MARKER_V4_SCHEMA_VERSION, + skill_schema=8, + ) + + def to_json(self) -> dict[str, Any]: + return self._external_capable_json(INSTALL_MARKER_V4_SCHEMA_VERSION) + + MarkerBuild: TypeAlias = InstallMarkerBuild | InstallMarkerBuildV3 -InstallMarker: TypeAlias = InstallMarkerV1 | InstallMarkerV2 | InstallMarkerV3 +InstallMarker: TypeAlias = ( + InstallMarkerV1 | InstallMarkerV2 | InstallMarkerV3 | InstallMarkerV4 +) def serialize_install_marker(payload: Mapping[str, Any]) -> bytes: @@ -704,7 +760,7 @@ def read_install_marker(raw: bytes | str) -> InstallMarker: def parse_install_marker(value: Any) -> InstallMarker: - """Parse one decoded marker schema 1, 2, or 3 and freeze set ordering.""" + """Parse one decoded marker schema 1 through 4 and freeze set ordering.""" body = _require_object(value, "marker") schema_version = body.get("schema_version") if ( @@ -737,8 +793,22 @@ def parse_install_marker(value: Any) -> InstallMarker: schema_version=INSTALL_MARKER_V2_SCHEMA_VERSION, ) - _validate_object_shape(body, _V3_REQUIRED_MEMBERS, _V3_MEMBERS, "marker v3") - return InstallMarkerV3( + if schema_version == INSTALL_MARKER_V3_SCHEMA_VERSION: + _validate_object_shape(body, _V3_REQUIRED_MEMBERS, _V3_MEMBERS, "marker v3") + return InstallMarkerV3( + **_parse_common(body), + build_roots=_parse_array(body["build_roots"], "build_roots"), + builds=_parse_builds_v3(body["builds"]), + build_source=( + _parse_build_source(body["build_source"]) + if "build_source" in body + else None + ), + schema_version=INSTALL_MARKER_V3_SCHEMA_VERSION, + ) + + _validate_object_shape(body, _V4_REQUIRED_MEMBERS, _V4_MEMBERS, "marker v4") + return InstallMarkerV4( **_parse_common(body), build_roots=_parse_array(body["build_roots"], "build_roots"), builds=_parse_builds_v3(body["builds"]), @@ -747,7 +817,7 @@ def parse_install_marker(value: Any) -> InstallMarker: if "build_source" in body else None ), - schema_version=INSTALL_MARKER_V3_SCHEMA_VERSION, + schema_version=INSTALL_MARKER_V4_SCHEMA_VERSION, ) @@ -767,7 +837,9 @@ def marker_can_be_current( return 0 <= skill_schema_version <= 5 if isinstance(marker, InstallMarkerV2): return 0 <= skill_schema_version <= 6 - return skill_schema_version == 7 + if isinstance(marker, InstallMarkerV3): + return skill_schema_version == 7 + return skill_schema_version == 8 def _parse_common(body: dict[str, Any]) -> dict[str, Any]: diff --git a/src/csk/installer.py b/src/csk/installer.py index 024d31b..d56b89e 100644 --- a/src/csk/installer.py +++ b/src/csk/installer.py @@ -874,7 +874,10 @@ def _existing_external_snapshot_key( marker = install_marker.read_install_marker(path.read_bytes()) except (OSError, install_marker.InstallMarkerError): continue - if not isinstance(marker, install_marker.InstallMarkerV3): + if not isinstance( + marker, + (install_marker.InstallMarkerV3, install_marker.InstallMarkerV4), + ): continue build = marker.builds.get(command) if ( @@ -2164,19 +2167,26 @@ def _build_private_misses( def run_build( frozen: build_source.FrozenSnapshot, command_spec: build_planner.BuildCommand = command, + provider_spec: build_planner.BuildProvider = provider, ) -> go_v1.BuildResult: + command_object: dict[str, Any] = { + "type": "build", + "driver": command_spec.driver, + "source_dir": command_spec.source_dir, + } + if command_spec.modules: + command_object["modules"] = list(command_spec.modules) return go_v1.build( go_v1.BuildRequest( toolchain_session=session, source_snapshot=frozen, - command_object={ - "type": "build", - "driver": command_spec.driver, - "source_dir": command_spec.source_dir, - }, + command_object=command_object, build_root=command_spec.build_root, source_dir=command_spec.source_dir, command=command_spec.name, + modules=command_spec.modules, + build_roots=provider_spec.build_roots, + runtime_roots=provider_spec.runtime_roots, ) ) @@ -3271,6 +3281,12 @@ def install_runtime_commands( activation_bin_dir: Path | None = None, ) -> set[str]: commands: set[str] = set() + # Fail closed at the single shim publication point. A manager that does not + # implement the selected script execution policy must never publish the + # command's shim, not even declared-only. + rejection = skillspec.script_execution_policy_rejection(plan.spec) + if rejection is not None: + raise InstallError(f"{plan.decl.name}: {rejection}") final_home = csk_home if activation_home is None else activation_home final_bin = bin_dir if activation_bin_dir is None else activation_bin_dir path_entries = _runtime_path_entries(plan, final_bin) @@ -3548,7 +3564,7 @@ def _marker_payload( requirers=tuple(requirers) if requirers else None, substituted=substituted, ) - if plan.spec.schema_version == 7: + if plan.spec.schema_version >= 7: marker_builds: dict[str, install_marker.InstallMarkerBuildV3] = {} for name, build in (builds or {}).items(): if isinstance(build, install_marker.InstallMarkerBuildV3): @@ -3563,12 +3579,23 @@ def _marker_payload( artifact_sha256=build.artifact_sha256, artifact_path=build.artifact_path, ) - marker: install_marker.InstallMarker = install_marker.InstallMarkerV3( - **common, - build_roots=plan.spec.build_roots, - builds=marker_builds, - build_source=build_source_identity, - ) + # Marker v3 records a schema-7 installation and marker v4 a schema-8 + # one. The two shapes are identical; only the manifest band differs. + marker: install_marker.InstallMarker + if plan.spec.schema_version == 7: + marker = install_marker.InstallMarkerV3( + **common, + build_roots=plan.spec.build_roots, + builds=marker_builds, + build_source=build_source_identity, + ) + else: + marker = install_marker.InstallMarkerV4( + **common, + build_roots=plan.spec.build_roots, + builds=marker_builds, + build_source=build_source_identity, + ) else: local_builds = { name: build @@ -3576,7 +3603,7 @@ def _marker_payload( if isinstance(build, install_marker.InstallMarkerBuild) } if len(local_builds) != len(builds or {}): - raise InstallError("external marker state requires skill schema 7") + raise InstallError("external marker state requires skill schema 7 or newer") marker = install_marker.InstallMarkerV2( **common, build_roots=plan.spec.build_roots, diff --git a/src/csk/skillcheck.py b/src/csk/skillcheck.py index b795fc7..0c9f6ba 100644 --- a/src/csk/skillcheck.py +++ b/src/csk/skillcheck.py @@ -40,6 +40,16 @@ def validate_skill(skill_dir: Path, *, locale_value: str | None = None) -> list[ ) if spec is not None: + rejection = skillspec.script_execution_policy_rejection(spec) + if rejection is not None: + issues.append( + ValidationIssue( + "error", + "skill.script_execution_policy_unsupported", + _skill_spec_path(skill_dir), + rejection, + ) + ) issues.extend(_runtime_root_reference_warnings(skill_dir, spec)) issues.extend(_command_resolution_warnings(skill_dir, spec)) diff --git a/src/csk/skillspec.py b/src/csk/skillspec.py index 0dd22d1..cad7acd 100644 --- a/src/csk/skillspec.py +++ b/src/csk/skillspec.py @@ -16,11 +16,12 @@ parse_repository_source, ) from .builds import GO_V1_DRIVER +from .builds.module_roots import ModuleRootError, validate_declaration from .identifiers import IDENTIFIER_RULE, is_valid_identifier, is_valid_portable_path SCHEMA_VERSION = 1 -SUPPORTED_SCHEMA_VERSIONS = {1, 2, 3, 4, 5, 6, 7} +SUPPORTED_SCHEMA_VERSIONS = {1, 2, 3, 4, 5, 6, 7, 8} CANONICAL_MANIFEST = "agent-skill.json" LEGACY_MANIFEST = "csk-skill.json" RUNTIME_FALLBACK = "agents/runtime.json" @@ -33,9 +34,42 @@ REQUIREMENT_REF_KINDS = {"tag", "revision"} _RANGE_MARKERS = ("^", "~", ">", "<", "*", " ") _SCHEMA_V1_RESERVED_TOP_LEVEL_FIELDS = frozenset( - {"build_roots", "build_repositories", "driver", "repository", "target"} + { + "build_roots", + "build_repositories", + "driver", + "repository", + "target", + "modules", + "execution_policy", + "interpreter", + } ) -_SCHEMA_V1_RESERVED_COMMAND_FIELDS = frozenset({"driver", "source_dir", "repository", "target"}) +_SCHEMA_V1_RESERVED_COMMAND_FIELDS = frozenset( + { + "driver", + "source_dir", + "repository", + "target", + "modules", + "execution_policy", + "interpreter", + } +) + +# Protocol 1.0 defines exactly one script execution policy and exactly two +# interpreter identifiers. Both value spaces are closed: a manifest that names +# anything else is invalid, never a forward-compatible extension. +SCRIPT_WORKER_V1_POLICY = "script-worker-v1" +SCRIPT_EXECUTION_POLICIES = frozenset({SCRIPT_WORKER_V1_POLICY}) +SCRIPT_INTERPRETERS = frozenset({"node-v1", "python3-v1"}) + +# csk does not implement ``script-worker-v1``. Protocol Core section 4.1.1 +# requires such a manager to reject an enforced command outright: installing it +# declared-only, downgrading it, or ignoring the field would publish a shim +# that runs package code the manifest says is contained. +SCRIPT_EXECUTION_POLICIES_IMPLEMENTED: frozenset[str] = frozenset() +SCRIPT_EXECUTION_POLICY_UNSUPPORTED = "script_execution_policy_unsupported" MCP_TRANSPORTS = {"stdio", "http"} MCP_REQUIRED_IN = {"any", "all"} @@ -58,6 +92,9 @@ class CommandSpec: source_dir: str | None = None repository: str | None = None target: str | None = None + execution_policy: str | None = None + interpreter: str | None = None + modules: tuple[str, ...] = () @dataclass(frozen=True) @@ -224,7 +261,13 @@ def _load_skill_manifest(path: Path) -> tuple[SkillSpec, dict[str, Any]]: command_type = raw.get("type") if command_type == "script": if schema >= 2: - _reject_unknown_fields(raw, {"type", "unix_path", "win_path"}, f"commands.{name}") + allowed_script_fields = {"type", "unix_path", "win_path"} + if schema >= 8: + allowed_script_fields |= {"execution_policy", "interpreter"} + _reject_unknown_fields(raw, allowed_script_fields, f"commands.{name}") + execution_policy, interpreter = _parse_script_execution_policy( + raw, schema=schema, label=f"commands.{name}" + ) unix_path = raw.get("unix_path") win_path = raw.get("win_path") if schema >= 2 and unix_path is None and win_path is None: @@ -250,6 +293,8 @@ def _load_skill_manifest(path: Path) -> tuple[SkillSpec, dict[str, Any]]: type="script", unix_path=unix_path, win_path=win_path, + execution_policy=execution_policy, + interpreter=interpreter, source=source_file, ) elif command_type == "system": @@ -273,18 +318,27 @@ def _load_skill_manifest(path: Path) -> tuple[SkillSpec, dict[str, Any]]: source=source_file, ) elif command_type == "build" and schema >= 6 and raw.get("driver") == GO_V1_DRIVER: - _reject_unknown_fields(raw, {"type", "driver", "source_dir"}, f"commands.{name}") + allowed_build_fields = {"type", "driver", "source_dir"} + if schema >= 8: + allowed_build_fields.add("modules") + _reject_unknown_fields(raw, allowed_build_fields, f"commands.{name}") driver = raw.get("driver") source_dir = _validate_relative_path( raw.get("source_dir"), field=f"commands.{name}.source_dir", strict_posix=True, ) + modules = ( + _parse_declared_modules(raw["modules"], label=f"commands.{name}") + if "modules" in raw + else () + ) commands[name] = CommandSpec( name=name, type="build", driver=driver, source_dir=source_dir, + modules=modules, source=source_file, ) elif command_type == "build" and schema >= 7 and raw.get("driver") == GO_REPOSITORY_V1_DRIVER: @@ -313,7 +367,7 @@ def _load_skill_manifest(path: Path) -> tuple[SkillSpec, dict[str, Any]]: else: raise SkillSpecError(f"Command {name!r} has unsupported type {command_type!r}") if schema >= 6: - _validate_build_layout(path.parent, build_roots, commands) + _validate_build_layout(path.parent, build_roots, runtime_roots, commands, schema=schema) if schema >= 7: _validate_repository_commands(build_repositories, commands) dependencies, requirements, mcp_servers = _parse_dependencies( @@ -697,10 +751,71 @@ def _overlapping_roots(roots: list[str] | tuple[str, ...]) -> tuple[str, str] | return None +def _parse_script_execution_policy( + raw: dict[str, Any], *, schema: int, label: str +) -> tuple[str | None, str | None]: + """Parse the schema-8 opt-in into the enforced script execution policy. + + ``execution_policy`` and ``interpreter`` are co-required. A manifest that + declares one without the other is invalid, and a manager must not resolve + the missing field to a default or install the command declared-only. + """ + + if schema < 8: + return None, None + has_policy = "execution_policy" in raw + has_interpreter = "interpreter" in raw + if not has_policy and not has_interpreter: + return None, None + if not has_policy or not has_interpreter: + missing = "interpreter" if not has_interpreter else "execution_policy" + raise SkillSpecError( + f"{label} declares an execution policy without {missing!r}; " + "the two fields are co-required" + ) + execution_policy = raw["execution_policy"] + interpreter = raw["interpreter"] + if not isinstance(execution_policy, str) or execution_policy not in SCRIPT_EXECUTION_POLICIES: + admitted = ", ".join(repr(value) for value in sorted(SCRIPT_EXECUTION_POLICIES)) + raise SkillSpecError( + f"{label}.execution_policy must be {admitted}, got {execution_policy!r}" + ) + if not isinstance(interpreter, str) or interpreter not in SCRIPT_INTERPRETERS: + admitted = ", ".join(repr(value) for value in sorted(SCRIPT_INTERPRETERS)) + raise SkillSpecError( + f"{label}.interpreter must be one of {admitted}, got {interpreter!r}" + ) + return execution_policy, interpreter + + +def _parse_declared_modules(raw: Any, *, label: str) -> tuple[str, ...]: + """Parse a present schema-8 ``modules`` member before snapshot validation. + + Absence is the default; an explicit ``null`` is not a spelling of absence + and never reaches here. + """ + + if not isinstance(raw, list): + raise SkillSpecError(f"{label}.modules must be a list of portable relative paths") + modules: list[str] = [] + for index, value in enumerate(raw): + modules.append( + _validate_relative_path( + value, + field=f"{label}.modules[{index}]", + strict_posix=True, + ) + ) + return tuple(modules) + + def _validate_build_layout( snapshot: Path, build_roots: tuple[str, ...], + runtime_roots: tuple[str, ...], commands: dict[str, CommandSpec], + *, + schema: int, ) -> None: used_roots: set[str] = set() for name in sorted(commands): @@ -719,6 +834,18 @@ def _validate_build_layout( field = f"commands.{name}.source_dir" _validate_link_free_directory(snapshot, source_dir, field=field, noun="source directory") _validate_nearest_go_module(snapshot, build_root, source_dir, field=field) + if schema >= 8: + try: + validate_declaration( + snapshot, + command.modules, + build_root=build_root, + build_roots=build_roots, + runtime_roots=runtime_roots, + label=f"commands.{name}", + ) + except ModuleRootError as exc: + raise SkillSpecError(str(exc)) from exc used_roots.add(build_root) for index, root in enumerate(build_roots): @@ -791,3 +918,29 @@ def _reject_unknown_fields(data: dict[str, Any], allowed: set[str], label: str) if unknown: joined = ", ".join(repr(item) for item in unknown) raise SkillSpecError(f"{label} has unsupported field(s): {joined}") + + +def enforced_script_commands(spec: SkillSpec) -> tuple[CommandSpec, ...]: + """Return every script command selecting an unimplemented execution policy.""" + return tuple( + command + for _, command in sorted(spec.commands.items()) + if command.type == "script" + and command.execution_policy is not None + and command.execution_policy not in SCRIPT_EXECUTION_POLICIES_IMPLEMENTED + ) + + +def script_execution_policy_rejection(spec: SkillSpec) -> str | None: + """Return the fail-closed diagnostic text, or ``None`` when installable.""" + unsupported = enforced_script_commands(spec) + if not unsupported: + return None + listed = ", ".join( + f"{command.name} ({command.execution_policy})" for command in unsupported + ) + return ( + f"{SCRIPT_EXECUTION_POLICY_UNSUPPORTED}: this manager does not implement " + f"the selected script execution policy, so it refuses to install " + f"{listed}. The command is not downgraded to a declared-only shim." + ) diff --git a/src/csk/status.py b/src/csk/status.py index 3979ad7..f7761df 100644 --- a/src/csk/status.py +++ b/src/csk/status.py @@ -726,7 +726,14 @@ def _node_build_statuses( bin_dir: Path, ) -> tuple[build_currentness.BuildStatus, ...]: marker_builds: Mapping[str, install_marker.MarkerBuild] = {} - if isinstance(marker_inspection.marker, (install_marker.InstallMarkerV2, install_marker.InstallMarkerV3)): + if isinstance( + marker_inspection.marker, + ( + install_marker.InstallMarkerV2, + install_marker.InstallMarkerV3, + install_marker.InstallMarkerV4, + ), + ): marker_builds = marker_inspection.marker.builds active = installer._active_build_command_names(node) commands = sorted(active | set(marker_builds)) @@ -1068,7 +1075,14 @@ def _unavailable_recorded_builds( marker = install_marker.read_install_marker(marker_path.read_bytes()) except Exception: # noqa: BLE001 - the scope error is already reported continue - if not isinstance(marker, (install_marker.InstallMarkerV2, install_marker.InstallMarkerV3)): + if not isinstance( + marker, + ( + install_marker.InstallMarkerV2, + install_marker.InstallMarkerV3, + install_marker.InstallMarkerV4, + ), + ): continue for name, recorded in marker.builds.items(): result.append( diff --git a/tests/test_install_marker_v4.py b/tests/test_install_marker_v4.py new file mode 100644 index 0000000..61e10dc --- /dev/null +++ b/tests/test_install_marker_v4.py @@ -0,0 +1,199 @@ +"""Install marker schema 4: marker-v3 meaning over a schema-8 manifest.""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +from csk import install_marker + + +SHA1 = "0123456789abcdef0123456789abcdef01234567" + + +def _local_build() -> install_marker.InstallMarkerBuildV3: + return install_marker.InstallMarkerBuildV3( + driver="go-v1", + receipt_schema_version=1, + execution_policy="manager-worker-v1", + cache_key="sha256:" + "1" * 64, + receipt_sha256="sha256:" + "e" * 64, + artifact_sha256="sha256:" + "d" * 64, + artifact_path="bin/local-helper", + ) + + +def _base(**changes: object) -> dict[str, Any]: + payload: dict[str, Any] = { + "name": "golden-skill", + "source": "golden-skill", + "ref_kind": "revision", + "ref": SHA1, + "commit": SHA1, + "content_sha256": "sha256:" + "a" * 64, + "locale": None, + "agents": ("codex_cli",), + "commands": ("helper", "local-helper"), + "dependencies": (), + "skill_schema_version": 8, + "runtime_roots": ("scripts",), + "installed_at": "2000-01-01T00:00:00Z", + "files": ("SKILL.md", "scripts/helper"), + "build_roots": ("tools/cli",), + "build_source": install_marker.BuildSourceIdentity( + algorithm="curator-build-source-v1", + content_sha256="sha256:" + "b" * 64, + ), + "builds": {"local-helper": _local_build()}, + } + payload.update(changes) + return payload + + +def test_marker_v4_round_trips_a_schema_eight_installation() -> None: + marker = install_marker.InstallMarkerV4(**_base()) + + decoded = json.loads(install_marker.serialize_install_marker(marker.to_json())) + + assert decoded["schema_version"] == 4 + assert decoded["skill_schema_version"] == 8 + assert decoded["builds"]["local-helper"] == { + "driver": "go-v1", + "receipt_schema_version": 1, + "execution_policy": "manager-worker-v1", + "cache_key": "sha256:" + "1" * 64, + "receipt_sha256": "sha256:" + "e" * 64, + "artifact_sha256": "sha256:" + "d" * 64, + "artifact_path": "bin/local-helper", + } + assert install_marker.parse_install_marker(decoded).to_json() == marker.to_json() + + +def test_marker_v4_reads_back_as_the_v4_model() -> None: + raw = install_marker.serialize_install_marker(install_marker.InstallMarkerV4(**_base()).to_json()) + + parsed = install_marker.read_install_marker(raw) + + assert isinstance(parsed, install_marker.InstallMarkerV4) + assert parsed.schema_version == 4 + + +def test_marker_v4_carries_no_script_execution_member() -> None: + decoded = install_marker.InstallMarkerV4(**_base()).to_json() + + assert "execution_policy" not in decoded + assert "interpreter" not in decoded + assert set(decoded) <= ( + install_marker._V4_MEMBERS | {"schema_version"} + ) + + +@pytest.mark.parametrize("skill_schema", [6, 7, 9]) +def test_marker_v4_binds_exactly_skill_schema_eight(skill_schema: int) -> None: + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.InstallMarkerV4(**_base(skill_schema_version=skill_schema)) + + assert raised.value.code == "install_marker_invalid" + + +def test_marker_v3_still_binds_exactly_skill_schema_seven() -> None: + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.InstallMarkerV3(**_base()) + + assert raised.value.code == "install_marker_invalid" + + +def test_marker_v4_requires_build_source_exactly_with_local_builds() -> None: + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.InstallMarkerV4(**_base(build_source=None)) + + assert raised.value.code == "install_marker_invalid" + + empty = install_marker.InstallMarkerV4(**_base(builds={}, build_source=None)) + + assert empty.to_json()["builds"] == {} + assert "build_source" not in empty.to_json() + + +def test_marker_v4_requires_build_roots_for_a_local_build() -> None: + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.InstallMarkerV4(**_base(build_roots=())) + + assert raised.value.code == "install_marker_invalid" + + +def test_marker_v4_rejects_a_build_that_is_not_an_installed_command() -> None: + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.InstallMarkerV4(**_base(commands=("helper",))) + + assert raised.value.code == "install_marker_invalid" + + +def test_marker_v4_sorts_every_set_like_array() -> None: + marker = install_marker.InstallMarkerV4( + **_base( + agents=("gemini_cli", "codex_cli"), + files=("scripts/helper", "SKILL.md"), + runtime_roots=("scripts",), + ) + ) + + decoded = marker.to_json() + + assert decoded["agents"] == ["codex_cli", "gemini_cli"] + assert decoded["files"] == ["SKILL.md", "scripts/helper"] + + +@pytest.mark.parametrize( + ("marker_schema", "skill_schema", "expected"), + [ + (4, 8, True), + (4, 7, False), + (3, 7, True), + (3, 8, False), + (2, 6, True), + (2, 8, False), + ], +) +def test_currentness_bands_are_one_marker_per_manifest_version( + marker_schema: int, skill_schema: int, expected: bool +) -> None: + if marker_schema == 2: + marker: install_marker.InstallMarker = install_marker.InstallMarkerV2( + **_base( + skill_schema_version=6, + builds={ + "local-helper": install_marker.InstallMarkerBuild( + driver="go-v1", + cache_key="sha256:" + "1" * 64, + receipt_sha256="sha256:" + "e" * 64, + artifact_sha256="sha256:" + "d" * 64, + artifact_path="bin/local-helper", + ) + }, + ) + ) + elif marker_schema == 3: + marker = install_marker.InstallMarkerV3(**_base(skill_schema_version=7)) + else: + marker = install_marker.InstallMarkerV4(**_base()) + + assert ( + install_marker.marker_can_be_current(marker, skill_schema_version=skill_schema) is expected + ) + + +def test_schema_version_four_is_a_supported_marker_schema() -> None: + assert install_marker.SUPPORTED_INSTALL_MARKER_SCHEMA_VERSIONS == frozenset({1, 2, 3, 4}) + + +def test_an_unsupported_marker_schema_is_still_rejected() -> None: + payload = install_marker.InstallMarkerV4(**_base()).to_json() + payload["schema_version"] = 5 + + with pytest.raises(install_marker.InstallMarkerError) as raised: + install_marker.parse_install_marker(payload) + + assert raised.value.code == "unsupported_install_marker_schema" diff --git a/tests/test_install_schema8.py b/tests/test_install_schema8.py new file mode 100644 index 0000000..a7546a5 --- /dev/null +++ b/tests/test_install_schema8.py @@ -0,0 +1,282 @@ +"""Installing a schema-8 skill: marker v4 and the fail-closed script policy.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from conftest import make_config, make_project, make_skill_repo, write_skillfile +from test_install import _stub_trusted_toolchain + +from csk import installer, skillcheck, skillspec +from csk.builds import go_v1 + + +def _manifest(commands: dict[str, Any], **changes: Any) -> str: + payload: dict[str, Any] = { + "schema_version": 8, + "capabilities": {}, + "runtime_roots": ["scripts"], + "commands": commands, + } + payload.update(changes) + return json.dumps(payload, indent=2) + "\n" + + +def _script_command(**extra: Any) -> dict[str, Any]: + command: dict[str, Any] = {"type": "script", "unix_path": "scripts/tool", "win_path": "scripts/tool.cmd"} + command.update(extra) + return command + + +_SCRIPT_FILES = { + "scripts/tool": "#!/usr/bin/env python3\nprint('tool')\n", + "scripts/tool.cmd": "@echo tool\r\n", +} + + +def test_a_schema_eight_installation_records_marker_v4(tmp_path, skills_root, csk_home) -> None: + project = make_project(tmp_path) + make_skill_repo( + skills_root, + "skill-tool", + {"agent-skill.json": _manifest({"tool": _script_command()}), **_SCRIPT_FILES}, + tag="v1", + ) + write_skillfile( + project, + {"schema_version": 1, "agents": ["claude_code"], "skills": [{"name": "skill-tool", "tag": "v1"}]}, + ) + cfg = make_config(csk_home, skills_root, project, agents=["claude_code"]) + + result = installer.install(cfg)[0] + + assert not result.errors + installed = project / ".agents" / "skills" / "skill-tool" + marker = json.loads((installed / ".csk-install.json").read_text(encoding="utf-8")) + assert marker["schema_version"] == 4 + assert marker["skill_schema_version"] == 8 + assert marker["commands"] == ["tool"] + assert marker["builds"] == {} + assert "build_source" not in marker + + +def test_a_schema_eight_installation_is_current_on_reinstall(tmp_path, skills_root, csk_home) -> None: + project = make_project(tmp_path) + make_skill_repo( + skills_root, + "skill-tool", + {"agent-skill.json": _manifest({"tool": _script_command()}), **_SCRIPT_FILES}, + tag="v1", + ) + write_skillfile( + project, + {"schema_version": 1, "agents": ["claude_code"], "skills": [{"name": "skill-tool", "tag": "v1"}]}, + ) + cfg = make_config(csk_home, skills_root, project, agents=["claude_code"]) + + first = installer.install(cfg)[0] + marker_path = project / ".agents" / "skills" / "skill-tool" / ".csk-install.json" + installed_at = json.loads(marker_path.read_text(encoding="utf-8"))["installed_at"] + second = installer.install(cfg)[0] + + assert not first.errors and not second.errors + assert json.loads(marker_path.read_text(encoding="utf-8"))["installed_at"] == installed_at + + +def test_an_enforced_script_command_fails_the_install_closed(tmp_path, skills_root, csk_home) -> None: + project = make_project(tmp_path) + make_skill_repo( + skills_root, + "skill-tool", + { + "agent-skill.json": _manifest( + { + "tool": _script_command( + execution_policy="script-worker-v1", + interpreter="python3-v1", + ) + } + ), + **_SCRIPT_FILES, + }, + tag="v1", + ) + write_skillfile( + project, + {"schema_version": 1, "agents": ["claude_code"], "skills": [{"name": "skill-tool", "tag": "v1"}]}, + ) + cfg = make_config(csk_home, skills_root, project, agents=["claude_code"]) + + result = installer.install(cfg)[0] + + assert result.errors + assert any("script_execution_policy_unsupported" in error for error in result.errors) + # Nothing was published: no shim, no marker, no context copy. + assert not (project / ".agents" / "skills" / "skill-tool").exists() + assert not (project / ".agents" / "bin" / "tool").exists() + assert not (csk_home / "runtime" / "skill-tool").exists() + + +def test_skill_check_reports_the_unsupported_policy_as_an_error(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text("---\nname: test\n---\n\n# Test\n", encoding="utf-8") + (tmp_path / "scripts").mkdir() + (tmp_path / "scripts" / "tool").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + (tmp_path / "agent-skill.json").write_text( + _manifest( + { + "tool": { + "type": "script", + "unix_path": "scripts/tool", + "execution_policy": "script-worker-v1", + "interpreter": "python3-v1", + } + } + ), + encoding="utf-8", + ) + + issues = skillcheck.validate_skill(tmp_path) + + assert skillcheck.has_errors(issues) + reported = [issue for issue in issues if issue.code == "skill.script_execution_policy_unsupported"] + assert len(reported) == 1 + assert reported[0].severity == "error" + assert skillspec.SCRIPT_EXECUTION_POLICY_UNSUPPORTED in reported[0].message + + +def test_the_shim_publication_point_refuses_an_enforced_command(tmp_path, skills_root, csk_home) -> None: + """The guard holds even when a caller reaches the publisher directly.""" + project = make_project(tmp_path) + repo, commit = make_skill_repo( + skills_root, + "skill-tool", + { + "agent-skill.json": _manifest( + { + "tool": _script_command( + execution_policy="script-worker-v1", + interpreter="python3-v1", + ) + } + ), + **_SCRIPT_FILES, + }, + tag="v1", + ) + spec = skillspec.load_skill_spec(repo) + plan = installer.SkillPlan( + decl=type("Decl", (), {"name": "skill-tool", "git": None, "source": "skill-tool"})(), + resolved=type("Resolved", (), {"kind": "tag", "ref": "v1", "commit": commit})(), + repo=repo, + snapshot=repo, + spec=spec, + ) + + with pytest.raises(installer.InstallError, match="script_execution_policy_unsupported"): + installer.install_runtime_commands(csk_home, project / "bin", plan) + + assert not (project / "bin").exists() + + +_MODULE_ROOT_FILES = { + "tools/cli/go.mod": ( + "module example.com/cli\n\n" + "go 1.23\n\n" + "require example.com/board v0.0.0\n\n" + "replace example.com/board => ../../pkg/board\n" + ), + "tools/cli/cmd/cli/main.go": "package main\n\nfunc main() {}\n", + "tools/cli/vendor/example.com/board/board.go": "package board\n", + "tools/cli/vendor/modules.txt": ( + "# example.com/board v0.0.0 => ../../pkg/board\n" + "## explicit; go 1.23\n" + "example.com/board\n" + "# example.com/board => ../../pkg/board\n" + ), + "pkg/board/go.mod": "module example.com/board\n\ngo 1.23\n", + "pkg/board/board.go": "package board\n", +} + + +def _module_root_manifest() -> str: + return json.dumps( + { + "schema_version": 8, + "capabilities": {}, + "build_roots": ["tools/cli"], + "commands": { + "cli": { + "type": "build", + "driver": "go-v1", + "source_dir": "tools/cli/cmd/cli", + "modules": ["pkg/board"], + } + }, + }, + indent=2, + ) + "\n" + + +def test_declared_modules_reach_the_driver_and_the_marker( + monkeypatch, tmp_path, skills_root, csk_home +) -> None: + project = make_project(tmp_path) + make_skill_repo( + skills_root, + "skill-build", + {"agent-skill.json": _module_root_manifest(), **_MODULE_ROOT_FILES}, + tag="v1", + ) + write_skillfile(project, {"schema_version": 1, "skills": [{"name": "skill-build", "tag": "v1"}]}) + cfg = make_config(csk_home, skills_root, project) + _stub_trusted_toolchain(monkeypatch) + observed: list[go_v1.BuildRequest] = [] + stubbed_build = go_v1.build + + def recording_build(request: go_v1.BuildRequest) -> go_v1.BuildResult: + observed.append(request) + return stubbed_build(request) + + monkeypatch.setattr(go_v1, "build", recording_build) + + result = installer.install(cfg)[0] + + assert not result.errors + assert [request.modules for request in observed] == [("pkg/board",)] + assert observed[0].command_object["modules"] == ["pkg/board"] + assert observed[0].build_roots == ("tools/cli",) + marker = json.loads( + (project / ".agents" / "skills" / "skill-build" / ".csk-install.json").read_text( + encoding="utf-8" + ) + ) + assert marker["schema_version"] == 4 + assert marker["skill_schema_version"] == 8 + assert marker["build_roots"] == ["tools/cli"] + assert list(marker["builds"]) == ["cli"] + assert marker["builds"]["cli"]["receipt_schema_version"] == 1 + assert marker["builds"]["cli"]["execution_policy"] == "manager-worker-v1" + + +def test_an_unused_module_declaration_fails_the_manifest(tmp_path, skills_root, csk_home) -> None: + project = make_project(tmp_path) + files = dict(_MODULE_ROOT_FILES) + del files["pkg/board/go.mod"] + del files["pkg/board/board.go"] + make_skill_repo( + skills_root, + "skill-build", + {"agent-skill.json": _module_root_manifest(), **files}, + tag="v1", + ) + write_skillfile(project, {"schema_version": 1, "skills": [{"name": "skill-build", "tag": "v1"}]}) + cfg = make_config(csk_home, skills_root, project) + + result = installer.install(cfg)[0] + + assert result.errors + assert any("build_module_root_declaration_invalid" in error for error in result.errors) + assert not (project / ".agents" / "skills" / "skill-build").exists() diff --git a/tests/test_module_roots.py b/tests/test_module_roots.py new file mode 100644 index 0000000..1ee311d --- /dev/null +++ b/tests/test_module_roots.py @@ -0,0 +1,796 @@ +"""Schema-8 first-party module roots: declaration, bijection, and scan surface. + +The cases mirror Protocol Core section 4.2.3 and the manager profile's fixed +order: declaration and containment before the fixed ``go list``, directive form +and bijection after it returns and before ``go build``. +""" + +from __future__ import annotations + +import functools +import json +import os +import shutil +import subprocess +from pathlib import Path +from typing import Any + +import pytest + +from csk.builds import go_v1, module_roots + + +BUILD_ROOT = "tools/cli" + + +# --- effective replace set ------------------------------------------------- + + +def test_only_a_hash_space_line_with_the_exact_arrow_is_an_annotation() -> None: + text = "\n".join( + [ + "# example.com/board v0.0.0", + "## explicit; go 1.23", + "example.com/board", + "#example.com/tight => ../../pkg/tight", + "# example.com/nospace =>../../pkg/nospace", + "# example.com/board => ../../pkg/board", + ] + ) + + assert module_roots.parse_effective_replacements(text) == ( + module_roots.Replacement("example.com/board", "../../pkg/board"), + ) + + +def test_a_selection_annotation_does_not_add_a_second_directive() -> None: + text = "\n".join( + [ + "# example.com/board v0.0.0 => ../../pkg/board", + "## explicit; go 1.23", + "example.com/board", + "# example.com/board => ../../pkg/board", + ] + ) + + assert module_roots.parse_effective_replacements(text) == ( + module_roots.Replacement("example.com/board", "../../pkg/board"), + ) + + +def test_a_versioned_left_directive_without_a_matching_unversioned_one_is_rejected() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.parse_effective_replacements("# example.com/board v1.2.3 => ../../pkg/board\n") + + assert raised.value.code == module_roots.CODE_DIRECTIVE_FORM_UNSUPPORTED + + +def test_a_selection_annotation_with_a_different_target_is_rejected() -> None: + text = "\n".join( + [ + "# example.com/board v0.0.0 => ../../pkg/other", + "# example.com/board => ../../pkg/board", + ] + ) + + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.parse_effective_replacements(text) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_FORM_UNSUPPORTED + + +def test_a_module_to_module_redirect_is_rejected() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.parse_effective_replacements("# example.com/board => example.com/fork v1.2.3\n") + + assert raised.value.code == module_roots.CODE_DIRECTIVE_FORM_UNSUPPORTED + + +@pytest.mark.parametrize( + "line", + [ + "# a b c => ../../pkg/board", + "# example.com/board => a b c", + "# => ../../pkg/board", + "# example.com/board => ", + ], +) +def test_an_unreadable_annotation_shape_is_rejected(line: str) -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.parse_effective_replacements(line + "\n") + + assert raised.value.code == module_roots.CODE_DIRECTIVE_FORM_UNSUPPORTED + + +# --- bijection ------------------------------------------------------------- + + +def _replacements(*pairs: tuple[str, str]) -> tuple[module_roots.Replacement, ...]: + return tuple(module_roots.Replacement(path, target) for path, target in pairs) + + +def test_the_bijection_admits_exactly_the_declared_directories() -> None: + admitted = module_roots.resolve_bijection( + ("pkg/board", "pkg/remoteconfig"), + _replacements( + ("example.com/board", "../../pkg/board"), + ("example.com/remoteconfig", "../../pkg/remoteconfig"), + ), + build_root=BUILD_ROOT, + ) + + assert admitted == frozenset({"example.com/board", "example.com/remoteconfig"}) + + +def test_an_empty_declaration_requires_an_empty_effective_replace_set() -> None: + assert module_roots.resolve_bijection((), (), build_root=BUILD_ROOT) == frozenset() + + +def test_a_replacement_escaping_the_snapshot_is_undeclared() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.resolve_bijection( + (), + _replacements(("example.com/escape", "../../../outside")), + build_root=BUILD_ROOT, + ) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_UNDECLARED + + +def test_a_replacement_naming_an_undeclared_directory_is_rejected() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.resolve_bijection( + (), + _replacements(("example.com/extra", "../../pkg/extra")), + build_root=BUILD_ROOT, + ) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_UNDECLARED + + +def test_a_declaration_named_by_no_replacement_is_rejected() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.resolve_bijection(("pkg/board",), (), build_root=BUILD_ROOT) + + assert raised.value.code == module_roots.CODE_DECLARATION_UNUSED + + +def test_two_replacements_may_not_name_one_declaration() -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.resolve_bijection( + ("pkg/board",), + _replacements( + ("example.com/board", "../../pkg/board"), + ("example.com/mirror", "../../pkg/board"), + ), + build_root=BUILD_ROOT, + ) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_UNDECLARED + + +@pytest.mark.parametrize("target", ["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/pkg/board", "..\\..\\pkg\\board", ""]) +def test_a_non_relative_replacement_target_never_resolves(target: str) -> None: + with pytest.raises(module_roots.ModuleRootError) as raised: + module_roots.resolve_bijection( + ("pkg/board",), + _replacements(("example.com/board", target)), + build_root=BUILD_ROOT, + ) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_UNDECLARED + + +# --- declaration and containment ------------------------------------------- + + +def _snapshot(root: Path, *, modules: tuple[str, ...] = ("pkg/board",)) -> Path: + (root / BUILD_ROOT).mkdir(parents=True, exist_ok=True) + (root / BUILD_ROOT / "go.mod").write_text("module example.com/cli\n\ngo 1.23\n", encoding="utf-8") + (root / "scripts").mkdir(exist_ok=True) + for relative in modules: + directory = root / Path(relative) + directory.mkdir(parents=True, exist_ok=True) + (directory / "go.mod").write_text("module example.com/x\n\ngo 1.23\n", encoding="utf-8") + return root + + +def _declare(root: Path, modules: tuple[str, ...], *, runtime_roots: tuple[str, ...] = ("scripts",)) -> None: + module_roots.validate_declaration( + root, + modules, + build_root=BUILD_ROOT, + build_roots=(BUILD_ROOT,), + runtime_roots=runtime_roots, + label="commands.cli", + ) + + +def test_a_valid_declaration_is_accepted(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=("pkg/board", "pkg/remoteconfig")) + + _declare(tmp_path, ("pkg/board", "pkg/remoteconfig")) + + +def test_a_missing_module_directory_is_declaration_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=()) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board",)) + + assert raised.value.code == module_roots.CODE_DECLARATION_INVALID + + +def test_a_module_directory_without_go_mod_is_declaration_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=()) + (tmp_path / "pkg" / "board").mkdir(parents=True) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board",)) + + assert raised.value.code == module_roots.CODE_DECLARATION_INVALID + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX symlink semantics") +def test_a_linked_module_directory_is_declaration_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=("pkg/real",)) + (tmp_path / "pkg" / "board").symlink_to(tmp_path / "pkg" / "real", target_is_directory=True) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board",)) + + assert raised.value.code == module_roots.CODE_DECLARATION_INVALID + + +@pytest.mark.parametrize("module", [".", "../pkg/board", "pkg\\board", "pkg//board"]) +def test_a_non_portable_declaration_is_declaration_invalid(tmp_path: Path, module: str) -> None: + _snapshot(tmp_path) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, (module,)) + + assert raised.value.code == module_roots.CODE_DECLARATION_INVALID + + +def test_a_duplicate_declaration_is_declaration_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board", "pkg/board")) + + assert raised.value.code == module_roots.CODE_DECLARATION_INVALID + + +def test_nested_declarations_are_containment_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=("pkg/board", "pkg/board/codec")) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board", "pkg/board/codec")) + + assert raised.value.code == module_roots.CODE_CONTAINMENT_INVALID + + +def test_a_declaration_below_the_build_root_is_containment_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=(f"{BUILD_ROOT}/pkg/lib",)) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, (f"{BUILD_ROOT}/pkg/lib",)) + + assert raised.value.code == module_roots.CODE_CONTAINMENT_INVALID + + +def test_a_declaration_below_a_runtime_root_is_containment_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path) + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/board",), runtime_roots=("pkg",)) + + assert raised.value.code == module_roots.CODE_CONTAINMENT_INVALID + + +def test_platform_case_folding_collisions_are_containment_invalid(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=("pkg/board",)) + upper = tmp_path / "pkg" / "Board" + upper.mkdir(parents=True, exist_ok=True) + (upper / "go.mod").write_text("module example.com/upper\n\ngo 1.23\n", encoding="utf-8") + + with pytest.raises(module_roots.ModuleRootError) as raised: + _declare(tmp_path, ("pkg/Board", "pkg/board")) + + assert raised.value.code == module_roots.CODE_CONTAINMENT_INVALID + + +def test_a_missing_modules_txt_yields_an_empty_effective_replace_set(tmp_path: Path) -> None: + build_root = _snapshot(tmp_path) / BUILD_ROOT + + assert module_roots.read_vendor_modules_text(build_root) == "" + + +def test_modules_txt_is_read_from_the_build_root_only(tmp_path: Path) -> None: + build_root = _snapshot(tmp_path) / BUILD_ROOT + (build_root / "vendor").mkdir() + (build_root / "vendor" / "modules.txt").write_text( + "# example.com/board => ../../pkg/board\n", encoding="utf-8" + ) + + assert module_roots.parse_effective_replacements( + module_roots.read_vendor_modules_text(build_root) + ) == (module_roots.Replacement("example.com/board", "../../pkg/board"),) + + +# --- driver wiring --------------------------------------------------------- + + +def _request(root: Path, modules: tuple[str, ...], **changes: Any) -> go_v1.BuildRequest: + snapshot = type("Snapshot", (), {"path": root})() + fields: dict[str, Any] = { + "toolchain_session": None, + "source_snapshot": snapshot, + "command_object": {"type": "build", "driver": "go-v1", "source_dir": BUILD_ROOT}, + "build_root": BUILD_ROOT, + "source_dir": BUILD_ROOT, + "command": "cli", + "modules": modules, + "build_roots": (BUILD_ROOT,), + "runtime_roots": ("scripts",), + } + fields.update(changes) + return go_v1.BuildRequest(**fields) + + +def test_the_driver_rejects_a_bad_declaration_before_go_list(tmp_path: Path) -> None: + _snapshot(tmp_path, modules=()) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._validate_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + assert raised.value.code == module_roots.CODE_CONTAINMENT_INVALID or ( + raised.value.code == module_roots.CODE_DECLARATION_INVALID + ) + + +def test_the_driver_resolves_the_bijection_from_modules_txt(tmp_path: Path) -> None: + build_root = _snapshot(tmp_path) / BUILD_ROOT + (build_root / "vendor").mkdir() + (build_root / "vendor" / "modules.txt").write_text( + "# example.com/board v0.0.0 => ../../pkg/board\n" + "## explicit; go 1.23\n" + "example.com/board\n" + "# example.com/board => ../../pkg/board\n", + encoding="utf-8", + ) + + admitted = go_v1._resolve_module_root_bijection(_request(tmp_path, ("pkg/board",)), build_root) + + assert admitted == frozenset({"example.com/board"}) + + +def test_the_driver_reports_the_stable_module_root_diagnostic(tmp_path: Path) -> None: + build_root = _snapshot(tmp_path) / BUILD_ROOT + (build_root / "vendor").mkdir() + (build_root / "vendor" / "modules.txt").write_text( + "# example.com/board => example.com/fork v1.2.3\n", encoding="utf-8" + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._resolve_module_root_bijection(_request(tmp_path, ()), build_root) + + assert raised.value.code == module_roots.CODE_DIRECTIVE_FORM_UNSUPPORTED + + +@pytest.mark.parametrize( + ("name", "code"), + [ + ("host.c", "go_native_input_forbidden"), + ("host.h", "go_native_input_forbidden"), + ("host.cpp", "go_native_input_forbidden"), + ("host.swigcxx", "go_native_input_forbidden"), + ("host.s", "go_assembly_forbidden"), + ("host.syso", "go_syso_forbidden"), + ], +) +def test_the_scan_surface_covers_native_inputs_in_a_declared_module( + tmp_path: Path, name: str, code: str +) -> None: + _snapshot(tmp_path) + (tmp_path / "pkg" / "board" / name).write_bytes(b"\x00") + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + assert raised.value.code == code + + +def test_the_scan_surface_covers_cgo_import_dynamic_in_a_declared_module(tmp_path: Path) -> None: + _snapshot(tmp_path) + (tmp_path / "pkg" / "board" / "board.go").write_text( + 'package board\n\n//go:cgo_import_dynamic libc_x x "libc.so"\n', encoding="utf-8" + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + assert raised.value.code == "go_forbidden_compiler_directive" + + +def test_the_scan_surface_rejects_a_workspace_inside_a_declared_module(tmp_path: Path) -> None: + _snapshot(tmp_path) + (tmp_path / "pkg" / "board" / "go.work").write_text("go 1.23\n", encoding="utf-8") + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + assert raised.value.code == "workspace_dependency_forbidden" + + +def test_the_scan_surface_rejects_a_toolchain_directive_in_a_declared_module(tmp_path: Path) -> None: + _snapshot(tmp_path) + (tmp_path / "pkg" / "board" / "go.mod").write_text( + "module example.com/board\n\ngo 1.23\n\ntoolchain go1.99.0\n", encoding="utf-8" + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + assert raised.value.code == "toolchain_switch_forbidden" + + +def test_the_scan_surface_ignores_the_trees_go_ignores(tmp_path: Path) -> None: + _snapshot(tmp_path) + for subtree in ("testdata", "vendor", ".hidden", "_ignored"): + directory = tmp_path / "pkg" / "board" / subtree + directory.mkdir() + (directory / "host.c").write_bytes(b"\x00") + (tmp_path / "pkg" / "board" / "board.go").write_text("package board\n", encoding="utf-8") + + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + +def test_the_scan_surface_accepts_a_plain_first_party_module(tmp_path: Path) -> None: + _snapshot(tmp_path) + (tmp_path / "pkg" / "board" / "board.go").write_text( + "package board\n\nfunc Name() string { return \"board\" }\n", encoding="utf-8" + ) + + go_v1._scan_declared_module_roots(_request(tmp_path, ("pkg/board",)), tmp_path) + + +# --- package graph admission ---------------------------------------------- + + +def _encode(packages: list[dict[str, object]]) -> bytes: + return b"".join(json.dumps(package, separators=(",", ":")).encode() + b"\n" for package in packages) + + +def _graph(root: Path, *, replace: bool = True) -> list[dict[str, object]]: + package_dir = root / "cmd" + package_dir.mkdir(parents=True, exist_ok=True) + (package_dir / "main.go").write_text("package main\nfunc main() {}\n", encoding="utf-8") + go_mod = root / "go.mod" + go_mod.write_text("module example.com/cli\ngo 1.25\n", encoding="utf-8") + vendored = root / "vendor" / "example.com" / "board" + vendored.mkdir(parents=True, exist_ok=True) + (vendored / "board.go").write_text("package board\n", encoding="utf-8") + (root / "vendor" / "modules.txt").write_text( + "# example.com/board v0.0.0 => ../../pkg/board\n" + "## explicit; go 1.25\n" + "example.com/board\n" + "# example.com/board => ../../pkg/board\n", + encoding="utf-8", + ) + module: dict[str, object] = {"Path": "example.com/board", "Version": "v0.0.0"} + if replace: + module["Replace"] = { + "Path": "../../pkg/board", + "Dir": str(root.parent.parent / "pkg" / "board"), + "GoMod": str(root.parent.parent / "pkg" / "board" / "go.mod"), + } + return [ + { + "Dir": str(package_dir), + "ImportPath": "example.com/cli/cmd", + "Name": "main", + "Root": str(root), + "Module": { + "Path": "example.com/cli", + "Main": True, + "Dir": str(root), + "GoMod": str(go_mod), + }, + "GoFiles": ["main.go"], + }, + { + "Dir": str(vendored), + "ImportPath": "example.com/board", + "Name": "board", + "DepOnly": True, + "Module": module, + "GoFiles": ["board.go"], + }, + ] + + +def test_a_replaced_module_is_admitted_only_on_the_bijected_set(tmp_path: Path) -> None: + root = tmp_path / "snapshot" / "tools" / "cli" + packages = _graph(root) + + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + replaced_modules=frozenset({"example.com/board"}), + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + ) + + assert raised.value.code == "vendor_metadata_inconsistent" + + +def test_an_unreplaced_vendored_module_still_validates_without_the_set(tmp_path: Path) -> None: + root = tmp_path / "snapshot" / "tools" / "cli" + packages = _graph(root, replace=False) + + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + ) + + +@pytest.mark.parametrize( + ("field", "value", "code"), + [ + ("SFiles", ["board.s"], "go_assembly_forbidden"), + ("SysoFiles", ["board.syso"], "go_syso_forbidden"), + ], +) +def test_the_audited_vendor_allowance_is_withheld_from_a_replaced_module( + tmp_path: Path, field: str, value: list[str], code: str +) -> None: + root = tmp_path / "snapshot" / "tools" / "cli" + packages = _graph(root) + packages[1][field] = value + for name in value: + (root / "vendor" / "example.com" / "board" / name).write_bytes(b"\x00") + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + replaced_modules=frozenset({"example.com/board"}), + ) + + assert raised.value.code == code + + +def test_go_generate_stays_inert_in_third_party_vendored_code(tmp_path: Path) -> None: + root = tmp_path / "snapshot" / "tools" / "cli" + packages = _graph(root, replace=False) + (root / "vendor" / "example.com" / "board" / "board.go").write_text( + "package board\n\n//go:generate echo hi\n", encoding="utf-8" + ) + + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + ) + + +def test_go_generate_is_rejected_in_the_vendor_copy_of_a_replaced_module(tmp_path: Path) -> None: + root = tmp_path / "snapshot" / "tools" / "cli" + packages = _graph(root) + (root / "vendor" / "example.com" / "board" / "board.go").write_text( + "package board\n\n//go:generate echo hi\n", encoding="utf-8" + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1.validate_package_graph( + _encode(packages), + build_root=root, + source_dir=root / "cmd", + goroot=tmp_path / "goroot", + replaced_modules=frozenset({"example.com/board"}), + ) + + assert raised.value.code == "go_generator_forbidden" + + +# --- package command surface ---------------------------------------------- + + +def test_the_closed_command_surface_admits_modules_only_when_declared(tmp_path: Path) -> None: + root = _snapshot(tmp_path) + request = _request( + root, + ("pkg/board",), + command_object={ + "type": "build", + "driver": "go-v1", + "source_dir": BUILD_ROOT, + "modules": ["pkg/board"], + }, + ) + + go_v1._validate_package_command_surface(request) + + +def test_an_undeclared_modules_field_cannot_reach_the_driver(tmp_path: Path) -> None: + root = _snapshot(tmp_path) + request = _request( + root, + (), + command_object={ + "type": "build", + "driver": "go-v1", + "source_dir": BUILD_ROOT, + "modules": ["pkg/board"], + }, + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._validate_package_command_surface(request) + + assert raised.value.code == go_v1.CODE_PACKAGE_INFLUENCE_FORBIDDEN + + +def test_a_modules_field_that_contradicts_the_validated_declaration_is_rejected(tmp_path: Path) -> None: + root = _snapshot(tmp_path) + request = _request( + root, + ("pkg/board",), + command_object={ + "type": "build", + "driver": "go-v1", + "source_dir": BUILD_ROOT, + "modules": ["pkg/other"], + }, + ) + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1._validate_package_command_surface(request) + + assert raised.value.code == go_v1.CODE_PACKAGE_INFLUENCE_FORBIDDEN + + +# --- real go list ---------------------------------------------------------- + + +GO = shutil.which(os.environ.get("CSK_GO_V1_GO_EXECUTABLE") or "go") + + +def _write_real_snapshot(root: Path) -> Path: + for relative, module_path, body in ( + ("pkg/board", "example.com/board", 'package board\n\nfunc Name() string { return "board" }\n'), + ("pkg/remoteconfig", "example.com/remoteconfig", 'package remoteconfig\n\nfunc Value() string { return "rc" }\n'), + ): + directory = root / Path(relative) + directory.mkdir(parents=True) + (directory / "go.mod").write_text(f"module {module_path}\n\ngo 1.23\n", encoding="utf-8") + (directory / f"{directory.name}.go").write_text(body, encoding="utf-8") + cli = root / BUILD_ROOT + cli.mkdir(parents=True) + (cli / "go.mod").write_text( + "module example.com/cli\n\n" + "go 1.23\n\n" + "require (\n\texample.com/board v0.0.0\n\texample.com/remoteconfig v0.0.0\n)\n\n" + "replace example.com/board => ../../pkg/board\n\n" + "replace example.com/remoteconfig => ../../pkg/remoteconfig\n", + encoding="utf-8", + ) + (cli / "main.go").write_text( + "package main\n\n" + 'import (\n\t"fmt"\n\n\t"example.com/board"\n\t"example.com/remoteconfig"\n)\n\n' + "func main() { fmt.Println(board.Name(), remoteconfig.Value()) }\n", + encoding="utf-8", + ) + return cli + + +@functools.cache +def _goroot() -> Path: + """Probe GOROOT once and resolve it, the way the manager does. + + ``toolchain`` resolves the trusted GOROOT and then pins that exact string + into every child environment, so ``go list`` reports each standard package + with a ``Root`` and a ``Dir`` under the resolved spelling. The test harness + has to do the same. Passing the unresolved probe instead fails wherever the + host spelling is a link: a GitHub Windows runner reaches its tool cache + through ``C:\\hostedtoolcache``, a junction onto ``D:\\hostedtoolcache``, so + the reported ``Root`` and the resolved one disagree for a reason that has + nothing to do with module roots. + """ + + assert GO is not None + completed = subprocess.run( + [GO, "env", "GOROOT"], + capture_output=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr.decode(errors="replace") + return Path(completed.stdout.decode().strip()).resolve(strict=True) + + +def _run_go(cli: Path, *arguments: str) -> subprocess.CompletedProcess[bytes]: + assert GO is not None + environment = dict(os.environ) + environment.update( + { + "GOROOT": str(_goroot()), + "GOFLAGS": "", + "GOWORK": "off", + "GOPROXY": "off", + "CGO_ENABLED": "0", + } + ) + return subprocess.run( + [GO, *arguments], + cwd=cli, + capture_output=True, + check=False, + env=environment, + ) + + +@pytest.mark.skipif(GO is None, reason="a native Go executable is not available") +def test_a_real_vendored_module_root_graph_passes_the_whole_fixed_order(tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot" + snapshot.mkdir() + cli = _write_real_snapshot(snapshot) + vendored = _run_go(cli, "mod", "vendor") + if vendored.returncode != 0: + pytest.skip(f"go mod vendor is unavailable here: {vendored.stderr.decode(errors='replace')}") + + annotations = (cli / "vendor" / "modules.txt").read_text(encoding="utf-8") + assert "# example.com/board => ../../pkg/board" in annotations + assert "# example.com/board v0.0.0 => ../../pkg/board" in annotations + + request = _request(snapshot, ("pkg/board", "pkg/remoteconfig")) + go_v1._validate_declared_module_roots(request, snapshot) + admitted = go_v1._resolve_module_root_bijection(request, cli) + assert admitted == frozenset({"example.com/board", "example.com/remoteconfig"}) + + listed = _run_go(cli, *go_v1.LIST_ARGUMENTS) + assert listed.returncode == 0, listed.stderr.decode(errors="replace") + + go_v1.validate_package_graph( + listed.stdout, + build_root=cli.resolve(strict=True), + source_dir=cli.resolve(strict=True), + goroot=_goroot(), + replaced_modules=admitted, + ) + go_v1._scan_declared_module_roots(request, snapshot) + + +@pytest.mark.skipif(GO is None, reason="a native Go executable is not available") +def test_a_real_replaced_module_graph_is_rejected_without_the_declaration(tmp_path: Path) -> None: + snapshot = tmp_path / "snapshot" + snapshot.mkdir() + cli = _write_real_snapshot(snapshot) + vendored = _run_go(cli, "mod", "vendor") + if vendored.returncode != 0: + pytest.skip(f"go mod vendor is unavailable here: {vendored.stderr.decode(errors='replace')}") + listed = _run_go(cli, *go_v1.LIST_ARGUMENTS) + assert listed.returncode == 0, listed.stderr.decode(errors="replace") + + with pytest.raises(go_v1.GoV1Error) as raised: + go_v1.validate_package_graph( + listed.stdout, + build_root=cli.resolve(strict=True), + source_dir=cli.resolve(strict=True), + goroot=_goroot(), + ) + + assert raised.value.code == "vendor_metadata_inconsistent" diff --git a/tests/test_skillspec.py b/tests/test_skillspec.py index 7d3005a..5d45a46 100644 --- a/tests/test_skillspec.py +++ b/tests/test_skillspec.py @@ -358,7 +358,7 @@ def test_rejects_path_traversal(tmp_path): def test_csk_skill_future_schema_fails(tmp_path): (tmp_path / "csk-skill.json").write_text( - json.dumps({"schema_version": 8, "commands": {}}), + json.dumps({"schema_version": 9, "commands": {}}), encoding="utf-8", ) with pytest.raises(skillspec.SkillSpecError, match="pipx upgrade cocoaskills"): diff --git a/tests/test_skillspec_schema8.py b/tests/test_skillspec_schema8.py new file mode 100644 index 0000000..a09b35f --- /dev/null +++ b/tests/test_skillspec_schema8.py @@ -0,0 +1,424 @@ +"""Manifest schema 8: enforced script policy and declared module roots.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from csk import skillspec + + +def _write_manifest(root: Path, payload: dict[str, Any], name: str = "agent-skill.json") -> None: + (root / name).write_text(json.dumps(payload), encoding="utf-8") + + +def _script_files(root: Path) -> None: + (root / "scripts").mkdir(parents=True, exist_ok=True) + (root / "scripts" / "helper").write_text("#!/usr/bin/env python3\n", encoding="utf-8") + + +def _module(root: Path, relative: str, module_path: str) -> None: + directory = root / Path(relative) + directory.mkdir(parents=True, exist_ok=True) + (directory / "go.mod").write_text(f"module {module_path}\n\ngo 1.23\n", encoding="utf-8") + + +def _build_files(root: Path) -> None: + (root / "tools" / "cli").mkdir(parents=True, exist_ok=True) + (root / "tools" / "cli" / "go.mod").write_text("module example.com/cli\n\ngo 1.23\n", encoding="utf-8") + (root / "tools" / "cli" / "main.go").write_text("package main\n", encoding="utf-8") + + +def _schema_v8(**changes: Any) -> dict[str, Any]: + payload: dict[str, Any] = {"schema_version": 8, "capabilities": {}, "commands": {}} + payload.update(changes) + return payload + + +# --- enforced script execution policy ------------------------------------- + + +@pytest.mark.parametrize("interpreter", ["python3-v1", "node-v1"]) +def test_schema_v8_parses_the_enforced_script_execution_policy(tmp_path: Path, interpreter: str) -> None: + _script_files(tmp_path) + _write_manifest( + tmp_path, + _schema_v8( + runtime_roots=["scripts"], + commands={ + "helper": { + "type": "script", + "unix_path": "scripts/helper", + "execution_policy": "script-worker-v1", + "interpreter": interpreter, + } + }, + ), + ) + + spec = skillspec.load_skill_spec(tmp_path) + + assert spec.schema_version == 8 + assert spec.commands["helper"].execution_policy == "script-worker-v1" + assert spec.commands["helper"].interpreter == interpreter + + +def test_schema_v8_keeps_an_unmarked_script_command_declared_only(tmp_path: Path) -> None: + _script_files(tmp_path) + _write_manifest( + tmp_path, + _schema_v8( + runtime_roots=["scripts"], + commands={"helper": {"type": "script", "unix_path": "scripts/helper"}}, + ), + ) + + spec = skillspec.load_skill_spec(tmp_path) + + assert spec.commands["helper"].execution_policy is None + assert spec.commands["helper"].interpreter is None + assert skillspec.script_execution_policy_rejection(spec) is None + + +@pytest.mark.parametrize( + ("extra", "expected"), + [ + ({"execution_policy": "script-worker-v1"}, "co-required"), + ({"interpreter": "python3-v1"}, "co-required"), + ], +) +def test_schema_v8_rejects_a_half_declared_execution_policy( + tmp_path: Path, extra: dict[str, Any], expected: str +) -> None: + _script_files(tmp_path) + command: dict[str, Any] = {"type": "script", "unix_path": "scripts/helper"} + command.update(extra) + _write_manifest(tmp_path, _schema_v8(runtime_roots=["scripts"], commands={"helper": command})) + + with pytest.raises(skillspec.SkillSpecError, match=expected): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize( + ("policy", "interpreter"), + [ + ("manager-worker-v1", "python3-v1"), + ("script-worker-v2", "python3-v1"), + ("script-worker-hardened-v1", "python3-v1"), + ("none", "python3-v1"), + ("script-worker-v1", "bash-v1"), + ("script-worker-v1", "powershell-v1"), + ("script-worker-v1", "python3"), + ], +) +def test_schema_v8_closes_the_policy_and_interpreter_value_spaces( + tmp_path: Path, policy: str, interpreter: str +) -> None: + _script_files(tmp_path) + _write_manifest( + tmp_path, + _schema_v8( + runtime_roots=["scripts"], + commands={ + "helper": { + "type": "script", + "unix_path": "scripts/helper", + "execution_policy": policy, + "interpreter": interpreter, + } + }, + ), + ) + + with pytest.raises(skillspec.SkillSpecError, match="execution_policy|interpreter"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize("field", ["execution_policy", "interpreter"]) +def test_schema_v8_rejects_the_policy_fields_on_a_system_command(tmp_path: Path, field: str) -> None: + _write_manifest( + tmp_path, + _schema_v8(commands={"git": {"type": "system", "command": "git", field: "script-worker-v1"}}), + ) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize("field", ["execution_policy", "interpreter", "modules"]) +@pytest.mark.parametrize("schema", [1, 2, 3, 4, 5, 6, 7]) +def test_schemas_one_through_seven_reject_the_schema_eight_command_fields( + tmp_path: Path, schema: int, field: str +) -> None: + _script_files(tmp_path) + payload: dict[str, Any] = { + "schema_version": schema, + "commands": {"helper": {"type": "script", "unix_path": "scripts/helper", field: "value"}}, + } + if schema >= 3: + payload["capabilities"] = {} + if schema >= 2: + payload["runtime_roots"] = ["scripts"] + _write_manifest(tmp_path, payload) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize("field", ["execution_policy", "interpreter", "modules"]) +@pytest.mark.parametrize("schema", [1, 2, 3, 4, 5, 6, 7]) +def test_schemas_one_through_seven_reject_the_schema_eight_top_level_fields( + tmp_path: Path, schema: int, field: str +) -> None: + payload: dict[str, Any] = {"schema_version": schema, "commands": {}, field: "value"} + if schema >= 3: + payload["capabilities"] = {} + _write_manifest(tmp_path, payload) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path) + + +def test_schema_v8_rejects_modules_at_the_top_level(tmp_path: Path) -> None: + _write_manifest(tmp_path, _schema_v8(modules=["pkg/board"])) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path) + + +def test_enforced_script_commands_are_refused_rather_than_downgraded(tmp_path: Path) -> None: + _script_files(tmp_path) + _write_manifest( + tmp_path, + _schema_v8( + runtime_roots=["scripts"], + commands={ + "helper": { + "type": "script", + "unix_path": "scripts/helper", + "execution_policy": "script-worker-v1", + "interpreter": "python3-v1", + }, + "plain": {"type": "script", "unix_path": "scripts/helper"}, + }, + ), + ) + + spec = skillspec.load_skill_spec(tmp_path) + rejection = skillspec.script_execution_policy_rejection(spec) + + assert rejection is not None + assert rejection.startswith("script_execution_policy_unsupported: ") + assert "helper (script-worker-v1)" in rejection + assert "plain" not in rejection + assert [command.name for command in skillspec.enforced_script_commands(spec)] == ["helper"] + + +# --- declared module roots ------------------------------------------------- + + +def _module_roots_manifest(modules: list[str] | None, runtime_roots: list[str]) -> dict[str, Any]: + command: dict[str, Any] = { + "type": "build", + "driver": "go-v1", + "source_dir": "tools/cli", + } + if modules is not None: + command["modules"] = modules + return _schema_v8( + build_roots=["tools/cli"], + runtime_roots=runtime_roots, + commands={"cli": command}, + ) + + +def test_schema_v8_parses_declared_module_roots(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "pkg/board", "example.com/board") + _module(tmp_path, "pkg/remoteconfig", "example.com/remoteconfig") + _write_manifest(tmp_path, _module_roots_manifest(["pkg/board", "pkg/remoteconfig"], ["scripts"])) + + spec = skillspec.load_skill_spec(tmp_path) + + assert spec.commands["cli"].modules == ("pkg/board", "pkg/remoteconfig") + + +@pytest.mark.parametrize("modules", [None, []]) +def test_an_absent_or_empty_module_list_keeps_the_schema_seven_meaning( + tmp_path: Path, modules: list[str] | None +) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _write_manifest(tmp_path, _module_roots_manifest(modules, ["scripts"])) + + spec = skillspec.load_skill_spec(tmp_path) + + assert spec.commands["cli"].modules == () + + +def test_an_explicit_null_module_list_is_not_a_spelling_of_absence(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + payload = _module_roots_manifest([], ["scripts"]) + payload["commands"]["cli"]["modules"] = None + _write_manifest(tmp_path, payload) + + with pytest.raises(skillspec.SkillSpecError, match="modules"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize("field", ["execution_policy", "interpreter"]) +def test_an_explicit_null_policy_field_is_not_a_spelling_of_absence( + tmp_path: Path, field: str +) -> None: + _script_files(tmp_path) + command: dict[str, Any] = {"type": "script", "unix_path": "scripts/helper", field: None} + _write_manifest(tmp_path, _schema_v8(runtime_roots=["scripts"], commands={"helper": command})) + + with pytest.raises(skillspec.SkillSpecError, match="co-required"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize( + "module", + [ + ".", + "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/pkg/board", + "pkg\\board", + "../pkg/board", + "pkg/./board", + "pkg/board/", + "pkg/con/board", + ], +) +def test_a_non_portable_module_declaration_is_rejected(tmp_path: Path, module: str) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "pkg/board", "example.com/board") + _write_manifest(tmp_path, _module_roots_manifest([module], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize("modules", [["pkg/board", "pkg/board"], [1], "pkg/board", [None], {}]) +def test_a_malformed_module_list_is_rejected(tmp_path: Path, modules: Any) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "pkg/board", "example.com/board") + _write_manifest(tmp_path, _module_roots_manifest(modules, ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError): + skillspec.load_skill_spec(tmp_path) + + +def test_a_module_directory_without_a_direct_go_mod_is_rejected(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + (tmp_path / "pkg" / "board").mkdir(parents=True) + _write_manifest(tmp_path, _module_roots_manifest(["pkg/board"], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_declaration_invalid"): + skillspec.load_skill_spec(tmp_path) + + +def test_a_missing_module_directory_is_rejected(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _write_manifest(tmp_path, _module_roots_manifest(["pkg/board"], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_declaration_invalid"): + skillspec.load_skill_spec(tmp_path) + + +def test_nested_declared_module_roots_are_rejected(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "pkg/board", "example.com/board") + _module(tmp_path, "pkg/board/codec", "example.com/board/codec") + _write_manifest(tmp_path, _module_roots_manifest(["pkg/board", "pkg/board/codec"], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_containment_invalid"): + skillspec.load_skill_spec(tmp_path) + + +def test_a_module_root_below_the_build_root_is_rejected(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "tools/cli/pkg/lib", "example.com/lib") + _write_manifest(tmp_path, _module_roots_manifest(["tools/cli/pkg/lib"], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_containment_invalid"): + skillspec.load_skill_spec(tmp_path) + + +def test_a_module_root_below_a_runtime_root_is_rejected(tmp_path: Path) -> None: + _build_files(tmp_path) + _module(tmp_path, "pkg/board", "example.com/board") + _write_manifest(tmp_path, _module_roots_manifest(["pkg/board"], ["pkg"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_containment_invalid"): + skillspec.load_skill_spec(tmp_path) + + +def test_case_colliding_module_roots_are_rejected_on_every_host(tmp_path: Path) -> None: + _build_files(tmp_path) + (tmp_path / "scripts").mkdir() + _module(tmp_path, "pkg/board", "example.com/board") + lower = tmp_path / "pkg" / "board" + upper = tmp_path / "pkg" / "Board" + if not upper.exists(): + # A case-insensitive host already resolves both spellings to one + # directory; the declaration is still two distinct protocol paths. + upper.mkdir(parents=True, exist_ok=True) + (upper / "go.mod").write_text("module example.com/upper\n\ngo 1.23\n", encoding="utf-8") + assert lower.is_dir() + _write_manifest(tmp_path, _module_roots_manifest(["pkg/Board", "pkg/board"], ["scripts"])) + + with pytest.raises(skillspec.SkillSpecError, match="build_module_root_containment_invalid"): + skillspec.load_skill_spec(tmp_path) + + +@pytest.mark.parametrize( + "command", + [ + {"type": "script", "unix_path": "scripts/helper", "modules": ["pkg/board"]}, + {"type": "system", "command": "git", "modules": ["pkg/board"]}, + ], +) +def test_modules_are_rejected_on_a_non_build_command(tmp_path: Path, command: dict[str, Any]) -> None: + _script_files(tmp_path) + _write_manifest(tmp_path, _schema_v8(runtime_roots=["scripts"], commands={"helper": command})) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path) + + +def test_modules_are_rejected_on_a_repository_build_command(tmp_path: Path) -> None: + _write_manifest( + tmp_path, + _schema_v8( + build_repositories={ + "tools": { + "git": "https://example.com/tools.git", + "locked_commit": {"object_format": "sha1", "hex": "a" * 40}, + } + }, + commands={ + "tool": { + "type": "build", + "driver": "go-repository-v1", + "repository": "tools", + "target": "tool", + "modules": ["pkg/board"], + } + }, + ), + ) + + with pytest.raises(skillspec.SkillSpecError, match="unsupported field"): + skillspec.load_skill_spec(tmp_path)