From 0f9ef0847ed5ab9ade349b8c6461728384d13b04 Mon Sep 17 00:00:00 2001 From: mozi Date: Thu, 17 Sep 2026 03:36:12 -0400 Subject: [PATCH] Add the composition lock and the kit installer kit.lock.json pins six standalone ingredients by annotated tag, peeled commit SHA, and tree hash (protean-tree-v1). install.sh, a thin entrypoint over build/install.py, resolves a selection, computes the hard requires closure, fetches and verifies each pin, stages and installs in dependency order, runs each ingredient's own gates in place, and reads every written file back against its cached blob. Two gates join the release run: build/check-lock.py validates the lock, the pin allowlist, the closure, install target collisions, and each pin's contract parity, and recomputes every tree hash with --verify. build/check-ingredient-contract.py checks each pinned ingredient at its pinned commit for its README contract line, its committed license, its descriptor, its entrypoint, its declared gates, and a non-empty tests directory. Exit codes are fixed at 0 to 6: usage, lock invalid, dependency, integrity, install, post-install verification. A run that installs part of the selection exits non-zero and never reports a partial success as success. The installer contains no ingredient payload and the composer uses no submodule. Order resolution reads the lock's installOrder and validates it against the dependency graph before use; a declaration that is not a valid topological order of the selection is reported and the resolver's own order is used. A requires cycle is a hard failure with the cycle path printed. Documentation: LICENSING.md is re-titled and re-scoped to the composer, names aska-digital/protean-kit as the source of truth, and states the three-zone model plus the ingredient-license boundary; the README documents the distribution, the dependency behaviour, the offline cache, staging and read-back, the exit table, and the limits. Evidence: build/verify-all.py runs ten gates, all passing; tests/test_kit_lock.py covers the lock, the resolver, the cache, the stage and promote path, the read-back, and the exit codes against fixture repositories, offline. --- .github/workflows/verify.yml | 9 + CHANGELOG.md | 18 + LICENSING.md | 34 +- README.md | 148 ++++++- build/check-ingredient-contract.py | 193 +++++++++ build/check-lock.py | 287 +++++++++++++ build/install.py | 651 +++++++++++++++++++++++++++++ build/verify-all.py | 24 +- install.sh | 26 ++ kit.lock.json | 302 +++++++++++++ tests/test_kit_lock.py | 448 ++++++++++++++++++++ 11 files changed, 2127 insertions(+), 13 deletions(-) create mode 100644 build/check-ingredient-contract.py create mode 100644 build/check-lock.py create mode 100644 build/install.py create mode 100644 install.sh create mode 100644 kit.lock.json create mode 100644 tests/test_kit_lock.py diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 611d072..dce3d96 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -32,3 +32,12 @@ jobs: - name: Verify surface-scan passes run: | python3 build/surface-scan.py + + - name: Verify the composition lock + run: python3 build/check-lock.py kit.lock.json + + - name: Verify ingredient contracts + run: python3 build/check-ingredient-contract.py --lock kit.lock.json + + - name: Run the composition lock tests + run: python3 -m unittest tests.test_kit_lock diff --git a/CHANGELOG.md b/CHANGELOG.md index f1d1985..d214451 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,24 @@ outside this repository. ## Unreleased +- **What:** add the composition lock and the installer. `kit.lock.json` pins six + standalone ingredients by annotated tag, peeled commit SHA, and tree hash + (`protean-tree-v1`). `install.sh` (a thin entrypoint over `build/install.py`) + resolves a selection, computes the hard `requires` closure, fetches and + verifies each pin, stages and installs in dependency order, and reads every + written file back. Two gates join the release run: `build/check-lock.py` and + `build/check-ingredient-contract.py`. Exit codes are fixed at `0` to `6`, and a + partial install is never reported as success. The composer contains no + ingredient payload and no submodule. + **Why:** the kit needs one command that installs the whole set, an install that + can be verified rather than trusted, and a manifest that a reader can audit + without executing anything. + **Evidence class:** [VERIFIED — internal operating record]; the loader, + the target, and the exit codes were exercised by `tests/test_kit_lock.py` + against fixture repositories. + **License:** the composer zone model is unchanged; each pinned ingredient is + MIT under its own committed `LICENSE` file. See `LICENSING.md`. + - **What:** add the anti-loop discipline contract. It defines four rules to prevent thinking loops: load once then use, read once then act, plan once then execute, and trust tool output as the receipt. Published artifacts and live pages still diff --git a/LICENSING.md b/LICENSING.md index 75cc0eb..aa69425 100644 --- a/LICENSING.md +++ b/LICENSING.md @@ -1,19 +1,35 @@ -# Licensing — Team6-kit +# Licensing — the Protean Kit distribution -This repo is a STANDALONE product built on the Hermes engine. It is not a -git-fork of Hermes: no upstream source or history is copied into this tree. +This repository is the distribution home of the Protean Kit. It is a +STANDALONE product built on the Hermes engine. It is not a git-fork of Hermes: +no upstream source or history is copied into this tree. -The two-zone license claim rests on this document + the README, not on a -git-history boundary. Read this before contributing. +- **Source of truth:** `https://github.com/aska-digital/protean-kit`. +- The zone model below rests on this document and the README, not on a + git-history boundary. Read this before contributing. ## Zones +The zone model is the one in the table below. Where a repository ships its own +committed `LICENSE` file, that file is authoritative for that repository. + | Zone | Paths | License | Notes | |---|---|---|---| | Engine-derived | `build/` tooling (scanner, gates, generator, manifest), generic skills under `templates/skills/` | **MIT — provenance today, obligation on arrival** | The repo's current content is entirely our own authored work (Apache-2.0). The MIT zone is a FORWARD CONTRACT: MIT attaches when engine-derived code actually lands here (the setup-agent PR, any vendored tooling). Today there are no MIT files in this tree; the zone names what the future upstream surface will be. Nothing here carries instance data. | -| Kit layer | `choreography/`, `templates/personas/`, `registry/kit.yaml`, `AUDIT/` | **Apache-2.0** | Our identity archetypes, orchestration contract, governance, and build evidence. | +| Composer layer | `kit.lock.json`, `install.sh`, `build/check-lock.py`, `build/check-ingredient-contract.py`, `choreography/`, `templates/personas/`, `registry/kit.yaml`, `AUDIT/` | **Apache-2.0** | The composition manifest, the installer, the composition gates, the identity archetypes, the orchestration contract, governance, and build evidence. | +| Ingredient layer | not in this repository | **MIT, per ingredient** | The ingredients the manifest pins are separate repositories. Each one commits its own `LICENSE` file, which is authoritative for it. This repository holds no ingredient payload: composition is manifest-only. | | Proprietary | `registry/packs/` parameter files | **Proprietary by contract** | Vertical packs are service deliverables — NEVER committed to this repo. The `packs/README.md` shape is public; the parameter files are not. | -| Product brand | `README.md`, `LICENSE` | Apache-2.0 (README), Apache-2.0 (LICENSE) | The product name `Team6-kit` is our brand. | +| Product brand | `README.md`, `LICENSE` | Apache-2.0 (README), Apache-2.0 (LICENSE) | The product name is the distribution's brand. | + +## Composition and pinning (lock version 1) + +The distribution pins each ingredient by annotated tag plus peeled commit SHA +and a tree hash (`protean-tree-v1`). A pin is immutable: a moved tag is an +integrity failure, never a silent upgrade, and the only legal change to a lock is +a new tag or a `lockVersion` bump. The installer verifies the pin, installs into +a staging directory, promotes an ingredient only after its own gates pass, and +then re-hashes every written file against the cached blob. Ingredient licenses +are declared per ingredient and enforced by the ingredient-contract gate. ## Rules @@ -95,7 +111,7 @@ the boundary is explicit: v1.2.0 adopts the canonical-site decision for the Team6 site: - **Canonical public site:** `https://team6.askaconsult.com/`. -- **Repository/source of truth:** `https://github.com/ahrazzle/team6-kit`. +- **Repository/source of truth:** `https://github.com/aska-digital/protean-kit`. - **GitHub Pages:** not used. The old Pages site was taken down. - **Deployment boundary:** the canonical site is served by the ASKA-managed Team6 deployment; DNS and deployment settings are outside this repo. @@ -105,6 +121,6 @@ v1.2.0 adopts the canonical-site decision for the Team6 site: ## Provenance - Engine: Hermes by Nous Research — MIT (https://github.com/NousResearch/hermes-agent) -- This repo: `Team6-kit` — Apache-2.0 core + proprietary packs by contract +- This repo: the Protean Kit distribution (`aska-digital/protean-kit`) — Apache-2.0 composer layer + proprietary packs by contract; pinned ingredients are MIT under their own committed LICENSE files - `AUDIT/fork-commit-1.md` — the first commit's cleanliness evidence (surface scan, staged-tree manifest, invariant check, verification lineage) diff --git a/README.md b/README.md index 6cef59f..f9f4313 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ corporate page is the service listing for the team that uses it. The interactive experience is served by the Team6 Frontier Vercel project through ASKA's `/team6` route. +Source of truth: https://github.com/aska-digital/protean-kit + ## What problem does it solve? One AI agent can lose track, skip steps, or claim work is done when it isn't. Team6-kit sets up several agents with separate jobs — planner, builder, checker — and rules so that: @@ -185,6 +187,148 @@ and establishes rules for token accounting (innermost spans only), cost honesty bounded retention with no secrets or raw prompts by default. Read `choreography/run-evidence.md`. +## The Protean Kit distribution (lock version 1) + +This repository is also the composer of the Protean Kit: it pins six standalone +ingredients, and one command installs the whole set. + +``` +bash install.sh --all --target ./protean-installed +``` + +Preview the plan first. The composer supports `--dry-run`; it prints the +selection, the resolved install order, the target, the network mode, and the +planned writes, and it writes nothing. + +``` +bash install.sh --all --target ./protean-installed --dry-run +``` + +The composer holds the lock, the installer, the documentation, and the gates. It +**contains no ingredient payload and no submodule**: composition is manifest-only. +Each ingredient is fetched from its pinned tag, verified against the tag's peeled +commit SHA and its tree hash, installed, and then read back file by file. + +### Ingredients + +| slug | one-line contract | requires | recommends | installs to | +|---|---|---|---|---| +| `protean-doctrine` | The operating doctrine: the default pipeline, role delegation map, handoff protocol, QA gates, and external-writing discipline. | none | none | `skills/protean-operating-doctrine`, `skills/external-writing-discipline`, `gates/protean-doctrine` | +| `protean-ops` | The dispatch/rotation/inflight ops kit: append-only record schemas and the deterministic gates that check them. | none | none | `records`, `scripts/protean-ops`, `gates/protean-ops` | +| `protean-sym2p` | SYM-2P: a compact versioned agentic message language - one canonical JSON packet per line over durable text artifacts - with validator, enums, and fixtures. | none | `protean-ops` | `SPEC.md`, `skills/sym2p`, `scripts/protean-sym2p`, `templates/protean-sym2p`, `examples/protean-sym2p`, `AUDIT/protean-sym2p`, `gates/protean-sym2p` | +| `protean-drafts` | The draft-review pipeline: render a draft as a self-contained dark HTML review page through one command that runs the prose, identifier, quote-integrity, and render-fidelity gates. | none | `protean-doctrine` | `skills/draft-review-html`, `scripts/protean-drafts`, `templates/protean-drafts`, `gates/protean-drafts` | +| `protean-github-flow` | The GitHub workflow skill pack: issue triage, issue-to-PR, PR lifecycle, PR audit, and upstream-contribution procedures. | none | `protean-drafts`, `protean-doctrine` | `skills/github-issues`, `skills/github-issue-to-pr`, `skills/github-pr-workflow`, `skills/github-pr-audit`, `skills/upstream-contribution-pr`, `gates/protean-github-flow` | +| `protean-control-plane` | The control-plane skill pack: one canonical procedure home that routes a request to the minimum skill bundle, with the gate table and failure policy. | `protean-doctrine`, `protean-ops` | `protean-drafts`, `protean-github-flow`, `protean-sym2p` | `skills/protean-control-plane`, `gates/protean-control-plane` | + +The exact install targets, the pinned SHA, and the tree hash of each ingredient +are recorded in `kit.lock.json`, which is authoritative. + +### Dependency behaviour + +Two edge kinds exist. `requires` is hard: the closure is fetched and installed. +`recommends` is soft: it is reported and never fetched. + +The lock declares the install order in `installOrder`, and the resolver validates +that declaration against the dependency graph before it is used. A declaration +that is not a valid topological order of the selection is reported, and the +resolver's own topological order is used instead. A `requires` cycle is a hard +failure with the cycle path printed, and it is never resolved by dropping an +edge. + +Only `protean-control-plane` declares `requires` edges, on `protean-doctrine` and +`protean-ops`. Every other edge in the version 1 set is `recommends`. + +``` +bash install.sh --ingredient protean-drafts --target ./protean-installed +``` + +`protean-drafts` has no hard requirements, so exactly one repository is fetched +and written. The full kit is not touched and not fetched. + +``` +bash install.sh --ingredient protean-control-plane --target ./protean-installed +``` + +`protean-control-plane` resolves to exactly three repositories: doctrine, ops, and +itself. Its skill cites the record gates, so it declares the dependency instead of +copying the files. + +``` +bash install.sh --ingredient protean-control-plane --no-deps --target ./protean-installed +``` + +`--no-deps` is a deliberate degraded mode. It writes exactly one repository and +reports the unresolved requirements as `degraded`, and the control plane reports +its cited gates as `unavailable` instead of failing for it. The control plane is +not fully operational in that mode, and the summary says so. + +### Offline and cache + +The cache is content-addressed by commit SHA: `///`, where the +cache root is `$PROTEAN_CACHE`, else `${XDG_CACHE_HOME:-$HOME/.cache}/protean-kit`. +A cache entry is immutable while it matches its SHA, and the same SHA is never +fetched twice. + +``` +bash install.sh --all --target ./protean-installed --offline +``` + +`--offline` makes zero network calls and installs from the cache only. A missing +or mismatched entry fails closed with exit 4, naming the slug. A warm cache run +with `--offline` is the way to prove an install needs no network. + +### Target, staging, and read-back + +The default target is `$HERMES_TEAM_SKILLS` when that is set, else `./installed`; +public instructions pass `--target` explicitly so the write boundary is visible. +Writes are staged under `/.protean-staging//` and promoted only +after that ingredient's own gates pass, so a mid-run failure leaves no half +written ingredient on the target. Every written file is then re-hashed against +its cached blob. + +### Troubleshooting + +The exit code is the reliable signal for automation. Errors go to stderr, the +summary to stdout. + +| Exit | Meaning | Recovery | +|---|---|---| +| `1` | usage or input is invalid | Correct the flag or the slug, and read `--help`. | +| `2` | the kit lock is invalid | Use a valid lock. Do not bypass the allowlist, the tag rule, or the schema check. | +| `3` | dependency resolution failed | Repair the lock, or use `--no-deps` only when degraded operation is acceptable. | +| `4` | integrity check failed | Refresh the cache in online mode from the pinned ref, or restore a matching warm cache. Never substitute another SHA. | +| `5` | install failed | Fix the target permissions or the ingredient defect. The run is not successful. | +| `6` | post-install verification failed | Understand the mismatch, then rerun. The target holds an incomplete install, and the summary reports it. | + +A run that installs five of six ingredients exits non-zero. There is no partial +success zero. + +### Verify this release + +``` +python3 build/check-lock.py kit.lock.json --verify +python3 build/check-ingredient-contract.py --lock kit.lock.json --verify +python3 -m unittest tests.test_kit_lock +``` + +`check-lock.py` validates the lock and, with `--verify`, recomputes every pin's +tag pairing and tree hash. `check-ingredient-contract.py` checks each pinned +ingredient at its pinned commit for `README.md`, a committed `LICENSE`, its +descriptor, its entrypoint, its declared gates, and a non-empty `tests/`. Without +a warm cache or a sibling checkout, both gates report the pins as unverified +rather than inventing a result. + +### Limits and open items + +- The ingredient repositories ship under the MIT license. Each commits its own + `LICENSE` file, which is authoritative for that repository. +- The version 1 set pins six ingredients. Whether every one of them belongs in + version 1 is an open item recorded by the project, not a decision made here. +- The protocol ingredient carries its own open items; the installer reports them + in its summary and never resolves them. +- The composer's own brand strings and the site registry still name the + predecessor product; that rename is a user decision, not a packaging change. + ## The main rules 1. **Everything on disk.** Progress is saved to files, so months later you can still pick up where you left off. @@ -209,7 +353,9 @@ The kit now includes a route-based model-policy catalogue for changing provider - **Engine:** Hermes by Nous Research, MIT license. This is not a fork; we build on top of it. - **This kit (profiles, rules, builder):** Apache-2.0. +- **Pinned ingredients (lock version 1):** MIT, each under its own committed `LICENSE` file. - **Vertical packs (paid settings files + service):** not in this repo; proprietary. - Optional local models used by an adapter have their own separate license. -Full details: `LICENSING.md`. +Licensing follows the zone model in `LICENSING.md`; the committed `LICENSE` file +in each repository is authoritative for that repository. diff --git a/build/check-ingredient-contract.py b/build/check-ingredient-contract.py new file mode 100644 index 0000000..dbb25cc --- /dev/null +++ b/build/check-ingredient-contract.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python3 +"""check-ingredient-contract.py - every pinned ingredient satisfies the ingredient contract. + +At its pinned commit, each ingredient repository must contain: + + README.md whose first line equals the descriptor's contract line + LICENSE a committed license file, MIT for this generation + protean-ingredient.json a parseable machine descriptor + install.sh a standalone entrypoint + tests/ at least one test file + its declared gates every gate script named by the descriptor exists + the manifest contract byte-for-byte equal to the lock entry + +Every check runs against the pinned tree, never against a working copy. + +Tree discovery, in order: --ingredients-dir/, then //. +When no tree is available and --verify is not given, the pin is reported as +unverified and the gate still exits 0, because a lock-only clone cannot fetch a +private ingredient. Pass --verify to make a missing tree a failure. + +Usage: + check-ingredient-contract.py [--lock kit.lock.json] [--cache DIR] + [--ingredients-dir DIR] [--verify] [--offline] +Exit: 0 pass (or unverified pins reported); 1 violation; 2 unusable input. +""" +import argparse +import json +import os +import re +import subprocess +import sys +import tempfile + +SHA_RE = re.compile(r"^[0-9a-f]{40}$") + + +def run(cmd, cwd=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +def gate_script(cmd): + """First token of a gate command that looks like a shipped script path.""" + for token in (cmd or "").split(): + if token.endswith(".py") or token.endswith(".sh"): + return token + return None + + +def find_tree(pin, cache, ingredients_dir): + if ingredients_dir: + candidate = os.path.join(ingredients_dir, pin["slug"]) + if os.path.isdir(os.path.join(candidate, ".git")): + return candidate, None + cached = os.path.join(cache, pin["slug"], pin["sha"]) + if os.path.isdir(os.path.join(cached, ".git")): + return cached, None + return None, "no tree available" + + +def fetch_tree(pin, cache, offline): + if offline: + return None, "not in cache under --offline" + os.makedirs(os.path.join(cache, pin["slug"]), exist_ok=True) + work = tempfile.mkdtemp(prefix="contract-", dir=os.path.join(cache, pin["slug"])) + run(["git", "init", "--quiet", work]) + run(["git", "remote", "add", "origin", pin["repo"]], cwd=work) + result = run(["git", "fetch", "--quiet", "--depth", "1", "origin", pin["ref"]], cwd=work) + if result.returncode != 0: + return None, "fetch failed: %s" % (result.stderr or "").strip()[:120] + peeled = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], cwd=work).stdout.strip() + if peeled != pin["sha"]: + return None, "%s resolves to %s, not the pinned sha" % (pin["ref"], peeled) + run(["git", "checkout", "--quiet", "--detach", pin["sha"]], cwd=work) + return work, None + + +def check_tree(pin, tree): + slug = pin["slug"] + problems = [] + readme = os.path.join(tree, "README.md") + if not os.path.isfile(readme): + problems.append("%s: README.md is missing" % slug) + license_file = os.path.join(tree, "LICENSE") + if not os.path.isfile(license_file): + problems.append("%s: LICENSE is missing (a README-only claim is not a license)" % slug) + else: + with open(license_file, encoding="utf-8", errors="replace") as fh: + head = fh.read(400) + if re.search(r"MIT License", head) is None: + problems.append("%s: LICENSE does not declare MIT" % slug) + descriptor_path = os.path.join(tree, "protean-ingredient.json") + descriptor = None + if not os.path.isfile(descriptor_path): + problems.append("%s: protean-ingredient.json is missing" % slug) + else: + try: + with open(descriptor_path, encoding="utf-8") as fh: + descriptor = json.load(fh) + except ValueError as exc: + problems.append("%s: protean-ingredient.json is not valid JSON: %s" % (slug, exc)) + except OSError as exc: + problems.append("%s: protean-ingredient.json is unreadable: %s" % (slug, exc)) + if not os.path.isfile(os.path.join(tree, "install.sh")): + problems.append("%s: install.sh is missing" % slug) + tests_dir = os.path.join(tree, "tests") + if not os.path.isdir(tests_dir): + problems.append("%s: tests/ is missing" % slug) + else: + entries = [n for n in os.listdir(tests_dir) if os.path.isfile(os.path.join(tests_dir, n))] + if not entries: + problems.append("%s: tests/ contains no test file" % slug) + if descriptor is not None: + if descriptor.get("slug") != slug: + problems.append("%s: descriptor slug is %r" % (slug, descriptor.get("slug"))) + if descriptor.get("contract") != pin.get("contract"): + problems.append("%s: descriptor contract differs from the lock contract" % slug) + if (descriptor.get("license") or "").upper() != "MIT": + problems.append("%s: descriptor does not declare MIT" % slug) + for gate in descriptor.get("gates") or []: + script = gate_script(gate.get("cmd")) + if script and not os.path.isfile(os.path.join(tree, script)): + problems.append("%s: declared gate script is missing: %s" % (slug, script)) + if os.path.isfile(readme) and pin.get("contract"): + with open(readme, encoding="utf-8", errors="replace") as fh: + first = fh.readline().rstrip("\n") + if first.strip() != pin["contract"].strip(): + problems.append("%s: README first line is not the contract line" % slug) + return problems + + +def main(): + parser = argparse.ArgumentParser( + prog="check-ingredient-contract.py", + description="Check every pinned ingredient against the ingredient contract.", + epilog="exit codes: 0 pass, 1 violation, 2 unusable input.") + parser.add_argument("--lock", default="kit.lock.json") + parser.add_argument("--cache", default=None) + parser.add_argument("--ingredients-dir", default=None) + parser.add_argument("--verify", action="store_true", + help="fetch a missing tree; a pin that cannot be read is a violation") + parser.add_argument("--offline", action="store_true") + args = parser.parse_args() + + if not os.path.isfile(args.lock): + print("FAIL: lock file not found: %s" % args.lock) + return 2 + try: + with open(args.lock, encoding="utf-8") as fh: + lock = json.load(fh) + except ValueError as exc: + print("FAIL: lock file is not valid JSON: %s" % exc) + return 2 + pins = lock.get("ingredients") + if not isinstance(pins, list) or not pins: + print("FAIL: the lock lists no ingredients") + return 2 + + cache = args.cache or os.environ.get("PROTEAN_CACHE") + if not cache: + xdg = os.environ.get("XDG_CACHE_HOME") + base = xdg if xdg else os.path.join(os.path.expanduser("~"), ".cache") + cache = os.path.join(base, "protean-kit") + + problems, unverified = [], [] + for pin in pins: + if not SHA_RE.match(pin.get("sha") or ""): + problems.append("%s: sha is not a 40-hex commit sha" % pin.get("slug")) + continue + tree, why = find_tree(pin, cache, args.ingredients_dir) + if tree is None and args.verify: + tree, why = fetch_tree(pin, cache, args.offline) + if tree is None: + unverified.append("%s (%s)" % (pin["slug"], why)) + continue + problems += check_tree(pin, tree) + + if unverified: + print("unverified pins (no tree; pass --verify): %s" % ", ".join(unverified)) + if problems: + print("INGREDIENT CONTRACT VIOLATION: %d" % len(problems)) + for problem in problems: + print(" " + problem) + return 1 + if unverified and args.verify: + print("FAIL: --verify requires every pin's tree") + return 1 + print("check-ingredient-contract: PASS ingredients=%d unverified=%d" + % (len(pins), len(unverified))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/check-lock.py b/build/check-lock.py new file mode 100644 index 0000000..6225c00 --- /dev/null +++ b/build/check-lock.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +"""check-lock.py - lock validity gate for the Protean Kit composition manifest. + +Validates kit.lock.json and every pin it records: + + schema required fields, types, lockVersion + slugs unique, well formed + refs annotated tag refs only (refs/tags/vX.Y.Z), never a branch, + and the ref must name the declared version + allowlist repo host and owner inside allowedHosts / allowedOwners + closure every requires edge resolves inside the same lock + cycles a requires cycle is a violation and the cycle path is printed + collisions two ingredients may not claim the same install target + contract parity the lock's contract line equals the ingredient descriptor's + install plan the lock's installTargets equal the descriptor's + tree hashes recomputed for every pin whose tree is available + +Modes: + + --static (default) no network. Verifies everything derivable from the lock, + plus parity against ingredient trees that are already + present in the cache or an ingredients directory. + --verify requires every pin's tree to be present (fetching it from + the pinned tag when needed) and recomputes the sha pairing + and treeSha256 for each one; a mismatch is exit 4. + +Usage: + check-lock.py [LOCK] [--cache DIR] [--ingredients-dir DIR] [--verify] [--offline] +Exit: 0 pass; 1 violation; 2 missing or unparseable input. +""" +import argparse +import hashlib +import json +import os +import re +import subprocess +import sys + +REF_RE = re.compile(r"^refs/tags/v\d+\.\d+\.\d+$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +SLUG_RE = re.compile(r"^[a-z][a-z0-9-]*$") +ALGORITHM = "protean-tree-v1" + + +def run(cmd, cwd=None): + return subprocess.run(cmd, cwd=cwd, capture_output=True, text=True) + + +def tree_sha256(repo_dir, commit): + result = run(["git", "ls-tree", "-r", "--full-tree", commit], cwd=repo_dir) + if result.returncode != 0: + return None + lines = [] + for line in result.stdout.splitlines(): + meta, _, path = line.partition("\t") + parts = meta.split() + if len(parts) != 3: + continue + mode, _kind, blob = parts + lines.append((mode + " " + path).encode("utf-8") + b"\x00" + + blob.encode("utf-8") + b"\n") + lines.sort(key=lambda raw: raw.split(b"\x00", 1)[0]) + digest = hashlib.sha256() + for line in lines: + digest.update(line) + return digest.hexdigest() + + +def topo_order(slugs, requires): + """Topological order over requires, ascending slug as the stable tiebreak.""" + members = set(slugs) + order, remaining = [], set(members) + while remaining: + ready = sorted(slug for slug in remaining + if not any(dep in remaining for dep in requires.get(slug, []) + if dep in members)) + if not ready: + return None, " -> ".join(sorted(remaining)) + order.append(ready[0]) + remaining.discard(ready[0]) + return order, None + + +def order_is_valid(order, slugs, requires): + members = set(slugs) + if set(order) != members or len(order) != len(members): + return False + placed = set() + for slug in order: + for dep in requires.get(slug, []): + if dep in members and dep not in placed: + return False + placed.add(slug) + return True + + +def descriptor_of(pin, cache, ingredients_dir): + candidates = [] + if ingredients_dir: + candidates.append(os.path.join(ingredients_dir, pin["slug"])) + candidates.append(os.path.join(cache, pin["slug"], pin["sha"])) + for cand in candidates: + path = os.path.join(cand, "protean-ingredient.json") + if os.path.isfile(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh), cand + return None, None + + +def main(): + parser = argparse.ArgumentParser( + prog="check-lock.py", + description="Validate the Protean Kit composition lock and its pins.", + epilog="exit codes: 0 pass, 1 violation, 2 missing or unparseable input.") + parser.add_argument("lock", nargs="?", default="kit.lock.json") + parser.add_argument("--cache", default=None, + help="ingredient cache root (default $PROTEAN_CACHE or the user cache)") + parser.add_argument("--ingredients-dir", default=None, + help="directory of ingredient checkouts named by slug") + parser.add_argument("--verify", action="store_true", + help="require every pinned tree and recompute its sha and treeSha256") + parser.add_argument("--offline", action="store_true", + help="with --verify, never fetch: require the cache to be warm") + args = parser.parse_args() + + if not os.path.isfile(args.lock): + print("FAIL: lock file not found: %s" % args.lock) + return 2 + try: + with open(args.lock, encoding="utf-8") as fh: + lock = json.load(fh) + except ValueError as exc: + print("FAIL: lock file is not valid JSON: %s" % exc) + return 2 + + violations, notes, unchecked = [], [], [] + if lock.get("lockVersion") != 1: + violations.append("lockVersion must be 1, got %r" % lock.get("lockVersion")) + hosts = lock.get("allowedHosts") or [] + owners = lock.get("allowedOwners") or [] + if not hosts or not owners: + violations.append("allowedHosts and allowedOwners are required") + pins = lock.get("ingredients") + if not isinstance(pins, list) or not pins: + print("FAIL: ingredients must be a non-empty list") + return 2 + + seen, requires, targets = {}, {}, {} + for pin in pins: + slug = pin.get("slug") + if not slug or not SLUG_RE.match(slug): + violations.append("invalid slug: %r" % slug) + continue + if slug in seen: + violations.append("duplicate slug: %s" % slug) + seen[slug] = pin + ref = pin.get("ref") or "" + if not REF_RE.match(ref): + violations.append("%s: ref must be refs/tags/vX.Y.Z (a tag, never a branch): %r" + % (slug, ref)) + if not SHA_RE.match(pin.get("sha") or ""): + violations.append("%s: sha must be a 40-hex peeled commit sha" % slug) + repo = pin.get("repo") or "" + match = re.match(r"^https://([^/]+)/([^/]+)/[^/]+$", repo) + if not match: + violations.append("%s: repo must be an https repository url: %r" % (slug, repo)) + else: + if match.group(1) not in hosts: + violations.append("%s: host %s is outside allowedHosts" % (slug, match.group(1))) + if match.group(2) not in owners: + violations.append("%s: owner %s is outside allowedOwners" % (slug, match.group(2))) + if pin.get("version") and ref and not ref.endswith("v" + str(pin["version"])): + violations.append("%s: ref %s does not name the declared version %s" + % (slug, ref, pin["version"])) + integrity = pin.get("integrity") or {} + if integrity.get("algorithm") != ALGORITHM: + violations.append("%s: integrity.algorithm must be %s" % (slug, ALGORITHM)) + if not re.match(r"^[0-9a-f]{64}$", integrity.get("treeSha256") or ""): + violations.append("%s: integrity.treeSha256 must be 64-hex" % slug) + if not isinstance(pin.get("installTargets"), list) or not pin["installTargets"]: + violations.append("%s: installTargets must be a non-empty list" % slug) + else: + for t in pin["installTargets"]: + key = t.rstrip("/") + if key in targets and targets[key] != slug: + violations.append("install target collision: %s claimed by %s and %s" + % (key, targets[key], slug)) + targets[key] = slug + requires[slug] = list(pin.get("requires") or []) + for field in ("version", "contract", "kind"): + if not pin.get(field): + violations.append("%s: %s is required" % (slug, field)) + + for slug, deps in requires.items(): + for dep in deps: + if dep not in seen: + violations.append("%s: requires %s, which is not in the lock" % (slug, dep)) + + order, cycle = topo_order(set(seen), requires) + if cycle: + violations.append("requires cycle: %s" % cycle) + locked_order = [s for s in (lock.get("installOrder") or []) if s in seen] + if locked_order and order and not order_is_valid(locked_order, set(seen), requires): + notes.append("recorded installOrder is not a valid topological order of the lock; the " + "computed order %s is authoritative" % ", ".join(order)) + + composer_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if os.path.isfile(os.path.join(composer_root, ".gitmodules")): + violations.append("a .gitmodules file exists in the composer tree") + if os.path.isdir(os.path.join(composer_root, "vendor")): + violations.append("a vendor/ payload tree exists in the composer tree") + + cache = args.cache or os.environ.get("PROTEAN_CACHE") + if not cache: + xdg = os.environ.get("XDG_CACHE_HOME") + base = xdg if xdg else os.path.join(os.path.expanduser("~"), ".cache") + cache = os.path.join(base, "protean-kit") + + for slug in sorted(seen): + pin = seen[slug] + tree = None + if args.ingredients_dir: + candidate = os.path.join(args.ingredients_dir, slug) + if os.path.isdir(os.path.join(candidate, ".git")): + tree = candidate + if tree is None and os.path.isdir(os.path.join(cache, slug, pin["sha"], ".git")): + tree = os.path.join(cache, slug, pin["sha"]) + if tree is None: + if args.verify: + if args.offline: + violations.append("%s: no tree available under --offline" % slug) + continue + os.makedirs(os.path.join(cache, slug), exist_ok=True) + import tempfile + work = tempfile.mkdtemp(prefix="check-lock-", dir=os.path.join(cache, slug)) + run(["git", "init", "--quiet", work]) + run(["git", "remote", "add", "origin", pin["repo"]], cwd=work) + run(["git", "fetch", "--quiet", "--depth", "1", "origin", pin["ref"]], cwd=work) + peeled = run(["git", "rev-parse", "FETCH_HEAD^{commit}"], cwd=work).stdout.strip() + if peeled != pin["sha"]: + violations.append("%s: %s resolves to %s, not the pinned %s" + % (slug, pin["ref"], peeled, pin["sha"])) + continue + run(["git", "checkout", "--quiet", "--detach", pin["sha"]], cwd=work) + tree = work + unchecked.append(slug) + else: + unchecked.append(slug) + if tree is None: + continue + head = run(["git", "rev-parse", "HEAD"], cwd=tree) + if head.returncode == 0 and head.stdout.strip() not in (pin["sha"], ""): + violations.append("%s: tree is at %s, not the pinned %s" + % (slug, head.stdout.strip(), pin["sha"])) + actual = tree_sha256(tree, pin["sha"]) + if actual is None: + violations.append("%s: cannot read the pinned commit %s in its tree" % (slug, pin["sha"])) + elif actual != pin["integrity"]["treeSha256"]: + violations.append("%s: treeSha256 mismatch\nexpected: %s\nactual: %s" + % (slug, pin["integrity"]["treeSha256"], actual)) + descriptor, _where = descriptor_of(pin, cache, args.ingredients_dir) + if descriptor is None: + unchecked.append(slug + " descriptor") + continue + if descriptor.get("contract") != pin.get("contract"): + violations.append("%s: lock contract differs from the ingredient descriptor's contract" + % slug) + if sorted(descriptor.get("installTargets") or []) != sorted(pin.get("installTargets") or []): + violations.append("%s: lock installTargets differ from the descriptor's" % slug) + + for note in notes: + print("note: " + note) + if unchecked: + print("unverified (no tree available; run --verify): %s" % ", ".join(sorted(set(unchecked)))) + if violations: + print("LOCK VIOLATION: %d" % len(violations)) + for v in violations: + print(" " + v) + return 1 + authoritative = locked_order if (locked_order and order + and order_is_valid(locked_order, set(seen), requires)) else order + print("check-lock: PASS ingredients=%d order=%s" % (len(seen), " -> ".join(authoritative or []))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/build/install.py b/build/install.py new file mode 100644 index 0000000..2279437 --- /dev/null +++ b/build/install.py @@ -0,0 +1,651 @@ +#!/usr/bin/env python3 +"""Protean Kit installer - resolves a selection from kit.lock.json, verifies every +pin, installs the selected ingredients, and reads every written file back. + +Composition is manifest-only: this program fetches ingredients from their pinned +tags and writes them into the target. It copies no ingredient payload into the +composer and uses no git submodules. + + bash install.sh --all --target + bash install.sh --ingredient --target + bash install.sh --ingredient --no-deps --target + bash install.sh --all --target --offline + bash install.sh --all --target --dry-run + +Exit codes: + 0 every selected ingredient fetched, integrity-verified, installed, read back + 1 usage or input: unknown flag, unknown slug, missing or unparseable lock + 2 lock invalid: schema, lockVersion, duplicate slug, non-tag ref, host or owner + outside the allowlist, a submodule or vendored payload tree in the composer + 3 dependency: missing closure member, requires cycle, unresolvable requirement + 4 integrity: sha mismatch, moved tag, tree hash mismatch, cache unknown under + --offline + 5 install: target not writable, or an ingredient gate failed in place + 6 verification: post-install read-back differs from the cached file + +A run that installs some but not all of the selection exits non-zero. There is no +partial-success zero. + +Stdlib only. No third-party import. +""" +import hashlib +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile + +REF_RE = re.compile(r"^refs/tags/v\d+\.\d+\.\d+$") +SHA_RE = re.compile(r"^[0-9a-f]{40}$") +LOCK_VERSION = 1 +LOCK_FILE = "kit.lock.json" +ALGORITHM = "protean-tree-v1" +CACHE_ENV = "PROTEAN_CACHE" +STAGING = ".protean-staging" + + +class Failure(Exception): + def __init__(self, code, message): + Exception.__init__(self, message) + self.code = code + self.message = message + + +def info(message): + sys.stdout.write(message + "\n") + + +def warn(message): + sys.stderr.write("warning: " + message + "\n") + + +def run(cmd, cwd=None, env=None): + return subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True) + + +def git(args, cwd=None): + result = run(["git"] + args, cwd=cwd) + if result.returncode != 0: + raise Failure(4, "git %s failed: %s" % (args[0], (result.stderr or "").strip()[:200])) + return result.stdout.strip() + + +def usage(): + info(__doc__.strip()) + + +def parse_args(argv): + opts = {"all": False, "ingredients": [], "no_deps": False, "offline": False, + "dry_run": False, "target": None, "lock": LOCK_FILE, "cache": None} + i = 0 + while i < len(argv): + arg = argv[i] + if arg in ("--help", "-h"): + usage() + raise SystemExit(0) + elif arg == "--all": + opts["all"] = True + i += 1 + elif arg == "--ingredient": + if i + 1 >= len(argv): + raise Failure(1, "usage: --ingredient needs a slug") + opts["ingredients"].append(argv[i + 1]) + i += 2 + elif arg == "--no-deps": + opts["no_deps"] = True + i += 1 + elif arg == "--offline": + opts["offline"] = True + i += 1 + elif arg == "--dry-run": + opts["dry_run"] = True + i += 1 + elif arg == "--target": + if i + 1 >= len(argv): + raise Failure(1, "usage: --target needs a directory") + opts["target"] = argv[i + 1] + i += 2 + elif arg.startswith("--target="): + opts["target"] = arg.split("=", 1)[1] + i += 1 + elif arg == "--lock": + if i + 1 >= len(argv): + raise Failure(1, "usage: --lock needs a path") + opts["lock"] = argv[i + 1] + i += 2 + elif arg == "--cache": + if i + 1 >= len(argv): + raise Failure(1, "usage: --cache needs a directory") + opts["cache"] = argv[i + 1] + i += 2 + else: + raise Failure(1, "usage: unknown flag: %s" % arg) + if not opts["all"] and not opts["ingredients"]: + raise Failure(1, "usage: select with --all or --ingredient ") + if opts["all"] and opts["ingredients"]: + raise Failure(1, "usage: --all and --ingredient are mutually exclusive") + if opts["no_deps"] and opts["all"]: + raise Failure(1, "usage: --no-deps applies to a single-ingredient selection") + if not opts["dry_run"] and not opts["target"]: + raise Failure(1, "usage: --target is required (or use --dry-run)") + return opts + + +def cache_root(opts): + if opts.get("cache"): + return os.path.abspath(os.path.expanduser(opts["cache"])) + env = os.environ.get(CACHE_ENV) + if env: + return os.path.abspath(os.path.expanduser(env)) + xdg = os.environ.get("XDG_CACHE_HOME") + base = xdg if xdg else os.path.join(os.path.expanduser("~"), ".cache") + return os.path.join(base, "protean-kit") + + +def target_root(opts): + if opts.get("target"): + return os.path.abspath(os.path.expanduser(opts["target"])) + team = os.environ.get("HERMES_TEAM_SKILLS") + if team: + return os.path.abspath(os.path.expanduser(team)) + return os.path.abspath("./installed") + + +# --------------------------------------------------------------------------- +# Lock loading and validation (exit 2) +# --------------------------------------------------------------------------- + +def load_lock(path): + if not os.path.isfile(path): + raise Failure(1, "lock file not found: %s" % path) + try: + with open(path, encoding="utf-8") as fh: + lock = json.load(fh) + except ValueError as exc: + raise Failure(1, "lock file is not valid JSON: %s" % exc) + if not isinstance(lock, dict): + raise Failure(2, "lock root must be an object") + return lock + + +def validate_lock(lock): + if lock.get("lockVersion") != LOCK_VERSION: + raise Failure(2, "lockVersion must be %d" % LOCK_VERSION) + kit = lock.get("kit") + if not isinstance(kit, dict) or not kit.get("name"): + raise Failure(2, "kit.name is required") + hosts = lock.get("allowedHosts") or [] + owners = lock.get("allowedOwners") or [] + if not hosts or not owners: + raise Failure(2, "allowedHosts and allowedOwners are required") + ingredients = lock.get("ingredients") + if not isinstance(ingredients, list) or not ingredients: + raise Failure(2, "ingredients must be a non-empty list") + seen = {} + for entry in ingredients: + slug = entry.get("slug") + if not slug or not re.match(r"^[a-z][a-z0-9-]*$", slug): + raise Failure(2, "invalid slug: %r" % slug) + if slug in seen: + raise Failure(2, "duplicate slug in the lock: %s" % slug) + seen[slug] = entry + ref = entry.get("ref") or "" + if not REF_RE.match(ref): + raise Failure(2, "%s: ref must be an annotated tag refs/tags/vX.Y.Z, got %r" % (slug, ref)) + if not SHA_RE.match(entry.get("sha") or ""): + raise Failure(2, "%s: sha must be a 40-hex peeled commit sha" % slug) + repo = entry.get("repo") or "" + match = re.match(r"^https://([^/]+)/([^/]+)/[^/]+$", repo) + if not match: + raise Failure(2, "%s: repo must be an https repository url, got %r" % (slug, repo)) + if match.group(1) not in hosts: + raise Failure(2, "%s: host %s is outside allowedHosts" % (slug, match.group(1))) + if match.group(2) not in owners: + raise Failure(2, "%s: owner %s is outside allowedOwners" % (slug, match.group(2))) + integrity = entry.get("integrity") or {} + if integrity.get("algorithm") != ALGORITHM: + raise Failure(2, "%s: integrity.algorithm must be %s" % (slug, ALGORITHM)) + if not re.match(r"^[0-9a-f]{64}$", integrity.get("treeSha256") or ""): + raise Failure(2, "%s: integrity.treeSha256 must be 64-hex" % slug) + if not isinstance(entry.get("installTargets"), list) or not entry["installTargets"]: + raise Failure(2, "%s: installTargets must be a non-empty list" % slug) + for field in ("contract", "version", "kind"): + if not entry.get(field): + raise Failure(2, "%s: %s is required" % (slug, field)) + for entry in ingredients: + for dep in list(entry.get("requires") or []): + if dep not in seen: + raise Failure(3, "%s requires %s, which is not in the lock" % (entry["slug"], dep)) + # install target collisions between ingredients + owner_of = {} + for entry in ingredients: + for t in entry["installTargets"]: + key = t.rstrip("/") + if key in owner_of and owner_of[key] != entry["slug"]: + raise Failure(2, "install target collision: %s is claimed by %s and %s" + % (key, owner_of[key], entry["slug"])) + owner_of[key] = entry["slug"] + return seen + + +def composer_tree_violations(composer_root): + problems = [] + if os.path.isfile(os.path.join(composer_root, ".gitmodules")): + problems.append("a .gitmodules file exists in the composer") + result = run(["git", "ls-files", "-s"], cwd=composer_root) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if line.startswith("160000"): + problems.append("a submodule entry is tracked in the composer") + break + if os.path.isdir(os.path.join(composer_root, "vendor")): + problems.append("a vendor/ payload tree exists in the composer") + return problems + + +# --------------------------------------------------------------------------- +# Selection, closure, order (exit 3) +# --------------------------------------------------------------------------- + +def resolve_selection(lock, seen, opts): + if opts["all"]: + selection = [e["slug"] for e in lock["ingredients"]] + else: + selection = list(opts["ingredients"]) + for slug in selection: + if slug not in seen: + raise Failure(1, "unknown ingredient: %s" % slug) + if opts["no_deps"]: + # Exactly the selection is written; its requirements are reported as + # degraded rather than resolved. + degraded = sorted({dep for slug in selection + for dep in (seen[slug].get("requires") or [])}) + return selection, list(selection), degraded + closure = list(selection) + pending = list(selection) + while pending: + slug = pending.pop() + for dep in seen[slug].get("requires") or []: + if dep not in closure: + closure.append(dep) + pending.append(dep) + degraded = [] + if not opts["all"]: + for slug in selection: + for dep in seen[slug].get("requires") or []: + if dep not in closure: + degraded.append(dep) + return selection, closure, sorted(set(degraded)) + + +def find_cycle(members_left, requires, members): + """Return the cycle path among the unresolved nodes, for the failure message.""" + state, stack = {}, [] + + def visit(slug): + colour = state.get(slug) + if colour == "done": + return None + if colour == "grey": + return stack[stack.index(slug):] + [slug] + state[slug] = "grey" + stack.append(slug) + for dep in sorted(requires.get(slug, [])): + if dep in members and dep in members_left: + cycle = visit(dep) + if cycle: + return cycle + stack.pop() + state[slug] = "done" + return None + + for slug in sorted(members_left): + cycle = visit(slug) + if cycle: + return cycle + return sorted(members_left) + + +def topo_order(closure, requires): + """Topological order over requires, ascending slug as the stable tiebreak. + + Repeatedly places the smallest-slug node whose hard dependencies are all + placed. A closure that never becomes fully ready holds a cycle, which is a + hard failure (exit 3) whose path is printed. + """ + members = set(closure) + order, remaining = [], set(members) + while remaining: + ready = sorted(slug for slug in remaining + if not any(dep in remaining for dep in requires.get(slug, []) + if dep in members)) + if not ready: + raise Failure(3, "requires cycle: %s" + % " -> ".join(find_cycle(remaining, requires, members))) + order.append(ready[0]) + remaining.discard(ready[0]) + return order + + +def order_is_valid(order, closure, requires): + """True when `order` lists the closure exactly once and respects every edge.""" + members = set(closure) + if set(order) != members or len(order) != len(members): + return False + placed = set() + for slug in order: + for dep in requires.get(slug, []): + if dep in members and dep not in placed: + return False + placed.add(slug) + return True + + +def resolve_order(closure, requires, locked_order): + """Resolve the install order without assuming either source. + + The lock declares the order; the resolver validates that declaration against + the dependency graph. A declaration that is not a valid topological order of + the selected closure is reported and the computed order wins. + """ + computed = topo_order(closure, requires) + members = set(closure) + recorded = [slug for slug in (locked_order or []) if slug in members] + if recorded and order_is_valid(recorded, closure, requires): + return recorded, None + if recorded: + return computed, ("recorded installOrder is not a valid topological order of the " + "selection; the computed order wins: %s" % ", ".join(computed)) + return computed, None + + +# --------------------------------------------------------------------------- +# Cache, fetch, integrity +# --------------------------------------------------------------------------- + +def tree_lines(repo_dir, commit): + out = git(["ls-tree", "-r", "--full-tree", commit], cwd=repo_dir) + lines = [] + for line in out.splitlines(): + meta, _, path = line.partition("\t") + parts = meta.split() + if len(parts) != 3: + continue + mode, _kind, blob = parts + lines.append((mode + " " + path).encode("utf-8") + b"\x00" + + blob.encode("utf-8") + b"\n") + lines.sort(key=lambda raw: raw.split(b"\x00", 1)[0]) + return lines + + +def tree_sha256(repo_dir, commit): + digest = hashlib.sha256() + for line in tree_lines(repo_dir, commit): + digest.update(line) + return digest.hexdigest() + + +def cache_entry(cache, slug, sha): + return os.path.join(cache, slug, sha) + + +def fetch_entry(entry, cache, offline): + slug, sha = entry["slug"], entry["sha"] + final = cache_entry(cache, slug, sha) + if os.path.isdir(os.path.join(final, ".git")): + head = run(["git", "rev-parse", "HEAD"], cwd=final) + if head.returncode == 0 and head.stdout.strip() == sha: + return final, "cache hit" + raise Failure(4, "%s: cache entry at %s does not match the pinned sha" % (slug, final)) + if offline: + raise Failure(4, "offline cache unavailable for %s at the pinned sha" % slug) + os.makedirs(os.path.join(cache, slug), exist_ok=True) + work = tempfile.mkdtemp(prefix="protean-fetch-", dir=os.path.dirname(final)) + try: + git(["init", "--quiet", work], cwd=None) + git(["remote", "add", "origin", entry["repo"]], cwd=work) + git(["fetch", "--quiet", "--depth", "1", "origin", entry["ref"]], cwd=work) + peeled = git(["rev-parse", "FETCH_HEAD^{commit}"], cwd=work) + if peeled != sha: + raise Failure(4, "%s: %s resolves to %s, not the pinned %s" + % (slug, entry["ref"], peeled, sha)) + git(["checkout", "--quiet", "--detach", sha], cwd=work) + actual = tree_sha256(work, sha) + expected = entry["integrity"]["treeSha256"] + if actual != expected: + raise Failure(4, "%s: treeSha256 mismatch\nexpected: %s\nactual: %s" + % (slug, expected, actual)) + os.rename(work, final) + return final, "fetched" + except Failure: + if os.path.isdir(work): + shutil.rmtree(work, ignore_errors=True) + raise + except OSError: + if os.path.isdir(work): + shutil.rmtree(work, ignore_errors=True) + raise + + +def verify_entry(entry, entry_dir): + slug = entry["slug"] + head = run(["git", "rev-parse", "HEAD"], cwd=entry_dir) + if head.returncode != 0 or head.stdout.strip() != entry["sha"]: + raise Failure(4, "%s: cache checkout does not sit at the pinned sha" % slug) + actual = tree_sha256(entry_dir, entry["sha"]) + expected = entry["integrity"]["treeSha256"] + if actual != expected: + raise Failure(4, "%s: treeSha256 mismatch\nexpected: %s\nactual: %s" + % (slug, expected, actual)) + return True + + +# --------------------------------------------------------------------------- +# Install and read-back +# --------------------------------------------------------------------------- + +def read_descriptor(entry_dir): + path = os.path.join(entry_dir, "protean-ingredient.json") + if not os.path.isfile(path): + raise Failure(5, "ingredient descriptor missing: %s" % path) + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def copy_paths(entry_dir, staging, targets): + written = [] + for rel in targets: + src = os.path.join(entry_dir, rel) + dest = os.path.join(staging, rel) + if os.path.isdir(src): + if os.path.isdir(dest): + shutil.rmtree(dest) + shutil.copytree(src, dest) + for root, _dirs, files in os.walk(dest): + for name in files: + written.append(os.path.relpath(os.path.join(root, name), staging)) + elif os.path.isfile(src): + os.makedirs(os.path.dirname(dest), exist_ok=True) + shutil.copy2(src, dest) + written.append(os.path.relpath(dest, staging)) + else: + raise Failure(5, "declared install target missing in the ingredient: %s" % rel) + return sorted(written) + + +def run_gates(descriptor, entry_dir, staging): + gates = descriptor.get("gates") or [] + ran = [] + for gate in gates: + if gate.get("postInstall") is False: + continue + cmd = gate.get("cmd") + if not cmd: + continue + cwd = os.path.join(staging, gate.get("cwd", ".")) + result = run(["bash", "-c", cmd], cwd=cwd) + ran.append({"name": gate.get("name"), "cmd": cmd, "exit": result.returncode}) + if result.returncode != 0: + raise Failure(5, "ingredient gate failed in place: %s (%s)\n%s" + % (gate.get("name"), cmd, (result.stdout + result.stderr).strip()[:400])) + return ran + + +def promote(staging, target, written): + for rel in written: + src = os.path.join(staging, rel) + dest = os.path.join(target, rel) + os.makedirs(os.path.dirname(dest), exist_ok=True) + if os.path.exists(dest): + os.remove(dest) + shutil.move(src, dest) + + +def read_back(entry_dir, target, written): + mismatches = [] + for rel in written: + src = os.path.join(entry_dir, rel) + dest = os.path.join(target, rel) + if not os.path.isfile(dest): + mismatches.append((rel, "missing after install")) + continue + with open(src, "rb") as fh: + want = hashlib.sha256(fh.read()).hexdigest() + with open(dest, "rb") as fh: + got = hashlib.sha256(fh.read()).hexdigest() + if want != got: + mismatches.append((rel, "content differs from the cached file")) + return mismatches + + +def install_one(entry, cache, opts, plan, entry_dir): + slug = entry["slug"] + target = plan["target"] + os.makedirs(target, exist_ok=True) + if not os.access(target, os.W_OK): + raise Failure(5, "target is not writable: %s" % target) + descriptor = read_descriptor(entry_dir) + staging_root = os.path.join(target, STAGING, slug) + if os.path.isdir(staging_root): + shutil.rmtree(staging_root) + os.makedirs(staging_root, exist_ok=True) + written = copy_paths(entry_dir, staging_root, entry["installTargets"]) + gates = run_gates(descriptor, entry_dir, staging_root) + promote(staging_root, target, written) + shutil.rmtree(os.path.join(target, STAGING), ignore_errors=True) + return written, gates + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + +def dry_run(lock, order, plan, cache, offline): + info("kit: %s %s" % (lock["kit"].get("name"), lock["kit"].get("version"))) + info("selection: %s" % plan["selection_label"]) + info("target: %s" % plan["target"]) + info("dependency mode: %s" % ("degraded (--no-deps)" if plan["no_deps"] else "full")) + info("install order: %s" % ", ".join(order)) + info("network mode: %s" % ("offline (cache only)" if offline else "online")) + info("planned writes (target-relative), per ingredient:") + for slug in order: + entry = plan["entries"][slug] + for rel in entry["installTargets"]: + state = "cached" if os.path.isdir(cache_entry(cache, slug, entry["sha"])) else "to fetch" + info(" %s: %s [%s]" % (slug, rel, state)) + info("gates: each ingredient's post-install gates run after staging and before promotion") + if plan["degraded"]: + info("degraded requirements reported: %s" % ", ".join(plan["degraded"])) + info("result: dry-run, no files written") + return 0 + + +def main(argv): + opts = parse_args(argv) + composer_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + lock_path = opts["lock"] + if not os.path.isabs(lock_path): + lock_path = os.path.join(os.getcwd(), lock_path) + lock = load_lock(lock_path) + seen = validate_lock(lock) + + problems = composer_tree_violations(composer_root) + if problems: + raise Failure(2, "composer tree is invalid: " + "; ".join(problems)) + + selection, closure, degraded = resolve_selection(lock, seen, opts) + for slug in closure: + if slug not in seen: + raise Failure(3, "required ingredient %s is not in the lock" % slug) + requires = {slug: list(seen[slug].get("requires") or []) for slug in closure} + order, order_note = resolve_order(closure, requires, lock.get("installOrder")) + if order_note: + warn(order_note) + + plan = { + "target": target_root(opts), + "selection_label": "--all" if opts["all"] else ", ".join(selection), + "no_deps": opts["no_deps"], + "degraded": degraded, + "entries": seen, + } + cache = cache_root(opts) + if opts["dry_run"]: + return dry_run(lock, order, plan, cache, opts["offline"]) + + info("resolve: %d selected, %d closure members" % (len(selection), len(order))) + info("order: %s" % " -> ".join(order)) + installed = [] + warnings = [] + for slug in order: + entry = seen[slug] + entry_dir, how = fetch_entry(entry, cache, opts["offline"]) + info("fetch: %s [%s]" % (slug, how)) + verify_entry(entry, entry_dir) + info("verify: %s [sha ok, tree ok]" % slug) + written, gates = install_one(entry, cache, opts, plan, entry_dir) + info("install: %s [staged, %d gate(s) passed, %d file(s) promoted]" + % (slug, len(gates), len(written))) + mismatches = read_back(entry_dir, plan["target"], written) + if mismatches: + detail = "; ".join("%s (%s)" % (rel, why) for rel, why in mismatches[:5]) + raise Failure(6, "post-install verification failed at %s: %s" + % (plan["target"], detail)) + info("readback: %s [%d file(s) verified]" % (slug, len(written))) + installed.append(slug) + for item in entry.get("open") or []: + warnings.append("%s carries open item %s" % (slug, item)) + for rec in entry.get("recommends") or []: + if rec not in order: + warnings.append("%s recommends %s (reported, not installed)" % (slug, rec)) + if opts["no_deps"]: + for dep in entry.get("requires") or []: + warnings.append("%s requires %s: degraded, not installed" % (slug, dep)) + + info("") + info("summary") + info(" target: %s" % plan["target"]) + info(" installed: %s" % ", ".join(installed)) + info(" selection: %s" % plan["selection_label"]) + info(" read-back: every written file compared to its cached blob") + for message in warnings: + info(" note: %s" % message) + info(" next: read each installed ingredient's README.md under the target") + info("result: ok, exit 0") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except Failure as failure: + sys.stderr.write("error: %s\n" % failure.message) + sys.stderr.write("exit: %d\n" % failure.code) + sys.exit(failure.code) + except SystemExit: + raise + except KeyboardInterrupt: + sys.stderr.write("error: interrupted\n") + sys.exit(5) diff --git a/build/verify-all.py b/build/verify-all.py index d8ef441..aee1049 100644 --- a/build/verify-all.py +++ b/build/verify-all.py @@ -5,14 +5,18 @@ Runs the repository's public gates in documented order. Uses subprocess with fail-closed behavior: nonzero exit on any failure. No dependencies beyond stdlib. -ORDER (as documented in choreography/release-gates.md): +ORDER (this list is the gate list; the composer holds no separate release-gates +document, and this docstring is authoritative): 1. sweep-gate.py — extraction source verification 2. review-gate.py — semantic sign-off enforcement 3. surface-scan.py — multi-surface leak detector 4. check-artifact-contract.py self-test 5. preflight/check.py self-test 6. report/check.py self-test - 7. fresh-clone test (generate.py fork-dryrun check) + 7. composition lock validity (check-lock.py, static) + 8. ingredient contract (check-ingredient-contract.py, static) + 9. composition lock tests (tests/test_kit_lock.py) + 10. fresh-clone test (generate.py fork-dryrun check) Exit: 0 = all gates pass; 1 = any gate failed. """ @@ -93,7 +97,21 @@ def main(): gates.append(("report/check self-test", [sys.executable, os.path.join(HERE, "report", "check.py"), "--selftest"])) - # 7. fresh-clone test (fork-dryrun.sh) + # 7. composition lock validity + gates.append(("check-lock.py", + [sys.executable, os.path.join(HERE, "check-lock.py"), + os.path.join(ROOT, "kit.lock.json")])) + + # 8. ingredient contract (reports pinned trees it cannot read, never invents one) + gates.append(("check-ingredient-contract.py", + [sys.executable, os.path.join(HERE, "check-ingredient-contract.py"), + "--lock", os.path.join(ROOT, "kit.lock.json")])) + + # 9. composition lock tests + gates.append(("composition lock tests", + [sys.executable, "-m", "unittest", "tests.test_kit_lock"])) + + # 10. fresh-clone test (fork-dryrun.sh) fork_dryrun = os.path.join(HERE, "fork-dryrun.sh") if os.path.isfile(fork_dryrun): # Use current repo as source and 'main' as branch; no private paths. diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..6256d73 --- /dev/null +++ b/install.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# Protean Kit installer entrypoint. +# +# bash install.sh --all --target +# bash install.sh --ingredient --target +# bash install.sh --ingredient --no-deps --target +# bash install.sh --all --target --offline +# bash install.sh --all --target --dry-run +# +# The installer resolves the selection from kit.lock.json, fetches each pinned +# ingredient from its annotated tag, verifies the tag's peeled commit sha and the +# ingredient tree hash, installs it, and reads every written file back. +# +# Exit codes: 0 ok, 1 usage/input, 2 lock invalid, 3 dependency, 4 integrity, +# 5 install, 6 post-install verification. A run that installs part of the +# selection exits non-zero: there is no partial-success zero. +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if ! command -v python3 >/dev/null 2>&1; then + printf 'error: install: python3 is required (%s)\n' "the installer is stdlib Python" >&2 + exit 1 +fi + +exec python3 "$ROOT/build/install.py" "$@" diff --git a/kit.lock.json b/kit.lock.json new file mode 100644 index 0000000..d69e908 --- /dev/null +++ b/kit.lock.json @@ -0,0 +1,302 @@ +{ + "lockVersion": 1, + "kit": { + "name": "protean-kit", + "version": "2.0.0", + "tag": "v2.0.0" + }, + "generatedAt": "2026-09-17T00:00:00Z", + "allowedHosts": [ + "github.com" + ], + "allowedOwners": [ + "aska-digital" + ], + "installOrder": [ + "protean-doctrine", + "protean-ops", + "protean-drafts", + "protean-github-flow", + "protean-sym2p", + "protean-control-plane" + ], + "ingredients": [ + { + "slug": "protean-doctrine", + "repo": "https://github.com/aska-digital/protean-doctrine", + "ref": "refs/tags/v1.0.2", + "sha": "f5d6f795181522249b6e8d18d483f6f2c85058a9", + "version": "1.0.2", + "contract": "The operating doctrine: the default pipeline, role delegation map, handoff protocol, QA gates, and external-writing discipline.", + "kind": "skills", + "installTargets": [ + "skills/protean-operating-doctrine", + "skills/external-writing-discipline", + "gates/protean-doctrine" + ], + "requires": [], + "recommends": [], + "integrity": { + "fileCount": 18, + "treeSha256": "faeecb7ce747b2c7ad205d97f947e198e6240362908263fd90365a64f3463b27", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-doctrine/check-internal-names.py ." + }, + { + "name": "no-dangling-refs", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-doctrine/check-no-dangling-refs.py ." + } + ], + "open": [] + }, + { + "slug": "protean-ops", + "repo": "https://github.com/aska-digital/protean-ops", + "ref": "refs/tags/v1.0.0", + "sha": "f2abc3bf6a7e0925d26e14606d0645a0ea765bad", + "version": "1.0.0", + "contract": "The dispatch/rotation/inflight ops kit: append-only record schemas and the deterministic gates that check them.", + "kind": "records", + "installTargets": [ + "records", + "scripts/protean-ops", + "gates/protean-ops" + ], + "requires": [], + "recommends": [], + "integrity": { + "fileCount": 48, + "treeSha256": "09170568f2b0b112745a9f4c9bd13f300d61f6c523fefae06a17aff662e8789c", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-ops/check-internal-names.py ." + }, + { + "name": "op-rotation", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-ops/check-rotation.py records/examples/valid/ROTATION-STATE.md --roster records/examples/valid/ROSTER.txt" + }, + { + "name": "op-inflight", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-ops/check-inflight.py records/examples/valid/INFLIGHT.md" + }, + { + "name": "op-learnings", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-ops/check-learnings.py records/examples/valid/LEARNINGS.md" + }, + { + "name": "op-hotpath-freeze", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-ops/check-hotpath-freeze.py records/examples/valid/hotpath-manifest.md5" + }, + { + "name": "op-decision-report", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-ops/check-decision-report.py records/examples/valid/decision-report.html --evidence records/examples/valid/decision-evidence.md" + } + ], + "open": [] + }, + { + "slug": "protean-drafts", + "repo": "https://github.com/aska-digital/protean-drafts", + "ref": "refs/tags/v1.0.0", + "sha": "f09703b05a5491074f870fe6fdc15f81baa71d0e", + "version": "1.0.0", + "contract": "The draft-review pipeline: render a draft as a self-contained dark HTML review page through one command that runs the prose, identifier, quote-integrity, and render-fidelity gates.", + "kind": "skills", + "installTargets": [ + "skills/draft-review-html", + "scripts/protean-drafts", + "templates/protean-drafts", + "gates/protean-drafts" + ], + "requires": [], + "recommends": [ + "protean-doctrine" + ], + "integrity": { + "fileCount": 22, + "treeSha256": "c7b37d090696aae67f84d20b76d702a3b05c9f6e9cc034dde8ddf4e7346ba99c", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-drafts/check-internal-names.py ." + }, + { + "name": "draft-prose", + "cwd": ".", + "postInstall": true, + "cmd": "python3 scripts/protean-drafts/check-prose.py skills/draft-review-html/SKILL.md" + }, + { + "name": "draft-renderer-defaults", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-drafts/check-renderer-defaults.py ." + } + ], + "open": [] + }, + { + "slug": "protean-github-flow", + "repo": "https://github.com/aska-digital/protean-github-flow", + "ref": "refs/tags/v1.0.0", + "sha": "e79f1685b0d429676fd5e4a6273da35d334a9b60", + "version": "1.0.0", + "contract": "The GitHub workflow skill pack: issue triage, issue-to-PR, PR lifecycle, PR audit, and upstream-contribution procedures.", + "kind": "skills", + "installTargets": [ + "skills/github-issues", + "skills/github-issue-to-pr", + "skills/github-pr-workflow", + "skills/github-pr-audit", + "skills/upstream-contribution-pr", + "gates/protean-github-flow" + ], + "requires": [], + "recommends": [ + "protean-drafts", + "protean-doctrine" + ], + "integrity": { + "fileCount": 28, + "treeSha256": "db593d7bf25f1b860d625ec6c8fcfe82a8ac32881cb5e6d2f9929c0371ba8982", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-github-flow/check-internal-names.py ." + }, + { + "name": "skill-frontmatter", + "cwd": ".", + "postInstall": false, + "cmd": "python3 gates/protean-github-flow/check-skill-frontmatter.py ." + } + ], + "open": [] + }, + { + "slug": "protean-sym2p", + "repo": "https://github.com/aska-digital/protean-sym2p", + "ref": "refs/tags/v1.0.0", + "sha": "efeec42a77c04bf4d3b40dbc780e07885f16f02f", + "version": "1.0.0", + "contract": "SYM-2P: a compact versioned agentic message language - one canonical JSON packet per line over durable text artifacts - with validator, enums, and fixtures.", + "kind": "skills", + "installTargets": [ + "SPEC.md", + "skills/sym2p", + "scripts/protean-sym2p", + "templates/protean-sym2p", + "examples/protean-sym2p", + "AUDIT/protean-sym2p", + "gates/protean-sym2p" + ], + "requires": [], + "recommends": [ + "protean-ops" + ], + "integrity": { + "fileCount": 47, + "treeSha256": "b88d896f5539edaf323bc18d161388c4e1e616348444782e46451ab7dca9a312", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-sym2p/check-internal-names.py ." + }, + { + "name": "sym2p-suite", + "cwd": ".", + "postInstall": false, + "cmd": "python3 tests/run-tests.py" + } + ], + "open": [ + "O-1", + "O-2", + "O-3", + "O-4", + "O-5", + "O-6", + "O-7", + "O-8", + "O-9" + ] + }, + { + "slug": "protean-control-plane", + "repo": "https://github.com/aska-digital/protean-control-plane", + "ref": "refs/tags/v1.0.0", + "sha": "f593dd1f816e8b348e2a6f5b37b2da6392f9b47a", + "version": "1.0.0", + "contract": "The control-plane skill pack: one canonical procedure home that routes a request to the minimum skill bundle, with the gate table and failure policy.", + "kind": "skills", + "installTargets": [ + "skills/protean-control-plane", + "gates/protean-control-plane" + ], + "requires": [ + "protean-doctrine", + "protean-ops" + ], + "recommends": [ + "protean-drafts", + "protean-github-flow", + "protean-sym2p" + ], + "integrity": { + "fileCount": 17, + "treeSha256": "c879d69ad8283bc40879334dbae415b4bb022f6d4e6dcb8a7f40aa82282e3fbd", + "algorithm": "protean-tree-v1" + }, + "gates": [ + { + "name": "internal-names", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-control-plane/check-internal-names.py ." + }, + { + "name": "control-plane-gates", + "cwd": ".", + "postInstall": true, + "cmd": "python3 gates/protean-control-plane/check-control-plane-gates.py" + } + ], + "open": [] + } + ] +} diff --git a/tests/test_kit_lock.py b/tests/test_kit_lock.py new file mode 100644 index 0000000..6e74ad3 --- /dev/null +++ b/tests/test_kit_lock.py @@ -0,0 +1,448 @@ +#!/usr/bin/env python3 +"""Tests for the composition lock, the installer, and the two new gates. + +Everything runs offline against fixture repositories built in a temporary +directory: a real git repository, an annotated tag, a warm cache entry, and a +lock file. No network access, no third-party import. + +Run: python3 -m unittest tests/test_kit_lock.py +""" +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import sys +import tempfile +import unittest + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +INSTALL = os.path.join(ROOT, "build", "install.py") +CHECK_LOCK = os.path.join(ROOT, "build", "check-lock.py") +CHECK_CONTRACT = os.path.join(ROOT, "build", "check-ingredient-contract.py") +CONTRACT = "The fixture capability: proves the composition contract end to end." + + +def load_install_module(): + spec = importlib.util.spec_from_file_location("kit_install", INSTALL) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +INSTALL_MODULE = load_install_module() + + +def git(args, cwd): + result = subprocess.run(["git"] + args, cwd=cwd, capture_output=True, text=True) + if result.returncode != 0: + raise AssertionError("git %s failed: %s" % (args, result.stderr)) + return result.stdout.strip() + + +IDENTITY = ["-c", "user.name=fixture", "-c", "user.email=fixture@example.com"] + + +def build_ingredient(base, slug, version="1.0.0", gate_exit=0, requires=None, + recommends=None, install_targets=None, contract=CONTRACT, + tree_tweak=None): + """A real fixture ingredient repository with an annotated tag.""" + path = os.path.join(base, slug) + os.makedirs(path, exist_ok=True) + git(["init", "--quiet", "-b", "main", path], cwd=base) + with open(os.path.join(path, "README.md"), "w", encoding="utf-8") as fh: + fh.write(contract + "\n\n# " + slug + "\n\nFixture ingredient for the installer tests.\n") + with open(os.path.join(path, "LICENSE"), "w", encoding="utf-8") as fh: + fh.write("MIT License\n\nCopyright (c) 2026 ASK\n") + targets = install_targets or ["payload/" + slug, "gates/" + slug] + os.makedirs(os.path.join(path, targets[0]), exist_ok=True) + with open(os.path.join(path, targets[0], "file.txt"), "w", encoding="utf-8") as fh: + fh.write("payload for " + slug + "\n") + os.makedirs(os.path.join(path, "tests"), exist_ok=True) + with open(os.path.join(path, "tests", "test_ok.py"), "w", encoding="utf-8") as fh: + fh.write("def test_ok():\n assert True\n") + os.makedirs(os.path.join(path, "gates", slug), exist_ok=True) + with open(os.path.join(path, "gates", slug, "check-fixture.py"), "w", encoding="utf-8") as fh: + fh.write("import sys\n\nprint('fixture gate')\nsys.exit(%d)\n" % gate_exit) + with open(os.path.join(path, "install.sh"), "w", encoding="utf-8") as fh: + fh.write("#!/usr/bin/env bash\nset -euo pipefail\necho fixture\n") + descriptor = { + "slug": slug, + "version": version, + "contract": contract, + "kind": "skills", + "entrypoint": "install.sh", + "installTargets": targets, + "gates": [{"name": "fixture", "cmd": "python3 gates/" + slug + "/check-fixture.py", "cwd": ".", + "postInstall": True}], + "requires": requires or [], + "recommends": recommends or [], + "runtime": {"python": ">=3.9", "bin": ["git"]}, + "license": "MIT", + "open": [], + } + with open(os.path.join(path, "protean-ingredient.json"), "w", encoding="utf-8") as fh: + json.dump(descriptor, fh, indent=2) + if tree_tweak: + with open(os.path.join(path, tree_tweak), "w", encoding="utf-8") as fh: + fh.write("extra\n") + git(["add", "-A"], cwd=path) + git(IDENTITY + ["commit", "--quiet", "-m", "%s %s" % (slug, version)], cwd=path) + git(IDENTITY + ["tag", "-a", "v" + version, "-m", "%s %s" % (slug, version)], cwd=path) + sha = git(["rev-parse", "HEAD"], cwd=path) + return {"path": path, "sha": sha, "targets": targets, "descriptor": descriptor} + + +def warm_cache(cache, slug, fixture): + entry = os.path.join(cache, slug, fixture["sha"]) + os.makedirs(os.path.dirname(entry), exist_ok=True) + subprocess.run(["git", "clone", "--quiet", fixture["path"], entry], check=True, + capture_output=True) + git(["checkout", "--quiet", "--detach", fixture["sha"]], cwd=entry) + return entry + + +def lock_entry(slug, fixture, cache=None): + tree = INSTALL_MODULE.tree_sha256(fixture["path"], fixture["sha"]) + return { + "slug": slug, + "repo": "https://github.com/aska-digital/" + slug, + "ref": "refs/tags/v1.0.0", + "sha": fixture["sha"], + "version": "1.0.0", + "contract": CONTRACT, + "kind": "skills", + "installTargets": fixture["targets"], + "requires": fixture["descriptor"]["requires"], + "recommends": fixture["descriptor"]["recommends"], + "integrity": {"fileCount": 0, "treeSha256": tree, "algorithm": "protean-tree-v1"}, + "gates": fixture["descriptor"]["gates"], + } + + +def write_lock(path, entries, order=None, **overrides): + lock = { + "lockVersion": 1, + "kit": {"name": "protean-kit", "version": "2.0.0", "tag": "v2.0.0"}, + "generatedAt": "2026-09-17T00:00:00Z", + "allowedHosts": ["github.com"], + "allowedOwners": ["aska-digital"], + "installOrder": order or [e["slug"] for e in entries], + "ingredients": entries, + } + lock.update(overrides) + with open(path, "w", encoding="utf-8") as fh: + json.dump(lock, fh, indent=2) + return path + + +def run(script, *args, cwd=None, env=None): + environment = dict(os.environ) + if env: + environment.update(env) + return subprocess.run([sys.executable, script] + [str(a) for a in args], + capture_output=True, text=True, cwd=cwd or ROOT, + env=environment, timeout=300) + + +class FixtureCase(unittest.TestCase): + def setUp(self): + self._tmp = tempfile.TemporaryDirectory() + self.base = self._tmp.name + self.repos = os.path.join(self.base, "repos") + self.cache = os.path.join(self.base, "cache") + os.makedirs(self.repos) + os.makedirs(self.cache) + self.addCleanup(self._tmp.cleanup) + + def ingredient(self, slug, **kwargs): + return build_ingredient(self.repos, slug, **kwargs) + + +class TestTreeHash(FixtureCase): + def test_tree_hash_is_deterministic(self): + fixture = self.ingredient("protean-one") + first = INSTALL_MODULE.tree_sha256(fixture["path"], fixture["sha"]) + second = INSTALL_MODULE.tree_sha256(fixture["path"], fixture["sha"]) + self.assertEqual(first, second) + self.assertRegex(first, r"^[0-9a-f]{64}$") + + def test_tree_hash_changes_with_content(self): + fixture = self.ingredient("protean-one") + before = INSTALL_MODULE.tree_sha256(fixture["path"], fixture["sha"]) + with open(os.path.join(fixture["path"], "README.md"), "a", encoding="utf-8") as fh: + fh.write("more\n") + git(["add", "-A"], cwd=fixture["path"]) + git(IDENTITY + ["commit", "--quiet", "-m", "change"], cwd=fixture["path"]) + after = INSTALL_MODULE.tree_sha256(fixture["path"], git(["rev-parse", "HEAD"], + cwd=fixture["path"])) + self.assertNotEqual(before, after) + + +class TestOrderAndClosure(FixtureCase): + def test_topological_order_breaks_ties_by_slug(self): + order = INSTALL_MODULE.topo_order( + {"protean-b", "protean-a", "protean-c"}, + {"protean-a": [], "protean-b": ["protean-a"], "protean-c": ["protean-a", "protean-b"]}) + self.assertEqual(order.index("protean-a"), 0) + self.assertEqual(order.index("protean-c"), 2) + + def test_cycle_is_a_dependency_failure(self): + try: + INSTALL_MODULE.topo_order({"protean-a", "protean-b"}, + {"protean-a": ["protean-b"], "protean-b": ["protean-a"]}) + except INSTALL_MODULE.Failure as failure: + self.assertEqual(failure.code, 3) + self.assertIn("cycle", failure.message) + else: + self.fail("cycle was not detected") + + def test_closure_follows_requires_only(self): + seen = {"protean-a": {"slug": "protean-a", "requires": [], "recommends": ["protean-z"]}, + "protean-b": {"slug": "protean-b", "requires": ["protean-a"], "recommends": []}, + "protean-z": {"slug": "protean-z", "requires": [], "recommends": []}} + lock = {"ingredients": [seen["protean-a"], seen["protean-b"], seen["protean-z"]]} + opts = {"all": False, "ingredients": ["protean-b"], "no_deps": False} + selection, closure, _degraded = INSTALL_MODULE.resolve_selection(lock, seen, opts) + self.assertEqual(selection, ["protean-b"]) + self.assertEqual(sorted(closure), ["protean-a", "protean-b"]) + + +class TestEndToEndInstall(FixtureCase): + def fixture_lock(self, **kwargs): + fixture = self.ingredient("protean-one", **kwargs) + warm_cache(self.cache, "protean-one", fixture) + entry = lock_entry("protean-one", fixture) + return fixture, write_lock(os.path.join(self.base, "kit.lock.json"), [entry]) + + def test_offline_warm_cache_installs_and_reads_back(self): + fixture, lock = self.fixture_lock() + target = os.path.join(self.base, "installed") + result = run(INSTALL, "--all", "--target", target, "--offline", "--cache", self.cache, + "--lock", lock) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("readback", result.stdout) + self.assertTrue(os.path.isfile(os.path.join(target, "payload", "protean-one", "file.txt"))) + self.assertFalse(os.path.isdir(os.path.join(target, ".protean-staging"))) + + def test_single_ingredient_dry_run_writes_nothing(self): + fixture, lock = self.fixture_lock() + target = os.path.join(self.base, "dry-target") + result = run(INSTALL, "--ingredient", "protean-one", "--target", target, "--dry-run", + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("no files written", result.stdout) + self.assertFalse(os.path.exists(target)) + + def test_cold_cache_offline_is_exit_4(self): + fixture = self.ingredient("protean-one") + entry = lock_entry("protean-one", fixture) + lock = write_lock(os.path.join(self.base, "kit.lock.json"), [entry]) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), "--offline", + "--cache", os.path.join(self.base, "empty-cache"), "--lock", lock) + self.assertEqual(result.returncode, 4, result.stdout + result.stderr) + self.assertIn("offline cache unavailable", result.stderr) + + def test_tampered_tree_hash_is_exit_4(self): + fixture = self.ingredient("protean-one") + warm_cache(self.cache, "protean-one", fixture) + entry = lock_entry("protean-one", fixture) + entry["integrity"]["treeSha256"] = "0" * 64 + lock = write_lock(os.path.join(self.base, "kit.lock.json"), [entry]) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), "--offline", + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 4, result.stdout + result.stderr) + self.assertIn("treeSha256 mismatch", result.stderr) + + def test_failing_post_install_gate_is_exit_5(self): + fixture = self.ingredient("protean-one", gate_exit=1) + warm_cache(self.cache, "protean-one", fixture) + entry = lock_entry("protean-one", fixture) + lock = write_lock(os.path.join(self.base, "kit.lock.json"), [entry]) + target = os.path.join(self.base, "t") + result = run(INSTALL, "--all", "--target", target, "--offline", "--cache", self.cache, + "--lock", lock) + self.assertEqual(result.returncode, 5, result.stdout + result.stderr) + self.assertIn("gate failed in place", result.stderr) + self.assertFalse(os.path.isfile(os.path.join(target, "payload", "protean-one", "file.txt")), + "a failed install promoted files") + + def test_branch_ref_is_exit_2(self): + fixture, lock = self.fixture_lock() + lock_data = json.load(open(lock, encoding="utf-8")) + lock_data["ingredients"][0]["ref"] = "refs/heads/main" + with open(lock, "w", encoding="utf-8") as fh: + json.dump(lock_data, fh) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + + def test_off_allowlist_owner_is_exit_2(self): + fixture, lock = self.fixture_lock() + lock_data = json.load(open(lock, encoding="utf-8")) + lock_data["ingredients"][0]["repo"] = "https://github.com/someone-else/protean-one" + with open(lock, "w", encoding="utf-8") as fh: + json.dump(lock_data, fh) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + + def test_duplicate_slug_is_exit_2(self): + fixture, lock = self.fixture_lock() + lock_data = json.load(open(lock, encoding="utf-8")) + lock_data["ingredients"].append(dict(lock_data["ingredients"][0])) + with open(lock, "w", encoding="utf-8") as fh: + json.dump(lock_data, fh) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + + def test_unknown_flag_is_exit_1(self): + result = run(INSTALL, "--nonsense") + self.assertEqual(result.returncode, 1) + self.assertIn("unknown flag", result.stderr) + + def test_unknown_slug_is_exit_1(self): + fixture, lock = self.fixture_lock() + result = run(INSTALL, "--ingredient", "protean-absent", "--target", + os.path.join(self.base, "t"), "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 1) + + def test_missing_closure_member_is_exit_3(self): + fixture = self.ingredient("protean-one", requires=["protean-absent"]) + warm_cache(self.cache, "protean-one", fixture) + entry = lock_entry("protean-one", fixture) + lock = write_lock(os.path.join(self.base, "kit.lock.json"), [entry]) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), "--offline", + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 3, result.stdout + result.stderr) + + def test_requires_cycle_is_exit_3(self): + one = self.ingredient("protean-one") + two = self.ingredient("protean-two") + entries = [lock_entry("protean-one", one), lock_entry("protean-two", two)] + entries[0]["requires"] = ["protean-two"] + entries[1]["requires"] = ["protean-one"] + lock = write_lock(os.path.join(self.base, "kit.lock.json"), entries) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), "--offline", + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 3, result.stdout + result.stderr) + self.assertIn("cycle", result.stderr) + + def test_no_deps_reports_degraded_without_failing(self): + one = self.ingredient("protean-one") + two = self.ingredient("protean-two", requires=["protean-one"]) + warm_cache(self.cache, "protean-one", one) + warm_cache(self.cache, "protean-two", two) + entries = [lock_entry("protean-one", one), lock_entry("protean-two", two)] + lock = write_lock(os.path.join(self.base, "kit.lock.json"), entries, + order=["protean-one", "protean-two"]) + target = os.path.join(self.base, "t") + result = run(INSTALL, "--ingredient", "protean-two", "--no-deps", "--target", target, + "--offline", "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("degraded", result.stdout) + self.assertFalse(os.path.isdir(os.path.join(target, "payload", "protean-one")), + "degraded mode installed a requirement") + + def test_install_target_collision_is_exit_2(self): + one = self.ingredient("protean-one") + two = self.ingredient("protean-two", install_targets=["payload/shared"]) + entries = [lock_entry("protean-one", one), lock_entry("protean-two", two)] + entries[0]["installTargets"] = ["payload/shared"] + lock = write_lock(os.path.join(self.base, "kit.lock.json"), entries) + result = run(INSTALL, "--all", "--target", os.path.join(self.base, "t"), "--offline", + "--cache", self.cache, "--lock", lock) + self.assertEqual(result.returncode, 2, result.stdout + result.stderr) + self.assertIn("collision", result.stderr) + + def test_read_back_detects_a_difference(self): + fixture, lock = self.fixture_lock() + target = os.path.join(self.base, "installed") + run(INSTALL, "--all", "--target", target, "--offline", "--cache", self.cache, "--lock", lock) + entry_dir = os.path.join(self.cache, "protean-one", fixture["sha"]) + rel = os.path.join("payload", "protean-one", "file.txt") + with open(os.path.join(target, rel), "a", encoding="utf-8") as fh: + fh.write("tampered\n") + mismatches = INSTALL_MODULE.read_back(entry_dir, target, [rel]) + self.assertEqual(len(mismatches), 1) + + def test_composer_tree_violations_are_detected(self): + with tempfile.TemporaryDirectory() as tmp: + with open(os.path.join(tmp, ".gitmodules"), "w", encoding="utf-8") as fh: + fh.write("[submodule]\n") + os.makedirs(os.path.join(tmp, "vendor")) + problems = INSTALL_MODULE.composer_tree_violations(tmp) + self.assertEqual(len(problems), 2) + + +class TestGates(FixtureCase): + def fixture_lock(self, slugs=("protean-one",)): + entries = [] + for slug in slugs: + fixture = self.ingredient(slug) + warm_cache(self.cache, slug, fixture) + entries.append(lock_entry(slug, fixture)) + return entries, write_lock(os.path.join(self.base, "kit.lock.json"), entries) + + def test_check_lock_passes_and_verifies_pins(self): + entries, lock = self.fixture_lock() + result = run(CHECK_LOCK, lock, "--cache", self.cache, "--verify", "--offline") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("check-lock: PASS", result.stdout) + + def test_check_lock_reports_drifted_tree_hash(self): + entries, lock = self.fixture_lock() + data = json.load(open(lock, encoding="utf-8")) + data["ingredients"][0]["integrity"]["treeSha256"] = "1" * 64 + with open(lock, "w", encoding="utf-8") as fh: + json.dump(data, fh) + result = run(CHECK_LOCK, lock, "--cache", self.cache, "--verify", "--offline") + self.assertEqual(result.returncode, 1) + self.assertIn("treeSha256 mismatch", result.stdout) + + def test_check_lock_reports_cycle(self): + one = self.ingredient("protean-one") + two = self.ingredient("protean-two") + entries = [lock_entry("protean-one", one), lock_entry("protean-two", two)] + entries[0]["requires"] = ["protean-two"] + entries[1]["requires"] = ["protean-one"] + lock = write_lock(os.path.join(self.base, "kit.lock.json"), entries) + result = run(CHECK_LOCK, lock, "--cache", self.cache) + self.assertEqual(result.returncode, 1) + self.assertIn("cycle", result.stdout) + + def test_check_ingredient_contract_passes(self): + entries, lock = self.fixture_lock() + result = run(CHECK_CONTRACT, "--lock", lock, "--cache", self.cache, "--verify", "--offline") + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("check-ingredient-contract: PASS", result.stdout) + + def test_check_ingredient_contract_reports_a_bad_readme(self): + entries, lock = self.fixture_lock() + entry_dir = os.path.join(self.cache, "protean-one", entries[0]["sha"]) + with open(os.path.join(entry_dir, "README.md"), "w", encoding="utf-8") as fh: + fh.write("A different first line\n") + result = run(CHECK_CONTRACT, "--lock", lock, "--cache", self.cache, "--verify", "--offline") + self.assertEqual(result.returncode, 1) + self.assertIn("README first line is not the contract line", result.stdout) + + def test_check_ingredient_contract_reports_a_missing_license(self): + entries, lock = self.fixture_lock() + entry_dir = os.path.join(self.cache, "protean-one", entries[0]["sha"]) + os.remove(os.path.join(entry_dir, "LICENSE")) + result = run(CHECK_CONTRACT, "--lock", lock, "--cache", self.cache, "--verify", "--offline") + self.assertEqual(result.returncode, 1) + self.assertIn("LICENSE is missing", result.stdout) + + def test_check_ingredient_contract_reports_unverified_without_trees(self): + entries, lock = self.fixture_lock() + result = run(CHECK_CONTRACT, "--lock", lock, + "--cache", os.path.join(self.base, "empty-cache")) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertIn("unverified pins", result.stdout) + + +if __name__ == "__main__": + unittest.main(verbosity=2)