From 81fb1d8f03c7ab1b1fadba2b725f967a3b1b1a28 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 02:45:14 +0200 Subject: [PATCH 01/27] fix(lifecycle): fail-closed release version chain and runtime freshness A release could publish a v2.2.2 tag with a 2.2.1 bundle inside (AUD-201), and an old lifecycle runtime could silently apply a new stack it does not know (AUD-202). - provider: accept only ainative-dev-stack-.zip, official and mirror alike; a mismatch is UPDATE_VERSION_MISMATCH before any download - updater: refuse a bundle whose internal VERSION differs from the release; require runtime == target with CLI_UPDATE_REQUIRED before download and before any write; refusals now write nothing, cache included - status/doctor: surface runtime_ready so a user sees that the CLI must be upgraded first - #131: every cache read reconciles a stale UPDATE_AVAILABLE notice against the live project version (UP_TO_DATE, fail-safe on malformed values), reads never rewrite the cache; rollback dry-run says would roll back and writes nothing - tests: version-chain mutation cases A-G, runtime freshness, stale cache; new non-vacuity cases prove each guard blocks (39/39) --- ainative/cli.py | 25 +- ainative/lifecycle/errors.py | 2 + ainative/lifecycle/provider.py | 61 ++- ainative/lifecycle/recovery.py | 4 +- ainative/lifecycle/status.py | 9 +- ainative/lifecycle/updater.py | 260 ++++++++++-- scripts/lifecycle_non_vacuity.py | 62 ++- tests/lifecycle_support.py | 18 + tests/test_lifecycle_update.py | 19 +- tests/test_lifecycle_update_integrity.py | 10 +- tests/test_lifecycle_update_version_chain.py | 423 +++++++++++++++++++ 11 files changed, 824 insertions(+), 69 deletions(-) create mode 100644 tests/test_lifecycle_update_version_chain.py diff --git a/ainative/cli.py b/ainative/cli.py index 41465f91..af8f192d 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -267,7 +267,7 @@ def _cmd_status(args: argparse.Namespace) -> int: def _cmd_doctor(args: argparse.Namespace) -> int: - from .lifecycle import recovery + from .lifecycle import recovery, updater from .knowledge import doctor as knowledgedoctor project = _project(args) @@ -289,6 +289,12 @@ def _cmd_doctor(args: argparse.Namespace) -> int: print(f" INTERRUPTED {item['id']} ({item['operation']}) — run `ainative repair`") for note in diagnosis.notes: print(f" note: {note}") + if diagnosis.update: + # Reported even when it cannot be applied: knowing a release exists + # and knowing this runtime cannot apply it are two different facts + # (#131 / AUD-202). + print("Updates") + print(f" {updater.notice_line(diagnosis.update)}") print("Knowledge:") print(f" status: {knowledge['status']} store: {knowledge['store']}") if knowledge["status"] != knowledgedoctor.STATUS_FAIL: @@ -365,10 +371,19 @@ def _cmd_update(args: argparse.Namespace) -> int: return EXIT_OK if args.update_command == "rollback": record = updater.rollback(project, dry_run=args.dry_run) - return _report(args, record, - f"rolled back to {record.get('to_version')} " - f"({len(record.get('restored', record.get('would_restore', [])))} files)" - + f"\nscope: {record.get('scope', '')}") + if record.get("dry_run"): + # A dry run that says "rolled back" is lying about the only thing + # it is allowed to do: describe (#131). + text = ("(dry-run — nothing was written)\n" + f"would roll back to {record.get('to_version')} " + f"({len(record.get('would_restore', []))} files to restore, " + f"{len(record.get('would_remove', []))} to remove)\n" + f"scope: {record.get('scope', '')}") + else: + text = (f"rolled back to {record.get('to_version')} " + f"({len(record.get('restored', []))} files)" + f"\nscope: {record.get('scope', '')}") + return _report(args, record, text) result = updater.apply(project, dry_run=args.dry_run, force=args.force) text = (f"{'(dry-run) ' if result.dry_run else ''}" diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index f93fe31e..c1c4cc06 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -35,6 +35,8 @@ "UPDATE_CHECK_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, + "UPDATE_VERSION_MISMATCH": EXIT_FAILED, + "CLI_UPDATE_REQUIRED": EXIT_FAILED, "ROLLBACK_UNAVAILABLE": EXIT_FAILED, "APPLY_FAILED": EXIT_FAILED, } diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index ba9294c0..8ee23fad 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -8,8 +8,10 @@ Both providers obey the same integrity contract: a release this stack is willing to install names exactly one archive AND the SHA-256 of those bytes. A source that cannot say what it published is refused rather than trusted -(#126). There is no unverified fallback path — not even the GitHub -`zipball_url`, which no digest can ever cover. +(#126). Within a release, the archive's filename must also carry the release's +own version: a bundle whose name says 2.2.1 under a tag that says 2.2.2 is +refused, digest or not (AUD-201). There is no unverified fallback path — +not even the GitHub `zipball_url`, which no digest can ever cover. This is not a plugin system. There is no registry, no discovery, no entry points: two classes and a factory that reads one environment variable. @@ -24,7 +26,7 @@ import urllib.error import urllib.request from dataclasses import dataclass -from pathlib import Path +from pathlib import Path, PurePosixPath from . import version as versionlib from .digest import digest_bytes @@ -40,6 +42,13 @@ LIFECYCLE_BUNDLE_PREFIX = "ainative-dev-stack-" LIFECYCLE_BUNDLE_SUFFIX = ".zip" + +def lifecycle_bundle_name(version: str) -> str: + """The one filename this stack accepts for a bundle of `version`.""" + + return f"{LIFECYCLE_BUNDLE_PREFIX}{version}{LIFECYCLE_BUNDLE_SUFFIX}" + + _SHA256 = re.compile(r"^[0-9a-f]{64}$") # Short on purpose: an update check runs in the background of a status command, @@ -106,15 +115,28 @@ def latest(self, channel: str) -> Release: if not isinstance(entry, dict) or not entry.get("version"): raise LifecycleError("UPDATE_UNAVAILABLE", f"local release index declares no {channel!r} channel") + version = str(entry["version"]) digest = entry.get("sha256") if not (isinstance(digest, str) and _SHA256.match(digest.strip().lower())): raise LifecycleError( "UPDATE_INTEGRITY_METADATA_MISSING", f"local release index declares no valid sha256 for " - f"{entry['version']}; refusing an update this stack cannot verify") + f"{version}; refusing an update this stack cannot verify") archive = entry.get("archive") + if archive is not None: + name = PurePosixPath(str(archive)).name + expected = lifecycle_bundle_name(version) + if name != expected: + # Same rule as the official source: a mirror publishing + # {version: 2.2.2, archive: ainative-dev-stack-2.2.1.zip} is + # refused rather than applied with a version nobody can name. + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"local release index declares version {version} but archive " + f"{str(archive)!r}; expected {expected!r}", + version=version, published=str(archive), expected=expected) url = str((self.root / archive).resolve()) if archive else None - return Release(version=str(entry["version"]), url=url, + return Release(version=version, url=url, digest=digest.strip().lower(), notes=str(entry.get("notes", "")), source=f"local:{self.root}") @@ -184,7 +206,7 @@ def latest(self, channel: str) -> Release: raise LifecycleError("UPDATE_UNAVAILABLE", f"latest release {tag} is a pre-release; " "the stable channel has nothing newer") - url, sha = _select_asset(document) + url, sha = _select_asset(document, str(parsed)) return Release(version=str(parsed), url=url, digest=sha, notes=str(document.get("body", ""))[:2000], source=self.url) @@ -194,17 +216,22 @@ def fetch(self, release: Release) -> bytes: return self._get(release.url, MAX_ARCHIVE_BYTES) -def _select_asset(document: dict) -> tuple[str | None, str | None]: - """Pick the lifecycle bundle and its published digest, or refuse. +def _select_asset(document: dict, expected_version: str) -> tuple[str | None, str | None]: + """Pick the lifecycle bundle of `expected_version` and its digest, or refuse. The official path accepts exactly one kind of asset: the lifecycle bundle - published beside the wheel and the sdist. A digest is not optional — an - update that cannot verify what it downloaded is not an update this stack - performs (#126). The `zipball_url` fallback this function used to return - (with `digest=None`, verifying nothing) is gone on purpose: no silent - unverified path exists. + published beside the wheel and the sdist, named after the version the + release itself declares. A mismatched name is a distinct refusal + (`UPDATE_VERSION_MISMATCH`), not "no bundle found": the digest may cover + bytes whose internal VERSION is a different release, which no comparison + can detect until after extraction - so it never reaches the download. A + digest is not optional either: an update that cannot verify what it + downloaded is not an update this stack performs (#126). The `zipball_url` + fallback this function used to return (with `digest=None`, verifying + nothing) is gone on purpose: no silent unverified path exists. """ + expected = lifecycle_bundle_name(expected_version) assets = document.get("assets") for asset in assets if isinstance(assets, list) else []: if not isinstance(asset, dict): @@ -215,6 +242,12 @@ def _select_asset(document: dict) -> tuple[str | None, str | None]: and name.endswith(LIFECYCLE_BUNDLE_SUFFIX) and isinstance(url, str)): continue + if name != expected: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"release {expected_version} publishes lifecycle bundle {name!r}; " + f"expected {expected!r}", + version=expected_version, published=name, expected=expected) digest = asset.get("digest") if not (isinstance(digest, str) and digest.startswith("sha256:")): raise LifecycleError( @@ -278,7 +311,7 @@ def copy_tree(source: Path, destination: Path) -> None: __all__ = [ "Release", "UpdateProvider", "LocalDirectoryProvider", "ReleaseApiProvider", - "build", "verify_archive", "copy_tree", + "build", "verify_archive", "copy_tree", "lifecycle_bundle_name", "PROVIDER_ENV", "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "DEFAULT_RELEASE_URL", "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", diff --git a/ainative/lifecycle/recovery.py b/ainative/lifecycle/recovery.py index 09d83e83..ab75f65a 100644 --- a/ainative/lifecycle/recovery.py +++ b/ainative/lifecycle/recovery.py @@ -182,7 +182,9 @@ def diagnose(project: Path, *, distribution: Distribution | None = None, "component": "lifecycle", "status": CORRUPTED, "detail": "transaction journal is malformed or has an illegal id; ignored"}) diagnosis.lock = locklib.describe(project) - diagnosis.update = updaterlib.cached_notice(project, allow_network=check_updates) + diagnosis.update = updaterlib.cached_notice( + project, allow_network=check_updates, + current=state.stack_version if state else None) return diagnosis diff --git a/ainative/lifecycle/status.py b/ainative/lifecycle/status.py index a9034e74..3442be31 100644 --- a/ainative/lifecycle/status.py +++ b/ainative/lifecycle/status.py @@ -74,11 +74,7 @@ def render(self) -> str: if self.update: lines.append("") lines.append("Updates") - latest = self.update.get("latest") - if self.update.get("status") == updaterlib.UPDATE_AVAILABLE and latest: - lines.append(f" {latest} available") - else: - lines.append(f" {self.update.get('status', 'unknown').lower()}") + lines.append(f" {updaterlib.notice_line(self.update)}") return "\n".join(lines) @@ -168,7 +164,8 @@ def build(project: Path, *, distribution: Distribution | None = None, components=_component_rows(distribution, state, diagnosis), healthy=diagnosis.healthy, lifecycle_notes=notes, verified=_verified_section(project, distribution, state), - update=updaterlib.cached_notice(project, allow_network=check_updates), + update=updaterlib.cached_notice(project, allow_network=check_updates, + current=state.stack_version), counts=diagnosis.counts()) diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 4ec904ff..44581c64 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -13,6 +13,16 @@ *Authority commands never call any of this.* `verify`, `converge`, `trust` and `work` are routed by the dispatcher without touching this module: a verdict must not depend on what a remote server said (ADR-0009 §6). + +*The runtime is the policy.* Applying release N+1 is done by the lifecycle code +of N+1: the manifests, the planner and the transaction engine all ship inside +the Python package, so a project whose assets were minted by a newer release +would be maintained by an older policy that cannot know them. `apply` therefore +refuses any target whose version differs from the running package +(`CLI_UPDATE_REQUIRED`), before downloading and before the first write. This is +strict equality on purpose: a compatibility matrix no release has needed yet +would be a silent permission system, and a silent permission system is what +this refusal exists to remove. """ from __future__ import annotations @@ -45,6 +55,9 @@ DISABLE_ENV = "AINATIVE_NO_UPDATE_CHECK" STAGED_RELATIVE = statelib.LIFECYCLE_DIRNAME / "staged" +UPGRADE_COMMAND_TEMPLATE = ( + 'pip install --upgrade ' + '"git+https://github.com/Rwanbt/ai-native-dev-stack.git@v{version}"') # A zip that expands to more than this, or holds more entries, is refused before # a single byte is written. Both are classic archive bombs. @@ -61,16 +74,29 @@ class CheckResult: from_cache: bool = False checked_at: str | None = None detail: str = "" + # Whether THIS runtime could apply `latest`. Recomputed from the running + # package at every read - never trusted from a cache written by another + # runtime, which is the stale-notice class this avoids (#131). + runtime_version: str = "" + runtime_ready: bool = True def to_record(self) -> dict: return {"status": self.status, "current": self.current, "latest": self.latest, "from_cache": self.from_cache, "checked_at": self.checked_at, - "detail": self.detail} + "detail": self.detail, "runtime_version": self.runtime_version, + "runtime_ready": self.runtime_ready} def message(self) -> str: if self.status == UPDATE_AVAILABLE: - return (f"AI Native {self.latest} is available.\nCurrent: {self.current}\n" - "Run `ainative update`") + lines = [f"AI Native {self.latest} is available.", + f"Current: {self.current}"] + if self.runtime_ready: + lines.append("Run `ainative update`") + else: + lines.append(f"This CLI runtime is {self.runtime_version}; release " + f"{self.latest} must be applied by runtime {self.latest}.") + lines.append(f"Upgrade the CLI first: {upgrade_command(str(self.latest))}") + return "\n".join(lines) if self.status == UP_TO_DATE: return f"Up to date ({self.current})." if self.status == DISABLED: @@ -82,6 +108,62 @@ def cache_path(project: Path) -> Path: return project / statelib.UPDATE_CACHE_RELATIVE +def runtime_version() -> str: + """The version of the package executing this code - not of any release.""" + + from ainative import __version__ + + return __version__ + + +def upgrade_command(version: str) -> str: + """The exact command that installs the runtime a target release needs.""" + + return UPGRADE_COMMAND_TEMPLATE.format(version=version) + + +def _runtime_fields(result: CheckResult) -> CheckResult: + """Fill the freshness fields from the running package, every time.""" + + runtime = runtime_version() + result.runtime_version = runtime + result.runtime_ready = (result.status != UPDATE_AVAILABLE + or result.latest is None + or runtime == result.latest) + return result + + +def _reconcile_cached(cached: dict, current: str) -> CheckResult: + """Read a cached answer without letting it outrank the state it describes. + + A cache written before an update lands still says UPDATE_AVAILABLE for the + version the project just moved to, and `update check` then announces + "2.2.1 is available. Current: 2.2.1" until the TTL expires (#131). The + comparison happens at every read, so the answer is corrected immediately + without mutating the cache - some readers are read-only by contract. + + A cached value that is not SemVer cannot establish "newer", and the + failure this guards against is announcing an update that is not one, so it + resolves to UP_TO_DATE with the reason stated in `detail`. + """ + + status = str(cached.get("status", CHECK_FAILED)) + latest = cached.get("latest") + detail = str(cached.get("detail", "")) + if status == UPDATE_AVAILABLE: + if not (isinstance(latest, str) and versionlib.parse(latest) is not None): + status = UP_TO_DATE + detail = "cached availability notice carries no usable version" + elif not versionlib.is_newer(latest, current): + status = UP_TO_DATE + detail = f"cached availability notice ({latest}) is not newer than {current}" + result = CheckResult(status=status, current=current, + latest=latest if isinstance(latest, str) else None, + from_cache=True, checked_at=cached.get("checked_at"), + detail=detail) + return _runtime_fields(result) + + def _read_cache(project: Path) -> dict | None: try: payload = json.loads(cache_path(project).read_text(encoding="utf-8")) @@ -119,12 +201,17 @@ def checks_disabled(state: statelib.InstallState | None) -> bool: def check(project: Path, *, force: bool = False, allow_network: bool = True, record: bool = True, state: statelib.InstallState | None = None, - source: DistributionSource | None = None) -> CheckResult: + source: DistributionSource | None = None, + release: providerlib.Release | None = None) -> CheckResult: """Resolve the newest compatible release. Never fatal, never a traceback. `record=False` answers without touching the cache. `update --dry-run` needs it: the cache is a file, and a dry run that wrote one was a dry run that changed the project (EMP-LC-024). + + `release` lets a caller that already resolved the release (the updater, + which must check the runtime contract before any write) share it instead of + asking the provider twice. """ project = installerlib.require_project(project) @@ -138,42 +225,82 @@ def check(project: Path, *, force: bool = False, allow_network: bool = True, if cached and not force: age = _cache_age(cached) if age is not None and age < interval: - return CheckResult(status=str(cached.get("status", CHECK_FAILED)), current=current, - latest=cached.get("latest"), from_cache=True, - checked_at=cached.get("checked_at"), - detail=str(cached.get("detail", ""))) + return _reconcile_cached(cached, current) if not allow_network or (checks_disabled(state) and not force): - return CheckResult(DISABLED, current, from_cache=False, detail="update checks disabled") + return _runtime_fields(CheckResult(DISABLED, current, from_cache=False, + detail="update checks disabled")) - try: - release = providerlib.build(channel).latest(channel) - except LifecycleError as error: - status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED - result = CheckResult(status, current, detail=error.message, - checked_at=statelib.now()) - if record: - _write_cache(project, result) - return result + if release is None: + try: + release = providerlib.build(channel).latest(channel) + except LifecycleError as error: + status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED + result = _runtime_fields(CheckResult(status, current, detail=error.message, + checked_at=statelib.now())) + if record: + _write_cache(project, result) + return result newer = versionlib.is_newer(release.version, current) - result = CheckResult(UPDATE_AVAILABLE if newer else UP_TO_DATE, current, - latest=release.version, notes=release.notes, - checked_at=statelib.now()) + result = _runtime_fields(CheckResult(UPDATE_AVAILABLE if newer else UP_TO_DATE, current, + latest=release.version, notes=release.notes, + checked_at=statelib.now())) if record: _write_cache(project, result) return result -def cached_notice(project: Path, *, allow_network: bool = False) -> dict | None: - """What a status line may print. Reads the cache; never dials out by itself.""" +def cached_notice(project: Path, *, allow_network: bool = False, + current: str | None = None) -> dict | None: + """What a status line may print. Reads the cache; never dials out by itself. + + `current` lets a caller that already loaded the install state pass the + project's own version, so a cache that predates an update cannot announce + an update that already happened (#131). Without it, the state is read + defensively: a corrupt state must not turn a status line into a crash. + """ if allow_network: return check(project).to_record() cached = _read_cache(project) if cached is None: return None - return {**cached, "from_cache": True} + if current is None: + current = _current_without_raising(project, cached) + return _reconcile_cached(cached, current).to_record() + + +def notice_line(record: dict | None) -> str: + """One line a status or doctor output prints for an update record. + + Single owner on purpose: two renderings of the same record drifted the + first time - `status` printed a raw status where `check` printed a + sentence - and this line is where the runtime-requirement is surfaced to + a user whose CLI cannot apply the release it sees. + """ + + if not record: + return "unknown" + status = record.get("status") + latest = record.get("latest") + if status == UPDATE_AVAILABLE and latest: + if record.get("runtime_ready", True): + return f"{latest} available" + return (f"{latest} available (CLI upgrade required first: " + f"{upgrade_command(str(latest))})") + return str(status or "unknown").lower() + + +def _current_without_raising(project: Path, cached: dict) -> str: + try: + state = statelib.load(project) + except LifecycleError: + state = None + if state is not None and state.stack_version: + return state.stack_version + fallback = cached.get("current") + return fallback if isinstance(fallback, str) and fallback else "0.0.0" # --- applying an update -------------------------------------------------- @@ -278,9 +405,43 @@ def rollback_candidate(project: Path): return max(candidates, key=lambda item: item.started_at, default=None) +def _require_matching_runtime(release: providerlib.Release) -> None: + """Refuse a target this lifecycle runtime does not know (AUD-202). + + The runtime and the target must be the same version - not a compatibility + matrix, an exact equality. The older runtime's manifests, planner and + transaction code are what would execute, and nothing inside that runtime + can prove they implement the policy the target release intends. The + refusal happens before the archive is fetched and before the first write: + it costs one bounded metadata request. + """ + + runtime = runtime_version() + if runtime == release.version: + return + direction = "older" if versionlib.is_newer(release.version, runtime) else "different" + raise LifecycleError( + "CLI_UPDATE_REQUIRED", + f"AI Native CLI runtime: {runtime}\n" + f"Target stack release: {release.version}\n\n" + f"This project cannot be updated safely with a {direction} lifecycle runtime.\n\n" + "Upgrade the CLI first:\n" + f" {upgrade_command(release.version)}\n\n" + "Then run:\n" + " ainative update", + runtime_version=runtime, target_version=release.version, + upgrade_command=upgrade_command(release.version)) + + def apply(project: Path, *, dry_run: bool = False, force: bool = False, distribution: manifestlib.Distribution | None = None) -> UpdateResult: - """check -> resolve -> stage -> verify -> transactional apply -> commit.""" + """resolve -> runtime gate -> check -> fetch -> verify -> apply -> commit. + + The gate order is load-bearing: the release is resolved (metadata only), + the runtime contract is checked, and only then is the archive downloaded. + Everything that can refuse happens before the first project write, so a + refused update leaves the project byte-identical. + """ project = installerlib.require_project(project) distribution = distribution or manifestlib.load() @@ -289,14 +450,41 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, raise LifecycleError("NOT_INSTALLED", f"no AI Native installation recorded in {project}") - outcome = check(project, force=True, record=not dry_run, state=state) - if outcome.status not in (UPDATE_AVAILABLE,) and not force: + channel = state.update_preferences.get("channel", "stable") or "stable" + provider = providerlib.build(channel) + try: + release = provider.latest(channel) + except LifecycleError as error: + # An unreachable or empty source degrades gracefully, exactly as + # `check` reports it. A source that answered with something this stack + # refuses (mismatched bundle version, missing digest) is not degraded + # connectivity - it is a refusal, and it propagates. + if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): + raise + outcome = _runtime_fields(CheckResult( + OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, + state.stack_version, detail=error.message, checked_at=statelib.now())) + if not dry_run: + _write_cache(project, outcome) + return UpdateResult(False, dry_run, state.stack_version, None, outcome) + + # Availability first, runtime contract second: a project already at the + # newest release has nothing to apply, so a runtime that differs is not + # asked to apply anything and gets the truthful "nothing to do" instead of + # a refusal about work nobody requested. + applying = force or versionlib.is_newer(release.version, state.stack_version) + if applying: + _require_matching_runtime(release) + + # `record=False`: every refusal below this line must leave the project + # byte-identical, cache included. The cache is refreshed by + # `ainative update check`, and corrected at read time (#131) - an attempt + # that did not apply anything writes nothing. + outcome = check(project, force=True, record=False, state=state, release=release) + if not applying: return UpdateResult(applied=False, dry_run=dry_run, from_version=state.stack_version, to_version=outcome.latest, check=outcome) - channel = state.update_preferences.get("channel", "stable") or "stable" - provider = providerlib.build(channel) - release = provider.latest(channel) payload = provider.fetch(release) providerlib.verify_archive(payload, release.digest) @@ -306,6 +494,15 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, root = _distribution_root(_safe_extract(payload, staging)) staged_source = DistributionSource(root=root.resolve(), origin="update", version=sourcelib.read_version(root)) + # The release named a version; the bytes name another one. SHA-256 + # covers integrity, not identity - this comparison is what makes the + # filename version and the internal VERSION one fact (AUD-201). + if staged_source.version != release.version: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"release {release.version} published a bundle whose internal " + f"VERSION is {staged_source.version!r}; refusing before any write", + release_version=release.version, bundle_version=staged_source.version) plan, _, _ = installerlib.plan_profile(project, distribution, staged_source, state.active_profile, operation="update", state=state) @@ -445,7 +642,8 @@ def _backed_up_version(project: Path, journal) -> str | None: __all__ = [ "UP_TO_DATE", "UPDATE_AVAILABLE", "OFFLINE", "CHECK_FAILED", "DISABLED", "DISABLE_ENV", - "CheckResult", "check", "cached_notice", "checks_disabled", + "CheckResult", "check", "cached_notice", "checks_disabled", "notice_line", + "runtime_version", "upgrade_command", "UpdateResult", "apply", "rollback", "rollback_candidate", "cache_path", "ROLLBACK_SCOPE", "MAX_EXPANDED_BYTES", "MAX_ARCHIVE_ENTRIES", diff --git a/scripts/lifecycle_non_vacuity.py b/scripts/lifecycle_non_vacuity.py index f060a34a..cf4d71e0 100644 --- a/scripts/lifecycle_non_vacuity.py +++ b/scripts/lifecycle_non_vacuity.py @@ -212,11 +212,69 @@ class Case: "staged_source.version,\n" " outcome, plan.to_record(), conflicts)\n"), Edit("ainative/lifecycle/updater.py", - " outcome = check(project, force=True, record=not dry_run, state=state)\n", - " outcome = check(project, force=True, state=state)\n")), + " outcome = check(project, force=True, record=False, state=state, " + "release=release)\n", + " outcome = check(project, force=True, state=state, release=release)\n")), test=("tests.test_lifecycle_update.UpdateApply" ".test_a_dry_run_update_writes_nothing"), ), + Case( + name="runtime_freshness_gate", + guards="an older lifecycle runtime must not apply a newer release", + edits=(Edit("ainative/lifecycle/updater.py", + " if runtime == release.version:\n" + " return\n", + " if True:\n return\n"),), + test=("tests.test_lifecycle_update_version_chain.RuntimeFreshness" + ".test_an_older_runtime_is_refused_before_any_write"), + ), + Case( + name="bundle_internal_version_gate", + guards="a bundle whose internal VERSION differs from the release is refused", + edits=(Edit("ainative/lifecycle/updater.py", + " if staged_source.version != release.version:\n", + " if False:\n"),), + test=("tests.test_lifecycle_update_version_chain.VersionChainRefusals" + ".test_a_bundle_whose_internal_version_differs_is_refused"), + ), + Case( + name="asset_version_gate", + guards="a release asset named for another version must be refused", + # Anchored on the raise that follows: the same condition line exists + # with twelve-space indentation in LocalDirectoryProvider, and a short + # anchor matches inside each other. + edits=(Edit("ainative/lifecycle/provider.py", + " if name != expected:\n" + " raise LifecycleError(\n" + ' "UPDATE_VERSION_MISMATCH",\n' + ' f"release {expected_version} publishes', + " if False:\n" + " raise LifecycleError(\n" + ' "UPDATE_VERSION_MISMATCH",\n' + ' f"release {expected_version} publishes'),), + test=("tests.test_lifecycle_update_version_chain.OfficialAssetSelection" + ".test_a_release_publishing_another_version_bundle_is_refused"), + ), + Case( + name="local_mirror_version_gate", + guards="a local mirror archive named for another version must be refused", + edits=(Edit("ainative/lifecycle/provider.py", + " if name != expected:\n" + " # Same rule as the official source", + " if False:\n" + " # Same rule as the official source"),), + test=("tests.test_lifecycle_update_version_chain.VersionChainRefusals" + ".test_a_local_index_whose_archive_names_another_version_is_refused"), + ), + Case( + name="stale_cache_reconciliation", + guards="a cached availability notice must not outrank the state it describes", + edits=(Edit("ainative/lifecycle/updater.py", + " elif not versionlib.is_newer(latest, current):\n", + " elif False:\n"),), + test=("tests.test_lifecycle_update_version_chain.StaleUpdateCache" + ".test_after_an_update_the_cached_availability_is_corrected"), + ), Case( name="ownership_flag_typing", guards="a non-boolean ownership flag must preserve, not delete", diff --git a/tests/lifecycle_support.py b/tests/lifecycle_support.py index 6f9aa453..b09e3ccc 100644 --- a/tests/lifecycle_support.py +++ b/tests/lifecycle_support.py @@ -134,6 +134,24 @@ def set_env(self, name: str, value: str) -> None: # --- helpers --------------------------------------------------------- + def assume_runtime(self, version: str) -> None: + """Make this process claim `version` as its lifecycle runtime. + + Update-path fixtures target versions other than the checkout's own, and + the production runtime gate compares the running package with the + target. Unit tests cannot install a second package into the running + interpreter; the real old-runtime -> new-release transition is exercised + with real wheels by `scripts/lifecycle_upgrade_e2e.py`. + """ + + from unittest import mock + + from ainative.lifecycle import updater + + patcher = mock.patch.object(updater, "runtime_version", lambda: version) + patcher.start() + self.addCleanup(patcher.stop) + def install(self, profile: str = "standard", **kwargs): from ainative.lifecycle import installer diff --git a/tests/test_lifecycle_update.py b/tests/test_lifecycle_update.py index 2ecf45f4..58503093 100644 --- a/tests/test_lifecycle_update.py +++ b/tests/test_lifecycle_update.py @@ -55,10 +55,20 @@ def setUp(self) -> None: self.releases = self.root / "releases" self.releases.mkdir() self.v2_tree = build_distribution_tree(self.root / "dist-v2", "2.0.0") - self.archive = make_release_archive(self.v2_tree, self.releases / "stack-2.0.0.zip") + # The mirror contract names archives exactly as the official release + # does; a shorter name is a different release this stack refuses. + self.archive = make_release_archive( + self.v2_tree, self.releases / "ainative-dev-stack-2.0.0.zip") self.publish("2.0.0", self.archive) self.set_env(providerlib.PROVIDER_ENV, "local") self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) + self.assume_runtime("2.0.0") + + def _v1_archive(self) -> Path: + """A v1 bundle the current v1 project would be *at*, not moving to.""" + + tree = build_distribution_tree(self.root / "dist-v1b", "1.0.0") + return make_release_archive(tree, self.releases / "ainative-dev-stack-1.0.0.zip") def publish(self, version: str, archive: Path, digest: str | None = None) -> None: payload = {"channels": {"stable": { @@ -80,7 +90,7 @@ def test_a_newer_release_is_reported_as_available(self): def test_the_same_version_produces_no_notification(self): self.install("standard") - self.publish("1.0.0", self.archive) + self.publish("1.0.0", self._v1_archive()) outcome = updaterlib.check(self.project, force=True) self.assertEqual(outcome.status, updaterlib.UP_TO_DATE) self.assertNotIn("available", outcome.message()) @@ -243,7 +253,7 @@ def test_a_tampered_archive_digest_stops_the_update_before_any_write(self): def test_an_update_with_nothing_newer_does_nothing(self): self.install("standard") - self.publish("1.0.0", self.archive) + self.publish("1.0.0", self._v1_archive()) result = updaterlib.apply(self.project, distribution=self.distribution) self.assertFalse(result.applied) @@ -278,7 +288,8 @@ def test_rollback_also_removes_the_files_the_update_created(self): newer = build_distribution_tree(self.root / "dist-v2b", "2.0.0", extra_skill="brand-new") - archive = make_release_archive(newer, self.releases / "stack-2.0.0b.zip") + archive = make_release_archive( + newer, self.releases / "ainative-dev-stack-2.0.0.zip") self.publish("2.0.0", archive) self.install("standard") diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index 16da5261..9309b2d4 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -46,6 +46,7 @@ class OfficialReleaseDocument(LifecycleTestCase): def setUp(self) -> None: super().setUp() self.install("standard") + self.assume_runtime("2.0.0") self.v2_tree = build_distribution_tree(self.root / "dist-v2", "2.0.0") self.archive = make_release_archive(self.v2_tree, self.root / "bundle-2.0.0.zip") self.archive_bytes = self.archive.read_bytes() @@ -85,13 +86,10 @@ def fake_get(url: str, limit: int) -> bytes: def snapshot(self) -> dict[str, str]: from ainative.lifecycle.digest import digest_file - # The update check is allowed to record its own cache before a download; - # everything else in the project must be untouched by a refused update. - excluded = {".ai-native/lifecycle/update-cache.json"} + # A refused update writes nothing at all - not a file, not the check + # cache (the updater records no cache before the version gates). return {path.relative_to(self.project).as_posix(): digest_file(path) or "" - for path in self.project.rglob("*") - if path.is_file() - and path.relative_to(self.project).as_posix() not in excluded} + for path in self.project.rglob("*") if path.is_file()} # --- selection and refusal ------------------------------------------ def test_the_lifecycle_bundle_and_its_published_digest_are_selected(self): diff --git a/tests/test_lifecycle_update_version_chain.py b/tests/test_lifecycle_update_version_chain.py new file mode 100644 index 00000000..3bb9c98e --- /dev/null +++ b/tests/test_lifecycle_update_version_chain.py @@ -0,0 +1,423 @@ +"""The release version chain: tag == VERSION == package == bundle == runtime. + +Two refusals that v2.2.1 did not implement are pinned here: + +* A release may not publish a lifecycle bundle named for another version, and + an old lifecycle runtime may not apply a target it does not know. The exact + transition with real wheels lives in `scripts/lifecycle_upgrade_e2e.py`; + these are the mutation-level proofs (AUD-201 / AUD-202). + +* A cached availability notice may not outrank the state it describes: after + an update lands, `update check` must stop announcing the version the project + just moved to (#131). +""" + +from __future__ import annotations + +import io +import json +import sys +import tarfile +import tempfile +import unittest +import zipfile +from hashlib import sha256 +from pathlib import Path +from unittest import mock + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +from tests.lifecycle_support import (LifecycleTestCase, build_distribution_tree, + make_release_archive, write_text) +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import state as statelib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.digest import digest_file +from ainative.lifecycle.errors import LifecycleError + +TARGET = "2.2.2" + + +def project_snapshot(project: Path) -> dict: + """Every file under the project, by relative path and digest.""" + + return {path.relative_to(project).as_posix(): digest_file(path) or "" + for path in project.rglob("*") if path.is_file()} + + +# --- F/G: the reusable release gate ----------------------------------------- + + +class ReleaseVersionGate(unittest.TestCase): + """`scripts/check_release_versions.py` refuses every split label.""" + + @classmethod + def setUpClass(cls) -> None: + scripts = REPO / "scripts" + if str(scripts) not in sys.path: + sys.path.insert(0, str(scripts)) + import check_release_versions + + cls.gate = check_release_versions + + def test_the_checkout_labels_agree_and_its_tag_names_the_version(self): + version = self.gate.check_labels(REPO) + self.assertEqual(self.gate.check_labels(REPO, f"v{version}"), version) + + def test_a_tag_that_does_not_name_the_version_is_refused(self): + version = self.gate.check_labels(REPO) + with self.assertRaises(self.gate.ReleaseVersionMismatch) as raised: + self.gate.check_labels(REPO, "v999.0.0") + self.assertIn(version, str(raised.exception)) + + def test_two_disagreeing_package_labels_refuse_the_gate(self): + """G: VERSION != ainative.__version__ still fails the build gate.""" + + from _payload_staging import VersionMismatch + + with tempfile.TemporaryDirectory(prefix="ainative-gate-") as staging: + root = Path(staging) + (root / "ainative").mkdir() + write_text(root / "VERSION", "1.0.0\n") + write_text(root / "ainative" / "__init__.py", '__version__ = "2.0.0"\n') + write_text(root / "AGENTS.md", "\n") + with self.assertRaises(VersionMismatch): + self.gate.check_labels(root) + + def test_an_agents_header_that_disagrees_is_refused(self): + with tempfile.TemporaryDirectory(prefix="ainative-gate-") as staging: + root = Path(staging) + (root / "ainative").mkdir() + write_text(root / "VERSION", "1.0.0\n") + write_text(root / "ainative" / "__init__.py", '__version__ = "1.0.0"\n') + write_text(root / "AGENTS.md", "\n") + with self.assertRaises(self.gate.ReleaseVersionMismatch): + self.gate.check_labels(root) + + +def make_wheel(dist: Path, declared: str, filename_version: str | None = None) -> Path: + path = dist / f"ainative_dev_stack-{filename_version or declared}-py3-none-any.whl" + metadata = (f"Metadata-Version: 2.1\nName: ainative-dev-stack\n" + f"Version: {declared}\n") + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"ainative_dev_stack-{declared}.dist-info/METADATA", metadata) + return path + + +def make_sdist(dist: Path, declared: str, filename_version: str | None = None) -> Path: + path = dist / f"ainative_dev_stack-{filename_version or declared}.tar.gz" + payload = (f"Metadata-Version: 2.1\nName: ainative-dev-stack\n" + f"Version: {declared}\n").encode("utf-8") + with tarfile.open(path, "w:gz") as archive: + info = tarfile.TarInfo(f"ainative_dev_stack-{declared}/PKG-INFO") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return path + + +def make_bundle(dist: Path, filename_version: str, internal_version: str) -> Path: + path = dist / f"ainative-dev-stack-{filename_version}.zip" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("VERSION", f"{internal_version}\n") + return path + + +class ReleaseDistGate(unittest.TestCase): + """`--dist`: filenames, wheel METADATA, sdist PKG-INFO, bundle VERSION.""" + + def gate_check(self, dist: Path): + scripts = REPO / "scripts" + if str(scripts) not in sys.path: + sys.path.insert(0, str(scripts)) + import check_release_versions + + return check_release_versions.check_dist(TARGET, dist) + + def test_a_consistent_dist_tree_passes_and_all_artifacts_are_named(self): + with tempfile.TemporaryDirectory(prefix="ainative-dist-") as staging: + dist = Path(staging) + make_bundle(dist, TARGET, TARGET) + make_wheel(dist, TARGET) + make_sdist(dist, TARGET) + checked = self.gate_check(dist) + self.assertIn(f"ainative-dev-stack-{TARGET}.zip", checked) + self.assertIn(f"ainative_dev_stack-{TARGET}-py3-none-any.whl", checked) + self.assertIn(f"ainative_dev_stack-{TARGET}.tar.gz", checked) + + def test_a_bundle_whose_internal_version_differs_is_refused(self): + with tempfile.TemporaryDirectory(prefix="ainative-dist-") as staging: + dist = Path(staging) + make_bundle(dist, TARGET, "2.2.1") + make_wheel(dist, TARGET) + make_sdist(dist, TARGET) + with self.assertRaises(Exception) as raised: + self.gate_check(dist) + self.assertIn("VERSION", str(raised.exception)) + + def test_a_wheel_wearing_the_wrong_version_metadata_is_refused(self): + with tempfile.TemporaryDirectory(prefix="ainative-dist-") as staging: + dist = Path(staging) + make_bundle(dist, TARGET, TARGET) + make_wheel(dist, "2.2.1", filename_version=TARGET) + make_sdist(dist, TARGET) + with self.assertRaises(Exception) as raised: + self.gate_check(dist) + self.assertIn("metadata version", str(raised.exception)) + + def test_an_artifact_of_another_version_is_refused(self): + with tempfile.TemporaryDirectory(prefix="ainative-dist-") as staging: + dist = Path(staging) + make_bundle(dist, TARGET, TARGET) + make_wheel(dist, TARGET) + make_sdist(dist, TARGET) + make_bundle(dist, "2.2.1", "2.2.1") + with self.assertRaises(Exception) as raised: + self.gate_check(dist) + self.assertIn("another version", str(raised.exception)) + + +# --- A: the official asset must be named for the release -------------------- + + +class OfficialAssetSelection(unittest.TestCase): + + def provider(self, tag: str, asset_name: str): + document = json.dumps({ + "tag_name": tag, + "assets": [{"name": asset_name, + "browser_download_url": f"https://example.invalid/{asset_name}", + "digest": "sha256:" + "0" * 64}], + }).encode("utf-8") + provider = providerlib.ReleaseApiProvider("https://example.invalid/releases/latest") + provider._get = lambda url, limit: document + return provider + + def test_a_release_publishing_another_version_bundle_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.provider("v2.2.2", "ainative-dev-stack-2.2.1.zip").latest("stable") + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + self.assertEqual(raised.exception.detail.get("published"), + "ainative-dev-stack-2.2.1.zip") + self.assertEqual(raised.exception.detail.get("expected"), + "ainative-dev-stack-2.2.2.zip") + + def test_the_matching_bundle_is_selected(self): + release = self.provider("v2.2.2", "ainative-dev-stack-2.2.2.zip").latest("stable") + self.assertEqual(release.version, "2.2.2") + self.assertEqual(release.digest, "0" * 64) + +# --- B/C/D/E: the chain at the updater and local mirror --------------------- + + +class VersionChainFixture(LifecycleTestCase): + """A v1 project, a local mirror publishing 2.2.2, a 2.2.2 runtime label.""" + + def setUp(self) -> None: + super().setUp() + self.releases = self.root / "releases" + self.releases.mkdir() + self.target_tree = build_distribution_tree(self.root / "dist-target", TARGET) + self.archive = make_release_archive( + self.target_tree, self.releases / f"ainative-dev-stack-{TARGET}.zip") + self.publish(TARGET, self.archive) + self.set_env(providerlib.PROVIDER_ENV, "local") + self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) + self.install("standard") + self.assume_runtime(TARGET) + + def publish(self, version: str, archive: Path, digest: str | None = None) -> None: + payload = {"channels": {"stable": { + "version": version, "archive": archive.name, + "sha256": digest if digest is not None + else sha256(archive.read_bytes()).hexdigest(), + "notes": f"release {version}"}}} + (self.releases / "releases.json").write_text(json.dumps(payload), encoding="utf-8") + + +class VersionChainRefusals(VersionChainFixture): + + def test_a_local_index_whose_archive_names_another_version_is_refused(self): + """C: {version: 2.2.2, archive: ainative-dev-stack-2.2.1.zip}.""" + + other = make_release_archive( + build_distribution_tree(self.root / "dist-other", "2.2.1"), + self.releases / "ainative-dev-stack-2.2.1.zip") + self.publish(TARGET, other) + with self.assertRaises(LifecycleError) as raised: + providerlib.build("stable").latest("stable") + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + + def test_an_index_refusal_reaches_the_updater_without_any_write(self): + other = make_release_archive( + build_distribution_tree(self.root / "dist-other", "2.2.1"), + self.releases / "ainative-dev-stack-2.2.1.zip") + self.publish(TARGET, other) + before = project_snapshot(self.project) + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + self.assertEqual(project_snapshot(self.project), before, + "a version-mismatched index touched the project") + + def test_a_bundle_whose_internal_version_differs_is_refused(self): + """B + E: filename 2.2.2, contents 2.2.1 - refused, zero writes.""" + + inner = build_distribution_tree(self.root / "dist-inner", "2.2.1") + archive = make_release_archive( + inner, self.releases / f"ainative-dev-stack-{TARGET}.zip") + self.publish(TARGET, archive) + before = project_snapshot(self.project) + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + self.assertEqual(raised.exception.detail.get("bundle_version"), "2.2.1") + self.assertEqual(project_snapshot(self.project), before, + "a mismatched bundle touched the project") + self.assertEqual(statelib.load(self.project).stack_version, "1.0.0") + + def test_a_consistent_release_applies_and_records_matching_versions(self): + """D: release 2.2.2 + bundle 2.2.2 + internal 2.2.2 -> applied.""" + + result = updaterlib.apply(self.project, distribution=self.distribution) + self.assertTrue(result.applied) + self.assertEqual(result.to_version, TARGET) + state = statelib.load(self.project) + self.assertEqual(state.stack_version, TARGET) + self.assertEqual(state.source_version, TARGET) + self.assertEqual(self.read("AGENTS.md"), f"# Engineering method {TARGET}\n") + self.assertTrue(result.rollback_available) + + +class RuntimeFreshness(VersionChainFixture): + + def test_an_older_runtime_is_refused_before_any_write(self): + before = project_snapshot(self.project) + with mock.patch.object(updaterlib, "runtime_version", lambda: "2.2.1"): + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") + self.assertEqual(raised.exception.detail["runtime_version"], "2.2.1") + self.assertEqual(raised.exception.detail["target_version"], TARGET) + self.assertIn(f"@v{TARGET}", raised.exception.detail["upgrade_command"]) + self.assertEqual(project_snapshot(self.project), before, + "a refused update touched the project") + self.assertEqual(statelib.load(self.project).stack_version, "1.0.0") + self.assertFalse(updaterlib.cache_path(self.project).exists(), + "the refusal wrote a cache entry") + + def test_a_dry_run_with_an_older_runtime_raises_without_writing(self): + before = project_snapshot(self.project) + with mock.patch.object(updaterlib, "runtime_version", lambda: "2.2.1"): + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, dry_run=True, distribution=self.distribution) + self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") + self.assertEqual(project_snapshot(self.project), before) + + def test_detection_still_works_with_an_older_runtime(self): + with mock.patch.object(updaterlib, "runtime_version", lambda: "2.2.1"): + outcome = updaterlib.check(self.project, force=True) + self.assertEqual(outcome.status, updaterlib.UPDATE_AVAILABLE) + self.assertEqual(outcome.latest, TARGET) + self.assertFalse(outcome.runtime_ready) + self.assertEqual(outcome.runtime_version, "2.2.1") + message = outcome.message() + self.assertIn("Upgrade the CLI first", message) + self.assertIn(f"@v{TARGET}", message) + + def test_a_status_notice_names_the_required_cli_upgrade(self): + with mock.patch.object(updaterlib, "runtime_version", lambda: "2.2.1"): + notice = updaterlib.cached_notice(self.project, allow_network=True) + self.assertFalse(notice["runtime_ready"]) + self.assertIn("CLI upgrade required", updaterlib.notice_line(notice)) + + def test_an_up_to_date_project_is_not_refused_by_a_newer_runtime(self): + """Nothing to apply means nothing to gate: the truthful answer stands.""" + + state = statelib.load(self.project) + state.stack_version = TARGET + statelib.save(self.project, state) + with mock.patch.object(updaterlib, "runtime_version", lambda: "9.9.9"): + result = updaterlib.apply(self.project, distribution=self.distribution) + self.assertFalse(result.applied) + self.assertEqual(result.check.status, updaterlib.UP_TO_DATE) + +class StaleUpdateCache(VersionChainFixture): + """#131: the cache may not announce an update the project already took.""" + + def write_cache(self, payload: dict) -> None: + statelib.write_atomic(updaterlib.cache_path(self.project), + json.dumps(payload, indent=2, sort_keys=True) + "\n") + + def test_after_an_update_the_cached_availability_is_corrected(self): + pre = updaterlib.check(self.project, force=True) + self.assertEqual(pre.status, updaterlib.UPDATE_AVAILABLE) + self.assertTrue(updaterlib.apply(self.project, distribution=self.distribution).applied) + + after = updaterlib.check(self.project) # no --force: the cache answers + self.assertTrue(after.from_cache) + self.assertEqual(after.status, updaterlib.UP_TO_DATE) + self.assertIn(f"Up to date ({TARGET})", after.message()) + + def test_cached_notice_after_an_update_does_not_announce_the_applied_release(self): + updaterlib.check(self.project, force=True) + updaterlib.apply(self.project, distribution=self.distribution) + state = statelib.load(self.project) + notice = updaterlib.cached_notice(self.project, current=state.stack_version) + self.assertEqual(notice["status"], updaterlib.UP_TO_DATE) + self.assertNotIn("available", updaterlib.notice_line(notice)) + + def test_a_cached_latest_older_than_the_project_is_up_to_date(self): + updaterlib.apply(self.project, distribution=self.distribution) + self.write_cache({"status": "UPDATE_AVAILABLE", "latest": "1.9.0", + "current": "1.9.0", "checked_at": statelib.now()}) + self.assertEqual(updaterlib.check(self.project).status, updaterlib.UP_TO_DATE) + + def test_a_malformed_cached_version_resolves_up_to_date_fail_safe(self): + self.write_cache({"status": "UPDATE_AVAILABLE", "latest": "not-a-version", + "current": "1.0.0", "checked_at": statelib.now()}) + outcome = updaterlib.check(self.project) + self.assertEqual(outcome.status, updaterlib.UP_TO_DATE) + self.assertIn("no usable version", outcome.detail) + + def test_reading_the_cache_never_rewrites_it(self): + updaterlib.check(self.project, force=True) # a real pre-update cache + updaterlib.apply(self.project, distribution=self.distribution) + cache = updaterlib.cache_path(self.project) + original = cache.read_bytes() + self.assertEqual(updaterlib.check(self.project).status, updaterlib.UP_TO_DATE) + self.assertEqual(cache.read_bytes(), original, "a read rewrote the cache") + + +class RollbackDryRunWording(VersionChainFixture): + + def test_the_cli_dry_run_says_would_roll_back_and_writes_nothing(self): + updaterlib.apply(self.project, distribution=self.distribution) + before = project_snapshot(self.project) + completed = self.cli("update", "rollback", "--dry-run") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("dry-run", completed.stdout) + self.assertIn("would roll back", completed.stdout) + self.assertNotIn("rolled back to", completed.stdout) + self.assertEqual(project_snapshot(self.project), before, + "a dry-run rollback wrote to the project") + + def test_the_json_dry_run_reports_dry_run_true(self): + updaterlib.apply(self.project, distribution=self.distribution) + completed = self.cli("update", "rollback", "--dry-run", "--json") + self.assertEqual(completed.returncode, 0, completed.stderr) + record = json.loads(completed.stdout) + self.assertTrue(record["dry_run"]) + self.assertEqual(record["operation"], "update rollback") + + def test_a_real_rollback_still_reports_the_restoration(self): + updaterlib.apply(self.project, distribution=self.distribution) + completed = self.cli("update", "rollback") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("rolled back to", completed.stdout) + self.assertEqual(statelib.load(self.project).stack_version, "1.0.0") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From acbe5e59ac4d708e328ab7dd775d3449a23fcae1 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 02:45:19 +0200 Subject: [PATCH 02/27] ci(release): version gate, real upgrade E2E, SHA-pinned actions, supply-chain baseline - release workflow refuses tag != v\2.2.2 and re-verifies every artifact (bundle filename+internal VERSION, wheel metadata, sdist PKG-INFO) through scripts/check_release_versions.py - new upgrade-e2e job (Linux/Windows/macOS): scripts/lifecycle_upgrade_e2e.py builds two real wheels, installs N in a fresh venv, proves CLI_UPDATE_REQUIRED with zero writes, upgrades the runtime, applies N+1, rolls back, and refuses a tampered bundle - all third-party Actions pinned to full commit SHAs; Dependabot for github-actions; SECURITY.md reporting path; CODEOWNERS for sensitive zones (#129) - Python 3.8 matrix entry best-effort is now actually wired; orphan tests/mv00 Multi-Vault feasibility suites run in CI --- .github/CODEOWNERS | 37 ++++ .github/dependabot.yml | 13 ++ .github/workflows/anti-debt-ci.yml | 52 ++--- .github/workflows/ci.yml | 93 +++++--- .github/workflows/release-assets.yml | 14 +- .github/workflows/sdist-roundtrip.yml | 4 +- SECURITY.md | 55 +++++ scripts/check_release_versions.py | 168 +++++++++++++++ scripts/lifecycle_upgrade_e2e.py | 295 ++++++++++++++++++++++++++ 9 files changed, 673 insertions(+), 58 deletions(-) create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 SECURITY.md create mode 100644 scripts/check_release_versions.py create mode 100644 scripts/lifecycle_upgrade_e2e.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..0f65f357 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,37 @@ +# Review ownership for sensitive zones. The repository currently has a single +# maintainer; this file reflects that reality and prepares the review boundary +# for a second maintainer rather than inventing one. + +# Default: everything not listed below falls to the maintainer. +* @Rwanbt + +# CI/CD pipelines and release plumbing: a change here can publish artifacts. +.github/workflows/ @Rwanbt +.github/dependabot.yml @Rwanbt + +# Lifecycle: installs, updates, deletes files in user projects. +ainative/lifecycle/ @Rwanbt +pyproject.toml @Rwanbt +_build_backend.py @Rwanbt +_payload_staging.py @Rwanbt +MANIFEST.in @Rwanbt +VERSION @Rwanbt + +# Authority-producing code and the trust boundary around it. +ainative_workplane/ @Rwanbt + +# Knowledge / Auto-Memory: stores user knowledge, must never leak it. +ainative/knowledge/ @Rwanbt + +# Multi-Vault isolation boundary. +ainative/multivault/ @Rwanbt + +# Installers, validators and gates that enforce the rules in AGENTS.md. +scripts/ @Rwanbt + +# Anti-debt agent: scanners that read user code and secrets. +stack/agents/anti-debt/ @Rwanbt + +# Security policy itself. +SECURITY.md @Rwanbt +CODEOWNERS @Rwanbt \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..c41a13e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + # The only third-party ecosystem this repository consumes. Python is absent + # on purpose: the lifecycle layer is stdlib-only (ADR-0009) and declares no + # runtime dependencies, so a pip ecosystem would watch files that do not + # exist. Re-add it when ADR-0009 changes, not before. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + commit-message: + prefix: "chore(deps)" \ No newline at end of file diff --git a/.github/workflows/anti-debt-ci.yml b/.github/workflows/anti-debt-ci.yml index 3f9308e9..6083c736 100644 --- a/.github/workflows/anti-debt-ci.yml +++ b/.github/workflows/anti-debt-ci.yml @@ -18,9 +18,9 @@ jobs: name: Validate JSON schemas runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Install pyyaml @@ -58,9 +58,9 @@ jobs: name: Test Critic Engine runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run critic tests @@ -75,9 +75,9 @@ jobs: name: Test Python tools syntax runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: "Syntax check (all Python: tools, kg, skills)" @@ -97,9 +97,9 @@ jobs: name: Test scan quality (corpus) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Install ruff @@ -115,13 +115,17 @@ jobs: # instead of only the mocked contracts (#127). run: python3 tests/test_scan_code_execution.py + - name: Secret reports carry fingerprints, never previews + working-directory: stack/agents/anti-debt + run: python3 tests/test_scan_secret_handling.py + test-layer0-kg: name: Test Layer 0 (Knowledge Graph) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run Layer 0 tests @@ -143,9 +147,9 @@ jobs: name: Test Layer 1+2 (static analysis + critic V2) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run Layer 1+2 tests @@ -156,9 +160,9 @@ jobs: name: Test adapters validation runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run adapter tests @@ -169,9 +173,9 @@ jobs: name: Test Layer 3 (V1.2 skills) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run Layer 3 V1.2 tests @@ -182,9 +186,9 @@ jobs: name: Test Layer 4+6+7 (orchestration, calibration, dashboard) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run Layer 4+6+7 tests @@ -195,9 +199,9 @@ jobs: name: Test LLM-as-judge (structural, no API) runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run LLM judge tests @@ -208,9 +212,9 @@ jobs: name: Test production mining tool runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Validate production_mining.py syntax and imports @@ -224,9 +228,9 @@ jobs: name: Test dashboard end-to-end runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Python - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: '3.11' - name: Run scan_periodic + dashboard diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5dc17c48..72d65ed2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,15 +12,20 @@ jobs: strategy: fail-fast: false matrix: - # The stack claims Python 3.8+ support — exercise the low and high ends. + # The lifecycle CLI requires 3.11+ (pyproject requires-python); this + # job runs the AI-docs and scripts suites, which the installed tooling + # claims at 3.8+ (docs/DISTRIBUTION-LIFECYCLE.md). python-version: ["3.9", "3.11", "3.13"] include: + # 3.8 is best-effort, as the docs state — and best-effort is wired + # through here rather than declared in an inert matrix property. - python-version: "3.8" - continue-on-error: true # 3.8 EOL oct 2024 — kept for backward-compat claim + continue-on-error: true + continue-on-error: ${{ matrix.continue-on-error || false }} steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v7 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} - name: Run test suite (stdlib unittest — zero dependencies) @@ -32,8 +37,8 @@ jobs: name: Conventions declared == conventions enforced runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - name: AGENTS.md and conventions.json must agree @@ -51,8 +56,8 @@ jobs: name: LOC budget gate runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" - name: Enforce the stack's own LOC rule on its own sources @@ -74,11 +79,11 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - - uses: actions/setup-node@v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" - name: v4 protocol — discovery, slug, markers, validator, maintenance @@ -99,8 +104,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" @@ -156,8 +161,8 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11", "3.13"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} @@ -204,6 +209,28 @@ jobs: # guard in a scratch copy and asserts the matching test then fails. run: python scripts/lifecycle_non_vacuity.py + upgrade-e2e: + name: Upgrade E2E old runtime to new release (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.11" + + # Unit suites patch the runtime label; this builds two actual packages, + # installs one into a fresh venv, and drives the console script across a + # real version transition: refusal by the old runtime, upgrade, apply, + # rollback, digest failure (AUD-202). The previous published release + # cannot serve as the old runtime: it predates the very refusal it would + # need to exhibit. + - name: Build two real wheels and cross the transition as a user would + run: python scripts/lifecycle_upgrade_e2e.py + knowledge-b1: name: Knowledge B1 schemas (${{ matrix.os }}, py${{ matrix.python-version }}) runs-on: ${{ matrix.os }} @@ -213,8 +240,8 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11", "3.13"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} @@ -259,8 +286,8 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.11", "3.13"] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: ${{ matrix.python-version }} @@ -287,8 +314,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest, macos-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" @@ -312,8 +339,8 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" @@ -462,8 +489,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" @@ -555,8 +582,8 @@ jobs: matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: "22" @@ -609,8 +636,8 @@ jobs: matrix: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - name: Run the Multi-Vault suites present at this revision @@ -629,4 +656,10 @@ jobs: exit 0 fi # A missing module must fail the job rather than silently shrink the run. - python -m unittest $modules -v \ No newline at end of file + python -m unittest $modules -v + + - name: Multi-Vault feasibility probes (MV-00) + # These suites existed since the MV-00 spikes and no workflow ever + # executed them; a probe regression shipped unnoticed. The Windows + # Job Object probe skips itself on other platforms. + run: python -m unittest discover -s tests/mv00 -p "test_*.py" -v \ No newline at end of file diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 636bf23c..36318591 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -11,10 +11,10 @@ jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: fetch-depth: 0 - - uses: actions/setup-python@v5 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - name: The tag must point at the current main @@ -24,6 +24,12 @@ jobs: set -e git fetch --no-tags origin main test "$(git rev-parse HEAD)" = "$(git rev-parse origin/main)" + - name: The tag must name the version the tree declares + # The tag is as much a version label as VERSION and __version__ are. + # Without this gate a v2.2.2 tag could publish an + # ainative-dev-stack-2.2.1.zip beside a 2.2.2 release (AUD-201), and + # every check downstream would still pass. + run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" - run: python -m pip install --disable-pip-version-check -q build - name: Build the wheel and sdist (refuses a two-version tree) # _build_backend refuses to stage when VERSION and ainative.__version__ @@ -31,6 +37,10 @@ jobs: run: python -m build - name: Build the lifecycle bundle consumed by `ainative update` run: python scripts/build_lifecycle_bundle.py + - name: Every built artifact must carry the release version + # Filenames, wheel METADATA, sdist PKG-INFO and the bundle's internal + # VERSION - the full chain this release promises a consumer. + run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" --dist dist - name: Write SHA256SUMS run: cd dist && sha256sum *.whl *.tar.gz *.zip > SHA256SUMS && cat SHA256SUMS - name: Publish assets to the GitHub release diff --git a/.github/workflows/sdist-roundtrip.yml b/.github/workflows/sdist-roundtrip.yml index 03c51082..1bdaebb4 100644 --- a/.github/workflows/sdist-roundtrip.yml +++ b/.github/workflows/sdist-roundtrip.yml @@ -9,8 +9,8 @@ jobs: round-trip: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: "3.11" - run: python -m pip install --disable-pip-version-check -q build diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..df01cce0 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,55 @@ +# Security Policy + +## Supported versions + +Only the latest release of the AI Native Dev Stack is supported. Security +fixes are prepared for the current major-minor line and published as a patch +release; older releases are not patched. + +| Version | Supported | +|---|---| +| latest release (currently 2.2.x) | yes | +| any earlier release | no | + +## Reporting a vulnerability + +Use GitHub private vulnerability reporting on this repository: +**Security** tab, **Report a vulnerability**. That channel keeps the report +private until a fix is published. + +Do not open a public issue for a suspected vulnerability. Do not include +secret values, live credentials, tokens or personal data in a public issue, +a pull request description, or a CI log: reports are public the moment they +are filed. Share only what is necessary to reproduce, and reference the +affected file and version. + +If private reporting is unavailable to you, open a minimal public issue saying +only that you have a security report and would like a private channel; leave +every technical detail out of it. + +## What to expect + +- Acknowledgement on a best-effort basis. This is a single-maintainer project; + no response-time SLA is promised. +- Confirmed findings are fixed on `dev`, released as a patch on `main`, and + credited in the release notes unless you ask otherwise. +- If a report is out of scope, the reasoning is stated in the same channel. + +## Security boundaries and threat model + +Read these before reporting; several properties are deliberate and documented: + +- `docs/DISTRIBUTION-LIFECYCLE.md` and `docs/adr/0009` - what the lifecycle + defends against, and what SHA-256 integrity does and does not prove. Release + artifacts are integrity-verified, not cryptographically authenticated by a + maintainer signature (unless issue #24 changes that). +- `stack/agents/anti-debt/docs/security-boundaries.md` - the anti-debt agent + sandboxing and egress boundaries. +- `docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md` - Knowledge/Auto-Memory + guarantees, including what is intentionally not shipped (canonical + auto-promotion, K5 = STOP). +- `docs/VERIFIED-WORK-PLANE.md` - the Verified Work Plane trust model. + +Out of scope: findings that amount to "the documentation says the trust model +is limited" where it is; vulnerabilities requiring a compromised maintainer +account or a compromised release source (that is the documented residual risk). \ No newline at end of file diff --git a/scripts/check_release_versions.py b/scripts/check_release_versions.py new file mode 100644 index 00000000..b4108908 --- /dev/null +++ b/scripts/check_release_versions.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""One gate for one fact: every version label of a release must agree. + +`VERSION`, `ainative.__version__` and the package metadata were already tied +together by the build (see `_payload_staging.py`). What remained open was the +release identity: a tag `v2.2.2` could promote a tree whose `VERSION` said +2.2.1, and the published bundle would then carry an internal VERSION no +consumer compared against the release it came from (AUD-201). This gate closes +the chain: + + TAG_VERSION + == ROOT_VERSION (VERSION) + == PACKAGE_VERSION (ainative.__version__) + == WHEEL_VERSION (filename and METADATA, with --dist) + == SDIST_VERSION (filename and PKG-INFO, with --dist) + == BUNDLE_VERSION (filename and internal VERSION, with --dist) + +The release workflow runs it before publishing and again over the built +artifacts; a maintainer can run it before tagging. It exits non-zero with the +disagreeing labels named, and never "fixes" anything. + +Usage: + python scripts/check_release_versions.py + python scripts/check_release_versions.py --tag v2.2.2 + python scripts/check_release_versions.py --tag v2.2.2 --dist dist +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tarfile +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +from _payload_staging import assert_version_consistency # noqa: E402 +from ainative.lifecycle.provider import lifecycle_bundle_name # noqa: E402 + +_STACK_VERSION = re.compile(r"stack-version:\s*([0-9][0-9A-Za-z.\-]*)") + + +class ReleaseVersionMismatch(RuntimeError): + """Two labels of the same release disagree.""" + + +def agents_stack_version(root: Path) -> str: + match = _STACK_VERSION.search((root / "AGENTS.md").read_text(encoding="utf-8")) + if match is None: + raise ReleaseVersionMismatch("AGENTS.md lost its stack-version header") + return match.group(1) + + +def check_labels(root: Path, tag: str | None = None) -> str: + """VERSION == ainative.__version__ == AGENTS.md, and (when given) the tag.""" + + version = assert_version_consistency(root) + declared = agents_stack_version(root) + if declared != version: + raise ReleaseVersionMismatch( + f"AGENTS.md declares stack-version {declared!r} but VERSION says {version!r}") + if tag is not None and tag != f"v{version}": + raise ReleaseVersionMismatch( + f"tag {tag!r} does not name the VERSION file ({version!r}); " + f"expected {f'v{version}'!r}") + return version + + +def _metadata_version(payload: bytes) -> str | None: + for line in payload.decode("utf-8", "replace").splitlines(): + if line.startswith("Version:"): + return line.split(":", 1)[1].strip() + return None + + +def check_dist(version: str, dist: Path) -> list[str]: + """Each built artifact must be named for, and declare, `version`.""" + + if not dist.is_dir(): + raise ReleaseVersionMismatch(f"no dist directory at {dist}") + checked: list[str] = [] + + bundle_name = lifecycle_bundle_name(version) + bundle = dist / bundle_name + if not bundle.is_file(): + raise ReleaseVersionMismatch( + f"lifecycle bundle {bundle_name!r} is missing from {dist}") + with zipfile.ZipFile(bundle) as archive: + internal = archive.read("VERSION").decode("utf-8").strip() + if internal != version: + raise ReleaseVersionMismatch( + f"lifecycle bundle {bundle_name!r} contains VERSION {internal!r}") + checked.append(bundle_name) + + wheels = sorted(dist.glob(f"ainative_dev_stack-{version}-*.whl")) + if not wheels: + raise ReleaseVersionMismatch( + f"no wheel built for {version} in {dist} (expected ainative_dev_stack-{version}-*.whl)") + for wheel in wheels: + with zipfile.ZipFile(wheel) as archive: + metadata = next((entry for entry in archive.namelist() + if entry.endswith(".dist-info/METADATA")), None) + if metadata is None: + raise ReleaseVersionMismatch(f"{wheel.name} carries no METADATA") + built = _metadata_version(archive.read(metadata)) + if built != version: + raise ReleaseVersionMismatch( + f"wheel {wheel.name!r} declares metadata version {built!r}") + checked.append(wheel.name) + + sdists = sorted(dist.glob(f"ainative_dev_stack-{version}.tar.gz")) + if not sdists: + raise ReleaseVersionMismatch( + f"no sdist built for {version} in {dist} (expected ainative_dev_stack-{version}.tar.gz)") + for sdist in sdists: + with tarfile.open(sdist, "r:gz") as archive: + pkg_info = next((entry for entry in archive.getmembers() + if entry.name.endswith("/PKG-INFO")), None) + if pkg_info is None: + raise ReleaseVersionMismatch(f"{sdist.name} carries no PKG-INFO") + extracted = archive.extractfile(pkg_info) + built = _metadata_version(extracted.read()) if extracted else None + if built != version: + raise ReleaseVersionMismatch( + f"sdist {sdist.name!r} declares metadata version {built!r}") + checked.append(sdist.name) + + # An artifact from another version in the same dist tree would be published + # beside this release and confuse every consumer that guesses by glob. + stale = sorted( + item.name for item in dist.iterdir() + if item.is_file() and item.name not in checked + and re.search(r"ainative[_-]dev[_-]stack[-_]", item.name) + and re.search(r"\d+\.\d+\.\d+", item.name)) + if stale: + raise ReleaseVersionMismatch( + f"artifacts for another version are present in {dist}: {stale}") + return checked + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--root", type=Path, default=REPO) + parser.add_argument("--tag", default=None, + help="the release tag being published, e.g. v2.2.2") + parser.add_argument("--dist", type=Path, default=None, + help="also verify the built artifacts in this directory") + args = parser.parse_args() + + try: + version = check_labels(args.root, args.tag) + artifacts = check_dist(version, args.dist) if args.dist else [] + except ReleaseVersionMismatch as refusal: + print(f"RELEASE_VERSION_MISMATCH: {refusal}", file=sys.stderr) + return 1 + + print(f"release version {version}: labels agree" + + (f"; artifacts verified: {', '.join(artifacts)}" if artifacts else "")) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py new file mode 100644 index 00000000..35522ef3 --- /dev/null +++ b/scripts/lifecycle_upgrade_e2e.py @@ -0,0 +1,295 @@ +#!/usr/bin/env python3 +"""The real upgrade transition: an older runtime refuses, the new one applies. + +This is not a fixture pair declared as N and N+1. It builds two real wheels and +drives the console script in a fresh virtual environment: + + N - the release under test: the current tree, built by `pip wheel`, the + same artifact the release workflow publishes (or a real published + wheel supplied with --wheel). + N+1 - a probe target: the same pipeline, run over a relabelled copy of the + tree whose VERSION, package version and AGENTS.md header say N+1. + +Why N is the release under test and not the previous published release: the +CLI_UPDATE_REQUIRED refusal ships *in this release*. The published v2.2.1 +predates it and would apply anything it is pointed at - that gap is permanent +history, not something a later release can retrofit. The property that must +hold from this release onward is: an installed runtime refuses any target whose +version differs from its own, and the target becomes applicable once the +matching runtime is installed. That property is what this script proves, with +two installed packages and no mocking. + +Flow: + 1. build wheel N (or reuse a published one), install it in a fresh venv; + 2. `ainative init --profile standard` into a fresh project; + 3. publish N+1 through a local release index (bundle + releases.json); + 4. `ainative update` from runtime N -> CLI_UPDATE_REQUIRED, zero writes; + 5. install wheel N+1 in the same venv, verify `ainative --version`; + 6. `ainative update` -> applied; state and assets are N+1; + 7. rollback dry-run says so; real rollback restores N; + 8. flip one byte of the bundle -> UPDATE_INTEGRITY_FAILED, zero writes. + +Usage: + python scripts/lifecycle_upgrade_e2e.py + python scripts/lifecycle_upgrade_e2e.py --wheel path/to/ainative_dev_stack-N-py3-none-any.whl + python scripts/lifecycle_upgrade_e2e.py --keep # inspect the workspace + +Exits 0 only when every step passed. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from hashlib import sha256 +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +from ainative.lifecycle.digest import digest_file # noqa: E402 + +CHECKS: list[str] = [] + + +class Failure(RuntimeError): + """A step that must not fail did.""" + + +def ok(message: str) -> None: + CHECKS.append(message) + print(f"[ok] {message}") + + +def run(argv, *, env=None, cwd=None, expect: int | None = 0, timeout=1800): + completed = subprocess.run([str(item) for item in argv], cwd=cwd, + env={**os.environ, **(env or {})}, + capture_output=True, text=True, stdin=subprocess.DEVNULL, + timeout=timeout) + if expect is not None and completed.returncode != expect: + raise Failure( + f"command exited {completed.returncode}, expected {expect}: " + + " ".join(map(str, argv)) + + f"\n--- stdout\n{completed.stdout[-3000:]}" + + f"\n--- stderr\n{completed.stderr[-3000:]}") + return completed + + +def snapshot(project: Path) -> dict: + return {path.relative_to(project).as_posix(): digest_file(path) or "" + for path in project.rglob("*") if path.is_file()} + + +def state_of(project: Path) -> dict: + return json.loads((project / ".ai-native" / "lifecycle" / "state.json") + .read_text(encoding="utf-8")) + + +def bump_patch(version: str) -> str: + major, minor, patch = version.split(".")[:3] + return f"{major}.{minor}.{int(patch) + 1}" + + +def python_in(venv: Path) -> Path: + if os.name == "nt": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def console_in(venv: Path) -> Path: + if os.name == "nt": + return venv / "Scripts" / "ainative.exe" + return venv / "bin" / "ainative" + +def relabel_tree(source_root: Path, destination: Path, version: str) -> Path: + """A copy of the tree whose three version labels say `version`.""" + + shutil.copytree(source_root, destination, + ignore=shutil.ignore_patterns(".git", "__pycache__", "*.pyc", + ".pytest_cache", "build", "dist", + "*.egg-info", ".ai-native", ".gstack", + "node_modules", ".venv", "venv")) + (destination / "VERSION").write_text(f"{version}\n", encoding="utf-8") + init = destination / "ainative" / "__init__.py" + text = init.read_text(encoding="utf-8") + marker = '__version__ = "' + start = text.index(marker) + len(marker) + end = text.index('"', start) + init.write_text(text[:start] + version + text[end:], encoding="utf-8") + agents = destination / "AGENTS.md" + text = agents.read_text(encoding="utf-8") + relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", + rf"\g<1>{version}", text, count=1) + agents.write_text(relabelled, encoding="utf-8") + return destination + + +def build_wheel(tree: Path, output: Path) -> Path: + run([sys.executable, "-m", "pip", "wheel", "--disable-pip-version-check", + "--no-deps", "--wheel-dir", output, tree]) + wheels = sorted(output.glob("*.whl")) + if len(wheels) != 1: + raise Failure(f"expected one wheel from {tree}, found {wheels}") + return wheels[0] + + +def build_bundle(tree: Path, output: Path) -> Path: + run([sys.executable, str(tree / "scripts" / "build_lifecycle_bundle.py"), + "--outdir", output]) + bundles = sorted(output.glob("ainative-dev-stack-*.zip")) + if len(bundles) != 1: + raise Failure(f"expected one bundle from {tree}, found {bundles}") + return bundles[0] + + +def publish(releases: Path, version: str, bundle: Path) -> None: + payload = {"channels": {"stable": { + "version": version, "archive": bundle.name, + "sha256": sha256(bundle.read_bytes()).hexdigest(), + "notes": f"probe release {version}"}}} + (releases / "releases.json").write_text(json.dumps(payload, indent=2) + "\n", + encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--wheel", default=None, + help="a prebuilt wheel to use as runtime N " + "(default: build one from this tree)") + parser.add_argument("--from-version", default=None, + help="runtime N version (default: this checkout's VERSION)") + parser.add_argument("--to-version", default=None, + help="probe target N+1 version (default: N + 1 patch)") + parser.add_argument("--keep", action="store_true", + help="keep the workspace and print its path") + args = parser.parse_args() + + from _payload_staging import read_version + + from_version = args.from_version or read_version(REPO) + if args.wheel and not args.from_version: + raise Failure("--wheel requires --from-version naming the wheel's version") + to_version = args.to_version or bump_patch(from_version) + if to_version == from_version: + raise Failure("the probe target must differ from the runtime under test") + + workspace = Path(tempfile.mkdtemp(prefix="ainative-upgrade-e2e-")) + failed = False + try: + venv = workspace / "venv" + run([sys.executable, "-m", "venv", venv]) + venv_python = python_in(venv) + console = console_in(venv) + + if args.wheel: + wheel_n = Path(args.wheel).resolve() + if not wheel_n.is_file(): + raise Failure(f"wheel not found: {wheel_n}") + else: + wheel_n = build_wheel(REPO, workspace / "dist-n") + run([venv_python, "-m", "pip", "install", "--disable-pip-version-check", + "-q", "--no-deps", wheel_n]) + ok(f"runtime N {from_version} installed from {wheel_n.name}") + + project = workspace / "project" + project.mkdir() + (project / "src").mkdir() + (project / "src" / "app.py").write_text("print('hi')\n", encoding="utf-8") + run([console, "init", "--profile", "standard", "--project", project], + env={"AINATIVE_STACK_SOURCE": ""}) + version_report = run([console, "--version"]) + if f"lifecycle: {from_version}" not in version_report.stdout: + raise Failure(f"installed runtime is not {from_version}:\n{version_report.stdout}") + installed_state = state_of(project) + if installed_state["stack_version"] != from_version: + raise Failure(f"fresh project is at {installed_state['stack_version']}, " + f"expected {from_version}") + ok(f"project initialized at {from_version} by the {from_version} runtime") + + probe_tree = relabel_tree(REPO, workspace / "probe", to_version) + wheel_n1 = build_wheel(probe_tree, workspace / "dist-n1") + releases = workspace / "releases" + releases.mkdir() + bundle_n1 = build_bundle(probe_tree, releases) + if bundle_n1.name != f"ainative-dev-stack-{to_version}.zip": + raise Failure(f"probe bundle is {bundle_n1.name}, expected the canonical name") + publish(releases, to_version, bundle_n1) + provider_env = {"AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(releases)} + ok(f"probe release {to_version} published through a local index") + + before = snapshot(project) + refused = run([console, "update", "--project", project], env=provider_env, expect=1) + if "CLI_UPDATE_REQUIRED" not in refused.stderr: + raise Failure(f"the old runtime did not refuse:\n{refused.stdout}\n{refused.stderr}") + if snapshot(project) != before: + raise Failure("the refused update wrote to the project") + if state_of(project)["stack_version"] != from_version: + raise Failure("the refused update moved the project state") + ok(f"runtime {from_version} refused target {to_version}: " + f"CLI_UPDATE_REQUIRED, zero writes") + + run([venv_python, "-m", "pip", "install", "--disable-pip-version-check", + "-q", "--upgrade", "--no-deps", wheel_n1]) + version_report = run([console, "--version"]) + if f"lifecycle: {to_version}" not in version_report.stdout: + raise Failure(f"runtime upgrade failed:\n{version_report.stdout}") + ok(f"runtime upgraded to {to_version} in the same environment") + + applied = run([console, "update", "--project", project], env=provider_env) + state = state_of(project) + if state["stack_version"] != to_version or state["source_version"] != to_version: + raise Failure(f"update did not land: {state['stack_version']}/" + f"{state['source_version']}, expected {to_version}") + expected_agents = (probe_tree / "AGENTS.md").read_bytes() + if (project / "AGENTS.md").read_bytes() != expected_agents: + raise Failure("project assets are not the probe release's assets") + ok(f"project updated to {to_version}: state, source_version and assets agree") + + before_rollback = snapshot(project) + dry = run([console, "update", "rollback", "--dry-run", "--project", project]) + if "would roll back" not in dry.stdout or "rolled back to" in dry.stdout: + raise Failure(f"dry-run wording is wrong:\n{dry.stdout}") + if snapshot(project) != before_rollback: + raise Failure("the dry-run rollback wrote to the project") + run([console, "update", "rollback", "--project", project]) + if state_of(project)["stack_version"] != from_version: + raise Failure("the rollback did not restore the previous version") + ok(f"rollback dry-run writes nothing; a real rollback restores {from_version}") + + mutated = bytearray(bundle_n1.read_bytes()) + mutated[-1] ^= 0xFF + bundle_n1.write_bytes(bytes(mutated)) + before_digest = snapshot(project) + digest_failure = run([console, "update", "--project", project], + env=provider_env, expect=1) + if "UPDATE_INTEGRITY_FAILED" not in digest_failure.stderr: + raise Failure(f"the tampered bundle was not refused:\n{digest_failure.stderr}") + if snapshot(project) != before_digest: + raise Failure("the tampered bundle wrote to the project") + ok("tampered bundle: UPDATE_INTEGRITY_FAILED, zero writes") + + print(f"\nUPGRADE E2E: PASS ({len(CHECKS)} checks, " + f"{from_version} -> {to_version} -> refusal -> upgrade)") + return 0 + except Failure as error: + failed = True + print(f"\nUPGRADE E2E: FAIL\n{error}", file=sys.stderr) + return 1 + finally: + if args.keep or failed: + print(f"workspace kept for inspection: {workspace}") + else: + shutil.rmtree(workspace, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file From f44385d233b24fb451f32bf1e00a445437b5b0a9 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 02:45:45 +0200 Subject: [PATCH 03/27] fix(anti-debt): fingerprint secrets instead of previewing them; fail loudly on parser defects - scan_security: trufflehog/gitleaks findings carry sha256(secret)[:12], never raw[:8], in ids or evidence (AUD-203); stable for the same input, distinct across secrets - scan_security/scan_deps/scan_code: a defect in a scanner's own parser is no longer converted into a warning entry (the clippy line_start class of #127); missing binaries, timeouts, non-zero exits and invalid external JSON still degrade to structured warnings - AI_CONTEXT: execution model documented as shell=False with the controlled cmd /c shim exception - tests: no-secret-fragment, stable/distinct fingerprint, parser-defect-propagates; CI step added --- .../skills/debt-scan/tools/AI_CONTEXT.md | 14 +- .../skills/debt-scan/tools/scan_code.py | 31 ++--- .../skills/debt-scan/tools/scan_deps.py | 15 ++- .../skills/debt-scan/tools/scan_security.py | 41 +++--- .../tests/test_scan_code_execution.py | 51 +++++++- .../tests/test_scan_secret_handling.py | 123 ++++++++++++++++++ .../agents/anti-debt/tools/finding_common.py | 12 ++ 7 files changed, 249 insertions(+), 38 deletions(-) create mode 100644 stack/agents/anti-debt/tests/test_scan_secret_handling.py diff --git a/stack/agents/anti-debt/skills/debt-scan/tools/AI_CONTEXT.md b/stack/agents/anti-debt/skills/debt-scan/tools/AI_CONTEXT.md index 8dfcca0f..d7ec1183 100644 --- a/stack/agents/anti-debt/skills/debt-scan/tools/AI_CONTEXT.md +++ b/stack/agents/anti-debt/skills/debt-scan/tools/AI_CONTEXT.md @@ -21,7 +21,14 @@ linters but degrade gracefully (warnings, not crashes) when a tool is absent. (e.g. rule code, AST hash, import name) — otherwise they collide and dedup drops one. - `SECRET_PATTERNS` is centralized in `finding_common` — add a pattern ONCE there, every scanner inherits it. +- Secret values are identified by `finding_common.secret_fingerprint` (SHA-256, + 12 hex digits). Never persist a preview, a prefix or the raw value in an id, + description, evidence or warning: reports are expected to be shareable and + committed (AUD-203). - A missing external tool must return a `{"warning": ...}` entry, never raise. + The subprocess call and the parser call are separate: missing binary, + timeout and non-zero exit degrade to warnings; a defect in a parser raises + and fails the scan (#127 class). ## Forbidden - Never silently narrow scope: a "complete" scan covers code+security+dependencies @@ -32,8 +39,11 @@ linters but degrade gracefully (warnings, not crashes) when a tool is absent. ## Common failure modes - Secret detection only runs in the heuristic path (linter absent) — if ruff is installed, provider-key detection comes from `scan_security` (trufflehog), not here. -- On Windows + `shell=True`, a missing binary returns rc=1 (not FileNotFound) — - `run_scanner` does an explicit `shutil.which` check first. +- Execution is `shell=False` with native argv (#127). On Windows, a + `.cmd`/`.bat` shim (npx, mvn, gradle) cannot be handed to CreateProcess, so + `scan_code.executable_argv` routes exactly those through `cmd /c` with the + resolved path, every argument still a separate argv element. Missing + binaries are caught by the explicit `shutil.which` check before the spawn. ## See also - `../../../tools/finding_common.py`, `../../../taxonomy/debt-categories.yaml`, diff --git a/stack/agents/anti-debt/skills/debt-scan/tools/scan_code.py b/stack/agents/anti-debt/skills/debt-scan/tools/scan_code.py index 83711a49..0a8623ef 100644 --- a/stack/agents/anti-debt/skills/debt-scan/tools/scan_code.py +++ b/stack/agents/anti-debt/skills/debt-scan/tools/scan_code.py @@ -256,25 +256,26 @@ def run_scanner(root: Path, argv, parser: str) -> list[dict]: timeout=300, shell=False, ) - # ruff + clippy return nonzero when issues found — that's OK - if result.returncode not in (0, 1): - return [{ - "warning": f"scanner exited with code {result.returncode}", - "stderr_tail": (result.stderr or "")[-500:], - }] - normalize = PARSERS.get(parser) - if normalize is None: - return [{"warning": f"no parser for {parser}"}] - return normalize(result.stdout, root) except subprocess.TimeoutExpired: - return [{"warning": f"scanner timed out after 300s"}] - except FileNotFoundError as e: + return [{"warning": "scanner timed out after 300s"}] + except OSError as error: return [{ - "warning": f"scanner binary not found: {e.filename}", + "warning": f"scanner could not run: {error}", "recommendation": f"install the scanner to enable {parser} support", }] - except Exception as e: - return [{"warning": f"scanner failed: {type(e).__name__}: {e}"}] + # ruff + clippy return nonzero when issues found — that's OK + if result.returncode not in (0, 1): + return [{ + "warning": f"scanner exited with code {result.returncode}", + "stderr_tail": (result.stderr or "")[-500:], + }] + normalize = PARSERS.get(parser) + if normalize is None: + return [{"warning": f"no parser for {parser}"}] + # Deliberately unguarded: a parser defect (the clippy line_start + # AttributeError of #127) must surface as a failed scan, not as a + # per-scanner warning that reads like a missing tool. + return normalize(result.stdout, root) def _augment_python(root: Path, findings: list) -> list: diff --git a/stack/agents/anti-debt/skills/debt-scan/tools/scan_deps.py b/stack/agents/anti-debt/skills/debt-scan/tools/scan_deps.py index f0480aa1..cf6dfc26 100644 --- a/stack/agents/anti-debt/skills/debt-scan/tools/scan_deps.py +++ b/stack/agents/anti-debt/skills/debt-scan/tools/scan_deps.py @@ -340,13 +340,18 @@ def main() -> int: cwd=str(root), timeout=600, ) - # audit tools exit nonzero on issues found — that's expected - findings = PARSERS[tool["name"]](result.stdout) - all_findings.extend(findings) except subprocess.TimeoutExpired: warnings.append({"warning": f"{tool['name']} timed out"}) - except Exception as e: - warnings.append({"warning": f"{tool['name']} failed: {type(e).__name__}: {e}"}) + continue + except OSError as error: + warnings.append({"warning": f"{tool['name']} could not run: {error}"}) + continue + # audit tools exit nonzero on issues found — that's expected. The + # parser call is deliberately unguarded: an external failure is the + # warning above, a defect in this scanner's parser must fail visibly + # (#127 class). + findings = PARSERS[tool["name"]](result.stdout) + all_findings.extend(findings) # Heuristic fallback for Python: detect outdated packages when pip-audit # is not installed. Findings are tagged confidence=0.5 to land in 'review' tier. diff --git a/stack/agents/anti-debt/skills/debt-scan/tools/scan_security.py b/stack/agents/anti-debt/skills/debt-scan/tools/scan_security.py index d232f4e8..5ff9c13a 100644 --- a/stack/agents/anti-debt/skills/debt-scan/tools/scan_security.py +++ b/stack/agents/anti-debt/skills/debt-scan/tools/scan_security.py @@ -14,7 +14,7 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent.parent / "tools")) -from finding_common import finding_id # noqa: E402 +from finding_common import finding_id, secret_fingerprint # noqa: E402 TOOLS = [ @@ -61,12 +61,13 @@ def normalize_trufflehog(stdout: str) -> list[dict]: source_path = item.get("SourceMetadata", {}).get("Data", {}).get("Filesystem", {}).get("file", "") raw = item.get("Raw", "") - # Truncate raw secret to first 8 chars to avoid leaking in reports - raw_preview = raw[:8] + "..." if len(raw) > 8 else raw + # A fingerprint identifies the secret across scans without carrying a + # single byte of it into the report (AUD-203). No preview, ever. + fingerprint = secret_fingerprint(raw) findings.append({ "id": finding_id("security", "secrets_in_code", source_path, "", - f"trufflehog:{item.get('DetectorName', '?')}:{raw_preview}"), + f"trufflehog:{item.get('DetectorName', '?')}:{fingerprint}"), "category": "security", "subcategory": "secrets_in_code", "severity": severity, @@ -74,7 +75,7 @@ def normalize_trufflehog(stdout: str) -> list[dict]: "description": f"Secret detected by trufflehog: {item.get('DetectorName', 'unknown')} ({'verified' if item.get('Verified') else 'unverified'})", "evidence": [ {"type": "file_location", "value": source_path}, - {"type": "tool_output", "tool": "trufflehog", "value": f"Detector {item.get('DetectorName', '?')} verified={item.get('Verified')}, preview={raw_preview}"}, + {"type": "tool_output", "tool": "trufflehog", "value": f"Detector {item.get('DetectorName', '?')} verified={item.get('Verified')}, fingerprint={fingerprint}"}, ], "confidence": confidence, "source": "tool:trufflehog", @@ -102,9 +103,12 @@ def normalize_gitleaks(stdout: str) -> list[dict]: items = [items] for item in items: + # Same contract as trufflehog: a fingerprint, never a preview. + fingerprint = secret_fingerprint(str(item.get("Secret", ""))) findings.append({ "id": finding_id("security", "secrets_in_code", item.get("File", ""), - str(item.get("StartLine", "?")), item.get("RuleID", "")), + str(item.get("StartLine", "?")), + f"{item.get('RuleID', '')}:{fingerprint}"), "category": "security", "subcategory": "secrets_in_code", "severity": "critical", @@ -112,7 +116,7 @@ def normalize_gitleaks(stdout: str) -> list[dict]: "description": f"Secret detected by gitleaks: {item.get('RuleID', 'unknown')}", "evidence": [ {"type": "file_location", "value": f"{item.get('File', '')}:{item.get('StartLine', '?')}"}, - {"type": "tool_output", "tool": "gitleaks", "value": f"Rule {item.get('RuleID', '?')} secret (preview {item.get('Secret', '')[:8]}...)"}, + {"type": "tool_output", "tool": "gitleaks", "value": f"Rule {item.get('RuleID', '?')} fingerprint={fingerprint}"}, ], "confidence": 0.95, "source": "tool:gitleaks", @@ -227,16 +231,23 @@ def main() -> int: cwd=str(root), timeout=600, ) - # trufflehog exits 0 even when secrets found; osv-scanner exits 1 - if result.returncode not in (0, 1): - warnings.append({"warning": f"{tool['name']} exited {result.returncode}", "stderr_tail": result.stderr[-300:]}) - continue - findings = PARSERS[tool["parser"]](result.stdout) - all_findings.extend(findings) except subprocess.TimeoutExpired: warnings.append({"warning": f"{tool['name']} timed out after 600s"}) - except Exception as e: - warnings.append({"warning": f"{tool['name']} failed: {type(e).__name__}: {e}"}) + continue + except OSError as error: + # Binary vanished between detection and run, permissions, ... + warnings.append({"warning": f"{tool['name']} could not run: {error}"}) + continue + # trufflehog exits 0 even when secrets found; osv-scanner exits 1 + if result.returncode not in (0, 1): + warnings.append({"warning": f"{tool['name']} exited {result.returncode}", "stderr_tail": result.stderr[-300:]}) + continue + # Deliberately unguarded: an external tool failure is a warning above, + # but a defect in this scanner's parser (AttributeError, TypeError, + # NameError) must fail the scan where it can be seen - the clippy + # parser's silent degradation is exactly what a broad except hid (#127). + findings = PARSERS[tool["parser"]](result.stdout) + all_findings.extend(findings) print(json.dumps({ "tools_installed": installed_tools, diff --git a/stack/agents/anti-debt/tests/test_scan_code_execution.py b/stack/agents/anti-debt/tests/test_scan_code_execution.py index 57add193..48706b1a 100644 --- a/stack/agents/anti-debt/tests/test_scan_code_execution.py +++ b/stack/agents/anti-debt/tests/test_scan_code_execution.py @@ -64,7 +64,10 @@ def test_run_scanner_uses_native_argv_and_no_shell(): def test_every_declared_scanner_passes_its_argv_unchanged(): root = Path(__file__).parent for parser, argv in SCANNER_ARGS.items(): - calls, fake_run = _capture_run(returncode=1, stdout="[]") + # Empty stdout: this test is about argv, and a JSON array is not a + # line-oriented clippy output - feeding it one on purpose is the + # parser-defect test below, which now (correctly) raises. + calls, fake_run = _capture_run(returncode=1, stdout="") with mock.patch.object(scan_code.subprocess, "run", fake_run), \ mock.patch.object(scan_code.shutil, "which", lambda name: f"/usr/bin/{name}"): scan_code.run_scanner(root, argv, parser) @@ -98,6 +101,52 @@ def fake_run(*args, **kwargs): assert findings == [{"warning": "scanner timed out after 300s"}], findings +def test_a_parser_defect_fails_the_scan_instead_of_degrading(): + """The #127 class: a parser bug must not read as a scanner warning. + + `line_start` read as a mapping raised AttributeError and the broad except + turned every clippy finding into a warning. The boundary is explicit now: + external failures degrade above, parser defects raise here. + """ + + root = Path(__file__).parent + calls, fake_run = _capture_run(returncode=0, stdout='["not-an-object"]') + with mock.patch.object(scan_code.subprocess, "run", fake_run), \ + mock.patch.object(scan_code.shutil, "which", lambda name: f"/usr/bin/{name}"): + try: + scan_code.run_scanner(root, SCANNER_ARGS["ruff"], "ruff") + except AttributeError: + return + raise AssertionError("a parser defect was converted into a scanner warning") + + +def test_an_unrunnable_binary_still_degrades_to_a_warning(): + def fake_run(*args, **kwargs): + raise FileNotFoundError(2, "No such file or directory") + + with mock.patch.object(scan_code.subprocess, "run", fake_run), \ + mock.patch.object(scan_code.shutil, "which", lambda name: f"/usr/bin/{name}"): + findings = scan_code.run_scanner(Path("."), SCANNER_ARGS["ruff"], "ruff") + assert findings and "could not run" in findings[0]["warning"], findings + + +def test_a_clippy_line_start_that_is_not_an_int_is_skipped_not_raised(): + """The exact #127 regression input: a non-integer line_start.""" + + line = json.dumps({ + "reason": "compiler-message", + "message": { + "code": {"code": "clippy::x"}, + "message": "y", + "spans": [{"file_name": "f.rs", "line_start": {"unexpected": True}, + "is_primary": True}], + }, + }) + findings = scan_code.normalize_clippy(line, Path(".")) + assert len(findings) == 1, findings + assert findings[0]["location"]["lines"] == "?", findings[0]["location"] + + def test_windows_cmd_shims_go_through_cmd_without_a_shell_string(): argv = ("npx", "--no-install", "eslint", "--format=json", ".") resolved = "C:\\Program Files\\nodejs\\npx.cmd" diff --git a/stack/agents/anti-debt/tests/test_scan_secret_handling.py b/stack/agents/anti-debt/tests/test_scan_secret_handling.py new file mode 100644 index 00000000..c6fb6996 --- /dev/null +++ b/stack/agents/anti-debt/tests/test_scan_secret_handling.py @@ -0,0 +1,123 @@ +"""test_scan_secret_handling.py — secrets are fingerprinted, never previewed. + +The security scanner used to persist `raw[:8] + "..."` in finding ids and +evidence, so any report - or CI log containing one - leaked the leading bytes +of every detected secret (AUD-203). These tests pin the replacement contract: +a non-reversible SHA-256 fingerprint, stable for the same input and distinct +for a different one, with no fragment of the secret anywhere in the output. + +They also pin the failure boundary introduced with #127's fix: an absent +external tool degrades to a warning; a defect in this scanner's own parser +raises instead of being relabelled as a scanner warning. +""" +import importlib.util +import json +import sys +from pathlib import Path + +TOOLS = Path(__file__).parent.parent / "skills" / "debt-scan" / "tools" +if str(TOOLS) not in sys.path: + sys.path.insert(0, str(TOOLS)) + +_spec = importlib.util.spec_from_file_location("scan_security", TOOLS / "scan_security.py") +scan_security = importlib.util.module_from_spec(_spec) +sys.modules["scan_security"] = scan_security +_spec.loader.exec_module(scan_security) + +# The canonical AWS documentation example key: fixture-shaped, never real. +FIXTURE_SECRET = "AKIAIOSFODNN7EXAMPLE" +OTHER_SECRET = "AKIAIOSFODNN7EXAMPLZ" + + +def trufflehog_payload(secret=FIXTURE_SECRET, verified=True): + return json.dumps({ + "DetectorName": "AWS", + "Verified": verified, + "Raw": secret, + "SourceMetadata": {"Data": {"Filesystem": {"file": "src/config.py"}}}, + }) + + +def assert_no_secret_fragment(serialized: str) -> None: + assert FIXTURE_SECRET not in serialized, "the raw secret reached the report" + assert FIXTURE_SECRET[:4] not in serialized, "a 4-char preview reached the report" + assert FIXTURE_SECRET[:8] not in serialized, "an 8-char preview reached the report" + assert "..." not in serialized, "the old preview shape is still emitted" + + +def test_trufflehog_reports_carry_no_secret_fragment(): + findings = scan_security.normalize_trufflehog(trufflehog_payload()) + assert len(findings) == 1, findings + assert_no_secret_fragment(json.dumps(findings)) + assert "fingerprint=" in findings[0]["evidence"][1]["value"], findings[0] + + +def test_trufflehog_fingerprint_is_stable_and_input_specific(): + first = scan_security.normalize_trufflehog(trufflehog_payload())[0] + again = scan_security.normalize_trufflehog(trufflehog_payload())[0] + other = scan_security.normalize_trufflehog( + trufflehog_payload(secret=OTHER_SECRET))[0] + assert first["id"] == again["id"], "the same secret produced two identities" + assert first["id"] != other["id"], "two secrets produced one identity" + fingerprint = first["evidence"][1]["value"].split("fingerprint=")[1] + assert len(fingerprint) == 12, fingerprint + assert all(character in "0123456789abcdef" for character in fingerprint) + + +def test_verified_and_unverified_keep_their_severity(): + verified = scan_security.normalize_trufflehog(trufflehog_payload(verified=True))[0] + unverified = scan_security.normalize_trufflehog( + trufflehog_payload(verified=False))[0] + assert verified["severity"] == "critical" + assert unverified["severity"] == "high" + + +def test_gitleaks_reports_carry_no_secret_fragment(): + payload = json.dumps([{ + "File": "src/app.py", "StartLine": 3, "RuleID": "aws-access-token", + "Secret": FIXTURE_SECRET, + }]) + findings = scan_security.normalize_gitleaks(payload) + assert len(findings) == 1, findings + assert_no_secret_fragment(json.dumps(findings)) + assert "fingerprint=" in findings[0]["evidence"][1]["value"], findings[0] + assert FIXTURE_SECRET[:4] not in findings[0]["id"], findings[0]["id"] + + +def test_fingerprint_helper_is_stable_and_non_reversible(): + from finding_common import secret_fingerprint + + assert secret_fingerprint(FIXTURE_SECRET) == secret_fingerprint(FIXTURE_SECRET) + assert secret_fingerprint(FIXTURE_SECRET) != secret_fingerprint(OTHER_SECRET) + fingerprint = secret_fingerprint(FIXTURE_SECRET) + assert len(fingerprint) == 12 + assert FIXTURE_SECRET[:8] not in fingerprint + + +def test_a_parser_defect_raises_instead_of_degrading_to_a_warning(): + # A JSON array where the parser expects objects: this is the #127 class, + # and it must surface as a failed scan, not as `scanner failed: ...`. + try: + scan_security.normalize_trufflehog(json.dumps(["not-an-object"])) + except AttributeError: + return + raise AssertionError("a parser defect was swallowed into a warning") + + +def test_a_missing_tool_is_reported_not_raised(): + assert scan_security.is_installed(["ainative-no-such-tool-xyz", "--version"]) is False + + +if __name__ == "__main__": + failures = [] + for _name, _function in sorted(globals().items()): + if _name.startswith("test_") and callable(_function): + try: + _function() + print(f"OK: {_name}") + except AssertionError as _error: + failures.append(_name) + print(f"FAILED: {_name}: {_error}") + if failures: + raise SystemExit(f"{len(failures)} test(s) failed: {', '.join(failures)}") + print("all scan secret-handling tests passed") \ No newline at end of file diff --git a/stack/agents/anti-debt/tools/finding_common.py b/stack/agents/anti-debt/tools/finding_common.py index ac314a40..f462e9b2 100644 --- a/stack/agents/anti-debt/tools/finding_common.py +++ b/stack/agents/anti-debt/tools/finding_common.py @@ -80,6 +80,18 @@ def load_conventions() -> dict: return _conventions_cache +def secret_fingerprint(secret: str) -> str: + """A stable, non-reversible identifier for a secret value. + + Reports must stay deduplicable across scans without ever carrying a + fragment of the secret itself: the old `raw[:8] + "..."` preview both + leaked the leading bytes and made the finding id unstable whenever the + surrounding value changed. The fingerprint is the first 12 hex digits of + the SHA-256 of the exact bytes the tool reported. + """ + return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:12] + + def finding_id( category: str, subcategory: str, From 3172eacaf30f3a6301e35451d0720db888f4545e Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 02:45:51 +0200 Subject: [PATCH 04/27] docs(release): v2.2.2 - README EN/FR parity, lifecycle docs, CHANGELOG, version bump - README EN and FR now describe the same operational facts: CLI-first upgrade then per-project update, the fail-closed release chain, Verified Work Plane, K5 STOP, Multi-Vault GUARDED; FR regains the missing components 13-17 and the Verified section - UPDATING.md and docs/DISTRIBUTION-LIFECYCLE.md document CLI_UPDATE_REQUIRED, UPDATE_VERSION_MISMATCH and the zero-write refusal order - K0-A capability probe flagged historical and pointed at the convergence matrix; K0-B index annotated - CHANGELOG 2.2.2; VERSION/__init__/AGENTS.md/README pins bumped together; scope figures re-measured --- AGENTS.md | 12 +- CHANGELOG.md | 77 ++++++++++ README.fr.md | 144 +++++++++++++++++- README.md | 22 ++- UPDATING.md | 18 ++- VERSION | 2 +- ainative/__init__.py | 2 +- docs/DISTRIBUTION-LIFECYCLE.md | 56 +++++-- docs/K0-A-CAPABILITIES.md | 11 ++ docs/K0-B-FREEZE-INDEX.md | 3 +- .../knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md | 2 +- 11 files changed, 322 insertions(+), 27 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 7c83734d..97f2d62a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + @@ -225,16 +225,16 @@ Central unread modules (>5 incoming imports): none ### This project — ai-native-dev-stack -Measured 2026-09-13 (v2.2.1 corrective: version invariants, update integrity, anti-debt scanner execution, CI coverage) via `git ls-files` (image excluded). Re-measure +Measured 2026-09-13 (v2.2.2 corrective: release version chain, runtime freshness, stale update cache, anti-debt secret fingerprints, CI coverage) via `git ls-files` (image excluded). Re-measure with `python3 scripts/measure_scope.py`; CI fails when these figures drift. | Scope | Tokens (÷4) | Files | Strategy | |---|---|---|---| -| Core stack (excl. anti-debt) | ~725 837 | 438 | **Layered read** — cartography first then targeted reads | -| Anti-debt agent | ~132 216 | 119 | Read its `AI_CONTEXT.md` and ADRs before its sources | -| Whole repo | ~858 054 | 557 | **Multi-phase workflow** — never a single direct read | +| Core stack (excl. anti-debt) | ~736 270 | 438 | **Layered read** — cartography first then targeted reads | +| Anti-debt agent | ~133 280 | 119 | Read its `AI_CONTEXT.md` and ADRs before its sources | +| Whole repo | ~869 550 | 557 | **Multi-phase workflow** — never a single direct read | -Do **not** read the whole repo in one pass: at ~858k tokens it does not fit, +Do **not** read the whole repo in one pass: at ~870k tokens it does not fit, and the strategy table above applies in full. Pick the scope the task needs — most work touches only one of the three halves below. diff --git a/CHANGELOG.md b/CHANGELOG.md index 874b955a..714c33a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,83 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.2.2] - 2026-09-13 + +Corrective release closing the post-v2.2.1 audit. Two architectural +invariants replace previously implicit behaviour: a release's version labels +now form one fail-closed chain from git tag to installed project, and a +lifecycle runtime that differs from the target release can no longer apply it. + +### Fixed + +- **Release version chain (AUD-201).** A tag `v2.2.2` could promote a tree + whose `VERSION` said 2.2.1, and the published bundle's internal `VERSION` + was never compared against the release it came from. The release workflow + now refuses `tag != v$(cat VERSION)` and re-checks every built artifact + (bundle filename and internal `VERSION`, wheel filename and `METADATA`, + sdist filename and `PKG-INFO`) through the reusable + `scripts/check_release_versions.py` gate. The official provider accepts only + `ainative-dev-stack-.zip` - a release publishing another + version's bundle is refused with the new stable code + `UPDATE_VERSION_MISMATCH`, before any download; the local mirror index obeys + the same rule. The updater also refuses a bundle whose internal `VERSION` + differs from the release it declared, before any project write. +- **Runtime freshness (AUD-202, #131).** `ainative update` now requires the + installed lifecycle runtime to be exactly the target release version. A + mismatch is refused with the new stable code `CLI_UPDATE_REQUIRED` - before + the archive is downloaded and before the first write, with the upgrade + command in the message and in the JSON `detail`. `update check`, + `status --check-updates` and `doctor --check-updates` still report what is + available and now say when the CLI must be upgraded first. +- **Stale update cache (#131).** After a successful update, the cached + availability notice could still announce the version the project had just + moved to (`2.2.1 is available. Current: 2.2.1`) until the TTL expired. Every + read of the cache now compares the cached `latest` against the live project + version: a notice that is not newer resolves to `UP_TO_DATE`, a malformed + cached version resolves fail-safe, and the cache is never rewritten by a + read-only command. +- **Rollback dry-run wording (#131).** `ainative update rollback --dry-run` + printed "rolled back to ..." while writing nothing. It now prints + "(dry-run - nothing was written)" and "would roll back to ...", keeps + `"dry_run": true` in JSON, and a test captures stdout to pin both. +- **Anti-debt secret previews (AUD-203).** trufflehog and gitleaks findings no + longer persist the first 8 characters of a detected secret in the id or the + evidence; they carry a non-reversible `sha256(secret)[:12]` fingerprint, + stable for the same input and distinct across secrets. +- **Anti-debt failure boundary.** A defect in a scanner's own parser + (`AttributeError`, `TypeError`, `NameError` - the class of the clippy + `line_start` bug of #127) is no longer converted into a `{"warning": ...}` + entry; it fails the scan visibly. Missing binaries, timeouts, non-zero exits + and invalid external JSON still degrade to structured warnings. + +### Added + +- Release gate script `scripts/check_release_versions.py`, run before and + after the release build: `TAG == VERSION == ainative.__version__ == + wheel/sdist/bundle labels == bundle internal VERSION`. +- Upgrade E2E `scripts/lifecycle_upgrade_e2e.py` and its CI job: two real + wheels are built, one installed into a fresh venv, and the console script + crosses the transition for real - old runtime refuses (`CLI_UPDATE_REQUIRED`, + zero writes), runtime upgraded, update applies, rollback restores, tampered + bundle refuses (`UPDATE_INTEGRITY_FAILED`, zero writes). +- Version-chain and stale-cache test suites, including mutation cases for + tag/asset/internal-VERSION mismatches and zero-write assertions; the + lifecycle non-vacuity suite proves each new guard actually blocks. +- Supply-chain baseline (#129): all GitHub Actions pinned to commit SHAs, + `.github/dependabot.yml` (github-actions), a real `SECURITY.md` reporting + path, and `.github/CODEOWNERS` for the sensitive zones. + +### Changed + +- Historical Knowledge documents (`docs/K0-A-CAPABILITIES.md` flagged, K0-B + index annotated) now point at `docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md` + as the current state instead of reading as current capability claims. +- CI: the Python 3.8 matrix entry's `continue-on-error` is actually wired + (best-effort documented surface); the orphan `tests/mv00` Multi-Vault + feasibility suites now run; `docs/AI_CONTEXT` for the anti-debt scanners + describes the real `shell=False` execution model of #127. + + ## [2.2.1] - 2026-09-13 Corrective release. v2.2.0 is rolled back on `main` by revert (`b0c7ffd`, diff --git a/README.fr.md b/README.fr.md index 17fa0e38..a67fa8aa 100644 --- a/README.fr.md +++ b/README.fr.md @@ -28,16 +28,42 @@ Les palliatifs habituels (coller des fichiers dans le contexte, écrire de longs Un **stack d'optimisation IA auto-maintenu** — un ensemble de documents structurés, scripts et hooks qui garde l'IA perpétuellement orientée sans intervention humaine : ``` +Méthode d'ingénierie (AGENTS.md) ← source canonique unique, partagée à chaque LLM Fichiers de contexte IA (par module) ← mis à jour à chaque édition de fichier Graphe de dépendances (graphify) ← réindexé à la demande Règles domaine (standalone) ← fichier unique injecté pour le code critique Coffre-fort mémoire (Obsidian) ← second cerveau persistant inter-sessions Mémoire Claude Code ← résumés de session auto-générés Écosystème de skills ← commandes de vérification domaine-spécifiques +Hooks universels ← mémoire de session, LOC gate (tout agent) +Agent anti-dette ← gouvernance déterministe de la dette technique Hook PostToolUse ← garde tout en sync automatiquement ``` -Une seule commande vérifie la santé du stack entier : `/verify-ai-docs` +Une seule commande vérifie la santé du stack entier : `/verify-ai-docs`. Transférez-la vers n'importe quelle machine ou LLM via **[PORTABILITY.md](PORTABILITY.md)**. + +--- + +## Principe de conception central + +Un **module** est un répertoire contenant un `AI_CONTEXT.md`. +Tout l'outillage (génération de résumés, assemblage de contexte, hooks, métriques) est bâti autour +de cette définition. Les fichiers source doivent être des **frères directs** de `AI_CONTEXT.md` — +les fichiers des sous-répertoires ne sont pas scannés. Cette contrainte de modules plats est +intentionnelle : elle impose une forte cohésion (un répertoire = une préoccupation = un contexte) +et garde le scanner simple et rapide. + +``` +✅ Structure de module valide ❌ Invalide (fichiers imbriqués ignorés) +my_module/ my_module/ +├── AI_CONTEXT.md ├── AI_CONTEXT.md +├── token.rs ├── service/ +├── session.rs │ └── token.rs ← NON scanné +└── utils.rs └── utils.rs +``` + +Si un sous-répertoire grossit au point d'avoir besoin de son propre contexte, promeuvez-le en +sous-module avec son propre `AI_CONTEXT.md`. --- @@ -320,6 +346,86 @@ Répond à « comment sait-on que ça marche ? » avec des mesures objectives d `/verify-ai-docs` régénère cet instantané à chaque exécution. +### 13. Méthode d'ingénierie canonique (`AGENTS.md`) + +La source unique des règles d'ingénierie que chaque agent IA suit — le noyau toujours actif +(taille fichiers/fonctions, gestion d'erreurs, nommage, git, direction des dépendances) plus +le playbook complet des réflexes seniors (ADR/RFC, sanitizers, FFI, hiérarchie de verrous, +télémétrie temps réel, tests fuzz/propriétés, scans supply-chain, budgets de performance…) +et la stratégie d'analyse/routage du codebase. Les configs d'outils **référencent** ce fichier +(`@AGENTS.md`) au lieu de le copier, pour que Claude Code, Cursor, Codex et MiniMax ne divergent +jamais. Lu nativement par Cursor/Codex ; importé par Claude Code ; inliné-et-synchronisé pour +les agents sans directive d'import. Voir **[PORTABILITY.md](PORTABILITY.md)**. + +### 14. Hooks universels (`hooks/`) + +Six hooks cross-agent qui automatisent la méthode quel que soit l'outil : chargement mémoire +au démarrage de session, sauvegarde en fin de session, régénération AI-summary en PostToolUse, +LOC gate en PreToolUse, injection graphify, et un garde de permissions en lecture seule sur +l'environnement. Chacun livre ses notes d'installation par agent ; les secrets sont lus depuis +l'environnement, jamais commités. + +### 15. Agent de gouvernance anti-dette (`stack/agents/anti-debt/`) + +Un agent de gouvernance de la dette technique, agnostique au LLM, qui contre le biais +« ship the MVP » : scanners déterministes + Critic Engine (tiers de confiance) + graphe de +connaissances SQLite + skills de gouvernance, avec des adaptateurs pour Claude Code, MiniMax +et les outils génériques. Les findings ont une **identité déterministe** (stable entre scans, +pour que dedup/historique/calibration fonctionnent), respectent des schémas JSON, et séparent +le triage déterministe des plans de remédiation LLM. Branché dans chaque agent via +`scripts/setup-agents.sh`. Les secrets ne sont jamais persistés, même partiellement : les +findings portent une empreinte `sha256[:12]` non réversible. + +### 16. Mises à jour portables et non destructives (`UPDATING.md`) + +Tout le stack se transfère sur une nouvelle machine ou un nouveau LLM via un clone + +`setup-agents.sh`, et reste à jour sans détruire la personnalisation de quiconque. +`stack-update-check.sh` détecte les changements upstream dans le *clone* partagé (lecture +seule) ; `/stack-upgrade` le fast-forward. Parce que les configs personnelles *référencent* +les fichiers partagés, un `git pull` met la méthode à jour pour tout le monde pendant que +chacun garde ses personnalisations. Pour les cibles inlinées (par ex. MiniMax), +`sync_inlined_method.py` rafraîchit un bloc géré. + +Un projet dans lequel vous avez **installé** le stack est une surface différente, mise à jour +par `ainative update` avec une propriété enregistrée fichier par fichier. Voir +**[UPDATING.md](UPDATING.md)** pour les deux. + +### 17. Distribution & Lifecycle (`ainative`) + +Une seule CLI possède l'installation, le choix de profil, la mise à jour et la suppression, +et elle enregistre ce qu'elle a écrit pour pouvoir le défaire : + +```bash +ainative init # choisir Standard ou Verified +ainative status # ce qui est installé, et sa santé +ainative profile switch verified # réversible, non destructif, dans les deux sens +ainative update check # la détection est automatique ; l'application jamais +ainative uninstall # retire le stack, garde votre travail +``` + +Chaque fichier géré porte le SHA-256 qu'il avait quand le stack l'a écrit : une mise à jour +n'écrase jamais votre modification et une désinstallation ne la supprime jamais. Les mutations +sont transactionnelles (sauvegarde → application → vérification → état commité *en dernier*), +donc une interruption laisse l'ancien état valide ou le nouveau, jamais un projet à moitié +installé. Voir **[docs/DISTRIBUTION-LIFECYCLE.md](docs/DISTRIBUTION-LIFECYCLE.md)** et +[ADR-0009](docs/adr/0009-distribution-profiles-and-lifecycle-ownership.md). + +**Une mise à jour se fait en deux temps quand le lifecycle a changé : la CLI d'abord, puis les +projets.** Le runtime qui applique une release est le code de cette release, donc `ainative update` +refuse une cible qui ne vient pas de lui (`CLI_UPDATE_REQUIRED`, avant tout téléchargement et +toute écriture) : + +```bash +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" +cd votre-projet && ainative update +``` + +`update check`, `status --check-updates` et `doctor --check-updates` fonctionnent avec n'importe +quel runtime et indiquent quand la CLI doit d'abord être mise à niveau. La chaîne de version +d'une release — tag git, `VERSION`, métadonnées du paquet, wheel/sdist, nom du bundle et +`VERSION` interne du bundle — est fail-closed : un tag qui ne nomme pas l'arbre qu'il pointe +ne peut pas publier. + --- ## Quel profil choisir ? @@ -373,6 +479,28 @@ ci-dessous). Garder les deux séparés, c'est ce qui rend les deux réversibles. --- +## Le Verified Work Plane + +Une porte déterministe entre un agent qui prétend avoir fini et un projet qui le croit. +Il décide la convergence à partir de contrats commités et de vérifications exécutées — +jamais à partir d'un récit, et jamais à partir de ce que l'appelant fournit. + + pip install . + ainative trust bootstrap --repo . --approval-root root.json --policy policy.json --by "vous" + ainative work admit .ai-native/work/w1 --repo . --by "vous" --artifact ... + ainative work new .ai-native/work/w1 --artifact ... + ainative converge --work .ai-native/work/w1 --repo . + +Codes de sortie : 0 CONVERGED, 1 NOT_CONVERGED, 2 INVALID, 3 INTERNAL_ERROR. + +Son modèle d'autorité a été clos par revue adversariale externe (P0 = 0, P1 = 0) et est gelé. +La confiance de genèse est une cérémonie privilégiée que le runtime ne peut pas vérifier : +**bootstrap la confiance avant qu'un agent contrôlé ait accès au dépôt** — c'est une exigence +de déploiement, pas un détail. Voir ADR-0006. + +Documentation complète, résultats empiriques et limitations connues : +[docs/VERIFIED-WORK-PLANE.md](docs/VERIFIED-WORK-PLANE.md). + ## Multi-Vault (GUARDED - pret pour la production) Multi-Vault maintient les frontieres de vault et de domaine de securite dans tous les @@ -442,7 +570,10 @@ d'agent (`.claude/skills` pour Claude Code, `.agents/skills` pour Codex, OpenCode et Cursor), pose `AGENTS.md` et `conventions.json`. ```bash -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.1" # release epinglee (reproductible) +# 1. Installez (ou mettez a niveau) la CLI, puis choisissez un profil dans votre projet. +# Relancez la ligne d'upgrade quand une release change le runtime du lifecycle ; +# `ainative update` vous le dira (CLI_UPDATE_REQUIRED). +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" # release epinglee (reproductible) cd /chemin/vers/votre-projet ainative init # demande Standard ou Verified @@ -489,6 +620,13 @@ moitié installé. Voir **[docs/DISTRIBUTION-LIFECYCLE.md](docs/DISTRIBUTION-LIFECYCLE.md)** et [ADR-0009](docs/adr/0009-distribution-profiles-and-lifecycle-ownership.md). +**Quand le lifecycle change, mettez à jour la CLI d'abord, puis chaque +projet.** Le runtime qui applique une release est le code de cette release ; +`ainative update` refuse la cible dans l'autre sens (`CLI_UPDATE_REQUIRED`, +avant tout téléchargement et toute écriture) et affiche la commande exacte. +`ainative update check`, `status --check-updates` et `doctor --check-updates` +fonctionnent avec n'importe quel runtime. + Déjà installé à l'ancienne, avant l'existence du lifecycle ? Rien à migrer à la main : `ainative init` détecte les fichiers présents et les adopte sans écraser ce que vous avez modifié. @@ -745,7 +883,7 @@ L'auto-découverte est basée sur la présence de `AI_CONTEXT.md`. Aucune liste Copier `tools/ai_docs/config.sh.example` vers `config.sh` et renseigner : - `GRAPHIFY_BIN` — chemin vers le binaire graphify - `OBSIDIAN_VAULT` — racine de votre coffre Obsidian -- `OBSIDIAN_PROJECT_DIR` — sous-dossier pour ce projet +- `OBSIDIAN_PROJECT_SLUG` — slug kebab-case canonique pour ce projet (par exemple `ai-native-dev-stack`) - `CLAUDE_MEMORY_KEY` — nom du sous-dossier dans `~/.claude/projects/` ### 3. `AI_CONTEXT.md` diff --git a/README.md b/README.md index d9042343..faf95fc0 100644 --- a/README.md +++ b/README.md @@ -404,6 +404,22 @@ leaves the old valid state or the new one, never a half-installed project. See **[docs/DISTRIBUTION-LIFECYCLE.md](docs/DISTRIBUTION-LIFECYCLE.md)** and [ADR-0009](docs/adr/0009-distribution-profiles-and-lifecycle-ownership.md). +**Updating is two steps when the lifecycle changed: CLI first, then projects.** +The runtime that applies a release is the release's own code, so `ainative update` +refuses a target it did not ship with (`CLI_UPDATE_REQUIRED`, before any download +and any write): + +```bash +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" +cd your-project && ainative update +``` + +`update check`, `status --check-updates` and `doctor --check-updates` work with +any runtime and say when the CLI must be upgraded first. A release's version +chain - git tag, `VERSION`, package metadata, wheel/sdist, bundle filename and +bundle internal `VERSION` - is fail-closed: a tag that does not name the tree it +points at cannot publish. + --- ## Which profile should I choose? @@ -547,8 +563,10 @@ ainative doctor # includes the Knowledge section ### For an existing project ```bash -# 1. Install the CLI (once), then choose a profile in your project -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.1" # pinned release (reproducible) +# 1. Install (or upgrade) the CLI, then choose a profile in your project. +# Re-run the upgrade line when a release changes the lifecycle runtime; +# `ainative update` tells you when (CLI_UPDATE_REQUIRED). +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" # pinned release (reproducible) cd your-project ainative init # asks Standard or Verified # or, non-interactively: diff --git a/UPDATING.md b/UPDATING.md index e4876b8d..527f1810 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -20,13 +20,27 @@ The full model — ownership, transactions, rollback, security boundaries — is ## Updating a project — `ainative update` +The runtime that applies an update is the policy the release ships: the +manifests, the planner and the transaction engine all live in the Python +package. When a release changes the lifecycle, upgrade the CLI **first**, then +update each project. `ainative update` refuses the other order with +`CLI_UPDATE_REQUIRED` before downloading anything: + ```bash -ainative update check # is there anything newer? +# 1. the CLI / runtime +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" + +# 2. each project +ainative update check # is there anything newer? (works with any runtime) ainative update --dry-run # exactly what would change -ainative update # apply it +ainative update # apply it (requires runtime == target release) ainative update rollback # undo it ``` +The refusal names both versions and prints the exact upgrade command. +`ainative status --check-updates` and `ainative doctor --check-updates` still +report what is available and say when the runtime must be upgraded first. + ### What it guarantees 1. **Your edits survive.** Every managed file carries the SHA-256 it had when diff --git a/VERSION b/VERSION index c043eea7..b1b25a5f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.1 +2.2.2 diff --git a/ainative/__init__.py b/ainative/__init__.py index 85c10642..9708a50c 100644 --- a/ainative/__init__.py +++ b/ainative/__init__.py @@ -9,6 +9,6 @@ from __future__ import annotations -__version__ = "2.2.1" +__version__ = "2.2.2" __all__ = ["__version__"] \ No newline at end of file diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index c776e14e..73e8ddbb 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -378,21 +378,56 @@ it. They may display an already-cached notice; nothing more. ### Applying ``` -check → resolve release → download → verify digest → validate archive paths - → extract to a staging directory → build the plan → backup +resolve release → runtime contract check → check → download → verify digest + → validate archive paths → extract to a staging directory + → bundle version check → build the plan → backup → transactional apply → commit state → record rollback metadata ``` -There is no `curl … | overwrite project` anywhere in this path. +There is no `curl … | overwrite project` anywhere in this path. Everything +that can refuse happens before the first project write: a refused update - +wrong runtime, wrong version, bad digest, bad archive - leaves the project +byte-identical, cache included. **The artifact, stated precisely.** The official provider consumes exactly one asset: the lifecycle bundle `ainative-dev-stack-.zip` published beside -the wheel and the sdist. Its SHA-256 must be published by the source (the -release asset digest); a release whose bundle carries no digest, or no bundle -at all, is refused with `UPDATE_INTEGRITY_METADATA_MISSING` before any download. -There is no `zipball` fallback and no unverified path. The internal mirror -contract is the same: a `releases.json` channel without a valid `sha256` is -refused. +the wheel and the sdist, named for the release's own version. A release whose +tag says 2.2.2 and whose bundle says 2.2.1 is refused with +`UPDATE_VERSION_MISMATCH` before any download; so is a bundle whose internal +`VERSION` file disagrees with the release it declared, and so is a local +mirror whose index declares one version and names another version's archive. +Its SHA-256 must be published by the source (the release asset digest); a +release whose bundle carries no digest, or no bundle at all, is refused with +`UPDATE_INTEGRITY_METADATA_MISSING` before any download. There is no `zipball` +fallback and no unverified path. The internal mirror contract is the same: a +`releases.json` channel without a valid `sha256` is refused. + +**The runtime must be the target.** Applying release N+1 is done by the +lifecycle code of N+1: the manifests, the planner and the transaction engine +ship inside the Python package. The updater therefore requires the installed +runtime to be exactly the target release version and refuses any other with +`CLI_UPDATE_REQUIRED`, before the download. The rule is strict equality, not a +compatibility matrix: no release has needed one, and a silent permission +system is the failure mode this refusal removes. Detection is unaffected - +`update check`, `status --check-updates` and `doctor --check-updates` work +with any runtime and say when a CLI upgrade is required first: + +```bash +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" +cd your-project && ainative update +``` + +**The release chain, stated precisely.** For a published release: + +``` +tag == VERSION == ainative.__version__ == wheel/sdist metadata + == bundle filename version == bundle internal VERSION +``` + +`scripts/check_release_versions.py` enforces the whole chain in the release +workflow, before publishing and again over the built artifacts; a tag that +does not name the tree's `VERSION` fails the release even when it points at +`main`. **Integrity, stated precisely.** SHA-256 over the release archive proves the bytes are the bytes the source described, and the archive's entry names are validated by the same containment rule as every other destination (an entry @@ -566,7 +601,8 @@ Stable error codes: `PROFILE_INVALID` · `COMPONENT_UNKNOWN` · `MANIFEST_INVALI `CONFIRMATION_REQUIRED` · `PATH_ESCAPE` · `INSTALL_STATE_CORRUPTED` · `NOT_INSTALLED` · `USER_MODIFIED_CONFLICT` · `TRANSACTION_IN_PROGRESS` · `RECOVERY_REQUIRED` · `LOCK_HELD` · `UPDATE_UNAVAILABLE` · `UPDATE_CHECK_FAILED` -· `UPDATE_INTEGRITY_FAILED` · `UPDATE_INTEGRITY_METADATA_MISSING` · `ROLLBACK_UNAVAILABLE` · `APPLY_FAILED`. +· `UPDATE_INTEGRITY_FAILED` · `UPDATE_INTEGRITY_METADATA_MISSING` · +`UPDATE_VERSION_MISMATCH` · `CLI_UPDATE_REQUIRED` · `ROLLBACK_UNAVAILABLE` · `APPLY_FAILED`. The CLI prints `refused: : ` on stderr — never a traceback — and `--json` emits `{"error": ..., "message": ..., "detail": {...}}`. diff --git a/docs/K0-A-CAPABILITIES.md b/docs/K0-A-CAPABILITIES.md index 0c65fdbb..979f9bf5 100644 --- a/docs/K0-A-CAPABILITIES.md +++ b/docs/K0-A-CAPABILITIES.md @@ -1,5 +1,16 @@ # K0-A Capability Matrix — executed +> [!WARNING] +> HISTORICAL K0 CAPABILITY PROBE. +> This document records pre-convergence evidence (2026-09-07) and MUST NOT be +> used as the current operational capability matrix. Rows such as +> `knowledge promote --approve`, `ContextPlanner ABSENT`, `locking unused` and +> `P1 stands` describe the pre-convergence state this project has since +> replaced; they are kept as the record of what was measured then. +> +> Current state: +> `docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md` + Evidence collected on `knowledge-lifecycle@a3f1a83`, re-verified identical on `main@599b765` (`git diff main..a3f1a83` is empty for every cited path: `ainative/lifecycle`, `ainative_workplane`, `tools`, `hooks`, `skills`, diff --git a/docs/K0-B-FREEZE-INDEX.md b/docs/K0-B-FREEZE-INDEX.md index 204005df..5609a546 100644 --- a/docs/K0-B-FREEZE-INDEX.md +++ b/docs/K0-B-FREEZE-INDEX.md @@ -6,7 +6,8 @@ Normative freeze documents: 2. `K0-B2-CONTEXT-AUTHORITY-FREEZE.md` 3. `K0-B3-MUTATION-TRUST-FREEZE.md` -Status: +Status snapshot (historical; the contracts below remain normative): + - K0-A: EXECUTED on branch evidence supplied by maintainer - K0-B1: GO / FROZEN diff --git a/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md b/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md index 0b16dcdd..3f88e2c5 100644 --- a/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md +++ b/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md @@ -2,7 +2,7 @@ STATUS: HISTORICAL INITIAL AUDIT + FINAL PARITY RECORD -Current qualified release candidate: v2.2.1 +Current qualified release candidate: v2.2.2 Initial baseline: `dev` @ a4dfe35 (K1-K4 owners). Legacy reference: `knowledge-lifecycle` @ 9eb5422, archived as tag `archive/knowledge-lifecycle-final`; the branch was deleted after the convergence. From f973974ee23947cb4d2b535627bbcd430cb4d6f2 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 12:57:52 +0200 Subject: [PATCH 05/27] feat(lifecycle): first-run product readiness - hook wiring, doctor, git policy, path gate - init now merges one owned PostToolUse entry into .claude/settings.json (json_hook component): user keys and groups preserved, idempotent, uninstall/rollback remove only the owned entry, unparsable JSON refused without rewriting - doctor gains an environment section (python, git repository/HEAD, node, hook status, harness integration, vault, Obsidian API, graphify, machine manifest, trust anchor) with OK/ABSENT_OPTIONAL/DEGRADED/FAIL; missing automation fails doctor, optional tools do not - Verified refuses a project that is not a Git repository (GIT_REPOSITORY_REQUIRED); Standard installs with an explicit degradation notice - action vocabulary no longer lies: BLOCK_WRITE/BLOCK_REMOVE replaced by REGION_WRITE/REGION_REMOVE/HOOK_WRITE/HOOK_REMOVE in plans (legacy journal names still readable) - Knowledge fresh-init fixed (#138): the managed .gitignore region carries .ai-native/state/, refusals print actionable remedies; knowledge state and trust anchor are owner-only on POSIX - machine paths removed from distributed files (Mavis-generated opencode artifacts deleted, enforcement script defaults removed, personal email out of fixtures); scripts/check_personal_paths.py gates tracked files with a reasoned allowlist and a mutation test --- README.fr.md | 2 +- README.md | 2 +- ainative/cli.py | 42 ++- ainative/knowledge/continuity.py | 2 +- ainative/knowledge/paths.py | 27 +- ainative/knowledge/store.py | 4 +- ainative/lifecycle/data/components.json | 15 +- ainative/lifecycle/data/profiles.json | 1 + ainative/lifecycle/environment.py | 244 ++++++++++++++++++ ainative/lifecycle/errors.py | 1 + ainative/lifecycle/external_json.py | 176 +++++++++++++ ainative/lifecycle/hooks.py | 103 ++++++++ ainative/lifecycle/installer.py | 25 ++ ainative/lifecycle/manifest.py | 11 +- ainative/lifecycle/planner.py | 71 ++++- ainative/lifecycle/recovery.py | 11 + ainative/lifecycle/state.py | 32 ++- ainative/lifecycle/transaction.py | 37 ++- ainative_workplane/bootstrap.py | 7 + scripts/check_personal_paths.py | 137 ++++++++++ scripts/enforced/enforcement_matrix.ps1 | 14 +- scripts/lifecycle_non_vacuity.py | 42 +++ stack/agents/anti-debt/RECAP.md | 4 +- ...025-calibration-semantics-cc-exceptions.md | 2 +- stack/agents/anti-debt/opencode/README.md | 12 + stack/agents/anti-debt/opencode/opencode.json | 118 --------- .../anti-debt/opencode/plugins/mavis.js | 2 - .../anti-debt/opencode/tools/ask_user.js | 2 - .../agents/anti-debt/opencode/tools/skill.js | 2 - tests/lifecycle_support.py | 11 + tests/test_lifecycle_hook_config.py | 237 +++++++++++++++++ tests/test_lifecycle_permissions.py | 60 +++++ tests/test_multivault_push_guard.py | 2 +- tests/test_multivault_qualification.py | 2 +- tests/test_multivault_transfer_engine.py | 2 +- tests/test_personal_paths_gate.py | 74 ++++++ tools/ai_docs/generate_all.py | 2 +- tools/ai_docs/run_hook.ps1 | 23 ++ 38 files changed, 1392 insertions(+), 169 deletions(-) create mode 100644 ainative/lifecycle/environment.py create mode 100644 ainative/lifecycle/external_json.py create mode 100644 ainative/lifecycle/hooks.py create mode 100644 scripts/check_personal_paths.py create mode 100644 stack/agents/anti-debt/opencode/README.md delete mode 100644 stack/agents/anti-debt/opencode/opencode.json delete mode 100644 stack/agents/anti-debt/opencode/plugins/mavis.js delete mode 100644 stack/agents/anti-debt/opencode/tools/ask_user.js delete mode 100644 stack/agents/anti-debt/opencode/tools/skill.js create mode 100644 tests/test_lifecycle_hook_config.py create mode 100644 tests/test_lifecycle_permissions.py create mode 100644 tests/test_personal_paths_gate.py create mode 100644 tools/ai_docs/run_hook.ps1 diff --git a/README.fr.md b/README.fr.md index a67fa8aa..6c1c0c5d 100644 --- a/README.fr.md +++ b/README.fr.md @@ -887,7 +887,7 @@ Copier `tools/ai_docs/config.sh.example` vers `config.sh` et renseigner : - `CLAUDE_MEMORY_KEY` — nom du sous-dossier dans `~/.claude/projects/` ### 3. `AI_CONTEXT.md` -Écrire un fichier par module en utilisant `templates/AI_CONTEXT_template.md`. Se concentrer sur : +Écrire un fichier par module en utilisant `.ai-native/templates/AI_CONTEXT_template.md`. Se concentrer sur : - Ce que fait le module (2-3 phrases) - Quelles fonctions s'exécutent sur quel thread - Ce qui est interdit ici diff --git a/README.md b/README.md index faf95fc0..17e9335c 100644 --- a/README.md +++ b/README.md @@ -879,7 +879,7 @@ Copy `tools/ai_docs/config.sh.example` to `config.sh` and fill in: - `CLAUDE_MEMORY_KEY` — subfolder name in `~/.claude/projects/` ### 3. `AI_CONTEXT.md` -Write one file per module using `templates/AI_CONTEXT_template.md`. Focus on: +Write one file per module using `.ai-native/templates/AI_CONTEXT_template.md`. Focus on: - What the module does (2-3 sentences) - Which functions run on which thread - What is forbidden here diff --git a/ainative/cli.py b/ainative/cli.py index af8f192d..bec53619 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -169,17 +169,40 @@ def _report(args: argparse.Namespace, record: dict, text: str) -> int: return EXIT_OK +# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` +# read to a user as "blocked" while the operation actually proceeded; the +# internal names stay stable for journals, the labels must not lie. +ACTION_LABELS = { + "CREATE": "create", + "REPLACE": "replace", + "REMOVE": "remove", + "SKIP": "skip", + "PRESERVE": "preserve", + "CONFLICT": "conflict", + "REGION_WRITE": "configure-region", + "REGION_REMOVE": "remove-region", + "HOOK_WRITE": "configure-hook", + "HOOK_REMOVE": "remove-hook", + "BLOCK_WRITE": "configure-region", + "BLOCK_REMOVE": "remove-region", +} + + +def _action_label(action: str) -> str: + return ACTION_LABELS.get(action, action.lower()) + + def _plan_text(result) -> str: plan = result.plan header = "(dry-run — nothing was written)\n" if result.dry_run else "" - counts = ", ".join(f"{action.lower()} {count}" + counts = ", ".join(f"{_action_label(action)} {count}" for action, count in sorted(plan.counts().items())) or "no changes" lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " f"{plan.to_profile or 'none'}", f" {counts}"] for change in plan.changes: if change.action in ("SKIP",) and not result.dry_run: continue - lines.append(f" {change.action:<12} {change.path}" + lines.append(f" {_action_label(change.action):<18} {change.path}" + (f" ({change.reason})" if change.reason else "")) for notice in result.notices: lines.append("") @@ -267,6 +290,7 @@ def _cmd_status(args: argparse.Namespace) -> int: def _cmd_doctor(args: argparse.Namespace) -> int: + from .lifecycle import environment from .lifecycle import recovery, updater from .knowledge import doctor as knowledgedoctor @@ -274,9 +298,14 @@ def _cmd_doctor(args: argparse.Namespace) -> int: diagnosis = recovery.diagnose(project, check_updates=args.check_updates) knowledge = knowledgedoctor.knowledge_status(project) knowledge_failed = knowledge["status"] == knowledgedoctor.STATUS_FAIL + checks = environment.environment_checks( + project, installed=diagnosis.installed, + verified=diagnosis.active_profile == "verified") + environment_failed = bool(environment.failing(checks)) if args.json: record = diagnosis.to_record() record["knowledge"] = knowledge + record["environment"] = checks _emit(record) else: print(f"Project: {diagnosis.project}") @@ -310,7 +339,14 @@ def _cmd_doctor(args: argparse.Namespace) -> int: f"trust: {knowledge['trust']}") else: print(f" FAIL: {knowledge['detail']}") - return EXIT_OK if (diagnosis.healthy and not knowledge_failed) else EXIT_FAILED + print("Environment") + for check in checks: + marker = environment.OK if check["status"] == environment.OK else check["status"] + print(f" {marker:<16} {check['name']}: {check['detail']}") + if check["impact"] and check["status"] != environment.OK: + print(f" {'':<16} -> {check['impact']}") + return EXIT_OK if (diagnosis.healthy and not knowledge_failed + and not environment_failed) else EXIT_FAILED def _cmd_repair(args: argparse.Namespace) -> int: diff --git a/ainative/knowledge/continuity.py b/ainative/knowledge/continuity.py index 4e0ca39c..b0adbc22 100644 --- a/ainative/knowledge/continuity.py +++ b/ainative/knowledge/continuity.py @@ -177,7 +177,7 @@ def _age(path: Path) -> tuple: if len(payload.encode("utf-8")) > MAX_CHECKPOINT_BYTES: raise KnowledgeError("KNOWLEDGE_CANDIDATE_TOO_LARGE", "checkpoint exceeds size bound") - statelib.write_atomic(directory / f"{record['checkpoint_id']}.json", + statelib.write_atomic_private(directory / f"{record['checkpoint_id']}.json", payload) return record diff --git a/ainative/knowledge/paths.py b/ainative/knowledge/paths.py index 976260c7..aafa76e1 100644 --- a/ainative/knowledge/paths.py +++ b/ainative/knowledge/paths.py @@ -88,17 +88,26 @@ def require_policy(project: Path) -> dict[str, Any]: root = Path(project) report = ensure_policy(root) if report["enforced"] is not True: - raise KnowledgeError("KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", - f"control path policy unverified ({report['reason']}); " - "refusing write") + raise KnowledgeError( + "KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", + f"Knowledge persistence needs a Git repository to verify its " + f"control paths ({report['reason']}).\n" + "Remedy: run `git init` in the project, then re-run the command.") if report["state_ignored"] is not True: - raise KnowledgeError("KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", - f"{STATE_DIRNAME.as_posix()} MUST be Git-ignored; " - "refusing Local Control write") + raise KnowledgeError( + "KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", + f"Refusing to persist local memory state that Git would commit: " + f"{STATE_DIRNAME.as_posix()} is not ignored.\n" + "Remedy: run `ainative init` (it updates the managed .gitignore " + "region), or add this line to .gitignore:\n" + f" {STATE_DIRNAME.as_posix()}/") if report["audit_ignored"] is not False: - raise KnowledgeError("KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", - f"{AUDIT_DIRNAME.as_posix()} MUST NOT be Git-ignored; " - "refusing audit write") + raise KnowledgeError( + "KNOWLEDGE_CONTROL_PATH_POLICY_INVALID", + f"Refusing a write whose audit trail would be invisible to Git: " + f"{AUDIT_DIRNAME.as_posix()} is ignored.\n" + "Remedy: remove the rule that ignores it from .gitignore, or run " + "`ainative init` to restore the managed region.") return report diff --git a/ainative/knowledge/store.py b/ainative/knowledge/store.py index 946e6903..6994b183 100644 --- a/ainative/knowledge/store.py +++ b/ainative/knowledge/store.py @@ -215,7 +215,7 @@ def _rewrite(path: Path, kind: str, records: list[dict]) -> None: payload = "".join(json.dumps(wrap(kind, _record_id(item, kind), item), sort_keys=True) + "\n" for item in records) - statelib.write_atomic(path, payload) + statelib.write_atomic_private(path, payload) def _record_id(item: dict, kind: str) -> str: @@ -313,7 +313,7 @@ def _rewrite_supports(path: Path, supports: list[dict], tombstones: list[dict]) sort_keys=True) + "\n" for item in supports) payload += "".join(json.dumps(wrap("tombstone", item["assertion_hash"], item), sort_keys=True) + "\n" for item in tombstones) - statelib.write_atomic(path, payload) + statelib.write_atomic_private(path, payload) def append_tombstone(project: Path, assertion_hash_value: str, *, reason: str, diff --git a/ainative/lifecycle/data/components.json b/ainative/lifecycle/data/components.json index f1120029..5b6485a9 100644 --- a/ainative/lifecycle/data/components.json +++ b/ainative/lifecycle/data/components.json @@ -30,6 +30,7 @@ "generate_metrics.py", "assemble_context.py", "run_hook.sh", + "run_hook.ps1", "find_python.sh", "config.sh.example" ], @@ -112,13 +113,25 @@ "lines": [ "tools/ai_docs/config.sh", ".ai-native/lifecycle/backups/", - ".ai-native/lifecycle/update-cache.json" + ".ai-native/lifecycle/update-cache.json", + ".ai-native/state/" ], "ownership": "EXTERNAL_CONFIG", "required": false, "title": "Git ignore entries", "description": "Machine-local paths that must never be committed." }, + "claude-hook": { + "version": 1, + "kind": "json_hook", + "destination": ".claude/settings.json", + "hook_event": "PostToolUse", + "hook_matcher": "Edit|Write", + "ownership": "EXTERNAL_CONFIG", + "required": false, + "title": "Claude Code PostToolUse hook", + "description": "The one owned hook entry that regenerates AI_SUMMARY.md after every edit. Merged into the user's settings.json, never replacing it." + }, "github-templates": { "version": 1, "kind": "tree", diff --git a/ainative/lifecycle/data/profiles.json b/ainative/lifecycle/data/profiles.json index 98fa1449..a62225e3 100644 --- a/ainative/lifecycle/data/profiles.json +++ b/ainative/lifecycle/data/profiles.json @@ -21,6 +21,7 @@ "skills-agents", "machine-config", "gitignore-entry", + "claude-hook", "github-templates" ] }, diff --git a/ainative/lifecycle/environment.py b/ainative/lifecycle/environment.py new file mode 100644 index 00000000..db65519e --- /dev/null +++ b/ainative/lifecycle/environment.py @@ -0,0 +1,244 @@ +"""What surrounds the project: the machine, the harness, the optional tools. + +`doctor` used to answer for the lifecycle only: the install state and the +managed files. Everything that makes the product *work* — Git, a Python, the +harness hook entry, a configured vault, an optional graph — was invisible, so +"healthy" could be printed for a project whose automation could never fire. + +Two rules shape this module. + +*Optional is not failing.* A missing Node, Graphify or vault is +`ABSENT_OPTIONAL`: it removes a capability, not the product. `DEGRADED` is for +something configured-but-incomplete or a missing prerequisite of a claimed +profile; `FAIL` is reserved for what this install itself owns (the hook entry) +or for a requirement of the running CLI (Python). + +*Every check states its impact.* "git: missing" is a fact a user cannot act on; +"without Git, Knowledge persistence and Verified provenance are unavailable" is +the same fact with its consequence, which is what the user needs. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import sys +import urllib.request +from pathlib import Path + +from . import hooks as hookslib + +OK = "OK" +ABSENT_OPTIONAL = "ABSENT_OPTIONAL" +DEGRADED = "DEGRADED" +FAIL = "FAIL" + +FAILING = (FAIL,) + +# Where the global installer writes its per-harness entries. Read-only probes: +# doctor never writes to a user's home. +HARNESS_TARGETS = ( + (".claude/CLAUDE.md", "Claude Code"), + (".codex/AGENTS.md", "Codex"), + (".config/opencode/AGENTS.md", "OpenCode"), + (".cursor/rules/ai-native-dev-stack.mdc", "Cursor"), + (".gemini/GEMINI.md", "Gemini"), + (".mavis/agents/mavis/agent.md", "Mavis"), +) + +MACHINE_MANIFEST = Path(".ai-native") / "machine.json" + + +def _run(argv: list[str], cwd: Path | None = None, timeout: int = 10): + try: + return subprocess.run(argv, capture_output=True, text=True, cwd=cwd, + timeout=timeout, check=False) + except (OSError, subprocess.SubprocessError): + return None + + +def git_root(project: Path) -> Path | None: + result = _run(["git", "-C", str(project), "rev-parse", "--show-toplevel"]) + if result is None or result.returncode != 0: + return None + top = result.stdout.strip() + return Path(top) if top else None + + +def git_info(project: Path) -> dict: + available = shutil.which("git") is not None + info = {"available": available, "repository": False, "root": None, + "head": None, "dirty": None} + if not available: + return info + root = git_root(project) + if root is None: + return info + info["repository"] = True + info["root"] = str(root) + head = _run(["git", "-C", str(root), "rev-parse", "--verify", "HEAD"]) + if head is not None and head.returncode == 0: + info["head"] = head.stdout.strip() + status = _run(["git", "-C", str(root), "status", "--porcelain"]) + if status is not None and status.returncode == 0: + info["dirty"] = bool(status.stdout.strip()) + return info + + +def _check(name: str, status: str, detail: str, impact: str = "") -> dict: + return {"name": name, "status": status, "detail": detail, "impact": impact} + + +def _python_check() -> dict: + version = f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}" + if sys.version_info < (3, 11): + return _check("python", FAIL, f"Python {version}", + "the lifecycle CLI requires Python 3.11 or newer") + return _check("python", OK, f"Python {version}") + + +def _git_check(info: dict) -> dict: + if not info["available"]: + return _check("git", DEGRADED, "git is not on PATH", + "Knowledge persistence and Verified provenance are unavailable") + if not info["repository"]: + return _check("git_repository", DEGRADED, "not inside a Git repository", + "Knowledge persistence and Verified provenance are unavailable; " + "run `git init` to enable them") + detail = f"repository {info['root']}" + if info["head"] is None: + return _check("git_repository", DEGRADED, detail + " (no commit yet)", + "Work Plane provenance needs a committed HEAD") + state = "dirty" if info["dirty"] else "clean" + return _check("git_repository", OK, f"{detail}, HEAD {info['head'][:12]}, {state}") + + +def _node_check() -> dict: + node = shutil.which("node") + if node is None: + return _check("node", ABSENT_OPTIONAL, "not installed", + "the session memory hooks need Node; the lifecycle does not") + result = _run([node, "--version"]) + version = result.stdout.strip() if result is not None and result.returncode == 0 else "?" + return _check("node", OK, f"Node {version}") + + +def _hook_check(project: Path) -> dict: + report = hookslib.hook_status(project) + status = report["status"] + if status == hookslib.CONFIGURED: + return _check("claude_hook", OK, "PostToolUse hook configured") + impact = ("AI_SUMMARY.md is not regenerated after edits; run `ainative init` " + "to (re)configure the hook") + if status == hookslib.CONFIG_INVALID: + return _check("claude_hook", FAIL, report["detail"], + "fix or remove .claude/settings.json, then run `ainative init`") + return _check("claude_hook", FAIL, f"{status}: {report['detail']}", impact) + + +def _harness_check(home: Path) -> dict: + found = [label for relative, label in HARNESS_TARGETS if (home / relative).is_file()] + if not found: + return _check("harness_integration", ABSENT_OPTIONAL, "no harness configured", + "machine-wide integration is separate: run " + "`python scripts/install_agents.py` from a stack checkout") + return _check("harness_integration", OK, ", ".join(found)) + + +def _vault_checks(project: Path, home: Path) -> list[dict]: + checks: list[dict] = [] + vault = os.environ.get("OBSIDIAN_VAULT", "").strip() + if not vault: + checks.append(_check("vault", ABSENT_OPTIONAL, "OBSIDIAN_VAULT is not set", + "session memory load/save stays inactive")) + else: + root = Path(vault) + registry = root / "_system" / "schemas" / "projects.json" + if root.is_dir() and registry.is_file(): + checks.append(_check("vault", OK, str(root))) + else: + checks.append(_check("vault", DEGRADED, + f"{root} is missing _system/schemas/projects.json", + "the v4 contract cannot be verified; harness vault " + "blocks stay inactive")) + url = os.environ.get("OBSIDIAN_API_URL", "").strip() + if not url: + checks.append(_check("obsidian_api", ABSENT_OPTIONAL, "OBSIDIAN_API_URL is not set")) + elif not _is_loopback(url): + checks.append(_check("obsidian_api", DEGRADED, f"non-loopback URL refused: {url}", + "only the local Obsidian REST API is supported")) + else: + reachable = _probe_loopback(url) + checks.append(_check("obsidian_api", OK if reachable else DEGRADED, + f"{url} {'reachable' if reachable else 'unreachable'}", + "" if reachable else "the session hooks will report their save as failed")) + return checks + + +def _is_loopback(url: str) -> bool: + return any(marker in url for marker in ("127.0.0.1", "localhost", "[::1]")) + + +def _probe_loopback(url: str) -> bool: + request = urllib.request.Request(url.rstrip("/") + "/", method="GET") + try: + with urllib.request.urlopen(request, timeout=2) as response: + return response.status < 500 + except Exception: + return False + + +def _graphify_check(project: Path) -> dict: + configured = os.environ.get("GRAPHIFY_BIN", "").strip() + graph = project / "graphify-out" / "graph.json" + if configured and Path(configured).is_file(): + return _check("graphify", OK, configured) + if graph.is_file(): + return _check("graphify", OK, str(graph)) + return _check("graphify", ABSENT_OPTIONAL, "not installed", + "dependency-path context in the assembler stays unavailable") + + +def _machine_check(home: Path) -> dict: + manifest = home / MACHINE_MANIFEST + if manifest.is_file(): + return _check("machine_integration", OK, str(manifest)) + return _check("machine_integration", ABSENT_OPTIONAL, "no machine manifest", + "run `python scripts/install_agents.py` from a stack checkout") + + +def _workplane_check(project: Path) -> dict: + anchor = project / ".ai-native" / "trust" / "project_trust.json" + if anchor.is_file(): + return _check("work_plane_trust", OK, "trust anchor present") + return _check("work_plane_trust", DEGRADED, "no trust anchor", + "Work Contracts are ungoverned until `ainative trust init` and " + "`ainative trust bootstrap` run") + + +def environment_checks(project: Path, *, installed: bool = False, + home: Path | None = None, verified: bool = False) -> list[dict]: + """Every check `doctor` reports beside the lifecycle diagnosis.""" + + project = Path(project).resolve() + home = Path(home) if home is not None else Path.home() + info = git_info(project) + checks = [_python_check(), _git_check(info), _node_check()] + if installed: + checks.append(_hook_check(project)) + checks.append(_harness_check(home)) + checks.extend(_vault_checks(project, home)) + checks.append(_graphify_check(project)) + checks.append(_machine_check(home)) + if verified: + checks.append(_workplane_check(project)) + return checks + + +def failing(checks: list[dict]) -> list[dict]: + return [check for check in checks if check["status"] in FAILING] + + +__all__ = ["OK", "ABSENT_OPTIONAL", "DEGRADED", "FAIL", "environment_checks", + "failing", "git_root", "git_info", "HARNESS_TARGETS", "MACHINE_MANIFEST"] \ No newline at end of file diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index c1c4cc06..0ea432fe 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -24,6 +24,7 @@ "DISTRIBUTION_SOURCE_UNAVAILABLE": EXIT_INVALID_REQUEST, "PROJECT_ROOT_INVALID": EXIT_INVALID_REQUEST, "CONFIRMATION_REQUIRED": EXIT_INVALID_REQUEST, + "GIT_REPOSITORY_REQUIRED": EXIT_INVALID_REQUEST, "PATH_ESCAPE": EXIT_INVALID_REQUEST, "INSTALL_STATE_CORRUPTED": EXIT_FAILED, "NOT_INSTALLED": EXIT_FAILED, diff --git a/ainative/lifecycle/external_json.py b/ainative/lifecycle/external_json.py new file mode 100644 index 00000000..dae4ca0e --- /dev/null +++ b/ainative/lifecycle/external_json.py @@ -0,0 +1,176 @@ +"""The owned entry inside a JSON configuration file. + +`.claude/settings.json` belongs to the harness and the user. What the stack +needs from it is one hook entry: a PostToolUse group that runs the project's +summary regenerator after every Edit or Write. Replacing the file would destroy +whatever else it holds; leaving the entry to the user's memory is what made the +central automation a manual step. + +So this module reads the document, takes back every entry it can prove it owns, +merges in exactly one canonical entry, and writes the document back with every +other key preserved. Ownership is the command target (`tools/ai_docs/run_hook`), +not a marker key: no harness schema has to tolerate a foreign field, and an +entry a user rewrote to run something else stops being ours — it is preserved, +never deleted. An unparsable document is refused, never rewritten. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +INDENT = 2 + +_TARGET = re.compile(r'"([^"]*tools/ai_docs/run_hook\.[A-Za-z0-9]+)"') + + +@dataclass(frozen=True) +class HookSpec: + event: str + matcher: str + command: str + marker: str = "tools/ai_docs/run_hook" + + def canonical_entry(self) -> dict: + return {"matcher": self.matcher, + "hooks": [{"type": "command", "command": self.command}]} + + +def _load(path: Path) -> tuple[Any | None, str | None]: + """(document, None), (None, reason) on error, or (None, None) when absent.""" + + if not path.is_file(): + return None, None + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + return None, f"unreadable: {error}" + try: + document = json.loads(text) + except ValueError as error: + return None, f"not valid JSON: {error}" + if not isinstance(document, dict): + return None, "the document is not a JSON object" + return document, None + + +def _groups(document: dict, spec: HookSpec) -> list: + hooks = document.get("hooks") + if not isinstance(hooks, dict): + return [] + groups = hooks.get(spec.event) + return groups if isinstance(groups, list) else [] + + +def owns_group(group: Any, spec: HookSpec) -> bool: + if not isinstance(group, dict): + return False + entries = group.get("hooks") + if not isinstance(entries, list): + return False + for entry in entries: + if not isinstance(entry, dict): + continue + command = entry.get("command") + if isinstance(command, str) and spec.marker in command.replace("\\", "/"): + return True + return False + + +def _owned(document: dict, spec: HookSpec) -> list: + return [group for group in _groups(document, spec) if owns_group(group, spec)] + + +def entry_command(path: Path, spec: HookSpec) -> str | None: + """The command of the single owned entry, or None when it is not there.""" + + document, error = _load(path) + if error is not None or document is None: + return None + groups = _owned(document, spec) + if len(groups) != 1: + return None + entries = groups[0].get("hooks") + for entry in entries if isinstance(entries, list) else []: + command = entry.get("command") if isinstance(entry, dict) else None + if isinstance(command, str) and spec.marker in command.replace("\\", "/"): + return command + return None + + +def command_target(command: str) -> str | None: + match = _TARGET.search(command.replace("\\", "/")) + return match.group(1) if match else None + + +def apply(path: Path, spec: HookSpec) -> tuple[str | None, bool, str | None]: + """(serialized document, changed, error). Creates the document when absent.""" + + document, error = _load(path) + if error is not None: + return None, False, error + before = path.read_text(encoding="utf-8") if path.is_file() else None + document = document if document is not None else {} + hooks = document.get("hooks") + if not isinstance(hooks, dict): + hooks = {} + document["hooks"] = hooks + groups = hooks.get(spec.event) + kept = [group for group in groups if not owns_group(group, spec)] \ + if isinstance(groups, list) else [] + kept.append(spec.canonical_entry()) + hooks[spec.event] = kept + rendered = json.dumps(document, indent=INDENT, sort_keys=True) + "\n" + return rendered, rendered != before, None + + +def remove(path: Path, spec: HookSpec) -> tuple[str | None, bool, str | None]: + """(serialized document or None to delete, changed, error).""" + + document, error = _load(path) + if error is not None or document is None: + return None, False, error + before = path.read_text(encoding="utf-8") + hooks = document.get("hooks") + if isinstance(hooks, dict): + for event in list(hooks): + groups = hooks[event] + if isinstance(groups, list): + hooks[event] = [group for group in groups if not owns_group(group, spec)] + if not hooks[event]: + del hooks[event] + if not hooks: + document.pop("hooks") + if not document: + return None, True, None + rendered = json.dumps(document, indent=INDENT, sort_keys=True) + "\n" + return rendered, rendered != before, None + + +def structural_status(path: Path, spec: HookSpec) -> tuple[str, str]: + """Vocabulary: ABSENT_FILE | CONFIG_INVALID | NOT_CONFIGURED | DUPLICATE | + COMMAND_INVALID | ENTRY_PRESENT.""" + + document, error = _load(path) + if error is not None: + return "CONFIG_INVALID", error + if document is None: + return "ABSENT_FILE", "settings.json does not exist" + groups = _owned(document, spec) + if not groups: + return "NOT_CONFIGURED", "no managed hook entry" + if len(groups) > 1: + return "DUPLICATE", f"{len(groups)} managed hook entries" + command = entry_command(path, spec) + if command is None: + return "COMMAND_INVALID", "the managed entry has no readable command" + if command_target(command) is None: + return "COMMAND_INVALID", f"no wrapper path in {command!r}" + return "ENTRY_PRESENT", command + + +__all__ = ["HookSpec", "apply", "remove", "structural_status", "entry_command", + "command_target", "owns_group"] \ No newline at end of file diff --git a/ainative/lifecycle/hooks.py b/ainative/lifecycle/hooks.py new file mode 100644 index 00000000..139263e9 --- /dev/null +++ b/ainative/lifecycle/hooks.py @@ -0,0 +1,103 @@ +"""What a project needs for its post-edit automation to actually run. + +A settings.json entry proves nothing on its own: the command has to point at a +wrapper that exists, in this project, on this machine. This module owns the one +true spelling of that entry — the command string, the wrapper it names, and the +diagnosis — so the planner, `doctor` and the tests cannot drift apart. + +The command is built with forward slashes on every platform: both bash and +PowerShell accept them, and it keeps one ownership marker +(`tools/ai_docs/run_hook`) valid on Windows and POSIX alike. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from .external_json import (HookSpec, apply, command_target, entry_command, + remove, structural_status) + +EVENT = "PostToolUse" +MATCHER = "Edit|Write" +WRAPPER_BASH = "tools/ai_docs/run_hook.sh" +WRAPPER_POWERSHELL = "tools/ai_docs/run_hook.ps1" +SETTINGS_RELATIVE = Path(".claude") / "settings.json" + +# Doctor vocabulary. +CONFIGURED = "HOOK_CONFIGURED" +NOT_CONFIGURED = "HOOK_NOT_CONFIGURED" +TARGET_MISSING = "HOOK_TARGET_MISSING" +COMMAND_INVALID = "HOOK_COMMAND_INVALID" +CONFIG_INVALID = "HOOK_CONFIG_INVALID" +VERSION_MISMATCH = "HOOK_VERSION_MISMATCH" + + +def wrapper_relative(platform_name: str | None = None) -> str: + return WRAPPER_POWERSHELL if (platform_name or os.name) == "nt" else WRAPPER_BASH + + +def hook_command(project: Path, platform_name: str | None = None) -> str: + target = (Path(project).resolve() / wrapper_relative(platform_name)).as_posix() + if (platform_name or os.name) == "nt": + return f'powershell -NoProfile -ExecutionPolicy Bypass -File "{target}"' + return f'bash "{target}"' + + +def spec(project: Path, platform_name: str | None = None) -> HookSpec: + return HookSpec(event=EVENT, matcher=MATCHER, + command=hook_command(project, platform_name)) + + +def settings_path(project: Path) -> Path: + return Path(project) / SETTINGS_RELATIVE + + +def apply_hook(project: Path, platform_name: str | None = None): + selected = spec(project, platform_name) + return apply(settings_path(project), selected) + + +def remove_hook(project: Path, platform_name: str | None = None): + selected = spec(project, platform_name) + return remove(settings_path(project), selected) + + +def hook_status(project: Path, platform_name: str | None = None) -> dict: + """One diagnosis dict: status (doctor vocabulary), detail, path, command.""" + + project = Path(project).resolve() + selected = spec(project, platform_name) + path = settings_path(project) + structural, detail = structural_status(path, selected) + + if structural in ("CONFIG_INVALID", "DUPLICATE", "COMMAND_INVALID"): + status = CONFIG_INVALID if structural != "COMMAND_INVALID" else COMMAND_INVALID + return {"status": status, "detail": detail, "path": str(path), + "command": None, "expected": selected.command} + if structural in ("ABSENT_FILE", "NOT_CONFIGURED"): + return {"status": NOT_CONFIGURED, "detail": detail, "path": str(path), + "command": None, "expected": selected.command} + + command = entry_command(path, selected) or "" + target = command_target(command) + if target is None: + return {"status": COMMAND_INVALID, "detail": f"no wrapper path in {command!r}", + "path": str(path), "command": command, "expected": selected.command} + if not Path(target).is_file(): + return {"status": TARGET_MISSING, + "detail": f"the hook wrapper {target} does not exist", + "path": str(path), "command": command, "expected": selected.command} + if command != selected.command: + return {"status": VERSION_MISMATCH, + "detail": "the entry points at a different wrapper than this install " + "would configure", + "path": str(path), "command": command, "expected": selected.command} + return {"status": CONFIGURED, "detail": "", "path": str(path), + "command": command, "expected": selected.command} + + +__all__ = ["EVENT", "MATCHER", "SETTINGS_RELATIVE", "spec", "settings_path", + "apply_hook", "remove_hook", "hook_status", "wrapper_relative", + "hook_command", "CONFIGURED", "NOT_CONFIGURED", "TARGET_MISSING", + "COMMAND_INVALID", "CONFIG_INVALID", "VERSION_MISMATCH"] \ No newline at end of file diff --git a/ainative/lifecycle/installer.py b/ainative/lifecycle/installer.py index 8ea09e75..9589d0ae 100644 --- a/ainative/lifecycle/installer.py +++ b/ainative/lifecycle/installer.py @@ -12,6 +12,7 @@ from dataclasses import dataclass from pathlib import Path +from . import environment from . import legacy as legacylib from . import manifest as manifestlib from . import planner as plannerlib @@ -158,6 +159,7 @@ def install(project: Path, target_profile: str, *, dry_run: bool = False, distribution = distribution or manifestlib.load() source = source or sourcelib.resolve() _blocking_transaction(project) + _require_git_for_verified(project, distribution, target_profile) plan, state, adoption = plan_profile(project, distribution, source, target_profile, operation=operation) @@ -192,9 +194,32 @@ def install(project: Path, target_profile: str, *, dry_run: bool = False, transaction=journal.identifier, notices=notices, legacy=adoption) +def _require_git_for_verified(project: Path, distribution: Distribution, + target_profile: str) -> None: + """Verified's provenance guarantees are impossible outside a Git repository. + + Installing the profile anyway and reporting "healthy" is the false-green + this exists to remove: work evidence, snapshots and freshness all resolve + against a repository that would not be there. + """ + + if "verified" not in distribution.inheritance_chain(target_profile): + return + if environment.git_root(project) is None: + raise LifecycleError( + "GIT_REPOSITORY_REQUIRED", + "the Verified profile's provenance guarantees require a Git repository; " + "run `git init` first, or use --profile standard") + + def _notices(project: Path, distribution: Distribution, plan: Plan, target_profile: str, adoption: legacylib.Adoption) -> list[str]: notices: list[str] = [] + if environment.git_root(project) is None: + notices.append( + "This project is not inside a Git repository. Standard works, but " + "Knowledge persistence and provenance are unavailable until `git init` " + "creates one; the Verified profile refuses to install without it.") if adoption.detected: notices.append( f"Existing AI Native installation detected ({len(adoption.adopted)} files). " diff --git a/ainative/lifecycle/manifest.py b/ainative/lifecycle/manifest.py index b1ff25d7..5632d098 100644 --- a/ainative/lifecycle/manifest.py +++ b/ainative/lifecycle/manifest.py @@ -22,9 +22,11 @@ KIND_FILE = "file" KIND_TEMPLATE = "template" KIND_EXTERNAL_BLOCK = "external_block" +KIND_JSON_HOOK = "json_hook" KIND_MARKER = "marker" KIND_DATA_ROOT = "data_root" -KINDS = (KIND_TREE, KIND_FILE, KIND_TEMPLATE, KIND_EXTERNAL_BLOCK, KIND_MARKER, KIND_DATA_ROOT) +KINDS = (KIND_TREE, KIND_FILE, KIND_TEMPLATE, KIND_EXTERNAL_BLOCK, KIND_JSON_HOOK, + KIND_MARKER, KIND_DATA_ROOT) MANAGED_IMMUTABLE = "MANAGED_IMMUTABLE" MANAGED_MUTABLE = "MANAGED_MUTABLE" @@ -39,6 +41,7 @@ KIND_FILE: ("source", "destination"), KIND_TEMPLATE: ("source", "destination"), KIND_EXTERNAL_BLOCK: ("destination", "marker", "lines"), + KIND_JSON_HOOK: ("destination", "hook_event", "hook_matcher"), KIND_MARKER: ("destination",), KIND_DATA_ROOT: ("paths",), } @@ -61,6 +64,8 @@ class Component: marker: str | None = None comment_prefix: str = "#" lines: tuple[str, ...] = () + hook_event: str | None = None + hook_matcher: str = "" platforms: tuple[str, ...] = () def applies_to(self, platform: str) -> bool: @@ -195,6 +200,8 @@ def _build_component(identifier: str, raw: Any) -> Component: marker=raw.get("marker"), comment_prefix=str(raw.get("comment_prefix", "#")), lines=_strings(raw.get("lines"), "lines", identifier), + hook_event=str(raw["hook_event"]) if raw.get("hook_event") else None, + hook_matcher=str(raw.get("hook_matcher", "")), platforms=_strings(raw.get("platforms"), "platforms", identifier), ) @@ -269,6 +276,6 @@ def load(data_dir: Path | None = None) -> Distribution: __all__ = [ "Component", "Profile", "Distribution", "load", "DATA_DIR", "KIND_TREE", "KIND_FILE", "KIND_TEMPLATE", "KIND_EXTERNAL_BLOCK", - "KIND_MARKER", "KIND_DATA_ROOT", "KINDS", + "KIND_JSON_HOOK", "KIND_MARKER", "KIND_DATA_ROOT", "KINDS", "MANAGED_IMMUTABLE", "MANAGED_MUTABLE", "USER_DATA", "EXTERNAL_CONFIG", "OWNERSHIPS", ] diff --git a/ainative/lifecycle/planner.py b/ainative/lifecycle/planner.py index a367bf98..63ebdcbb 100644 --- a/ainative/lifecycle/planner.py +++ b/ainative/lifecycle/planner.py @@ -13,6 +13,8 @@ from typing import Iterable, Sequence from . import digest as digestlib +from . import external_json +from . import hooks as hookslib from . import manifest as manifestlib from .errors import LifecycleError from .external import BlockSpec @@ -29,14 +31,25 @@ SKIP = "SKIP" PRESERVE = "PRESERVE" CONFLICT = "CONFLICT" +# Write/remove the managed region of a file the stack does not own. +REGION_WRITE = "REGION_WRITE" +REGION_REMOVE = "REGION_REMOVE" +# Configure/remove the managed hook entry inside a JSON file the user owns. +HOOK_WRITE = "HOOK_WRITE" +HOOK_REMOVE = "HOOK_REMOVE" +# Legacy names: v2.2.2 and earlier wrote BLOCK_WRITE/BLOCK_REMOVE for region +# operations. They are read back for undo but never emitted again - "BLOCK" +# read to users as "blocked" while the operation actually proceeded. BLOCK_WRITE = "BLOCK_WRITE" BLOCK_REMOVE = "BLOCK_REMOVE" +LEGACY_ACTIONS = (BLOCK_WRITE, BLOCK_REMOVE) -MUTATING_ACTIONS = (CREATE, REPLACE, REMOVE, BLOCK_WRITE, BLOCK_REMOVE) -# Every action this code writes into a journal. Anything else read back -# from one was not written by this code and is ignored. +MUTATING_ACTIONS = (CREATE, REPLACE, REMOVE, REGION_WRITE, REGION_REMOVE, + HOOK_WRITE, HOOK_REMOVE) +# Every action this code writes into a journal, plus the legacy names it can +# still read from older journals. Anything else was not written by this code. ACTIONS = (CREATE, REPLACE, REMOVE, SKIP, PRESERVE, CONFLICT, - BLOCK_WRITE, BLOCK_REMOVE) + REGION_WRITE, REGION_REMOVE, HOOK_WRITE, HOOK_REMOVE, *LEGACY_ACTIONS) @dataclass(frozen=True) @@ -256,11 +269,29 @@ def plan_component_install(project: Path, component: Component, source: Distribu destination = component.destination or "" target = resolve_within(project, destination) _, changed = _external_preview(target, component) - action = BLOCK_WRITE if changed else SKIP + action = REGION_WRITE if changed else SKIP return [Change(action, destination, component.identifier, component.ownership, "managed region in a file the stack does not own", kind="external_block")] + if component.kind == manifestlib.KIND_JSON_HOOK: + destination = component.destination or "" + target = resolve_within(project, destination) + selected = hook_spec(project, component) + _, changed, error = external_json.apply(target, selected) + if error is not None: + # Never rewrite a document we cannot parse: the user fixes it, then + # re-runs. Preferring the fix over the install is the fail-safe. + return [Change(CONFLICT, destination, component.identifier, + component.ownership, + f"existing file cannot be merged ({error}); fix or remove " + "it, then re-run", + kind="json_hook")] + action = HOOK_WRITE if changed else SKIP + return [Change(action, destination, component.identifier, component.ownership, + "managed hook entry in a file the stack does not own", + kind="json_hook")] + changes: list[Change] = [] wanted: list[str] = [] for destination, source_relative in component_files(component, source): @@ -281,6 +312,15 @@ def _external_preview(target: Path, component: Component) -> tuple[str, bool]: return external.apply(target, block_spec(component)) +def hook_spec(project: Path, component: Component) -> external_json.HookSpec: + """The one hook entry this component owns, for this project.""" + + return external_json.HookSpec( + event=component.hook_event or hookslib.EVENT, + matcher=component.hook_matcher or hookslib.MATCHER, + command=hookslib.hook_command(project)) + + def removal_change(project: Path, entry: ManagedFile, *, purge: bool, note: str = "") -> Change: """What removing one recorded path would do. The single decision point. @@ -298,9 +338,16 @@ def removal_change(project: Path, entry: ManagedFile, *, purge: bool, # The region is delimited by markers this stack writes, and only the # bytes between them are taken back. The markers are the ownership # proof, which is why `created_by_ainative` does not gate this. - return Change(BLOCK_REMOVE, entry.path, entry.component, entry.ownership, + return Change(REGION_REMOVE, entry.path, entry.component, entry.ownership, f"remove only the managed region{suffix}", kind="external_block") + if entry.kind == "json_hook": + # Only entries whose command targets this project's wrapper are ours; + # an entry the user rewrote to run something else is not matched and + # not touched. + return Change(HOOK_REMOVE, entry.path, entry.component, entry.ownership, + f"remove only the managed hook entry{suffix}", kind="json_hook") + if entry.kind == "data_root": return Change(REMOVE if purge else PRESERVE, entry.path, entry.component, entry.ownership, @@ -429,6 +476,12 @@ def managed_entries(component: Component, changes: Sequence[Change]) -> list[Man entries.append(ManagedFile(change.path, component.identifier, component.ownership, None, created_by_ainative=True, kind="external_block")) continue + if change.kind == "json_hook": + # Same posture for the hook entry: the file is the user's, the one + # entry that names our wrapper is ours. + entries.append(ManagedFile(change.path, component.identifier, component.ownership, + None, created_by_ainative=True, kind="json_hook")) + continue if change.action == CONFLICT: continue entries.append(ManagedFile(change.path, component.identifier, component.ownership, @@ -438,8 +491,10 @@ def managed_entries(component: Component, changes: Sequence[Change]) -> list[Man __all__ = [ "CREATE", "REPLACE", "REMOVE", "SKIP", "PRESERVE", "CONFLICT", - "BLOCK_WRITE", "BLOCK_REMOVE", "MUTATING_ACTIONS", "ACTIONS", + "REGION_WRITE", "REGION_REMOVE", "HOOK_WRITE", "HOOK_REMOVE", + "BLOCK_WRITE", "BLOCK_REMOVE", "LEGACY_ACTIONS", + "MUTATING_ACTIONS", "ACTIONS", "Change", "Plan", "block_spec", "marker_payload", "component_files", "plan_component_install", "plan_component_removal", - "build_install_plan", "build_uninstall_plan", "managed_entries", + "build_install_plan", "build_uninstall_plan", "managed_entries", "hook_spec", ] diff --git a/ainative/lifecycle/recovery.py b/ainative/lifecycle/recovery.py index ab75f65a..57752bb7 100644 --- a/ainative/lifecycle/recovery.py +++ b/ainative/lifecycle/recovery.py @@ -16,6 +16,7 @@ from pathlib import Path from . import digest as digestlib +from . import hooks as hookslib from . import legacy as legacylib from . import lock as locklib from . import manifest as manifestlib @@ -105,6 +106,16 @@ def _file_finding(project: Path, entry: statelib.ManagedFile, "detail": "user data root (present)" if target.exists() else "user data root (none yet)"} + if entry.kind == "json_hook": + report = hookslib.hook_status(project) + status = {hookslib.CONFIGURED: OK, + hookslib.CONFIG_INVALID: CORRUPTED, + hookslib.COMMAND_INVALID: CORRUPTED, + hookslib.VERSION_MISMATCH: CORRUPTED}.get(report["status"], MISSING) + detail = report["detail"] or report["status"] + return {"path": entry.path, "component": entry.component, + "status": status, "detail": detail} + if entry.kind == "external_block": spec = BlockSpec(component.marker or component.identifier, component.comment_prefix, component.lines) diff --git a/ainative/lifecycle/state.py b/ainative/lifecycle/state.py index 106e92e9..b1cdd207 100644 --- a/ainative/lifecycle/state.py +++ b/ainative/lifecycle/state.py @@ -45,7 +45,7 @@ # state module depend on the catalogue it is meant to outlive; the two are kept # equal by `test_lifecycle_security.py`. OWNERSHIPS = ("MANAGED_IMMUTABLE", "MANAGED_MUTABLE", "USER_DATA", "EXTERNAL_CONFIG") -MANAGED_KINDS = ("file", "external_block", "data_root") +MANAGED_KINDS = ("file", "external_block", "json_hook", "data_root") _DIGEST = re.compile(r"^[0-9a-f]{64}$") @@ -295,6 +295,33 @@ def write_bytes_atomic(path: Path, payload: bytes) -> None: raise +def restrict_to_owner(path: Path) -> None: + """Make one file owner-only where the platform has POSIX modes. + + Private state (the trust anchor, the Knowledge store) is not a secret in + the cryptographic sense, but on a shared machine another account has no + reason to read a user's memory or authority material. Windows has no + equivalent mode bit here; ACLs are a separate, larger surface and nothing + in this stack claims them. + """ + + if os.name == "nt": + return + try: + os.chmod(path, 0o600) + except OSError: + # A filesystem without POSIX modes (some network mounts) accepts the + # write; refusing the operation over the mode would be worse. + return + + +def write_atomic_private(path: Path, payload: str) -> None: + """`write_atomic`, then owner-only on POSIX. Used for private state.""" + + write_atomic(path, payload) + restrict_to_owner(path) + + def save(project: Path, state: InstallState) -> Path: state.updated_at = now() path = state_path(project) @@ -310,6 +337,7 @@ def remove(project: Path) -> None: "SCHEMA_VERSION", "LIFECYCLE_DIRNAME", "STATE_RELATIVE", "TRANSACTIONS_RELATIVE", "BACKUPS_RELATIVE", "UPDATE_CACHE_RELATIVE", "DEFAULT_UPDATE_PREFERENCES", "ManagedFile", "InstallState", "state_path", "exists", "load", "save", "remove", - "write_atomic", "write_bytes_atomic", "now", "new_identifier", + "write_atomic", "write_bytes_atomic", "write_atomic_private", "restrict_to_owner", + "now", "new_identifier", "OWNERSHIPS", "MANAGED_KINDS", ] diff --git a/ainative/lifecycle/transaction.py b/ainative/lifecycle/transaction.py index e11db883..800a4da4 100644 --- a/ainative/lifecycle/transaction.py +++ b/ainative/lifecycle/transaction.py @@ -26,6 +26,8 @@ from . import digest as digestlib from . import external +from . import external_json +from . import hooks as hookslib from . import manifest as manifestlib from . import planner as plannerlib from . import state as statelib @@ -298,12 +300,35 @@ def _outcome(self, change: Change, target: Path): return self._file_payload(change) if change.action == plannerlib.REMOVE: return self.DELETED - if change.action == plannerlib.BLOCK_WRITE: + if change.action in (plannerlib.REGION_WRITE, plannerlib.BLOCK_WRITE): return self._block_outcome(change, target, remove=False) - if change.action == plannerlib.BLOCK_REMOVE: + if change.action in (plannerlib.REGION_REMOVE, plannerlib.BLOCK_REMOVE): return self._block_outcome(change, target, remove=True) + if change.action == plannerlib.HOOK_WRITE: + return self._hook_outcome(change, target, remove=False) + if change.action == plannerlib.HOOK_REMOVE: + return self._hook_outcome(change, target, remove=True) return None + def _hook_outcome(self, change: Change, target: Path, *, remove: bool): + """The merged document bytes, or DELETED / None as for any other change.""" + + component = self.distribution.component(change.component) + selected = plannerlib.hook_spec(self.project, component) + operation = external_json.remove if remove else external_json.apply + content, changed, error = operation(target, selected) + if error is not None: + # The plan refused an unparsable document; reaching here means the + # file changed between planning and applying, so the transaction + # fails and rolls back instead of guessing. + raise LifecycleError("APPLY_FAILED", + f"{change.path} cannot be merged: {error}") + if not changed: + return None + if content is None: + return self.DELETED + return content.encode("utf-8") + def _perform(self, change: Change, target: Path, outcome) -> None: if change.action == plannerlib.REMOVE: self._remove_path(change, target) @@ -388,6 +413,9 @@ def _verify(self) -> None: "after being written") elif change.action == plannerlib.REMOVE and target.exists(): raise LifecycleError("APPLY_FAILED", f"{change.path} still exists after removal") + elif change.action == plannerlib.HOOK_WRITE and not target.is_file(): + raise LifecycleError("APPLY_FAILED", + f"{change.path} was not written with the hook entry") def _backup_install_state(self) -> None: """Save the install state this transaction is about to replace. @@ -505,10 +533,13 @@ def undo(project: Path, journal: Journal) -> dict: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(saved, target) restored.append(path) - elif record.get("action") == plannerlib.CREATE and target.is_file(): + elif (record.get("action") in (plannerlib.CREATE, plannerlib.REGION_WRITE, + plannerlib.HOOK_WRITE) + and target.is_file()): # Created by this transaction, so there is nothing to restore: the # previous state did not have it. Leaving it behind is what made # `update rollback` produce a v1 project holding v2's new files. + # Region and hook writes that created their file behave the same. target.unlink(missing_ok=True) removed.append(path) diff --git a/ainative_workplane/bootstrap.py b/ainative_workplane/bootstrap.py index 769b0152..2ee5399e 100644 --- a/ainative_workplane/bootstrap.py +++ b/ainative_workplane/bootstrap.py @@ -130,6 +130,13 @@ def bootstrap(repository_root: str | os.PathLike[str], *, approval_root: Mapping validate_artifact(anchor) target.parent.mkdir(parents=True, exist_ok=True) target.write_text(json.dumps(anchor, sort_keys=True, separators=(",", ":")), encoding="utf-8") + if os.name != "nt": + # The anchor is authority material on a shared machine; owner-only + # where the platform has POSIX modes. Windows ACLs are not claimed. + try: + os.chmod(target, 0o600) + except OSError: + pass return target diff --git a/scripts/check_personal_paths.py b/scripts/check_personal_paths.py new file mode 100644 index 00000000..51adabd1 --- /dev/null +++ b/scripts/check_personal_paths.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""No machine-specific path may ship. + +A public repository that contains `C:\\Users\\` in a distributed file +ships that someone's home directory to every clone, and a test that only runs +on that machine. The Anti-Debt adapters were committed once with the Mavis +daemon's absolute paths, and the enforcement script carried the maintainer's +defaults; neither is a contract a stranger can satisfy. + +The gate scans every tracked text file and refuses the shapes that cannot be +portable: a Windows user profile, a macOS user home, a Linux home, a Documents +directory, or the maintainer's local account name. Two allowlists exist and +each entry states its reason: + +* whole files - historical evidence whose bytes are the record (rewriting a + qualification log to hide the interpreter path would falsify evidence); +* specific lines - documentation that demonstrates the forbidden pattern on + purpose, or a placeholder like `/Users/you`. + +Usage: + python scripts/check_personal_paths.py # verify (exit 1 on a hit) + python scripts/check_personal_paths.py --json +""" + +from __future__ import annotations + +import argparse +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +PATTERNS = ( + ("windows-profile", re.compile(r"[A-Za-z]:[\\/]+Users[\\/]+[A-Za-z0-9_.-]+")), + ("mac-home", re.compile(r"(? list[str]: + out = subprocess.run(["git", "-C", str(root), "ls-files"], + capture_output=True, text=True, check=True).stdout + return [name for name in out.splitlines() if name and not name.endswith(SKIP_SUFFIXES)] + + +def scan(root: Path, files: list[str] | None = None) -> list[dict]: + """Every violation, as (file, line number, pattern, excerpt). + + `files` lets the mutation test feed a scratch tree; production always + scans what Git tracks. + """ + + violations: list[dict] = [] + for name in (files if files is not None else tracked_files(root)): + if name in ALLOWED_FILES: + continue + path = root / name + try: + text = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + markers = ALLOWED_LINES.get(name, ()) + for number, line in enumerate(text.splitlines(), 1): + if any(marker in line for marker in markers): + continue + for label, pattern in PATTERNS: + match = pattern.search(line) + if match: + violations.append({"file": name, "line": number, "pattern": label, + "match": match.group(0), + "excerpt": line.strip()[:140]}) + return violations + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--json", action="store_true") + parser.add_argument("--root", type=Path, default=REPO) + args = parser.parse_args() + + violations = scan(Path(args.root)) + if args.json: + print(json.dumps({"violations": violations, "passed": not violations}, + indent=2, sort_keys=True)) + elif violations: + print(f"MACHINE_PATH_VIOLATION: {len(violations)} hit(s)", file=sys.stderr) + for item in violations: + print(f" {item['file']}:{item['line']} [{item['pattern']}] " + f"{item['match']}", file=sys.stderr) + else: + print("no machine-specific paths in tracked files") + return 1 if violations else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/enforced/enforcement_matrix.ps1 b/scripts/enforced/enforcement_matrix.ps1 index b314c567..d26abe9a 100644 --- a/scripts/enforced/enforcement_matrix.ps1 +++ b/scripts/enforced/enforcement_matrix.ps1 @@ -1,13 +1,17 @@ #Requires -Version 5.1 # OPTIONAL HIGH-ASSURANCE HARDENING (EXPERIMENTAL) - not part of the normal installation. # Runs UNDER the dedicated workload principal (scheduled task). Writes results only. +# Every path is supplied by the operator (argument or environment); no machine +# default is baked in, because this repository is public and the maintainer's +# layout is not a contract. param( - [string]$OutputPath = "D:\App\ainative-enforced-runtime\results.json", - [string]$WorkspacePath = "D:\App\ainative-enforced-runtime\workspace", - [string]$VaultPath = "D:\Documents\Obsidian\IA_Dev_Brain", - [string]$AuthorityStorePath = "D:\App\ai-native-dev-stack\.ai-native", - [string]$ObsidianCredentialsPath = "D:\Documents\Obsidian\IA_Dev_Brain\.obsidian\plugins\obsidian-local-rest-api\data.json" + [string]$OutputPath = $env:AINATIVE_ENFORCED_RESULTS, + [string]$WorkspacePath = $env:AINATIVE_ENFORCED_WORKSPACE, + [string]$VaultPath = $env:OBSIDIAN_VAULT, + [string]$AuthorityStorePath = $env:AINATIVE_ENFORCED_AUTHORITY, + [string]$ObsidianCredentialsPath = $env:AINATIVE_ENFORCED_OBSIDIAN_CREDENTIALS ) +if (-not $OutputPath) { throw "OutputPath is required (or set AINATIVE_ENFORCED_RESULTS). This script carries no machine defaults." } $results = @() function Record-Test([string]$Name, [string]$Path, [string]$Mode) { diff --git a/scripts/lifecycle_non_vacuity.py b/scripts/lifecycle_non_vacuity.py index cf4d71e0..0ce2a115 100644 --- a/scripts/lifecycle_non_vacuity.py +++ b/scripts/lifecycle_non_vacuity.py @@ -218,6 +218,48 @@ class Case: test=("tests.test_lifecycle_update.UpdateApply" ".test_a_dry_run_update_writes_nothing"), ), + Case( + name="hook_merge_preserves_user_groups", + guards="configuring the hook must not drop the user's own PostToolUse groups", + # Anchored on the comprehension predicate: the same line appears in + # `remove`, and the first occurrence (in `apply`) is the guarded one. + edits=(Edit("ainative/lifecycle/external_json.py", + "if not owns_group(group, spec)]", + "if False]"),), + test=("tests.test_lifecycle_hook_config.HookConfiguration" + ".test_a_user_settings_file_is_merged_not_replaced"), + ), + Case( + name="hook_entry_never_duplicated", + guards="a re-init must not append a second managed hook entry", + edits=(Edit("ainative/lifecycle/external_json.py", + "if not owns_group(group, spec)]", + "if True]"),), + test=("tests.test_lifecycle_hook_config.HookConfiguration" + ".test_reinit_does_not_duplicate_the_hook"), + ), + Case( + name="hook_invalid_json_is_refused", + guards="an unparsable settings.json must be refused, never rewritten", + edits=(Edit("ainative/lifecycle/planner.py", + " if error is not None:\n", + " if False:\n"),), + test=("tests.test_lifecycle_hook_config.HookConfiguration" + ".test_invalid_settings_json_is_refused_without_writing"), + ), + Case( + name="verified_requires_git", + guards="Verified must refuse a project that is not a Git repository", + edits=(Edit("ainative/lifecycle/installer.py", + " if environment.git_root(project) is None:\n" + " raise LifecycleError(\n" + ' "GIT_REPOSITORY_REQUIRED",', + " if False:\n" + " raise LifecycleError(\n" + ' "GIT_REPOSITORY_REQUIRED",'),), + test=("tests.test_lifecycle_hook_config.NonGitPolicy" + ".test_verified_refuses_outside_git"), + ), Case( name="runtime_freshness_gate", guards="an older lifecycle runtime must not apply a newer release", diff --git a/stack/agents/anti-debt/RECAP.md b/stack/agents/anti-debt/RECAP.md index 848443ab..3bd5d88b 100644 --- a/stack/agents/anti-debt/RECAP.md +++ b/stack/agents/anti-debt/RECAP.md @@ -189,9 +189,9 @@ Le plan d'origine avait mentionné 2 hooks Codex qui n'ont pas été universalis - **PreToolUse Bash avec injection graphify** : quand l'utilisateur tape `grep`/`rg`/`find`, injecter `GRAPH_REPORT.md` - **PermissionRequest readonly env-prefix** : auto-allow `RUST_LOG=debug cat file.txt` -**Localisation** : `D:\App\ai-native-dev-stack\hooks/` (déjà créé pour hooks universels) +**Localisation** : `hooks/` (déjà créé pour hooks universels) -**Action** : déplacer/porter ces 2 hooks depuis `C:\Users\barat\.codex\hooks.json` vers le format agent-neutre. +**Action** : déplacer/porter ces 2 hooks depuis `~/.codex/hooks.json` vers le format agent-neutre. **Effort estimé** : 2 heures. diff --git a/stack/agents/anti-debt/docs/adr/0025-calibration-semantics-cc-exceptions.md b/stack/agents/anti-debt/docs/adr/0025-calibration-semantics-cc-exceptions.md index cd318465..fe123856 100644 --- a/stack/agents/anti-debt/docs/adr/0025-calibration-semantics-cc-exceptions.md +++ b/stack/agents/anti-debt/docs/adr/0025-calibration-semantics-cc-exceptions.md @@ -95,4 +95,4 @@ exception ne couvre que ces deux parsers nommés, pas une dispense générale. - [ADR-0021](0021-critic-self-challenge.md) — Critic self-challenge (calibration empirique, drift) - `tools/calibration.py` — implémentation de `propose_thresholds()` - `tools/AI_CONTEXT.md` — invariant de synchro des seuils reject/review -- `C:\Users\barat\.claude\CLAUDE.md` § « Taille des fonctions et complexité cyclomatique » (exception parsers) +- The operator's global `CLAUDE.md` - function size and cyclomatic complexity (parser exception). diff --git a/stack/agents/anti-debt/opencode/README.md b/stack/agents/anti-debt/opencode/README.md new file mode 100644 index 00000000..513ce317 --- /dev/null +++ b/stack/agents/anti-debt/opencode/README.md @@ -0,0 +1,12 @@ +# Mavis/OpenCode generated files - never committed + +The Mavis daemon generates `opencode.json`, `plugins/mavis.js` and `tools/*.js` +in this directory, and every one of them contains absolute paths from the +machine that generated it (the daemon installation, the user's `~/.mavis` +skills). They were committed once by accident and shipped a maintainer's home +directory to every clone. + +They are machine-local by nature: regenerate them with your own Mavis daemon on +your own machine. Nothing in the stack installs from this directory - +`scripts/install_agents.py` owns the OpenCode integration and writes its plugin +to `~/.config/opencode/plugins/ai-native-dev-stack.ts`. \ No newline at end of file diff --git a/stack/agents/anti-debt/opencode/opencode.json b/stack/agents/anti-debt/opencode/opencode.json deleted file mode 100644 index 880e0998..00000000 --- a/stack/agents/anti-debt/opencode/opencode.json +++ /dev/null @@ -1,118 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "provider": { - "minimax": { - "name": "MiniMax", - "npm": "@ai-sdk/anthropic", - "models": { - "MiniMax-M2.7": { - "name": "MiniMax-M2.7", - "attachment": false, - "reasoning": true, - "temperature": true, - "tool_call": true, - "limit": { - "context": 200000, - "output": 128000 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "thinking_config": { - "mode": "forced_on" - } - }, - "MiniMax-M2.7-highspeed": { - "name": "MiniMax-M2.7-highspeed", - "attachment": false, - "reasoning": true, - "temperature": true, - "tool_call": true, - "limit": { - "context": 200000, - "output": 128000 - }, - "modalities": { - "input": [ - "text" - ], - "output": [ - "text" - ] - }, - "thinking_config": { - "mode": "forced_on" - } - }, - "MiniMax-M3": { - "name": "MiniMax-M3", - "attachment": true, - "reasoning": true, - "temperature": true, - "tool_call": true, - "limit": { - "context": 450000, - "output": 128000 - }, - "modalities": { - "input": [ - "text", - "image", - "video" - ], - "output": [ - "text" - ] - }, - "options": { - "reasoningSummary": "auto" - }, - "variants": { - "none-thinking": { - "thinking": { - "type": "disabled" - } - }, - "thinking": { - "thinking": { - "type": "adaptive" - } - } - }, - "thinking_config": { - "mode": "switchable", - "default_value": "true" - } - } - }, - "whitelist": [ - "MiniMax-M2.7-highspeed", - "MiniMax-M2.7", - "MiniMax-M3" - ], - "options": { - "apiKey": "sk-xxx", - "baseURL": "https://agent.minimax.io/mavis/api/v1/llm/v1" - } - } - }, - "permission": { - "*": "allow", - "question": "deny" - }, - "skills": { - "paths": [ - "C:\\Users\\barat\\.mavis\\skills", - "C:\\Users\\barat\\.mavis\\agents\\anti-debt\\skills", - "C:\\Users\\barat\\.mavis\\.builtin-skills" - ] - }, - "plugin": [ - "mavis" - ] -} diff --git a/stack/agents/anti-debt/opencode/plugins/mavis.js b/stack/agents/anti-debt/opencode/plugins/mavis.js deleted file mode 100644 index b3f4c790..00000000 --- a/stack/agents/anti-debt/opencode/plugins/mavis.js +++ /dev/null @@ -1,2 +0,0 @@ -// Auto-generated by Mavis daemon — do not edit -export { default } from "C:\\Users\\barat\\AppData\\Local\\Programs\\MiniMax Code\\resources\\resources\\daemon\\node_modules\\@mavis\\opencode-plugin\\index.js" diff --git a/stack/agents/anti-debt/opencode/tools/ask_user.js b/stack/agents/anti-debt/opencode/tools/ask_user.js deleted file mode 100644 index 463d1128..00000000 --- a/stack/agents/anti-debt/opencode/tools/ask_user.js +++ /dev/null @@ -1,2 +0,0 @@ -// Auto-generated by Mavis daemon — do not edit -export { default } from "C:\\Users\\barat\\AppData\\Local\\Programs\\MiniMax Code\\resources\\resources\\daemon\\node_modules\\@mavis\\opencode-skill-tool\\ask-user-tool.js" diff --git a/stack/agents/anti-debt/opencode/tools/skill.js b/stack/agents/anti-debt/opencode/tools/skill.js deleted file mode 100644 index 73df5bab..00000000 --- a/stack/agents/anti-debt/opencode/tools/skill.js +++ /dev/null @@ -1,2 +0,0 @@ -// Auto-generated by Mavis daemon — do not edit -export { default } from "C:\\Users\\barat\\AppData\\Local\\Programs\\MiniMax Code\\resources\\resources\\daemon\\node_modules\\@mavis\\opencode-skill-tool\\index.js" diff --git a/tests/lifecycle_support.py b/tests/lifecycle_support.py index b09e3ccc..74da725a 100644 --- a/tests/lifecycle_support.py +++ b/tests/lifecycle_support.py @@ -65,6 +65,7 @@ def build_distribution_tree(root: Path, version: str = "1.0.0", *, "assemble_context.py"): write_text(tools / name, f"# {name} {version}\n") write_text(tools / "run_hook.sh", f"#!/bin/sh\n# {version}\n") + write_text(tools / "run_hook.ps1", f"# {version}\n") write_text(tools / "find_python.sh", "#!/bin/sh\nexit 0\n") write_text(tools / "config.sh.example", f"VAULT=\n# {version}\n") @@ -109,6 +110,16 @@ def setUp(self) -> None: (self.project / "src").mkdir(parents=True) write_text(self.project / "src" / "app.py", "print('hi')\n") write_text(self.project / "README.md", "my project\n") + # A project is a Git repository: Verified refuses to install without + # one, and the Knowledge control-path policy verifies against it. An + # inherited repository from an enclosing directory would make every + # test depend on where the developer's temp directory happens to sit. + subprocess.run(["git", "init", "-q", str(self.project)], check=True, + capture_output=True) + subprocess.run(["git", "-C", str(self.project), "config", "user.email", + "test@example.invalid"], check=True, capture_output=True) + subprocess.run(["git", "-C", str(self.project), "config", "user.name", + "Lifecycle Test"], check=True, capture_output=True) self.distribution_root = build_distribution_tree(self.root / "dist-v1", "1.0.0") self.distribution = manifestlib.load() self.source = sourcelib.DistributionSource( diff --git a/tests/test_lifecycle_hook_config.py b/tests/test_lifecycle_hook_config.py new file mode 100644 index 00000000..f8406f6f --- /dev/null +++ b/tests/test_lifecycle_hook_config.py @@ -0,0 +1,237 @@ +"""The PostToolUse hook: configured by init, merged, preserved, diagnosed. + +The central promise of the product — edit a source file, its AI_SUMMARY.md is +regenerated — used to require the user to hand-edit `.claude/settings.json`. +These tests pin the new contract end to end: `init` merges exactly one owned +entry into a file the user owns, a re-init never duplicates it, an uninstall and +a rollback remove only what is ours, an unparsable document is refused instead +of rewritten, and `doctor` fails when the automation it installed is missing. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, read_text, write_text +from ainative.lifecycle import environment +from ainative.lifecycle import hooks as hookslib +from ainative.lifecycle import recovery +from ainative.lifecycle import transaction as txnlib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.errors import LifecycleError + +USER_SETTINGS = { + "model": "opus", + "permissions": {"allow": ["Bash(ls:*)"]}, + "hooks": { + "PreToolUse": [{"matcher": "Bash", + "hooks": [{"type": "command", "command": "echo user-pre"}]}], + "PostToolUse": [{"matcher": "Read", + "hooks": [{"type": "command", "command": "echo user-post"}]}], + }, +} + + +def load_settings(project: Path) -> dict: + return json.loads((project / ".claude" / "settings.json").read_text(encoding="utf-8")) + + +def owned_groups(settings: dict) -> list: + from ainative.lifecycle.external_json import owns_group + spec = hookslib.spec(Path(".")) + groups = settings.get("hooks", {}).get("PostToolUse", []) + return [group for group in groups if owns_group(group, spec)] + + +class HookConfiguration(LifecycleTestCase): + + def test_init_configures_the_posttooluse_hook(self): + self.install("standard") + settings = load_settings(self.project) + groups = owned_groups(settings) + self.assertEqual(len(groups), 1, settings) + command = groups[0]["hooks"][0]["command"] + self.assertIn("tools/ai_docs/run_hook", command.replace("\\", "/")) + wrapper = self.project / hookslib.wrapper_relative() + self.assertTrue(wrapper.is_file(), wrapper) + report = hookslib.hook_status(self.project) + self.assertEqual(report["status"], hookslib.CONFIGURED, report) + + def test_a_user_settings_file_is_merged_not_replaced(self): + write_text(self.project / ".claude" / "settings.json", + json.dumps(USER_SETTINGS, indent=2) + "\n") + self.install("standard") + settings = load_settings(self.project) + self.assertEqual(settings["model"], "opus") + self.assertEqual(settings["permissions"], USER_SETTINGS["permissions"]) + self.assertEqual(settings["hooks"]["PreToolUse"], USER_SETTINGS["hooks"]["PreToolUse"]) + command = json.dumps(settings["hooks"]["PostToolUse"]) + self.assertIn("echo user-post", command) + self.assertEqual(len(owned_groups(settings)), 1, settings) + + def test_reinit_does_not_duplicate_the_hook(self): + self.install("standard") + before = read_text(self.project / ".claude" / "settings.json") + self.install("standard") + after = read_text(self.project / ".claude" / "settings.json") + self.assertEqual(before, after) + self.assertEqual(len(owned_groups(load_settings(self.project))), 1) + + def test_invalid_settings_json_is_refused_without_writing(self): + broken = "{ not json at all" + write_text(self.project / ".claude" / "settings.json", broken) + result = self.install("standard") + self.assertEqual(read_text(self.project / ".claude" / "settings.json"), broken) + conflicts = [change for change in result.plan.changes + if change.action == "CONFLICT" and change.path == ".claude/settings.json"] + self.assertTrue(conflicts, result.plan.changes) + findings = [item for item in recovery.diagnose(self.project).findings + if item["component"] == "claude-hook"] + self.assertEqual(findings[0]["status"], recovery.CORRUPTED, findings) + + def test_uninstall_removes_only_the_managed_entry(self): + write_text(self.project / ".claude" / "settings.json", + json.dumps(USER_SETTINGS, indent=2) + "\n") + self.install("standard") + self.uninstall() + settings = load_settings(self.project) + self.assertIn("echo user-post", json.dumps(settings["hooks"]["PostToolUse"])) + self.assertEqual(owned_groups(settings), []) + + def test_uninstall_deletes_a_settings_file_we_created_alone(self): + self.install("standard") + self.assertTrue((self.project / ".claude" / "settings.json").is_file()) + self.uninstall() + self.assertFalse((self.project / ".claude" / "settings.json").exists()) + + def test_a_rewritten_hook_entry_is_preserved(self): + self.install("standard") + settings = load_settings(self.project) + for group in settings["hooks"]["PostToolUse"]: + for entry in group.get("hooks", []): + if "tools/ai_docs/run_hook" in entry.get("command", ""): + entry["command"] = "echo my own workflow" + write_text(self.project / ".claude" / "settings.json", + json.dumps(settings, indent=2) + "\n") + self.uninstall() + self.assertIn("echo my own workflow", + read_text(self.project / ".claude" / "settings.json")) + + def test_rollback_removes_a_hook_file_the_install_created(self): + self.install("standard") + journals = [item for item in txnlib.read_journals(self.project) + if item.operation == "init" and item.state == txnlib.COMMITTED] + self.assertTrue(journals) + txnlib.undo(self.project, max(journals, key=lambda item: item.started_at)) + self.assertFalse((self.project / ".claude" / "settings.json").exists()) + + def test_the_installed_wrapper_regenerates_a_summary(self): + self.install("standard") + # The fixture distribution ships stub tooling; the wrapper contract is + # what this test exercises, so the real generator is copied in. The + # generator itself is covered by the ai_docs suite. + import shutil as _shutil + + from tests.lifecycle_support import REPO as _REPO + for name in ("run_hook.sh", "run_hook.ps1", "update_on_edit.py", + "generate_ai_summary.py", "module_discovery.py", + "source_config.py"): + _shutil.copy2(_REPO / "tools" / "ai_docs" / name, + self.project / "tools" / "ai_docs" / name) + module = self.project / "src" / "demo" + write_text(module / "AI_CONTEXT.md", "# Demo\nPurpose: test.\n") + write_text(module / "code.py", "def hello():\n return 1\n") + payload = json.dumps({"tool_name": "Edit", + "tool_input": {"file_path": str(module / "code.py")}}) + wrapper = self.project / hookslib.wrapper_relative() + if os.name == "nt": + completed = subprocess.run( + ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", + "-File", str(wrapper)], + input=payload, capture_output=True, text=True, timeout=180) + else: + completed = subprocess.run(["bash", str(wrapper)], input=payload, + capture_output=True, text=True, timeout=180) + self.assertEqual(completed.returncode, 0, completed.stderr) + summary = module / "AI_SUMMARY.md" + self.assertTrue(summary.is_file(), completed.stderr) + self.assertIn("AI_SUMMARY", read_text(summary)) + + +class HookDiagnostics(LifecycleTestCase): + + def test_doctor_json_reports_the_hook_as_ok(self): + completed = self.cli("init", "--profile", "standard") + self.assertEqual(completed.returncode, 0, completed.stderr) + completed = self.cli("doctor", "--json") + record = json.loads(completed.stdout) + hook = next(item for item in record["environment"] if item["name"] == "claude_hook") + self.assertEqual(hook["status"], environment.OK, hook) + + def test_doctor_fails_when_the_configured_entry_disappears(self): + self.cli("init", "--profile", "standard") + (self.project / ".claude" / "settings.json").unlink() + completed = self.cli("doctor", "--json") + self.assertEqual(completed.returncode, 1) + record = json.loads(completed.stdout) + hook = next(item for item in record["environment"] if item["name"] == "claude_hook") + self.assertEqual(hook["status"], environment.FAIL, hook) + + def test_doctor_reports_a_stale_wrapper_as_version_mismatch(self): + self.install("standard") + settings = load_settings(self.project) + for group in settings["hooks"]["PostToolUse"]: + for entry in group.get("hooks", []): + command = entry.get("command", "") + if "tools/ai_docs/run_hook" in command: + entry["command"] = command.replace( + str(self.project).replace("\\", "/"), + "/somewhere/else") + write_text(self.project / ".claude" / "settings.json", + json.dumps(settings, indent=2) + "\n") + report = hookslib.hook_status(self.project) + self.assertIn(report["status"], + (hookslib.TARGET_MISSING, hookslib.VERSION_MISMATCH), report) + + +class NonGitPolicy(LifecycleTestCase): + + def nogit_project(self) -> Path: + # GIT_CEILING_DIRECTORIES stops Git from discovering a repository that + # encloses the test temp directory (the developer's home can be one). + self.set_env("GIT_CEILING_DIRECTORIES", str(self.root)) + other = self.root / "plain" + (other / "src").mkdir(parents=True) + write_text(other / "src" / "app.py", "print('hi')\n") + return other + + def test_standard_installs_outside_git_with_a_notice(self): + from ainative.lifecycle import installer as installerlib + other = self.nogit_project() + result = installerlib.install(other, "standard", distribution=self.distribution, + source=self.source) + self.assertTrue(result.applied) + self.assertTrue(any("not inside a Git repository" in notice + for notice in result.notices), result.notices) + report = environment.environment_checks(other, installed=True) + git = next(check for check in report if check["name"] == "git_repository") + self.assertEqual(git["status"], environment.DEGRADED, git) + + def test_verified_refuses_outside_git(self): + from ainative.lifecycle import installer as installerlib + other = self.nogit_project() + with self.assertRaises(LifecycleError) as raised: + installerlib.install(other, "verified", distribution=self.distribution, + source=self.source) + self.assertEqual(raised.exception.code, "GIT_REPOSITORY_REQUIRED") + self.assertFalse((other / ".ai-native").exists(), + "a refused Verified install wrote to the project") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_lifecycle_permissions.py b/tests/test_lifecycle_permissions.py new file mode 100644 index 00000000..453cd2bd --- /dev/null +++ b/tests/test_lifecycle_permissions.py @@ -0,0 +1,60 @@ +"""Permissions: private state owner-only, managed docs readable (POSIX). + +The stack used to write everything with whatever the umask produced, and the +Knowledge store's own control-path policy could not be exercised on a fresh +install because `.ai-native/state/` was not in the managed ignore region. + +These tests run where POSIX mode bits exist. On Windows the mode is not the +mechanism (ACLs are a separate surface this project does not claim), so the +suite skips with that reason rather than asserting a translation. +""" + +from __future__ import annotations + +import os +import stat +import sys +import unittest + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import state as statelib + +POSIX_ONLY = unittest.skipIf(os.name == "nt", + "POSIX mode bits only; Windows ACLs are not claimed") + + +@POSIX_ONLY +class PrivateStatePermissions(LifecycleTestCase): + + def test_write_atomic_private_is_owner_only(self): + target = self.root / "private.json" + statelib.write_atomic_private(target, "{}\n") + mode = stat.S_IMODE(target.stat().st_mode) + self.assertEqual(mode, 0o600, oct(mode)) + + def test_managed_documents_stay_readable(self): + self.install("standard") + agents = self.project / "AGENTS.md" + mode = stat.S_IMODE(agents.stat().st_mode) + self.assertTrue(mode & 0o044, f"managed doc not group/other readable: {oct(mode)}") + + def test_knowledge_state_is_owner_only_and_workable_after_init(self): + self.install("standard") + # The managed .gitignore region now carries .ai-native/state/, so a + # capture works on a fresh install without any manual edit (#138). + module = self.project / "notes.jsonl" + completed = self.cli( + "knowledge", "learn", + "--claim", "A permission probe observation.", + "--kind", "observation", + "--identity-key", "project/project/cache/ttl") + self.assertEqual(completed.returncode, 0, completed.stderr) + state_files = list((self.project / ".ai-native" / "state" / "knowledge").glob("*.jsonl")) + self.assertTrue(state_files, "no knowledge state file was written") + for path in state_files: + mode = stat.S_IMODE(path.stat().st_mode) + self.assertEqual(mode, 0o600, f"{path.name}: {oct(mode)}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_multivault_push_guard.py b/tests/test_multivault_push_guard.py index a39a16b8..d939206c 100644 --- a/tests/test_multivault_push_guard.py +++ b/tests/test_multivault_push_guard.py @@ -56,7 +56,7 @@ def intent(**overrides) -> PushIntent: "transport_policy": transport_policy(), "candidate_object_set_digest": "candidates", "scan_result_digest": "scan", - "expected_git_identity": "Rwanbt ", + "expected_git_identity": "Maintainer ", } fields.update(overrides) return PushIntent(**fields) diff --git a/tests/test_multivault_qualification.py b/tests/test_multivault_qualification.py index 4bb89d98..e04f8c03 100644 --- a/tests/test_multivault_qualification.py +++ b/tests/test_multivault_qualification.py @@ -191,5 +191,5 @@ def intent_stub(): ), candidate_object_set_digest="candidates", scan_result_digest="scan", - expected_git_identity="Rwanbt ", + expected_git_identity="Maintainer ", ) \ No newline at end of file diff --git a/tests/test_multivault_transfer_engine.py b/tests/test_multivault_transfer_engine.py index 72967940..44ce2835 100644 --- a/tests/test_multivault_transfer_engine.py +++ b/tests/test_multivault_transfer_engine.py @@ -149,7 +149,7 @@ def begin_push(self, engine, *, refspec="refs/heads/main:refs/heads/main", obser exact_refspec=refspec, observed_remote=observed if observed is not None else observed_remote(self.url), observed_transport=transport if transport is not None else observed_transport(), - expected_git_identity="Rwanbt ", + expected_git_identity="Maintainer ", ) def test_governed_fetch_records_evidence_and_state(self): diff --git a/tests/test_personal_paths_gate.py b/tests/test_personal_paths_gate.py new file mode 100644 index 00000000..0c1b99ce --- /dev/null +++ b/tests/test_personal_paths_gate.py @@ -0,0 +1,74 @@ +"""The machine-path gate must actually block, and its allowlist must be earned. + +Two failure modes matter more than the happy path: a gate that cannot fail +(mutation proof, first test) and a gate that fails on legitimate documentation +(the allowed-line cases). The allowlist entries exist because rewriting +historical evidence would change the record, not because the pattern is fine. +""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +_spec = importlib.util.spec_from_file_location( + "check_personal_paths", REPO / "scripts" / "check_personal_paths.py") +gate = importlib.util.module_from_spec(_spec) +sys.modules["check_personal_paths"] = gate +_spec.loader.exec_module(gate) + + +class GateOnTheCheckout(unittest.TestCase): + + def test_the_shipped_tree_has_no_machine_paths(self): + self.assertEqual(gate.scan(REPO), []) + + +class GateMutation(unittest.TestCase): + """The property is that an injected machine path fails the gate.""" + + def run_gate_on(self, content: str) -> list[dict]: + with tempfile.TemporaryDirectory(prefix="path-gate-") as staging: + root = Path(staging) + (root / "module.py").write_text(content, encoding="utf-8") + return gate.scan(root, files=["module.py"]) + + def test_a_windows_profile_path_is_refused(self): + hits = self.run_gate_on('HOME = r"C:\\Users\\example\\project"\n') + self.assertTrue(hits, "an injected Windows profile path passed the gate") + self.assertEqual(hits[0]["pattern"], "windows-profile") + + def test_a_posix_home_path_is_refused(self): + hits = self.run_gate_on('root = "/home/example/project"\n') + self.assertTrue(hits, "an injected Linux home path passed the gate") + + def test_a_documents_directory_is_refused(self): + hits = self.run_gate_on('vault = "D:\\\\Documents\\\\Obsidian"\n') + self.assertTrue(hits) + + def test_the_local_account_in_a_path_is_refused(self): + hits = self.run_gate_on('p = "/home/barat/project"\n') + self.assertTrue(hits) + + def test_a_deliberate_documentation_example_is_allowed(self): + # The real README sentence that documents the absence of a hard-coded + # path; using the shipped line keeps this test honest about escaping. + line = next(item for item in + (REPO / "README.md").read_text(encoding="utf-8").splitlines() + if "There is no" in item and "Documents" in item) + with tempfile.TemporaryDirectory(prefix="path-gate-") as staging: + root = Path(staging) + (root / "README.md").write_text(line + "\n", encoding="utf-8") + hits = gate.scan(root, files=["README.md"]) + self.assertEqual(hits, []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tools/ai_docs/generate_all.py b/tools/ai_docs/generate_all.py index 72060157..1b531613 100644 --- a/tools/ai_docs/generate_all.py +++ b/tools/ai_docs/generate_all.py @@ -62,7 +62,7 @@ def main() -> int: if not modules: print(f"No AI_CONTEXT.md files found under {root}") print("Create an AI_CONTEXT.md in each module directory first.") - print("See templates/AI_CONTEXT_template.md for the format.") + print("See .ai-native/templates/AI_CONTEXT_template.md for the format.") return 1 print(f"Found {len(modules)} module(s) under {root}") diff --git a/tools/ai_docs/run_hook.ps1 b/tools/ai_docs/run_hook.ps1 new file mode 100644 index 00000000..d3d09572 --- /dev/null +++ b/tools/ai_docs/run_hook.ps1 @@ -0,0 +1,23 @@ +# run_hook.ps1 - Claude Code PostToolUse wrapper (Windows native). +# Reads one hook payload from redirected stdin and delegates to +# update_on_edit.py next to this script. Always exits 0 so it never blocks the +# editor; when no usable Python is found it skips silently rather than failing +# the tool call. +# [Console]::In, not Get-Content: PowerShell 5.1's Get-Content has a mandatory +# -Path and does not read redirected stdin on its own. +$ErrorActionPreference = "SilentlyContinue" +$PAYLOAD = [Console]::In.ReadToEnd() +$SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path +$UPDATE_SCRIPT = Join-Path $SCRIPT_DIR "update_on_edit.py" + +$PY = $null +foreach ($candidate in @("python", "python3", "py")) { + $found = Get-Command $candidate -ErrorAction SilentlyContinue + if ($found) { $PY = $found.Source; break } +} + +if ($PY -and (Test-Path $UPDATE_SCRIPT) -and $PAYLOAD) { + $env:PYTHONIOENCODING = "utf-8" + $PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output +} +exit 0 \ No newline at end of file From 7cf0a6ee3c9e627dbe7267c03c6567344eb6bbd9 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 12:58:00 +0200 Subject: [PATCH 06/27] ci: run the hook, first-run policy and machine-path gates --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d65ed2..5d8dcaa5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,13 @@ jobs: # the whole thing in one pass. run: python scripts/measure_scope.py + - name: No machine-specific path may ship + # A committed C:\Users\ ships a home directory to every + # clone; the Mavis adapters did exactly that. The gate has a reasoned + # allowlist for historical evidence and its mutation test runs in the + # lifecycle job. + run: python scripts/check_personal_paths.py + loc-gate: name: LOC budget gate runs-on: ubuntu-latest @@ -204,6 +211,9 @@ jobs: - name: Implementation Economy skill contract (#53 - never executed before) run: python -m unittest tests.test_implementation_economy_skill -v + - name: Hook configuration, first-run policy and the machine-path gate + run: python -m unittest tests.test_lifecycle_hook_config tests.test_lifecycle_permissions tests.test_personal_paths_gate -v + - name: Non-vacuity — each guard must actually block # A protection nobody has seen refuse is a comment. This reverts each # guard in a scratch copy and asserts the matching test then fails. From e0710417e5da1fac0db93319b886a0ccfa6150f1 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 13:03:43 +0200 Subject: [PATCH 07/27] feat(workplane): trust init onboarding, schema validation, stable refusals - trust_schema owns the approval-root/policy shapes: validation through contracts.validate_artifact (one structural owner) plus semantic coherence (self-commitments, predicate vocabulary, observable facts); codes TRUST_ROOT_INVALID, TRUST_POLICY_INVALID, TRUST_SCHEMA_UNSUPPORTED - 'ainative trust init' scaffolds a minimal coherent pair and claims no authority; bootstrap remains the ceremony; predicate mismatch between policy and bootstrap is refused - bootstrap validates before reading: {} no longer raises KeyError with exit 1 (NOT_CONVERGED's code); workplane refusals now print 'refused: CODE: message' on stderr and the same record as JSON on stdout, always exit 2 - scripts/verified_first_run_e2e.py: fresh wheel + venv + git project, full governed workflow to CONVERGED through the CLI only, plus the refusal battery; CI job on 3 OS; tests.test_trust_onboarding covers scaffold and refusals --- .github/workflows/ci.yml | 23 +++ ainative_workplane/bootstrap.py | 6 + ainative_workplane/cli.py | 119 ++++++++++++- ainative_workplane/trust_schema.py | 205 +++++++++++++++++++++ scripts/verified_first_run_e2e.py | 276 +++++++++++++++++++++++++++++ tests/test_trust_onboarding.py | 158 +++++++++++++++++ 6 files changed, 784 insertions(+), 3 deletions(-) create mode 100644 ainative_workplane/trust_schema.py create mode 100644 scripts/verified_first_run_e2e.py create mode 100644 tests/test_trust_onboarding.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5d8dcaa5..7a27d64c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -135,6 +135,9 @@ jobs: - name: CLI, integrations and local pilot harnesses run: python -m unittest tests.test_workplane_cli tests.test_workplane_integrations_metrics tests.test_workplane_pilot tests.test_workplane_harness_matrix tests.test_workplane_historical_case -v + - name: Trust onboarding - scaffold, validation and refusals + run: python -m unittest tests.test_trust_onboarding -v + - name: The package installs and exposes its console entry point shell: bash run: | @@ -241,6 +244,26 @@ jobs: - name: Build two real wheels and cross the transition as a user would run: python scripts/lifecycle_upgrade_e2e.py + verified-first-run: + name: Verified first run (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.11" + + # A stranger's Verified first run through the CLI only: fresh wheel, + # fresh venv, fresh git project, init, trust init, bootstrap, work, + # verify, converge. If a step needed a file no command produced, this + # fails - that is the property (AUD-204/205). + - name: Fresh wheel, fresh project, governed workflow to CONVERGED + run: python scripts/verified_first_run_e2e.py + knowledge-b1: name: Knowledge B1 schemas (${{ matrix.os }}, py${{ matrix.python-version }}) runs-on: ${{ matrix.os }} diff --git a/ainative_workplane/bootstrap.py b/ainative_workplane/bootstrap.py index 2ee5399e..a962ef6f 100644 --- a/ainative_workplane/bootstrap.py +++ b/ainative_workplane/bootstrap.py @@ -35,6 +35,7 @@ from .predicates import predicate_refusal, predicate_requirements from .provenance import UNOBSERVED, commit_count, observe_artifact, observe_artifacts from .trust import approval_root_commitment, policy_commitment +from .trust_schema import require_matching_predicate, validate_approval_root, validate_policy # Repository-relative, and deliberately beside the work directories rather # than inside any one of them. @@ -113,8 +114,13 @@ def bootstrap(repository_root: str | os.PathLike[str], *, approval_root: Mapping target = root / TRUST_RELATIVE if target.exists(): raise BootstrapError("PROJECT_TRUST_ALREADY_INITIALIZED") + # Validated before anything is read from them: an empty document used to + # raise a bare KeyError from the line below (AUD-204). + approval_root = validate_approval_root(approval_root) + policy = validate_policy(policy) if predicate_requirements(predicate_id) is None: raise BootstrapError(f"PROJECT_TRUST_INVALID:UNKNOWN_PREDICATE:{predicate_id}") + require_matching_predicate(policy, predicate_id) anchor: dict[str, Any] = { "schema_name": "project_trust", "schema_version": 1, diff --git a/ainative_workplane/cli.py b/ainative_workplane/cli.py index cfba2f94..741fec8a 100644 --- a/ainative_workplane/cli.py +++ b/ainative_workplane/cli.py @@ -20,12 +20,30 @@ from .convergence import VERDICT_EXIT_CODES from .evaluator import EvaluationError, evaluate_work, run_verification from .runner import VerificationRunner +from .trust_schema import (DEFAULT_PREDICATE, TrustSchemaError, scaffold_approval_root, + scaffold_policy, validate_approval_root, validate_policy) def _load(path: Path) -> Any: return json.loads(Path(path).read_text(encoding="utf-8")) +def _load_required(path: Path, code: str, label: str) -> Any: + """A typed refusal for the three ways a user-provided file can fail.""" + + candidate = Path(path) + try: + text = candidate.read_text(encoding="utf-8") + except FileNotFoundError: + raise TrustSchemaError(code, f"{label} file not found: {candidate}") from None + except OSError as error: + raise TrustSchemaError(code, f"{label} cannot be read: {error}") from error + try: + return json.loads(text) + except json.JSONDecodeError as error: + raise TrustSchemaError(code, f"{label} is not valid JSON: {error}") from error + + def _emit(payload: Any) -> None: print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) @@ -55,6 +73,15 @@ def build_parser() -> argparse.ArgumentParser: trust = commands.add_parser("trust", help="Pin what a project trusts, before any work contract exists. PRIVILEGED: see ADR-0006.") trust_commands = trust.add_subparsers(dest="trust_command", required=True) + scaffold = trust_commands.add_parser("init", help="Write the approval-root and policy documents a project will bootstrap from. This is scaffolding, not authority: nothing is trusted until `trust bootstrap` runs.") + scaffold.add_argument("--repo", type=Path, default=Path.cwd()) + scaffold.add_argument("--by", required=True, help="Who is preparing these documents (recorded as a name, not a proof).") + scaffold.add_argument("--predicate", default=DEFAULT_PREDICATE, + help="The predicate the documents configure (default: recorded_owner_ack).") + scaffold.add_argument("--outdir", type=Path, default=None, + help="Where to write them (default: /.ai-native/trust/setup).") + scaffold.add_argument("--force", action="store_true", + help="Overwrite documents a previous `trust init` wrote.") initialize = trust_commands.add_parser("bootstrap", help="Establish the project trust anchor. PRIVILEGED trust-establishment: the Work Plane cannot verify who performed it, so a controlled agent must not be given authority to run it. Refuses to replace an existing anchor.") initialize.add_argument("--repo", type=Path, default=Path.cwd()) initialize.add_argument("--approval-root", type=Path, required=True) @@ -140,12 +167,78 @@ def _work(args: argparse.Namespace) -> int: return 0 +def _trust_init(args: argparse.Namespace) -> int: + """Candidate documents only. The ceremony stays `trust bootstrap`.""" + + repo = Path(args.repo).resolve() + outdir = Path(args.outdir) if args.outdir else repo / ".ai-native" / "trust" / "setup" + root_path = outdir / "approval-root.json" + policy_path = outdir / "policy.json" + if not args.force: + existing = [path for path in (root_path, policy_path) if path.exists()] + if existing: + raise TrustSchemaError( + "TRUST_SCAFFOLD_EXISTS", + "refusing to overwrite " + ", ".join(str(path) for path in existing) + + "; re-run with --force to replace the scaffolding") + + policy = scaffold_policy(predicate_id=args.predicate) + approval_root = scaffold_approval_root(policy, initialized_by=args.by) + # Prove the scaffold satisfies the same validators bootstrap applies; a + # scaffold that cannot bootstrap would be a trap. + validate_approval_root(approval_root) + validate_policy(policy) + + outdir.mkdir(parents=True, exist_ok=True) + root_path.write_text(json.dumps(approval_root, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + policy_path.write_text(json.dumps(policy, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + + def relative(path: Path) -> str: + try: + return path.relative_to(repo).as_posix() + except ValueError: + return str(path) + + next_command = ("ainative trust bootstrap --repo . " + f"--approval-root {relative(root_path)} " + f"--policy {relative(policy_path)} " + f"--by \"{args.by}\" --predicate {args.predicate}") + note = "" + if args.predicate == "signature": + note = ("The signature predicate also needs --signer : Git " + "verifying a signature is not this project authorizing the signer.") + _emit({ + "operation": "trust init", + "authority": "none - scaffolding only; trust is established by `ainative trust bootstrap`", + "repo": str(repo), + "approval_root": str(root_path), + "policy": str(policy_path), + "predicate": args.predicate, + "next_command": next_command, + "note": note, + }) + return 0 + + def _trust(args: argparse.Namespace) -> int: + if args.trust_command == "init": + return _trust_init(args) if args.trust_command == "bootstrap": - path = bootstrap(args.repo, approval_root=_load(args.approval_root), policy=_load(args.policy), initialized_by=args.by, predicate_id=args.predicate, authorized_signers=args.signer) + path = bootstrap(args.repo, + approval_root=_load_required(args.approval_root, + "TRUST_ROOT_INVALID", "approval root"), + policy=_load_required(args.policy, + "TRUST_POLICY_INVALID", "policy"), + initialized_by=args.by, predicate_id=args.predicate, + authorized_signers=args.signer) # Labelled, because everything downstream derives its authority from # this one act and the runtime cannot verify who performed it. - _emit({"authority": "privileged_trust_establishment", "anchor": str(path), "trust": load_trust_anchor(path)}) + _emit({"authority": "privileged_trust_establishment", "anchor": str(path), + "trust": load_trust_anchor(path), + "next": f"commit {Path(path)} so the anchor's provenance is " + "GIT_RECORDED; `ainative trust show` reports GOVERNED once it is"}) return 0 located = locate_trust_anchor(args.repo) if located is None: @@ -203,9 +296,29 @@ def main(argv: list[str] | None = None) -> int: if args.entrypoint == "debug": return _debug_run_command(args) return _converge(args) + except TrustSchemaError as refusal: + _refuse(refusal.code, refusal.message) + return 2 + except BootstrapError as refusal: + message = str(refusal) + code, separator, _ = message.partition(":") + _refuse(code if separator else "TRUST_BOOTSTRAP_FAILED", message) + return 2 except (EvaluationError, ControllerError, BootstrapError, ContractError, json.JSONDecodeError, OSError, ValueError) as refusal: - print(f"refused: {refusal}", file=sys.stderr) + _refuse(getattr(refusal, "code", "WORKPLANE_REFUSAL"), str(refusal)) return 2 +def _refuse(code: str, message: str) -> None: + """A stable code on stderr, the same record as JSON on stdout. + + Scripts read stdout; humans read stderr. Neither gets a traceback for a + validation failure, and the exit code stays 2 (INVALID) - 1 means + NOT_CONVERGED and must never be produced by a syntax error (AUD-204). + """ + + print(f"refused: {code}: {message}", file=sys.stderr) + _emit({"error": code, "message": message}) + + __all__ = ["build_parser", "main"] diff --git a/ainative_workplane/trust_schema.py b/ainative_workplane/trust_schema.py new file mode 100644 index 00000000..982a6326 --- /dev/null +++ b/ainative_workplane/trust_schema.py @@ -0,0 +1,205 @@ +"""The shapes trust onboarding produces, validated before anything consumes them. + +The approval root and the policy were implicit structures: `bootstrap()` read +them with `approval_root["uid"]`, so an empty `{}` produced a `KeyError` +traceback and exit 1 (which the Work Plane contract reserves for +NOT_CONVERGED). The only complete examples lived in test fixtures and the +dogfood script, so a user had to reverse-engineer two JSON documents to use a +profile sold for teams. + +This module is the single source of truth for both shapes: + +* `validate_approval_root` and `validate_policy` raise `TrustSchemaError` with + the stable codes `TRUST_ROOT_INVALID`, `TRUST_POLICY_INVALID` or + `TRUST_SCHEMA_UNSUPPORTED` - never a bare `KeyError`; +* `scaffold_policy` and `scaffold_approval_root` produce a minimal, coherent, + *untrusted* pair: writing them establishes nothing until the explicit + `trust bootstrap` ceremony runs. Scaffolding is not authority. + +Validation goes through `contracts.validate_artifact`, so the structural rules +(uid grammar, digest shape, provenance values, schema versions) have exactly +one owner; this module adds the semantic coherence the schema cannot see: the +self-commitments, the predicate vocabulary and the predicate agreement between +the policy and the bootstrap act. +""" + +from __future__ import annotations + +import time +from typing import Any, Mapping + +from .contracts import ContractError, generate_uid, validate_artifact +from .predicates import predicate_requirements +from .provenance import OBSERVABLE_FACTS +from .trust import approval_root_commitment, policy_commitment + +ROOT_CODE = "TRUST_ROOT_INVALID" +POLICY_CODE = "TRUST_POLICY_INVALID" +UNSUPPORTED_CODE = "TRUST_SCHEMA_UNSUPPORTED" + +SUPPORTED_SCHEMA_VERSION = 1 +DEFAULT_PREDICATE = "recorded_owner_ack" +SCAFFOLD_PROVENANCE = "GIT_RECORDED" + + +class TrustSchemaError(RuntimeError): + """A refusal carrying a stable code the CLI prints and scripts parse.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + self.message = message + + +_UNSUPPORTED_CONTRACT_CODES = ("UNSUPPORTED_SCHEMA", "UNSUPPORTED_SCHEMA_VERSION") + + +def _translate(error: ContractError, code: str, label: str) -> TrustSchemaError: + stable = UNSUPPORTED_CODE if error.code in _UNSUPPORTED_CONTRACT_CODES else code + return TrustSchemaError(stable, f"{label}: {error}") + + +def _require_present(document: Any, label: str, code: str) -> Mapping[str, Any]: + if not isinstance(document, Mapping): + raise TrustSchemaError(code, f"{label} must be a JSON object") + return document + + +def validate_approval_root(document: Any) -> Mapping[str, Any]: + """The root document, or `TRUST_ROOT_INVALID` / `TRUST_SCHEMA_UNSUPPORTED`.""" + + root = _require_present(document, "approval root", ROOT_CODE) + name = root.get("schema_name") + if name is None: + raise TrustSchemaError(ROOT_CODE, "approval root declares no schema_name") + if name != "approval_root": + raise TrustSchemaError( + UNSUPPORTED_CODE, + f"approval root declares schema_name {name!r}; expected 'approval_root'") + try: + validate_artifact(dict(root)) + except ContractError as error: + raise _translate(error, ROOT_CODE, "approval root") from error + if root.get("root_digest") != approval_root_commitment(root): + raise TrustSchemaError( + ROOT_CODE, + "root_digest does not commit to the document's content; regenerate it " + "with `ainative trust init`") + return root + + +def validate_policy(document: Any) -> Mapping[str, Any]: + """The policy document, or `TRUST_POLICY_INVALID` / `TRUST_SCHEMA_UNSUPPORTED`.""" + + policy = _require_present(document, "policy", POLICY_CODE) + name = policy.get("schema_name") + if name is None: + raise TrustSchemaError(POLICY_CODE, "policy declares no schema_name") + if name != "project_policy": + raise TrustSchemaError( + UNSUPPORTED_CODE, + f"policy declares schema_name {name!r}; expected 'project_policy'") + try: + validate_artifact(dict(policy)) + except ContractError as error: + raise _translate(error, POLICY_CODE, "policy") from error + + for field in ("approval_predicate", "waiver_approval_rule", "human_approval_rule"): + predicate_id = policy[field].get("predicate_id") + if predicate_requirements(predicate_id) is None: + raise TrustSchemaError( + POLICY_CODE, + f"{field}.predicate_id {predicate_id!r} names no known predicate; " + "known: " + ", ".join(sorted( + candidate for candidate in + ("signature", "git_review", "ci_attestation", "recorded_owner_ack")))) + for field in ("required_mutation_facts", "required_evidence_facts"): + unknown = [fact for fact in policy[field] if fact not in OBSERVABLE_FACTS] + if unknown: + raise TrustSchemaError( + POLICY_CODE, f"{field} names facts the runtime cannot observe: " + + ", ".join(sorted(unknown))) + + if not _policy_digests_match(policy): + raise TrustSchemaError( + POLICY_CODE, + "policy_digest fields do not commit to the document's content; " + "regenerate them with `ainative trust init`") + return policy + + +def _policy_digests_match(policy: Mapping[str, Any]) -> bool: + commitment = policy_commitment(policy) + for field in ("approval_predicate", "waiver_approval_rule", "human_approval_rule"): + if policy[field].get("policy_digest") != commitment: + return False + return True + + +def require_matching_predicate(policy: Mapping[str, Any], predicate_id: str) -> None: + """The anchor records one predicate; the policy configures one. They agree.""" + + configured = policy["approval_predicate"].get("predicate_id") + if configured != predicate_id: + raise TrustSchemaError( + POLICY_CODE, + f"--predicate {predicate_id!r} does not match the policy's " + f"approval_predicate {configured!r}; bootstrap the predicate the " + "policy actually configures") + + +def scaffold_policy(*, predicate_id: str = DEFAULT_PREDICATE, + required_facts: Mapping[str, bool] | None = None, + promotion_policy: str = "explicit") -> dict[str, Any]: + """A minimal coherent policy. Nothing here is authority until bootstrap.""" + + if predicate_requirements(predicate_id) is None: + raise TrustSchemaError( + UNSUPPORTED_CODE, + f"predicate {predicate_id!r} names no known predicate; known: " + "signature, git_review, ci_attestation, recorded_owner_ack") + facts = dict(required_facts or {"git_recorded": True}) + unknown = [fact for fact in facts if fact not in OBSERVABLE_FACTS] + if unknown: + raise TrustSchemaError(UNSUPPORTED_CODE, + "required facts the runtime cannot observe: " + + ", ".join(sorted(unknown))) + placeholder = "0" * 64 + policy: dict[str, Any] = { + "schema_name": "project_policy", + "schema_version": SUPPORTED_SCHEMA_VERSION, + "approval_predicate": {"predicate_id": predicate_id, "policy_digest": placeholder}, + "required_mutation_facts": dict(facts), + "required_evidence_facts": dict(facts), + "waiver_approval_rule": {"predicate_id": predicate_id, "policy_digest": placeholder}, + "human_approval_rule": {"predicate_id": predicate_id, "policy_digest": placeholder}, + "promotion_policy": promotion_policy, + } + commitment = policy_commitment(policy) + for field in ("approval_predicate", "waiver_approval_rule", "human_approval_rule"): + policy[field]["policy_digest"] = commitment + return policy + + +def scaffold_approval_root(policy: Mapping[str, Any], *, initialized_by: str, + provenance: str = SCAFFOLD_PROVENANCE) -> dict[str, Any]: + """A minimal coherent root. `initialized_by` is a name, not a proof.""" + + root: dict[str, Any] = { + "schema_name": "approval_root", + "schema_version": SUPPORTED_SCHEMA_VERSION, + "uid": generate_uid("root"), + "root_digest": "0" * 64, + "policy_digest": policy_commitment(policy), + "root_provenance": provenance, + "bootstrap": {"initialized_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "initialized_by": initialized_by}, + } + root["root_digest"] = approval_root_commitment(root) + return root + + +__all__ = ["TrustSchemaError", "ROOT_CODE", "POLICY_CODE", "UNSUPPORTED_CODE", + "SUPPORTED_SCHEMA_VERSION", "DEFAULT_PREDICATE", + "validate_approval_root", "validate_policy", "require_matching_predicate", + "scaffold_policy", "scaffold_approval_root"] \ No newline at end of file diff --git a/scripts/verified_first_run_e2e.py b/scripts/verified_first_run_e2e.py new file mode 100644 index 00000000..716de19b --- /dev/null +++ b/scripts/verified_first_run_e2e.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +"""A stranger's Verified first run, through the CLI only. + +Nothing here reads the repository, the tests, or this file's imports: it builds +(or is given) a wheel, installs it in a fresh virtual environment, and follows +the commands the README documents. The contract this proves: + + fresh wheel -> fresh git project -> init --profile verified + -> trust init (scaffold) -> trust bootstrap -> commit + -> work admit -> work new -> verify -> converge = CONVERGED + +and the refusal battery a user hits by mistake: `{}`, a missing file, invalid +JSON, an unsupported schema, a predicate that contradicts the policy - each one +a stable code, exit 2, no traceback. + +The scaffolds are generated by the product. If a step needs a file no command +produced, this script must fail: that is the property being tested. +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +from ainative_workplane.contracts import generate_uid # noqa: E402 + +CHECKS: list[str] = [] +PLACEHOLDER_DIGEST = "0" * 64 + + +class Failure(RuntimeError): + """A step that must not fail did.""" + + +def ok(message: str) -> None: + CHECKS.append(message) + print(f"[ok] {message}") + + +def run(argv, *, cwd=None, env=None, expect=0, timeout=1200): + completed = subprocess.run([str(item) for item in argv], cwd=cwd, + env={**os.environ, **(env or {})}, + capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=timeout) + if expect is not None and completed.returncode != expect: + raise Failure(f"exit {completed.returncode}, expected {expect}: " + + " ".join(map(str, argv)) + + f"\n--- stdout\n{completed.stdout[-2500:]}" + + f"\n--- stderr\n{completed.stderr[-2500:]}") + return completed + + +def refuse(argv, *, code: str, cwd=None, env=None): + completed = run(argv, cwd=cwd, env=env, expect=2) + combined = completed.stdout + completed.stderr + if "Traceback" in combined: + raise Failure(f"a traceback reached the user:\n{combined[-1500:]}") + if code not in combined: + raise Failure(f"expected {code} in the refusal, got:\n{combined[-1500:]}") + return completed + + +def git(repo: Path, *args: str) -> None: + run(["git", "-C", str(repo), *args]) + + +def build_artifacts(project: Path, policy: dict, root: dict) -> dict: + """One requirement, one criterion, one task, one verification. Mirrors the + shapes the Work Plane documents; every uid is generated by the running CLI + process where one exists (trust init produced root/policy).""" + + requirement = generate_uid("req") + criterion = generate_uid("ac") + task = generate_uid("task") + verification = generate_uid("verify") + registry_reference = generate_uid("work") + registry = { + "schema_name": "command_registry", "schema_version": 1, + "commands": {"check": {"argv": [sys.executable, "tests/check.py"], + "timeout_seconds": 60, + "substance": {"type": "unittest", + "minimum_observations": 1}}}, + } + return { + "requirements": [{"schema_name": "requirements", "schema_version": 1, + "uid": requirement, "statement": "the value stays one", + "acceptance_criteria": [{"uid": criterion, + "digest": PLACEHOLDER_DIGEST}]}], + "acceptance_criteria": [{"schema_name": "acceptance_criteria", + "schema_version": 1, "uid": criterion, + "requirement": {"uid": requirement, + "digest": PLACEHOLDER_DIGEST}, + "criterion": "the module still exposes VALUE", + "verification_specifications": [ + {"uid": verification, "digest": PLACEHOLDER_DIGEST}]}], + "tasks": [{"schema_name": "tasks", "schema_version": 1, "uid": task, + "requirements": [{"uid": requirement, "digest": PLACEHOLDER_DIGEST}], + "implementation_paths": ["src/app.py"]}], + "verification_specifications": [{ + "schema_name": "verification_specification", "schema_version": 1, + "uid": verification, + "acceptance_criteria": [{"uid": criterion, "digest": PLACEHOLDER_DIGEST}], + "command_registry": {"uid": registry_reference, + "digest": PLACEHOLDER_DIGEST}, + "relationship": "black_box", "execution_scope": ["tests/check.py"], + "covered_implementation_paths": ["src/app.py"], "dependencies": [], + "substance_requirement": "unittest", + "required_evidence_provenance": "GIT_RECORDED", "command": "check"}], + "project_policy": policy, + "approval_root": root, + "command_registry": registry, + }, verification + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--wheel", default=None, + help="a prebuilt wheel (default: build one from this tree)") + parser.add_argument("--keep", action="store_true") + args = parser.parse_args() + + workspace = Path(tempfile.mkdtemp(prefix="ainative-verified-e2e-")) + failed = False + try: + if args.wheel: + wheel = Path(args.wheel).resolve() + if not wheel.is_file(): + raise Failure(f"wheel not found: {wheel}") + else: + dist = workspace / "dist" + run([sys.executable, "-m", "pip", "wheel", "--disable-pip-version-check", + "--no-deps", "--wheel-dir", dist, REPO]) + wheels = sorted(dist.glob("*.whl")) + if len(wheels) != 1: + raise Failure(f"expected one wheel, found {wheels}") + wheel = wheels[0] + + venv = workspace / "venv" + run([sys.executable, "-m", "venv", venv]) + bindir = venv / ("Scripts" if os.name == "nt" else "bin") + python = bindir / ("python.exe" if os.name == "nt" else "python") + cli = bindir / ("ainative.exe" if os.name == "nt" else "ainative") + run([python, "-m", "pip", "install", "--disable-pip-version-check", "-q", + "--no-deps", wheel]) + ok(f"fresh venv installed the wheel {wheel.name}") + + project = workspace / "project" + (project / "src").mkdir(parents=True) + (project / "tests").mkdir() + (project / "src" / "app.py").write_text("VALUE = 1\n", encoding="utf-8") + (project / "tests" / "check.py").write_text( + "print('Ran 1 test in 0.001s')\nprint('OK')\n", encoding="utf-8") + git(project, "init", "-q") + git(project, "config", "user.email", "verified-e2e@example.invalid") + git(project, "config", "user.name", "Verified E2E") + git(project, "add", "-A") + git(project, "commit", "-qm", "initial") + + completed = run([cli, "init", "--profile", "verified", "--project", project]) + ok("init --profile verified succeeded in a fresh Git project") + + doctor = run([cli, "doctor", "--json", "--project", project]) + record = json.loads(doctor.stdout) + hook = next(item for item in record["environment"] + if item["name"] == "claude_hook") + if hook["status"] != "OK": + raise Failure(f"the hook is not configured after init: {hook}") + ok("doctor is green and reports the PostToolUse hook configured") + + scaffold = run([cli, "trust", "init", "--repo", project, "--by", "e2e"]) + scaffold_record = json.loads(scaffold.stdout) + root_path = Path(scaffold_record["approval_root"]) + policy_path = Path(scaffold_record["policy"]) + if not root_path.is_file() or not policy_path.is_file(): + raise Failure("trust init reported files it did not write") + if scaffold_record["authority"].startswith("none"): + ok("trust init wrote the scaffold and claims no authority") + else: + raise Failure("trust init claimed authority it cannot have") + + run([cli, "trust", "bootstrap", "--repo", project, + "--approval-root", root_path, "--policy", policy_path, + "--by", "e2e", "--predicate", "recorded_owner_ack"]) + git(project, "add", "-A") + git(project, "commit", "-qm", "trust anchor") + show = run([cli, "trust", "show", "--repo", project]) + show_record = json.loads(show.stdout) + if not show_record.get("governed"): + raise Failure(f"the anchor does not govern its own project: {show_record}") + ok("trust bootstrap + commit: trust show reports governed") + + policy = json.loads(policy_path.read_text(encoding="utf-8")) + root = json.loads(root_path.read_text(encoding="utf-8")) + artifacts, verification_uid = build_artifacts(project, policy, root) + work_dir = project / ".ai-native" / "work" / "w1" + artifact_args = [item for name, payload in artifacts.items() + for item in ("--artifact", + f"{name}={json.dumps(payload)}")] + run([cli, "work", "admit", work_dir, "--repo", project, "--by", "e2e", + *artifact_args]) + git(project, "add", "-A") + git(project, "commit", "-qm", "work creation approval") + run([cli, "work", "new", work_dir, *artifact_args]) + git(project, "add", "-A") + git(project, "commit", "-qm", "work contract") + ok("work admit + work new: the contract is created under the anchor") + + verified = run([cli, "verify", "--work", work_dir, "--repo", project, + "--verification", verification_uid]) + run([cli, "converge", "--work", work_dir, "--repo", project]) + git(project, "add", "-A") + git(project, "commit", "-qm", "verification evidence") + converge = run([cli, "converge", "--work", work_dir, "--repo", project]) + if '"verdict":"CONVERGED"' not in converge.stdout.replace(" ", ""): + raise Failure(f"the governed workflow did not converge:\n{converge.stdout[:1500]}") + ok("verify + converge through the production boundary: CONVERGED") + + # --- the refusal battery a user hits by mistake --------------------- + empty = workspace / "empty.json" + empty.write_text("{}", encoding="utf-8") + broken = workspace / "broken.json" + broken.write_text("{not json", encoding="utf-8") + unsupported = workspace / "unsupported.json" + unsupported.write_text(json.dumps({"schema_name": "approval_root", + "schema_version": 99}), encoding="utf-8") + + fresh = workspace / "fresh" + (fresh).mkdir() + git(fresh, "init", "-q") + git(fresh, "config", "user.email", "e2e@example.invalid") + git(fresh, "config", "user.name", "E2E") + + base = [cli, "trust", "bootstrap", "--repo", fresh, "--by", "e2e"] + refuse([*base, "--approval-root", empty, "--policy", empty], code="TRUST_ROOT_INVALID") + refuse([*base, "--approval-root", workspace / "missing.json", "--policy", empty], + code="TRUST_ROOT_INVALID") + refuse([*base, "--approval-root", broken, "--policy", empty], + code="TRUST_ROOT_INVALID") + refuse([*base, "--approval-root", unsupported, "--policy", empty], + code="TRUST_SCHEMA_UNSUPPORTED") + refuse([*base, "--approval-root", root_path, "--policy", empty], + code="TRUST_POLICY_INVALID") + refuse([cli, "trust", "bootstrap", "--repo", fresh, + "--approval-root", root_path, "--policy", policy_path, + "--by", "e2e", "--predicate", "signature"], + code="TRUST_POLICY_INVALID") + refuse([cli, "trust", "init", "--repo", project, "--by", "e2e"], + code="TRUST_SCAFFOLD_EXISTS") + ok("refusals: {} / missing / invalid JSON / bad schema / bad policy / " + "predicate mismatch / existing scaffold - stable codes, exit 2, no traceback") + + print(f"\nVERIFIED FIRST RUN: PASS ({len(CHECKS)} checks)") + return 0 + except Failure as error: + failed = True + print(f"\nVERIFIED FIRST RUN: FAIL\n{error}", file=sys.stderr) + return 1 + finally: + if args.keep or failed: + print(f"workspace kept for inspection: {workspace}") + else: + shutil.rmtree(workspace, ignore_errors=True) + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/tests/test_trust_onboarding.py b/tests/test_trust_onboarding.py new file mode 100644 index 00000000..2167d0d5 --- /dev/null +++ b/tests/test_trust_onboarding.py @@ -0,0 +1,158 @@ +"""Trust onboarding: a scaffold a user can produce, refusals without tracebacks. + +The `{}` case is the regression: bootstrap.py read `approval_root["uid"]` and +raised a bare KeyError, whose traceback exited 1 - the code the Work Plane +contract reserves for NOT_CONVERGED. A malformed document is INVALID (2), with +a stable code both a human and a script can read. +""" + +from __future__ import annotations + +import contextlib +import io +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +from ainative_workplane import cli as workplane_cli +from ainative_workplane.bootstrap import bootstrap +from ainative_workplane.trust_schema import (POLICY_CODE, ROOT_CODE, UNSUPPORTED_CODE, + TrustSchemaError, scaffold_approval_root, + scaffold_policy, validate_approval_root, + validate_policy) + + +def cli_capture(arguments: list[str]) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + code = workplane_cli.main(arguments) + return code, out.getvalue(), err.getvalue() + + +class ScaffoldValidation(unittest.TestCase): + + def test_the_scaffold_satisfies_the_validators_bootstrap_applies(self): + policy = scaffold_policy() + root = scaffold_approval_root(policy, initialized_by="someone") + self.assertIs(validate_policy(policy), policy) + self.assertIs(validate_approval_root(root), root) + + def test_an_empty_root_is_invalid_not_unsupported(self): + with self.assertRaises(TrustSchemaError) as raised: + validate_approval_root({}) + self.assertEqual(raised.exception.code, ROOT_CODE) + + def test_a_root_with_an_unknown_schema_is_unsupported(self): + with self.assertRaises(TrustSchemaError) as raised: + validate_approval_root({"schema_name": "something_else", + "schema_version": 1}) + self.assertEqual(raised.exception.code, UNSUPPORTED_CODE) + + def test_an_unsupported_version_is_unsupported(self): + policy = scaffold_policy() + root = scaffold_approval_root(policy, initialized_by="someone") + root["schema_version"] = 99 + with self.assertRaises(TrustSchemaError) as raised: + validate_approval_root(root) + self.assertEqual(raised.exception.code, UNSUPPORTED_CODE) + + def test_a_root_digest_that_does_not_commit_is_refused(self): + policy = scaffold_policy() + root = scaffold_approval_root(policy, initialized_by="someone") + root["bootstrap"]["initialized_by"] = "someone else" + with self.assertRaises(TrustSchemaError) as raised: + validate_approval_root(root) + self.assertEqual(raised.exception.code, ROOT_CODE) + self.assertIn("root_digest", raised.exception.message) + + def test_a_policy_digest_that_does_not_commit_is_refused(self): + policy = scaffold_policy() + policy["promotion_policy"] = "changed after the commitment" + with self.assertRaises(TrustSchemaError) as raised: + validate_policy(policy) + self.assertEqual(raised.exception.code, POLICY_CODE) + + def test_an_unknown_predicate_names_the_known_ones(self): + with self.assertRaises(TrustSchemaError) as raised: + scaffold_policy(predicate_id="vibes") + self.assertEqual(raised.exception.code, UNSUPPORTED_CODE) + self.assertIn("recorded_owner_ack", raised.exception.message) + + def test_bootstrap_refuses_a_predicate_the_policy_does_not_configure(self): + directory = tempfile.TemporaryDirectory(prefix="trust-onboarding-") + self.addCleanup(directory.cleanup) + repo = Path(directory.name) + policy = scaffold_policy(predicate_id="recorded_owner_ack") + root = scaffold_approval_root(policy, initialized_by="someone") + with self.assertRaises(TrustSchemaError) as raised: + bootstrap(repo, approval_root=root, policy=policy, + initialized_by="someone", predicate_id="signature") + self.assertEqual(raised.exception.code, POLICY_CODE) + + +class CliRefusals(unittest.TestCase): + """Exit code 2, a stable code on stderr, the same record as JSON.""" + + def setUp(self): + self.directory = tempfile.TemporaryDirectory(prefix="trust-cli-") + self.addCleanup(self.directory.cleanup) + self.repo = Path(self.directory.name) + subprocess.run(["git", "init", "-q", str(self.repo)], check=True, + capture_output=True) + + def empty(self, name: str, payload: str) -> Path: + path = self.repo / name + path.write_text(payload, encoding="utf-8") + return path + + def test_the_empty_document_refuses_with_a_stable_code(self): + document = self.empty("empty.json", "{}") + code, out, err = cli_capture([ + "trust", "bootstrap", "--repo", str(self.repo), + "--approval-root", str(document), "--policy", str(document), + "--by", "tester"]) + self.assertEqual(code, 2) + self.assertIn("refused:", err) + self.assertNotIn("Traceback", err + out) + record = json.loads(out) + self.assertEqual(record["error"], ROOT_CODE) + + def test_a_missing_file_refuses_with_the_root_code(self): + missing = self.repo / "missing.json" + code, out, err = cli_capture([ + "trust", "bootstrap", "--repo", str(self.repo), + "--approval-root", str(missing), "--policy", str(missing), + "--by", "tester"]) + self.assertEqual(code, 2) + self.assertEqual(json.loads(out)["error"], ROOT_CODE) + + def test_trust_init_writes_a_usable_scaffold_and_claims_no_authority(self): + code, out, err = cli_capture([ + "trust", "init", "--repo", str(self.repo), "--by", "tester"]) + self.assertEqual(code, 0, err) + record = json.loads(out) + self.assertTrue(record["authority"].startswith("none")) + root = json.loads(Path(record["approval_root"]).read_text(encoding="utf-8")) + policy = json.loads(Path(record["policy"]).read_text(encoding="utf-8")) + validate_approval_root(root) + validate_policy(policy) + self.assertFalse((self.repo / ".ai-native" / "trust" + / "project_trust.json").exists(), + "trust init created an anchor; scaffolding is not authority") + + def test_trust_init_refuses_to_overwrite_without_force(self): + cli_capture(["trust", "init", "--repo", str(self.repo), "--by", "tester"]) + code, out, err = cli_capture(["trust", "init", "--repo", str(self.repo), + "--by", "tester"]) + self.assertEqual(code, 2) + self.assertEqual(json.loads(out)["error"], "TRUST_SCAFFOLD_EXISTS") + code, out, _ = cli_capture(["trust", "init", "--repo", str(self.repo), + "--by", "tester", "--force"]) + self.assertEqual(code, 0) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From 6cbab17ae52a0ee4903954bb9d1ab3fb05fe1a7a Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 13:13:41 +0200 Subject: [PATCH 08/27] feat(lifecycle): versioned update protocol v2, authenticated checks, atomic release - protocol v2: bundles are ainative-lifecycle-v2-.zip with a lifecycle-protocol.json at the root and the payload under stack/; runtimes <= v2.2.2 cannot resolve it even from a mirror (layout containment, AUD-205), and v2.2.2 refuses at the runtime gate first - updater validates the protocol document (version, release, payload root) before trusting the payload - GITHUB_TOKEN/GH_TOKEN support for update checks: sent as a bearer header, never logged, never cached; 403/429 reported as a rate-limit diagnosis - 'update check --strict' exits non-zero when the source could not be consulted; default documents exit 0 != reachable - release workflow: draft -> upload (no clobber) -> verify names/sizes/digests -> publish, with GitHub build-provenance attestations; scripts/check_published_assets.py and the extended check_release_versions.py (--without-bundle for the PyPI half) - new publish-pypi.yml using PyPI Trusted Publishing (OIDC); docs/RELEASING.md documents setup, verification and the no-clobber recovery path; tests for the transport, containment and both gates --- .github/workflows/publish-pypi.yml | 61 +++++++ .github/workflows/release-assets.yml | 43 ++++- README.fr.md | 6 + README.md | 7 + SECURITY.md | 10 +- UPDATING.md | 7 + ainative/cli.py | 7 + ainative/lifecycle/provider.py | 48 +++++- ainative/lifecycle/updater.py | 55 +++++- docs/DISTRIBUTION-LIFECYCLE.md | 27 ++- docs/RELEASING.md | 98 +++++++++++ scripts/build_lifecycle_bundle.py | 56 ++++-- scripts/check_published_assets.py | 97 +++++++++++ scripts/check_release_versions.py | 107 ++++++++---- scripts/lifecycle_upgrade_e2e.py | 16 +- tests/test_lifecycle_update.py | 6 +- tests/test_lifecycle_update_integrity.py | 4 +- tests/test_lifecycle_update_transport.py | 171 +++++++++++++++++++ tests/test_lifecycle_update_version_chain.py | 32 ++-- tests/test_lifecycle_version_invariants.py | 14 +- tests/test_release_assets_gate.py | 137 +++++++++++++++ 21 files changed, 915 insertions(+), 94 deletions(-) create mode 100644 .github/workflows/publish-pypi.yml create mode 100644 docs/RELEASING.md create mode 100644 scripts/check_published_assets.py create mode 100644 tests/test_lifecycle_update_transport.py create mode 100644 tests/test_release_assets_gate.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 00000000..6edc966f --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,61 @@ +name: Publish to PyPI + +# Standard distribution path: installable with +# pip install ainative-dev-stack== +# via PyPI Trusted Publishing (OIDC), not a long-lived API token. +# +# One-time setup by the maintainer (external to this repository): +# 1. Create the project on PyPI (first upload) or reserve the name; +# 2. PyPI -> Account -> Publishing -> add a trusted publisher for: +# owner: Rwanbt repository: ai-native-dev-stack +# workflow: publish-pypi.yml environment: pypi +# 3. Create the `pypi` environment in the repository settings (no secrets: +# OIDC only). +# See docs/RELEASING.md. +# +# Until the trusted publisher exists PyPI rejects the upload with +# `invalid-publisher`; the GitHub Release path (release-assets.yml) is +# unaffected and keeps working. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Existing tag to build and publish (e.g. v2.3.0)" + required: true + +permissions: + contents: read + id-token: write + +jobs: + pypi: + name: Build and publish to PyPI + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/ainative-dev-stack + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + ref: ${{ github.event.release.tag_name || inputs.tag }} + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.11" + - name: The tag must name the version the tree declares + run: | + set -e + python scripts/check_release_versions.py --tag "${{ github.event.release.tag_name || inputs.tag }}" + - run: python -m pip install --disable-pip-version-check -q build + - name: Build the wheel and sdist + run: python -m build + - name: Both artifacts must carry the release version + # PyPI ships the wheel and the sdist, not the lifecycle bundle; the + # gate checks the same label chain for the half being published. + run: python scripts/check_release_versions.py --tag "${{ github.event.release.tag_name || inputs.tag }}" --dist dist --without-bundle + - name: Publish to PyPI (Trusted Publishing) + uses: pypa/gh-action-pypi-publish@ec4db0b4ddc65acdf4bff5fa45ac92d78b56bdf0 # v1.9.0 + with: + packages-dir: dist \ No newline at end of file diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 36318591..81cf8ea5 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -6,15 +6,17 @@ on: permissions: contents: write + id-token: write + attestations: write jobs: publish: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 with: python-version: "3.11" - name: The tag must point at the current main @@ -38,18 +40,41 @@ jobs: - name: Build the lifecycle bundle consumed by `ainative update` run: python scripts/build_lifecycle_bundle.py - name: Every built artifact must carry the release version - # Filenames, wheel METADATA, sdist PKG-INFO and the bundle's internal - # VERSION - the full chain this release promises a consumer. + # Filenames, wheel METADATA, sdist PKG-INFO and the protocol document + # and payload VERSION inside the bundle. run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" --dist dist - name: Write SHA256SUMS run: cd dist && sha256sum *.whl *.tar.gz *.zip > SHA256SUMS && cat SHA256SUMS - - name: Publish assets to the GitHub release + - name: Attest the build provenance of every artifact + # Standard, verifiable, automatable: GitHub attests that these bytes + # were produced by this workflow, in this repository, at this commit. + # Clients verify with `gh attestation verify -R `. + uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 + with: + subject-path: | + dist/*.whl + dist/*.tar.gz + dist/*.zip + - name: Create the release as a draft + # Draft first: a release uploaded asset by asset is briefly visible + # with a partial set. Nothing is published until every asset is up and + # verified. No --clobber: an existing asset of the same name must fail + # the publish loudly, never be silently replaced. env: GH_TOKEN: ${{ github.token }} run: | set -e gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1 \ - || gh release create "${GITHUB_REF_NAME}" --generate-notes --title "${GITHUB_REF_NAME}" - # No --clobber: an existing asset of the same name must fail the - # publish loudly, never be silently replaced. - gh release upload "${GITHUB_REF_NAME}" dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS \ No newline at end of file + || gh release create "${GITHUB_REF_NAME}" --draft --generate-notes \ + --title "${GITHUB_REF_NAME}" + test "$(gh release view "${GITHUB_REF_NAME}" --json isDraft --jq .isDraft)" = "true" + gh release upload "${GITHUB_REF_NAME}" \ + dist/*.whl dist/*.tar.gz dist/*.zip dist/SHA256SUMS + - name: Verify the uploaded assets, then publish the release + env: + GH_TOKEN: ${{ github.token }} + run: | + set -e + gh release view "${GITHUB_REF_NAME}" --json assets > dist/assets.json + python scripts/check_published_assets.py --dist dist --assets-json dist/assets.json + gh release edit "${GITHUB_REF_NAME}" --draft=false \ No newline at end of file diff --git a/README.fr.md b/README.fr.md index 6c1c0c5d..e7e268df 100644 --- a/README.fr.md +++ b/README.fr.md @@ -426,6 +426,12 @@ d'une release — tag git, `VERSION`, métadonnées du paquet, wheel/sdist, nom `VERSION` interne du bundle — est fail-closed : un tag qui ne nomme pas l'arbre qu'il pointe ne peut pas publier. +Les releases parlent un protocole de mise à jour versionné (v2) : le bundle est +`ainative-lifecycle-v2-.zip` et un runtime antérieur à v2.2.2 ne peut pas le consommer +même depuis un miroir — le format lui-même le refuse. Chaque artefact publié porte aussi une +attestation de provenance de build GitHub (`gh attestation verify -R Rwanbt/ai-native-dev-stack`) ; +SHA-256 seul signifie intégrité, pas signature humaine (voir docs/RELEASING.md). + --- ## Quel profil choisir ? diff --git a/README.md b/README.md index 17e9335c..6e165237 100644 --- a/README.md +++ b/README.md @@ -420,6 +420,13 @@ chain - git tag, `VERSION`, package metadata, wheel/sdist, bundle filename and bundle internal `VERSION` - is fail-closed: a tag that does not name the tree it points at cannot publish. +Releases speak a versioned update protocol (v2): the lifecycle bundle is +`ainative-lifecycle-v2-.zip` and a runtime older than v2.2.2 cannot +consume one even when a mirror hands it the file - the format itself refuses it. +Every published artifact also carries a GitHub build-provenance attestation +(`gh attestation verify -R Rwanbt/ai-native-dev-stack`); SHA-256 alone +still means integrity, not a human signature (see docs/RELEASING.md). + --- ## Which profile should I choose? diff --git a/SECURITY.md b/SECURITY.md index df01cce0..c7ac7aff 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -39,10 +39,12 @@ every technical detail out of it. Read these before reporting; several properties are deliberate and documented: -- `docs/DISTRIBUTION-LIFECYCLE.md` and `docs/adr/0009` - what the lifecycle - defends against, and what SHA-256 integrity does and does not prove. Release - artifacts are integrity-verified, not cryptographically authenticated by a - maintainer signature (unless issue #24 changes that). +- `docs/DISTRIBUTION-LIFECYCLE.md`, `docs/RELEASING.md` and `docs/adr/0009` - + what the lifecycle defends against, and what SHA-256 integrity does and does + not prove. Release artifacts carry a GitHub **build provenance attestation** + (`gh attestation verify -R Rwanbt/ai-native-dev-stack`), which binds + them to the workflow, repository and commit that produced them; they are still + not signed by a human maintainer identity, and #24 tracks that distinction. - `stack/agents/anti-debt/docs/security-boundaries.md` - the anti-debt agent sandboxing and egress boundaries. - `docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md` - Knowledge/Auto-Memory diff --git a/UPDATING.md b/UPDATING.md index 527f1810..b3687a7e 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -41,6 +41,13 @@ The refusal names both versions and prints the exact upgrade command. `ainative status --check-updates` and `ainative doctor --check-updates` still report what is available and say when the runtime must be upgraded first. +Releases from the protocol v2 line cannot be consumed by a runtime older than +v2.2.2 at all: the bundle is named `ainative-lifecycle-v2-.zip` and its +payload sits under `stack/`, which the old selector and the old extractor both +refuse. That containment is deliberate - an old runtime must never apply a +policy it does not know - and the only path forward is upgrading the CLI, then +running `ainative update` in each project. + ### What it guarantees 1. **Your edits survive.** Every managed file carries the SHA-256 it had when diff --git a/ainative/cli.py b/ainative/cli.py index bec53619..d8512d01 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -104,6 +104,9 @@ def build_parser() -> argparse.ArgumentParser: update_commands = update.add_subparsers(dest="update_command") update_check = update_commands.add_parser("check", help="Is a newer release available?") update_check.add_argument("--force", action="store_true", help="ignore the cache") + update_check.add_argument("--strict", action="store_true", + help="exit non-zero when the source could not be consulted " + "(OFFLINE / CHECK_FAILED); for CI gates") _add_common(update_check, dry_run=False) update_rollback = update_commands.add_parser( "rollback", help="Restore the project assets the last update replaced.") @@ -404,6 +407,10 @@ def _cmd_update(args: argparse.Namespace) -> int: _emit(outcome.to_record()) else: print(outcome.message()) + if args.strict and outcome.status in (updater.OFFLINE, updater.CHECK_FAILED): + # Exit 0 means "the answer is what it is", never "the source was + # reachable"; a CI gate that needs the second statement asks for it. + return EXIT_FAILED return EXIT_OK if args.update_command == "rollback": record = updater.rollback(project, dry_run=args.dry_run) diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index 8ee23fad..9ec4b3d0 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -32,15 +32,32 @@ from .digest import digest_bytes from .errors import LifecycleError + +def _update_token() -> str: + """The token this environment offers, if any. Never logged anywhere.""" + + for name in TOKEN_ENVS: + value = (os.environ.get(name) or "").strip() + if value: + return value + return "" + DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" # "github" (default) | "local" LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" - -# The one asset the official update path consumes. Published by -# `scripts/build_lifecycle_bundle.py` beside the wheel and the sdist. -LIFECYCLE_BUNDLE_PREFIX = "ainative-dev-stack-" +# Authenticated checks, when the environment provides a token. Never logged, +# never persisted: NAT-shared users hit the anonymous rate limit otherwise. +TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN") + +# The update protocol. v1 (runtimes <= 2.2.2) selected any +# `ainative-dev-stack-*.zip`; v2 names its bundle `ainative-lifecycle-v2-*` and +# wraps the payload so a v1 runtime cannot consume it even through a mirror +# that names the file for it. See docs/DISTRIBUTION-LIFECYCLE.md section 8. +UPDATE_PROTOCOL_VERSION = 2 +LIFECYCLE_BUNDLE_PREFIX = "ainative-lifecycle-v2-" LIFECYCLE_BUNDLE_SUFFIX = ".zip" +PROTOCOL_MANIFEST = "lifecycle-protocol.json" def lifecycle_bundle_name(version: str) -> str: @@ -49,6 +66,13 @@ def lifecycle_bundle_name(version: str) -> str: return f"{LIFECYCLE_BUNDLE_PREFIX}{version}{LIFECYCLE_BUNDLE_SUFFIX}" +def protocol_document(version: str) -> dict: + """The manifest a v2 bundle carries at its root.""" + + return {"schema_name": "lifecycle_protocol", "protocol_version": UPDATE_PROTOCOL_VERSION, + "release_version": version, "payload_root": "stack"} + + _SHA256 = re.compile(r"^[0-9a-f]{64}$") # Short on purpose: an update check runs in the background of a status command, @@ -175,11 +199,22 @@ def __init__(self, url: str | None = None) -> None: def _get(self, url: str, limit: int) -> bytes: if not url.lower().startswith("https://"): raise LifecycleError("UPDATE_CHECK_FAILED", f"refusing a non-HTTPS source: {url}") - request = urllib.request.Request(url, headers={ - "User-Agent": "ainative-lifecycle", "Accept": "application/vnd.github+json"}) + headers = {"User-Agent": "ainative-lifecycle", + "Accept": "application/vnd.github+json"} + token = _update_token() + if token: + headers["Authorization"] = f"Bearer {token}" + request = urllib.request.Request(url, headers=headers) try: with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT_SECONDS) as response: payload = response.read(limit + 1) + except urllib.error.HTTPError as error: + # The status is the diagnosis; the message never echoes headers. + detail = ("the release source rate-limited this check; set GITHUB_TOKEN " + "or GH_TOKEN to raise the limit" + if error.code in (403, 429) + else f"the release source answered HTTP {error.code}") + raise LifecycleError("UPDATE_CHECK_FAILED", detail) from error except (urllib.error.URLError, OSError, ValueError) as error: raise LifecycleError("UPDATE_CHECK_FAILED", f"cannot reach the release source: {error}") from error @@ -312,6 +347,7 @@ def copy_tree(source: Path, destination: Path) -> None: __all__ = [ "Release", "UpdateProvider", "LocalDirectoryProvider", "ReleaseApiProvider", "build", "verify_archive", "copy_tree", "lifecycle_bundle_name", + "protocol_document", "UPDATE_PROTOCOL_VERSION", "PROTOCOL_MANIFEST", "TOKEN_ENVS", "PROVIDER_ENV", "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "DEFAULT_RELEASE_URL", "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 44581c64..0b93a842 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -370,8 +370,57 @@ def _safe_extract(payload: bytes, destination: Path) -> Path: return root -def _distribution_root(extracted: Path) -> Path: - """A release archive usually wraps everything in one top-level directory.""" +def _distribution_root(extracted: Path, release: providerlib.Release | None = None) -> Path: + """The payload root of an extracted bundle. + + A v2 bundle carries `lifecycle-protocol.json` at its root and the payload + under the directory that document names. The protocol version and the + release version are validated before the payload is trusted; a bundle that + names another release is refused here as well as at the internal-VERSION + gate, because the two files are written by different steps and only one of + them can be regenerated by a stale cache. + + A bare payload at the archive root stays accepted: that is the layout + fixtures and direct downloads use. + """ + + manifest = extracted / providerlib.PROTOCOL_MANIFEST + if manifest.is_file(): + try: + document = json.loads(manifest.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"{providerlib.PROTOCOL_MANIFEST} is unreadable: {error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"{providerlib.PROTOCOL_MANIFEST} is not an object") + protocol_version = document.get("protocol_version") + if protocol_version != providerlib.UPDATE_PROTOCOL_VERSION: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"bundle declares lifecycle protocol {protocol_version!r}; this runtime " + f"speaks protocol {providerlib.UPDATE_PROTOCOL_VERSION}") + declared = str(document.get("release_version", "")) + if release is not None and declared != release.version: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"bundle declares release_version {declared!r} but the release says " + f"{release.version!r}", + release_version=release.version, bundle_version=declared) + payload_root = document.get("payload_root") + if not isinstance(payload_root, str) or not payload_root: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "bundle declares no payload_root") + try: + relative = validate_relative(payload_root) + except LifecycleError as error: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"bundle payload_root is not a safe path: {error.message}") from error + payload = extracted.joinpath(*relative.parts) + if not (payload / "VERSION").is_file(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"bundle payload root {payload_root!r} holds no VERSION") + return payload if (extracted / "VERSION").is_file(): return extracted @@ -491,7 +540,7 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, staging = Path(tempfile.mkdtemp(prefix="ainative-update-", dir=str(_staging_root(project)))) try: - root = _distribution_root(_safe_extract(payload, staging)) + root = _distribution_root(_safe_extract(payload, staging), release) staged_source = DistributionSource(root=root.resolve(), origin="update", version=sourcelib.read_version(root)) # The release named a version; the bytes name another one. SHA-256 diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 73e8ddbb..26505c5b 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -364,7 +364,16 @@ Preferences live in the install state: ``` `AINATIVE_NO_UPDATE_CHECK=1` disables every network check — for CI and offline -machines. +machines. When `GITHUB_TOKEN` or `GH_TOKEN` is present in the environment, the +check sends it as a bearer token (many users share one NAT address and hit the +anonymous rate limit); the token is never logged, never written to the cache, +and never included in an error message. A 403/429 answer is reported as a +rate-limit diagnosis with the same remedy. + +`ainative update check` exits 0 whatever the answer: `exit 0` means "the answer +is what it is", never "the source was reachable". A CI gate that needs the +second statement runs `ainative update check --strict`, which exits 1 on +`OFFLINE` / `CHECK_FAILED`. ### No network inside an authority command @@ -390,8 +399,19 @@ wrong runtime, wrong version, bad digest, bad archive - leaves the project byte-identical, cache included. **The artifact, stated precisely.** The official provider consumes exactly one -asset: the lifecycle bundle `ainative-dev-stack-.zip` published beside -the wheel and the sdist, named for the release's own version. A release whose +asset: the lifecycle bundle `ainative-lifecycle-v2-.zip` published +beside the wheel and the sdist, named for the release's own version. Inside, +a `lifecycle-protocol.json` document names the protocol version, the release +version and the payload root (`stack/`), and the payload's `VERSION` is compared +with the release again. + +**Containment of pre-2.2.2 runtimes, stated precisely.** Runtimes up to v2.2.2 +looked for `ainative-dev-stack-*.zip` and found their payload by taking the +single top-level directory that held a `VERSION` file. A protocol v2 bundle +matches neither rule, so such a runtime refuses it with zero project writes even +when a mirror hands it the file directly. v2.2.2 itself refuses a v2 release at +the runtime gate (`CLI_UPDATE_REQUIRED`) before the download. The documented +path is always the same: upgrade the CLI first, then update each project. A release whose tag says 2.2.2 and whose bundle says 2.2.1 is refused with `UPDATE_VERSION_MISMATCH` before any download; so is a bundle whose internal `VERSION` file disagrees with the release it declared, and so is a local @@ -562,6 +582,7 @@ Files the stack never wrote are in the "kept" column of every row. | Number | Source of truth | Changes when | |---|---|---| | Stack release | `VERSION` | a release is cut | +| Update protocol | `provider.UPDATE_PROTOCOL_VERSION` | the bundle format changes (currently 2) | | Lifecycle state schema | `state.SCHEMA_VERSION` | `state.json`'s shape changes | | Work Plane runtime | `ainative_workplane.__version__` | the verdict engine is released | | Artifact schema | `contracts.SUPPORTED_SCHEMA_VERSIONS` | a Work Plane artifact changes shape | diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 00000000..9dc2b517 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,98 @@ +# Releasing + +One page for the maintainer. Every step is a command or a gate, not a +reminder; the gates fail closed and none of them fixes anything silently. + +## Before the tag + +1. `main` is the SHA to promote, `dev` merged into it, CI green **on that SHA** + (the release workflow re-checks that the tag points at `origin/main`). +2. Working tree clean; `python scripts/check_release_versions.py` passes + (tag comes later, so labels only). +3. `CHANGELOG.md` has the version's section; `AGENTS.md`'s `stack-version` + header, `VERSION` and `ainative.__version__` moved together (the build + refuses a tree carrying two versions). +4. `python scripts/measure_scope.py` passes (AGENTS.md's scope table is a fact + with an expiry date). + +## Tag and release + +```bash +git checkout main && git pull --ff-only +git tag v2.3.0 +git push origin v2.3.0 +``` + +The **Release assets** workflow then runs, in order: + +1. the tag points at `origin/main`; +2. `check_release_versions.py --tag` (tag == VERSION == package == AGENTS.md); +3. wheel, sdist, lifecycle bundle build; the bundle is + `ainative-lifecycle-v2-.zip`; +4. `check_release_versions.py --dist` re-reads every artifact (filenames, wheel + METADATA, sdist PKG-INFO, protocol document, payload VERSION); +5. `SHA256SUMS` is written; +6. **build provenance is attested** for every artifact + (`actions/attest-build-provenance`): GitHub signs a statement that these + bytes came from this workflow, this repository, this commit; +7. the release is created as a **draft**, assets are uploaded (no `--clobber`), + `check_published_assets.py` compares the uploaded names, sizes and digests + against `dist/`, and only then is the draft published. + +A failure at any step leaves a draft release (or none) - never a visible +release with a partial asset set. + +## PyPI + +The **Publish to PyPI** workflow runs when a release is published (or on +`workflow_dispatch` with an existing tag) and uses PyPI Trusted Publishing +(OIDC). No API token is stored anywhere. + +One-time setup, by the maintainer, outside this repository: + +1. Create or claim the project `ainative-dev-stack` on PyPI (the first upload + of a new name reserves it; use a TestPyPI rehearsal first if unsure); +2. PyPI -> Account -> Publishing -> **Add a new pending publisher** (or the + project's Publishing settings once it exists): + - owner: `Rwanbt` + - repository: `ai-native-dev-stack` + - workflow: `publish-pypi.yml` + - environment: `pypi` +3. In the repository settings, create the environment `pypi` (no secrets). + +Until the trusted publisher exists, PyPI rejects the upload with +`invalid-publisher`. That does not affect the GitHub Release path. + +```bash +# what a user then runs +pip install ainative-dev-stack==2.3.0 +# or, isolated: +pipx install ainative-dev-stack==2.3.0 +``` + +## Verifying what was published + +```bash +# integrity: the bytes match the published sums +gh release download v2.3.0 --dir dist +cd dist && sha256sum -c SHA256SUMS + +# provenance: which workflow, repository and commit produced them +gh attestation verify dist/ainative_dev_stack-2.3.0-py3-none-any.whl -R Rwanbt/ai-native-dev-stack + +# PyPI provenance (attestations are published with the files) +python -m pip install --upgrade pypi-attestations +python -m pypi_attestations verify dist/ainative_dev_stack-2.3.0-py3-none-any.whl +``` + +`SHA256SUMS` alone proves integrity against the source that published it; it +does not authenticate a maintainer against a compromised source. The build +attestation is what binds an artifact to a specific workflow run - and #24 +remains the place where stronger, end-to-end signing is tracked. + +## If something is wrong after the tag + +Assets are never replaced (`gh release upload` without `--clobber` refuses an +existing name). The recovery is a **new patch version**: fix, qualify, tag. +The broken tag and release stay as the historical record of what was +distributed - v2.2.0 was rolled back exactly this way. \ No newline at end of file diff --git a/scripts/build_lifecycle_bundle.py b/scripts/build_lifecycle_bundle.py index 5eff10be..68750c35 100644 --- a/scripts/build_lifecycle_bundle.py +++ b/scripts/build_lifecycle_bundle.py @@ -1,22 +1,26 @@ #!/usr/bin/env python3 """Build the lifecycle bundle published with every release. -The official update path consumes `ainative-dev-stack-.zip`: the -installer payload — exactly the files `source.py` and the manifest expect — -packed at the archive root, so `_distribution_root()` finds `VERSION` without -guessing. This is the only artifact `ainative update` downloads, and its -SHA-256 travels in the release metadata (asset digest) and in SHA256SUMS. +The official update path consumes +`ainative-lifecycle-v2-.zip`: a protocol document at the archive root, +the installer payload under `stack/`, and a `protocol/` directory that describes +the format. The wrapping is deliberate. Runtimes up to v2.2.2 selected any +`ainative-dev-stack-*.zip` and found the payload by looking for a single +top-level directory with a VERSION file; this layout names another file and +ships two top-level directories, so a legacy runtime refuses it even when a +mirror hands it the file directly (AUD-205). + +Refuses to build when VERSION and ainative.__version__ disagree (#125). Usage: python scripts/build_lifecycle_bundle.py [--outdir dist] - -Refuses to build when VERSION and ainative.__version__ disagree (#125). """ from __future__ import annotations import argparse import hashlib +import json import sys import tempfile import zipfile @@ -27,28 +31,52 @@ sys.path.insert(0, str(REPO)) from _payload_staging import assert_version_consistency, stage_payload # noqa: E402 +from ainative.lifecycle.provider import (PROTOCOL_MANIFEST, # noqa: E402 + lifecycle_bundle_name, protocol_document) # A fixed timestamp so rebuilding the same tree does not produce a different # archive only because of file mtimes. FIXED_TIMESTAMP = (2026, 1, 1, 0, 0, 0) +PROTOCOL_README = """# Lifecycle bundle protocol v2 + +The payload this archive carries is installed by a lifecycle runtime that +speaks protocol v2. The layout is: + + lifecycle-protocol.json protocol version, release version, payload root + stack/ the distribution payload (VERSION at its root) + protocol/ this document + +A runtime older than v2.2.2 selects bundles by the `ainative-dev-stack-*` name +and looks for the payload in a single top-level directory. It therefore cannot +consume this archive - by design. Upgrade the CLI first. +""" + + +def _write(archive: zipfile.ZipFile, name: str, payload: bytes) -> None: + info = zipfile.ZipInfo(name, date_time=FIXED_TIMESTAMP) + info.compress_type = zipfile.ZIP_DEFLATED + info.external_attr = 0o644 << 16 + archive.writestr(info, payload) + def build(outdir: Path) -> Path: - """Pack the staged payload into `ainative-dev-stack-.zip`.""" + """Pack the staged payload into the v2 lifecycle bundle for this tree.""" version = assert_version_consistency(REPO) outdir.mkdir(parents=True, exist_ok=True) - bundle = outdir / f"ainative-dev-stack-{version}.zip" + bundle = outdir / lifecycle_bundle_name(version) with tempfile.TemporaryDirectory(prefix="ainative-bundle-") as staging: payload = stage_payload(REPO, Path(staging) / "payload") entries = sorted(path for path in payload.rglob("*") if path.is_file()) with zipfile.ZipFile(bundle, "w", zipfile.ZIP_DEFLATED) as archive: + _write(archive, PROTOCOL_MANIFEST, + (json.dumps(protocol_document(version), indent=2, sort_keys=True) + + "\n").encode("utf-8")) + _write(archive, "protocol/README.md", PROTOCOL_README.encode("utf-8")) for path in entries: - info = zipfile.ZipInfo(path.relative_to(payload).as_posix(), - date_time=FIXED_TIMESTAMP) - info.compress_type = zipfile.ZIP_DEFLATED - info.external_attr = 0o644 << 16 - archive.writestr(info, path.read_bytes()) + _write(archive, f"stack/{path.relative_to(payload).as_posix()}", + path.read_bytes()) return bundle diff --git a/scripts/check_published_assets.py b/scripts/check_published_assets.py new file mode 100644 index 00000000..ff5c2950 --- /dev/null +++ b/scripts/check_published_assets.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Verify what a release actually published, before it is made visible. + +A release that is uploaded asset by asset is briefly a published release with +a partial set: a user installing during that window gets whatever arrived +first. The workflow therefore creates the release as a *draft*, uploads +everything, and runs this check before flipping it visible. The check compares +the assets GitHub reports - name, size and, when the platform provides it, +digest - against the bytes in `dist/`. A missing asset, a size mismatch or a +digest mismatch keeps the release a draft. + +Usage: + gh release view v2.3.0 --json assets > assets.json + python scripts/check_published_assets.py --dist dist --assets-json assets.json +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import sys +from pathlib import Path + + +class PublishedAssetMismatch(RuntimeError): + """The published set does not match the built set.""" + + +def local_assets(dist: Path) -> dict[str, dict]: + assets: dict[str, dict] = {} + for path in sorted(dist.iterdir()): + if not path.is_file(): + continue + payload = path.read_bytes() + assets[path.name] = {"size": len(payload), + "sha256": hashlib.sha256(payload).hexdigest()} + if not assets: + raise PublishedAssetMismatch(f"no files in {dist}") + return assets + + +def compare(dist: Path, published: list) -> list[str]: + expected = local_assets(dist) + seen: dict[str, dict] = {} + for asset in published: + if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): + raise PublishedAssetMismatch(f"malformed asset record: {asset!r}") + seen[asset["name"]] = asset + + missing = sorted(set(expected) - set(seen)) + if missing: + raise PublishedAssetMismatch(f"assets missing from the release: {missing}") + extra = sorted(set(seen) - set(expected)) + if extra: + raise PublishedAssetMismatch(f"assets on the release that were not built: {extra}") + + checked: list[str] = [] + for name, wanted in expected.items(): + published_asset = seen[name] + if published_asset.get("size") != wanted["size"]: + raise PublishedAssetMismatch( + f"{name}: published size {published_asset.get('size')} != built size " + f"{wanted['size']}") + digest = published_asset.get("digest") + if isinstance(digest, str) and digest.startswith("sha256:"): + if digest.split(":", 1)[1].lower() != wanted["sha256"]: + raise PublishedAssetMismatch( + f"{name}: published digest does not match the built bytes") + checked.append(name) + return checked + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--dist", type=Path, required=True) + parser.add_argument("--assets-json", type=Path, required=True) + args = parser.parse_args() + + published = json.loads(args.assets_json.read_text(encoding="utf-8")) + if isinstance(published, dict): + published = published.get("assets") + if not isinstance(published, list): + print("PUBLISHED_ASSETS_INVALID: no asset list in the JSON", file=sys.stderr) + return 1 + try: + checked = compare(args.dist, published) + except PublishedAssetMismatch as refusal: + print(f"PUBLISHED_ASSETS_MISMATCH: {refusal}", file=sys.stderr) + return 1 + print(f"published assets verified: {', '.join(checked)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/check_release_versions.py b/scripts/check_release_versions.py index b4108908..40eec637 100644 --- a/scripts/check_release_versions.py +++ b/scripts/check_release_versions.py @@ -11,23 +11,26 @@ TAG_VERSION == ROOT_VERSION (VERSION) == PACKAGE_VERSION (ainative.__version__) - == WHEEL_VERSION (filename and METADATA, with --dist) - == SDIST_VERSION (filename and PKG-INFO, with --dist) - == BUNDLE_VERSION (filename and internal VERSION, with --dist) + == WHEEL_VERSION (filename and METADATA) + == SDIST_VERSION (filename and PKG-INFO) + == BUNDLE_VERSION (filename, protocol document, payload VERSION) The release workflow runs it before publishing and again over the built -artifacts; a maintainer can run it before tagging. It exits non-zero with the -disagreeing labels named, and never "fixes" anything. +artifacts; the PyPI workflow runs the wheel/sdist half with --without-bundle +(PyPI ships no lifecycle bundle); a maintainer can run it before tagging. It +exits non-zero with the disagreeing labels named, and never "fixes" anything. Usage: python scripts/check_release_versions.py python scripts/check_release_versions.py --tag v2.2.2 python scripts/check_release_versions.py --tag v2.2.2 --dist dist + python scripts/check_release_versions.py --tag v2.2.2 --dist dist --without-bundle """ from __future__ import annotations import argparse +import json import re import sys import tarfile @@ -39,9 +42,13 @@ sys.path.insert(0, str(REPO)) from _payload_staging import assert_version_consistency # noqa: E402 -from ainative.lifecycle.provider import lifecycle_bundle_name # noqa: E402 +from ainative.lifecycle.provider import (PROTOCOL_MANIFEST, # noqa: E402 + UPDATE_PROTOCOL_VERSION, + lifecycle_bundle_name) _STACK_VERSION = re.compile(r"stack-version:\s*([0-9][0-9A-Za-z.\-]*)") +_ARTIFACT = re.compile(r"ainative[_-](?:dev[_-]stack|lifecycle[_-]v\d+)[-_]") +_SEMVER = re.compile(r"\d+\.\d+\.\d+") class ReleaseVersionMismatch(RuntimeError): @@ -77,29 +84,53 @@ def _metadata_version(payload: bytes) -> str | None: return None -def check_dist(version: str, dist: Path) -> list[str]: - """Each built artifact must be named for, and declare, `version`.""" +def _stale_artifacts(dist: Path, checked: set[str], version: str) -> list[str]: + """Files that name another version would be published beside this release.""" - if not dist.is_dir(): - raise ReleaseVersionMismatch(f"no dist directory at {dist}") - checked: list[str] = [] + stale: list[str] = [] + for item in sorted(dist.iterdir()): + if not item.is_file() or item.name in checked: + continue + if _ARTIFACT.search(item.name) and _SEMVER.search(item.name) \ + and version not in item.name: + stale.append(item.name) + return stale + +def _check_bundle(version: str, dist: Path, checked: set[str]) -> None: bundle_name = lifecycle_bundle_name(version) bundle = dist / bundle_name if not bundle.is_file(): raise ReleaseVersionMismatch( f"lifecycle bundle {bundle_name!r} is missing from {dist}") with zipfile.ZipFile(bundle) as archive: - internal = archive.read("VERSION").decode("utf-8").strip() - if internal != version: + names = archive.namelist() + if PROTOCOL_MANIFEST not in names: + raise ReleaseVersionMismatch( + f"lifecycle bundle {bundle_name!r} carries no {PROTOCOL_MANIFEST}") + document = json.loads(archive.read(PROTOCOL_MANIFEST).decode("utf-8")) + if document.get("protocol_version") != UPDATE_PROTOCOL_VERSION: + raise ReleaseVersionMismatch( + f"lifecycle bundle {bundle_name!r} declares protocol " + f"{document.get('protocol_version')!r}") + if document.get("release_version") != version: + raise ReleaseVersionMismatch( + f"lifecycle bundle {bundle_name!r} declares release_version " + f"{document.get('release_version')!r}") + payload_root = str(document.get("payload_root", "")) + payload_version = archive.read(f"{payload_root}/VERSION").decode("utf-8").strip() + if payload_version != version: raise ReleaseVersionMismatch( - f"lifecycle bundle {bundle_name!r} contains VERSION {internal!r}") - checked.append(bundle_name) + f"lifecycle bundle {bundle_name!r} contains VERSION {payload_version!r}") + checked.add(bundle_name) + +def _check_wheel(version: str, dist: Path, checked: set[str]) -> None: wheels = sorted(dist.glob(f"ainative_dev_stack-{version}-*.whl")) if not wheels: raise ReleaseVersionMismatch( - f"no wheel built for {version} in {dist} (expected ainative_dev_stack-{version}-*.whl)") + f"no wheel built for {version} in {dist} " + f"(expected ainative_dev_stack-{version}-*.whl)") for wheel in wheels: with zipfile.ZipFile(wheel) as archive: metadata = next((entry for entry in archive.namelist() @@ -110,12 +141,15 @@ def check_dist(version: str, dist: Path) -> list[str]: if built != version: raise ReleaseVersionMismatch( f"wheel {wheel.name!r} declares metadata version {built!r}") - checked.append(wheel.name) + checked.add(wheel.name) + +def _check_sdist(version: str, dist: Path, checked: set[str]) -> None: sdists = sorted(dist.glob(f"ainative_dev_stack-{version}.tar.gz")) if not sdists: raise ReleaseVersionMismatch( - f"no sdist built for {version} in {dist} (expected ainative_dev_stack-{version}.tar.gz)") + f"no sdist built for {version} in {dist} " + f"(expected ainative_dev_stack-{version}.tar.gz)") for sdist in sdists: with tarfile.open(sdist, "r:gz") as archive: pkg_info = next((entry for entry in archive.getmembers() @@ -127,19 +161,28 @@ def check_dist(version: str, dist: Path) -> list[str]: if built != version: raise ReleaseVersionMismatch( f"sdist {sdist.name!r} declares metadata version {built!r}") - checked.append(sdist.name) - - # An artifact from another version in the same dist tree would be published - # beside this release and confuse every consumer that guesses by glob. - stale = sorted( - item.name for item in dist.iterdir() - if item.is_file() and item.name not in checked - and re.search(r"ainative[_-]dev[_-]stack[-_]", item.name) - and re.search(r"\d+\.\d+\.\d+", item.name)) + checked.add(sdist.name) + + +def check_dist(version: str, dist: Path, *, require_bundle: bool = True) -> list[str]: + """Each built artifact must be named for, and declare, `version`. + + `require_bundle=False` is the PyPI path: PyPI ships the wheel and the + sdist, not the lifecycle bundle, which GitHub Releases carries. + """ + + if not dist.is_dir(): + raise ReleaseVersionMismatch(f"no dist directory at {dist}") + checked: set[str] = set() + if require_bundle: + _check_bundle(version, dist, checked) + _check_wheel(version, dist, checked) + _check_sdist(version, dist, checked) + stale = _stale_artifacts(dist, checked, version) if stale: raise ReleaseVersionMismatch( f"artifacts for another version are present in {dist}: {stale}") - return checked + return sorted(checked) def main() -> int: @@ -150,11 +193,15 @@ def main() -> int: help="the release tag being published, e.g. v2.2.2") parser.add_argument("--dist", type=Path, default=None, help="also verify the built artifacts in this directory") + parser.add_argument("--without-bundle", action="store_true", + help="check only the wheel and the sdist (the PyPI surface)") args = parser.parse_args() try: version = check_labels(args.root, args.tag) - artifacts = check_dist(version, args.dist) if args.dist else [] + artifacts = (check_dist(version, args.dist, + require_bundle=not args.without_bundle) + if args.dist else []) except ReleaseVersionMismatch as refusal: print(f"RELEASE_VERSION_MISMATCH: {refusal}", file=sys.stderr) return 1 @@ -165,4 +212,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index 35522ef3..1f7089c5 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -143,7 +143,7 @@ def build_wheel(tree: Path, output: Path) -> Path: def build_bundle(tree: Path, output: Path) -> Path: run([sys.executable, str(tree / "scripts" / "build_lifecycle_bundle.py"), "--outdir", output]) - bundles = sorted(output.glob("ainative-dev-stack-*.zip")) + bundles = sorted(output.glob("ainative-lifecycle-v2-*.zip")) if len(bundles) != 1: raise Failure(f"expected one bundle from {tree}, found {bundles}") return bundles[0] @@ -219,7 +219,7 @@ def main() -> int: releases = workspace / "releases" releases.mkdir() bundle_n1 = build_bundle(probe_tree, releases) - if bundle_n1.name != f"ainative-dev-stack-{to_version}.zip": + if bundle_n1.name != f"ainative-lifecycle-v2-{to_version}.zip": raise Failure(f"probe bundle is {bundle_n1.name}, expected the canonical name") publish(releases, to_version, bundle_n1) provider_env = {"AINATIVE_UPDATE_PROVIDER": "local", @@ -228,14 +228,20 @@ def main() -> int: before = snapshot(project) refused = run([console, "update", "--project", project], env=provider_env, expect=1) - if "CLI_UPDATE_REQUIRED" not in refused.stderr: + # A v2-aware older runtime refuses with CLI_UPDATE_REQUIRED; a runtime + # that predates the v2 protocol never selects the asset and refuses + # with a check/integrity code. Both leave the project untouched. + refusal_codes = ("CLI_UPDATE_REQUIRED", "UPDATE_INTEGRITY_METADATA_MISSING", + "UPDATE_VERSION_MISMATCH", "UPDATE_INTEGRITY_FAILED") + observed = next((code for code in refusal_codes if code in refused.stderr), None) + if observed is None: raise Failure(f"the old runtime did not refuse:\n{refused.stdout}\n{refused.stderr}") if snapshot(project) != before: raise Failure("the refused update wrote to the project") if state_of(project)["stack_version"] != from_version: raise Failure("the refused update moved the project state") ok(f"runtime {from_version} refused target {to_version}: " - f"CLI_UPDATE_REQUIRED, zero writes") + f"{observed}, zero writes") run([venv_python, "-m", "pip", "install", "--disable-pip-version-check", "-q", "--upgrade", "--no-deps", wheel_n1]) @@ -292,4 +298,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/tests/test_lifecycle_update.py b/tests/test_lifecycle_update.py index 58503093..2b646d88 100644 --- a/tests/test_lifecycle_update.py +++ b/tests/test_lifecycle_update.py @@ -58,7 +58,7 @@ def setUp(self) -> None: # The mirror contract names archives exactly as the official release # does; a shorter name is a different release this stack refuses. self.archive = make_release_archive( - self.v2_tree, self.releases / "ainative-dev-stack-2.0.0.zip") + self.v2_tree, self.releases / "ainative-lifecycle-v2-2.0.0.zip") self.publish("2.0.0", self.archive) self.set_env(providerlib.PROVIDER_ENV, "local") self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) @@ -68,7 +68,7 @@ def _v1_archive(self) -> Path: """A v1 bundle the current v1 project would be *at*, not moving to.""" tree = build_distribution_tree(self.root / "dist-v1b", "1.0.0") - return make_release_archive(tree, self.releases / "ainative-dev-stack-1.0.0.zip") + return make_release_archive(tree, self.releases / "ainative-lifecycle-v2-1.0.0.zip") def publish(self, version: str, archive: Path, digest: str | None = None) -> None: payload = {"channels": {"stable": { @@ -289,7 +289,7 @@ def test_rollback_also_removes_the_files_the_update_created(self): newer = build_distribution_tree(self.root / "dist-v2b", "2.0.0", extra_skill="brand-new") archive = make_release_archive( - newer, self.releases / "ainative-dev-stack-2.0.0.zip") + newer, self.releases / "ainative-lifecycle-v2-2.0.0.zip") self.publish("2.0.0", archive) self.install("standard") diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index 9309b2d4..b56bb634 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -35,8 +35,8 @@ from ainative.lifecycle import updater as updaterlib from ainative.lifecycle.errors import LifecycleError -BUNDLE_NAME = "ainative-dev-stack-2.0.0.zip" -BUNDLE_URL = "https://example.invalid/ainative-dev-stack-2.0.0.zip" +BUNDLE_NAME = "ainative-lifecycle-v2-2.0.0.zip" +BUNDLE_URL = "https://example.invalid/ainative-lifecycle-v2-2.0.0.zip" RELEASE_URL = "https://example.invalid/releases/latest" diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py new file mode 100644 index 00000000..b03b11ef --- /dev/null +++ b/tests/test_lifecycle_update_transport.py @@ -0,0 +1,171 @@ +"""Update transport: authenticated checks, rate-limit reporting, protocol v2. + +The anonymous GitHub check is rate-limited per IP, and many users share one +NAT; a `GITHUB_TOKEN`/`GH_TOKEN` must be usable without ever being logged or +persisted. A legacy runtime (<= v2.2.2) must not be able to consume a protocol +v2 bundle even when a mirror names the file for it - the *layout* refuses it, +not just the official asset selector. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +import zipfile +from pathlib import Path +from unittest import mock + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.errors import LifecycleError + + +class AuthenticatedChecks(unittest.TestCase): + + def tearDown(self): + for name in providerlib.TOKEN_ENVS: + os.environ.pop(name, None) + super().tearDown() + + def test_a_token_in_the_environment_is_sent_as_a_header(self): + os.environ["GITHUB_TOKEN"] = "ghp_" + "x" * 36 + captured = {} + + class Response: + status = 200 + + def read(self, limit): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def fake_urlopen(request, timeout=None): + captured["headers"] = dict(request.headers) + return Response() + + with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): + providerlib.ReleaseApiProvider("https://example.invalid")._get( + "https://example.invalid", 100) + self.assertEqual(captured["headers"].get("Authorization"), + "Bearer ghp_" + "x" * 36) + + def test_no_token_means_no_authorization_header(self): + captured = {} + + class Response: + status = 200 + + def read(self, limit): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def fake_urlopen(request, timeout=None): + captured["headers"] = dict(request.headers) + return Response() + + with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): + providerlib.ReleaseApiProvider("https://example.invalid")._get( + "https://example.invalid", 100) + self.assertNotIn("Authorization", captured["headers"]) + + def test_a_rate_limited_check_says_so_without_echoing_the_token(self): + os.environ["GH_TOKEN"] = "github_pat_secret_value" + error = providerlib.urllib.error.HTTPError( + "https://example.invalid", 403, "Forbidden", {}, None) + + def fake_urlopen(request, timeout=None): + raise error + + with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): + with self.assertRaises(LifecycleError) as raised: + providerlib.ReleaseApiProvider("https://example.invalid")._get( + "https://example.invalid", 100) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertIn("rate-limited", raised.exception.message) + self.assertNotIn("github_pat_secret_value", raised.exception.message) + + +class ProtocolV2Containment(unittest.TestCase): + """The layout, not the selector, is what a legacy runtime cannot consume.""" + + def bundle(self, directory: Path) -> Path: + path = directory / "ainative-lifecycle-v2-2.2.3.zip" + protocol = {"schema_name": "lifecycle_protocol", "protocol_version": 2, + "release_version": "2.2.3", "payload_root": "stack"} + with zipfile.ZipFile(path, "w") as archive: + archive.writestr("lifecycle-protocol.json", json.dumps(protocol)) + archive.writestr("protocol/README.md", "v2\n") + archive.writestr("stack/VERSION", "2.2.3\n") + return path + + def test_a_v1_root_finder_cannot_resolve_the_v2_layout(self): + """Replays the <= 2.2.2 rule: VERSION at root, or one top-level dir.""" + + with tempfile.TemporaryDirectory(prefix="v2-layout-") as staging: + root = Path(staging) + with zipfile.ZipFile(self.bundle(root)) as archive: + archive.extractall(root / "extracted") + extracted = root / "extracted" + + def v1_distribution_root(): + if (extracted / "VERSION").is_file(): + return extracted + children = [item for item in extracted.iterdir() if item.is_dir()] + if len(children) == 1 and (children[0] / "VERSION").is_file(): + return children[0] + return None + + self.assertIsNone(v1_distribution_root(), + "a legacy runtime found a payload in the v2 layout") + + def test_the_current_runtime_resolves_and_validates_the_v2_layout(self): + with tempfile.TemporaryDirectory(prefix="v2-layout-") as staging: + root = Path(staging) + with zipfile.ZipFile(self.bundle(root)) as archive: + archive.extractall(root / "extracted") + release = providerlib.Release(version="2.2.3", url=None, + digest="0" * 64) + payload = updaterlib._distribution_root(root / "extracted", release) + self.assertEqual((payload / "VERSION").read_text(encoding="utf-8").strip(), + "2.2.3") + + def test_a_protocol_document_naming_another_release_is_refused(self): + with tempfile.TemporaryDirectory(prefix="v2-layout-") as staging: + root = Path(staging) + with zipfile.ZipFile(self.bundle(root)) as archive: + archive.extractall(root / "extracted") + other = providerlib.Release(version="9.9.9", url=None, digest="0" * 64) + with self.assertRaises(LifecycleError) as raised: + updaterlib._distribution_root(root / "extracted", other) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + + +class StrictCheck(LifecycleTestCase): + + def test_strict_exits_non_zero_when_the_source_cannot_be_consulted(self): + self.install("standard") + completed = self.cli("update", "check", "--strict") + self.assertEqual(completed.returncode, 1, completed.stdout) + record = json.loads(self.cli("update", "check", "--strict", "--json").stdout) + self.assertIn(record["status"], (updaterlib.OFFLINE, updaterlib.CHECK_FAILED)) + + def test_without_strict_the_answer_is_exit_zero(self): + self.install("standard") + completed = self.cli("update", "check") + self.assertEqual(completed.returncode, 0, completed.stdout) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_lifecycle_update_version_chain.py b/tests/test_lifecycle_update_version_chain.py index 3bb9c98e..e9093937 100644 --- a/tests/test_lifecycle_update_version_chain.py +++ b/tests/test_lifecycle_update_version_chain.py @@ -117,10 +117,18 @@ def make_sdist(dist: Path, declared: str, filename_version: str | None = None) - return path -def make_bundle(dist: Path, filename_version: str, internal_version: str) -> Path: - path = dist / f"ainative-dev-stack-{filename_version}.zip" +def make_bundle(dist: Path, filename_version: str, internal_version: str, + declared_version: str | None = None) -> Path: + """A v2-protocol bundle: protocol document at the root, payload under stack/.""" + + path = dist / f"ainative-lifecycle-v2-{filename_version}.zip" + protocol = {"schema_name": "lifecycle_protocol", "protocol_version": 2, + "release_version": declared_version or filename_version, + "payload_root": "stack"} with zipfile.ZipFile(path, "w") as archive: - archive.writestr("VERSION", f"{internal_version}\n") + archive.writestr("lifecycle-protocol.json", json.dumps(protocol)) + archive.writestr("protocol/README.md", "protocol v2\n") + archive.writestr("stack/VERSION", f"{internal_version}\n") return path @@ -142,7 +150,7 @@ def test_a_consistent_dist_tree_passes_and_all_artifacts_are_named(self): make_wheel(dist, TARGET) make_sdist(dist, TARGET) checked = self.gate_check(dist) - self.assertIn(f"ainative-dev-stack-{TARGET}.zip", checked) + self.assertIn(f"ainative-lifecycle-v2-{TARGET}.zip", checked) self.assertIn(f"ainative_dev_stack-{TARGET}-py3-none-any.whl", checked) self.assertIn(f"ainative_dev_stack-{TARGET}.tar.gz", checked) @@ -196,15 +204,15 @@ def provider(self, tag: str, asset_name: str): def test_a_release_publishing_another_version_bundle_is_refused(self): with self.assertRaises(LifecycleError) as raised: - self.provider("v2.2.2", "ainative-dev-stack-2.2.1.zip").latest("stable") + self.provider("v2.2.2", "ainative-lifecycle-v2-2.2.1.zip").latest("stable") self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") self.assertEqual(raised.exception.detail.get("published"), - "ainative-dev-stack-2.2.1.zip") + "ainative-lifecycle-v2-2.2.1.zip") self.assertEqual(raised.exception.detail.get("expected"), - "ainative-dev-stack-2.2.2.zip") + "ainative-lifecycle-v2-2.2.2.zip") def test_the_matching_bundle_is_selected(self): - release = self.provider("v2.2.2", "ainative-dev-stack-2.2.2.zip").latest("stable") + release = self.provider("v2.2.2", "ainative-lifecycle-v2-2.2.2.zip").latest("stable") self.assertEqual(release.version, "2.2.2") self.assertEqual(release.digest, "0" * 64) @@ -220,7 +228,7 @@ def setUp(self) -> None: self.releases.mkdir() self.target_tree = build_distribution_tree(self.root / "dist-target", TARGET) self.archive = make_release_archive( - self.target_tree, self.releases / f"ainative-dev-stack-{TARGET}.zip") + self.target_tree, self.releases / f"ainative-lifecycle-v2-{TARGET}.zip") self.publish(TARGET, self.archive) self.set_env(providerlib.PROVIDER_ENV, "local") self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) @@ -243,7 +251,7 @@ def test_a_local_index_whose_archive_names_another_version_is_refused(self): other = make_release_archive( build_distribution_tree(self.root / "dist-other", "2.2.1"), - self.releases / "ainative-dev-stack-2.2.1.zip") + self.releases / "ainative-lifecycle-v2-2.2.1.zip") self.publish(TARGET, other) with self.assertRaises(LifecycleError) as raised: providerlib.build("stable").latest("stable") @@ -252,7 +260,7 @@ def test_a_local_index_whose_archive_names_another_version_is_refused(self): def test_an_index_refusal_reaches_the_updater_without_any_write(self): other = make_release_archive( build_distribution_tree(self.root / "dist-other", "2.2.1"), - self.releases / "ainative-dev-stack-2.2.1.zip") + self.releases / "ainative-lifecycle-v2-2.2.1.zip") self.publish(TARGET, other) before = project_snapshot(self.project) with self.assertRaises(LifecycleError) as raised: @@ -266,7 +274,7 @@ def test_a_bundle_whose_internal_version_differs_is_refused(self): inner = build_distribution_tree(self.root / "dist-inner", "2.2.1") archive = make_release_archive( - inner, self.releases / f"ainative-dev-stack-{TARGET}.zip") + inner, self.releases / f"ainative-lifecycle-v2-{TARGET}.zip") self.publish(TARGET, archive) before = project_snapshot(self.project) with self.assertRaises(LifecycleError) as raised: diff --git a/tests/test_lifecycle_version_invariants.py b/tests/test_lifecycle_version_invariants.py index 3302ee67..f1b09136 100644 --- a/tests/test_lifecycle_version_invariants.py +++ b/tests/test_lifecycle_version_invariants.py @@ -9,6 +9,7 @@ from __future__ import annotations +import json import re import shutil import subprocess @@ -66,10 +67,17 @@ def test_the_lifecycle_bundle_carries_the_package_version(self): with tempfile.TemporaryDirectory(prefix="ainative-bundle-") as staging: bundle = build(Path(staging) / "dist") - self.assertEqual(bundle.name, f"ainative-dev-stack-{ainative.__version__}.zip") + self.assertEqual(bundle.name, + f"ainative-lifecycle-v2-{ainative.__version__}.zip") with zipfile.ZipFile(bundle) as archive: - self.assertEqual(archive.read("VERSION").decode("utf-8").strip(), - ainative.__version__) + document = json.loads(archive.read( + "lifecycle-protocol.json").decode("utf-8")) + self.assertEqual(document["protocol_version"], 2) + self.assertEqual(document["release_version"], ainative.__version__) + payload_root = document["payload_root"] + self.assertEqual( + archive.read(f"{payload_root}/VERSION").decode("utf-8").strip(), + ainative.__version__) def test_two_disagreeing_labels_refuse_the_build(self): with tempfile.TemporaryDirectory(prefix="ainative-mismatch-") as staging: diff --git a/tests/test_release_assets_gate.py b/tests/test_release_assets_gate.py new file mode 100644 index 00000000..a135e298 --- /dev/null +++ b/tests/test_release_assets_gate.py @@ -0,0 +1,137 @@ +"""The release gates: the dist chain, the PyPI half, the published-asset check. + +Three separate statements a release must be able to make, each with its own +failure mode a stranger would only discover after publishing: + +* every built artifact carries the release version (PyPI runs the wheel/sdist + half without the lifecycle bundle); +* the release GitHub is about to make visible carries exactly the built assets, + at the same sizes and digests; +* the gate itself fails when a label drifts - a gate that cannot fail is a + comment. +""" + +from __future__ import annotations + +import importlib.util +import io +import json +import sys +import tarfile +import tempfile +import unittest +import zipfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +for extra in (REPO / "scripts", REPO): + if str(extra) not in sys.path: + sys.path.insert(0, str(extra)) + +import check_release_versions as gate # noqa: E402 +import check_published_assets as published # noqa: E402 + + +def make_wheel(dist: Path, version: str) -> Path: + path = dist / f"ainative_dev_stack-{version}-py3-none-any.whl" + with zipfile.ZipFile(path, "w") as archive: + archive.writestr(f"ainative_dev_stack-{version}.dist-info/METADATA", + f"Metadata-Version: 2.1\nName: ainative-dev-stack\nVersion: {version}\n") + return path + + +def make_sdist(dist: Path, version: str) -> Path: + path = dist / f"ainative_dev_stack-{version}.tar.gz" + payload = f"Metadata-Version: 2.1\nName: ainative-dev-stack\nVersion: {version}\n".encode() + with tarfile.open(path, "w:gz") as archive: + info = tarfile.TarInfo(f"ainative_dev_stack-{version}/PKG-INFO") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return path + + +class DistChain(unittest.TestCase): + + def test_the_pypi_half_passes_without_a_bundle(self): + with tempfile.TemporaryDirectory(prefix="pypi-gate-") as staging: + dist = Path(staging) + make_wheel(dist, "2.3.0") + make_sdist(dist, "2.3.0") + checked = gate.check_dist("2.3.0", dist, require_bundle=False) + self.assertIn("ainative_dev_stack-2.3.0-py3-none-any.whl", checked) + self.assertIn("ainative_dev_stack-2.3.0.tar.gz", checked) + + def test_the_default_requires_the_bundle(self): + with tempfile.TemporaryDirectory(prefix="pypi-gate-") as staging: + dist = Path(staging) + make_wheel(dist, "2.3.0") + make_sdist(dist, "2.3.0") + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_dist("2.3.0", dist) + + def test_a_stale_artifact_of_another_version_is_refused(self): + with tempfile.TemporaryDirectory(prefix="pypi-gate-") as staging: + dist = Path(staging) + make_wheel(dist, "2.3.0") + make_sdist(dist, "2.3.0") + make_wheel(dist, "2.2.2") + with self.assertRaises(gate.ReleaseVersionMismatch) as raised: + gate.check_dist("2.3.0", dist, require_bundle=False) + self.assertIn("another version", str(raised.exception)) + + +class PublishedAssets(unittest.TestCase): + + def dist(self) -> tuple[tempfile.TemporaryDirectory, Path]: + directory = tempfile.TemporaryDirectory(prefix="assets-gate-") + root = Path(directory.name) + make_wheel(root, "2.3.0") + make_sdist(root, "2.3.0") + return directory, root + + def published(self, dist: Path, **overrides) -> list[dict]: + import hashlib + + assets = [] + for path in sorted(dist.iterdir()): + payload = path.read_bytes() + asset = {"name": path.name, "size": len(payload), + "digest": "sha256:" + hashlib.sha256(payload).hexdigest()} + asset.update(overrides.get(path.name, {})) + assets.append(asset) + return assets + + def test_a_matching_published_set_passes(self): + directory, dist = self.dist() + self.addCleanup(directory.cleanup) + checked = published.compare(dist, self.published(dist)) + self.assertEqual(len(checked), 2) + + def test_a_missing_asset_is_refused(self): + directory, dist = self.dist() + self.addCleanup(directory.cleanup) + assets = self.published(dist)[1:] + with self.assertRaises(published.PublishedAssetMismatch): + published.compare(dist, assets) + + def test_a_size_or_digest_mismatch_is_refused(self): + directory, dist = self.dist() + self.addCleanup(directory.cleanup) + wheel = next(dist.glob("*.whl")) + with self.assertRaises(published.PublishedAssetMismatch): + published.compare(dist, self.published(dist, **{wheel.name: {"size": 1}})) + with self.assertRaises(published.PublishedAssetMismatch): + published.compare(dist, self.published( + dist, **{wheel.name: {"digest": "sha256:" + "0" * 64}})) + + +class GateNonVacuity(unittest.TestCase): + """A drifting tag must fail the gate; otherwise it guards nothing.""" + + def test_a_tag_that_does_not_name_the_version_fails(self): + with self.assertRaises(gate.ReleaseVersionMismatch): + gate.check_labels(REPO, "v999.0.0") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From f34bfac26bef6d78d68000c379bc3a3319cd9e98 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 13:36:46 +0200 Subject: [PATCH 09/27] feat(machine): machine ownership manifest, safe uninstall, dogfood fix (#137, #18-#20) - every global install now records ~/.ai-native/machine.json: each link, managed block and rendered file with its source or digest and the stack version - scripts/machine_lifecycle.py owns the reversal rule: recorded+unmodified assets are removed (links/junctions are removed, never followed), user files and user-modified assets are preserved and reported, empty directories the uninstall created are pruned up to the standard harness roots, the manifest is deleted only after success - install_agents.py --uninstall [--dry-run] prints the plan; dry runs are pure (tests caught the helpers mutating during classification); rendered files are written as bytes so their recorded digest matches the file on Windows - lifecycle_dogfood.py force-adds .ai-native in its scratch clone (#137: the repo's own gitignore kept the anchor uncommitted, so verified_anchor counted zero commits); the first full dogfood run then caught a stale non-vacuity anchor, fixed - scripts/tests/test_machine_lifecycle.py covers record, dry-run purity, user-file preservation, modified-asset preservation, idempotence and malformed manifests --- .github/workflows/ci.yml | 3 + scripts/install_agents.py | 87 ++++++- scripts/lifecycle_dogfood.py | 5 + scripts/lifecycle_non_vacuity.py | 12 +- scripts/machine_lifecycle.py | 290 ++++++++++++++++++++++++ scripts/tests/test_machine_lifecycle.py | 95 ++++++++ 6 files changed, 484 insertions(+), 8 deletions(-) create mode 100644 scripts/machine_lifecycle.py create mode 100644 scripts/tests/test_machine_lifecycle.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a27d64c..ab76880b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -217,6 +217,9 @@ jobs: - name: Hook configuration, first-run policy and the machine-path gate run: python -m unittest tests.test_lifecycle_hook_config tests.test_lifecycle_permissions tests.test_personal_paths_gate -v + - name: Update transport, protocol v2 and the release gates + run: python -m unittest tests.test_lifecycle_update_transport tests.test_release_assets_gate -v + - name: Non-vacuity — each guard must actually block # A protection nobody has seen refuse is a comment. This reverts each # guard in a scratch copy and asserts the matching test then fails. diff --git a/scripts/install_agents.py b/scripts/install_agents.py index c9457372..d32ae0df 100644 --- a/scripts/install_agents.py +++ b/scripts/install_agents.py @@ -33,6 +33,7 @@ from __future__ import annotations import argparse +import hashlib import json import os from pathlib import Path @@ -40,6 +41,8 @@ import subprocess import sys +import machine_lifecycle + # Shared engineering method — installed unconditionally when a harness # directory is targeted. The block carries the canonical rules, not a # pointer; tools without an @file directive depend on it being inline. @@ -83,6 +86,20 @@ def __init__(self, stack: Path, home: Path, dry_run: bool, check: bool, self.changes = 0 self.vault = vault self.slug = slug + # What this run established ownership of, for ~/.ai-native/machine.json. + self.assets: list[dict] = [] + + def _record(self, kind: str, target: Path, **fields: object) -> None: + """One machine-owned asset. Recorded on OK as well as on write: a + re-run must not drop the record of something it still owns.""" + + try: + relative = target.resolve().relative_to(self.home).as_posix() + except (OSError, ValueError): + relative = target.as_posix() + record = {"kind": kind, "path": relative, **fields} + if record not in self.assets: + self.assets.append(record) def report(self, status: str, path: Path, detail: str = "") -> None: suffix = f" ({detail})" if detail else "" @@ -119,6 +136,7 @@ def link(self, source: Path, target: Path) -> None: source = source.resolve() if self._points_at(target, source): self.report("OK", target) + self._record("link", target, source=str(source)) return if target.exists() or target.is_symlink(): self.errors += 1 @@ -133,7 +151,18 @@ def link(self, source: Path, target: Path) -> None: if self.dry_run: self.report("LINK", target, str(source)) else: + before = len([asset for asset in self.assets if asset["path"] == + self._relative(target)]) self._create_link(source, target) + if len([asset for asset in self.assets if asset["path"] == + self._relative(target)]) == before: + self._record("link", target, source=str(source)) + + def _relative(self, target: Path) -> str: + try: + return target.resolve().relative_to(self.home).as_posix() + except (OSError, ValueError): + return target.as_posix() def _create_link(self, source: Path, target: Path) -> None: """Symlink, falling back to a Windows junction when symlinks are denied. @@ -265,6 +294,8 @@ def _write_managed_block(self, path: Path, block: str, ) if current == wanted: self.report("OK", path) + self._record("block", path, begin=begin, end=end, label=label, + preamble=preamble) return if self.check: self.errors += 1 @@ -277,6 +308,8 @@ def _write_managed_block(self, path: Path, block: str, else: path.write_text(wanted, encoding="utf-8") self.report("UPDATED", path) + self._record("block", path, begin=begin, end=end, label=label, + preamble=preamble) def remove_managed_block(self, path: Path, begin: str, end: str, label: str) -> None: @@ -316,6 +349,8 @@ def rendered_file(self, template: Path, target: Path) -> None: current = target.read_text(encoding="utf-8") if target.is_file() else "" if current == wanted: self.report("OK", target) + self._record("rendered", target, + digest=hashlib.sha256(wanted.encode("utf-8")).hexdigest()) return if target.exists() and GENERATED not in current: self.errors += 1 @@ -330,8 +365,13 @@ def rendered_file(self, template: Path, target: Path) -> None: if self.dry_run: self.report("RENDER", target, str(template)) else: - target.write_text(wanted, encoding="utf-8") + # Bytes, not text mode: `write_text` translates \n to \r\n on + # Windows, and the recorded digest is of the rendered text, so the + # file would never match its own record (the EMP-LC-025 class). + target.write_bytes(wanted.encode("utf-8")) self.report("RENDERED", target) + self._record("rendered", target, + digest=hashlib.sha256(wanted.encode("utf-8")).hexdigest()) def parse_args() -> argparse.Namespace: @@ -342,6 +382,12 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--home", type=Path, default=Path.home(), help="target home directory") parser.add_argument("--dry-run", action="store_true", help="show changes without writing") parser.add_argument("--check", action="store_true", help="verify an existing installation") + parser.add_argument("--uninstall", action="store_true", + help="reverse the recorded machine installation " + "(~/.ai-native/machine.json). Removes only assets this " + "installer wrote and the user has not modified; user " + "files and edited managed files are preserved. " + "Combine with --dry-run to preview.") parser.add_argument("--vault", type=Path, default=None, help="v4 Obsidian vault path (default: $OBSIDIAN_VAULT). " "When given, every supported harness gets a Vault " @@ -388,8 +434,37 @@ def resolve_vault_pair(args: argparse.Namespace) -> tuple[Path | None, str | Non return (status.vault, status.slug, None) +def machine_uninstall(args: argparse.Namespace) -> int: + """Print the reversal plan or apply it, then report like every other mode.""" + + try: + record = machine_lifecycle.uninstall(args.home, dry_run=args.dry_run) + except machine_lifecycle.MachineLifecycleError as refusal: + print(f"ERROR: {refusal}", file=sys.stderr) + return 2 + mode = "dry-run" if args.dry_run else "uninstall" + print(f"Machine {mode}: {len(record['removed'])} removal(s), " + f"{len(record['preserved'])} preserved, {len(record['missing'])} already absent.") + for path in record["removed"]: + print(f" REMOVE {path}") + for path in record["preserved"]: + print(f" PRESERVE {path}") + if record.get("detail"): + print(f" note: {record['detail']}") + if not record["removed"] and not record["preserved"] and record.get("detail") is None: + print(" note: the manifest recorded no assets") + if not args.dry_run: + print("Restart running AI clients to reload their configuration.") + return 0 + + def main() -> int: args = parse_args() + if args.uninstall: + if args.check: + print("ERROR: --check and --uninstall are mutually exclusive.", file=sys.stderr) + return 2 + return machine_uninstall(args) if args.check and args.dry_run: print("ERROR: --check and --dry-run are mutually exclusive.", file=sys.stderr) return 2 @@ -470,6 +545,16 @@ def main() -> int: mode = "check" if args.check else "dry-run" if args.dry_run else "install" print(f"\nStack {mode} ({sys.platform}): {installer.changes} change(s), " f"{installer.errors} issue(s).") + if not args.check and not args.dry_run and not installer.errors: + # The machine ownership record: written only after the run that made + # those files ours, and only when nothing failed. + version = "unknown" + version_file = stack / "VERSION" + if version_file.is_file(): + version = version_file.read_text(encoding="utf-8").strip() + manifest = machine_lifecycle.save(args.home, version=version, + assets=installer.assets) + print(f"Machine manifest: {manifest} ({len(installer.assets)} asset(s))") if not args.check: print("Restart running AI clients so they reload global rules, skills and plugins.") return 1 if installer.errors else 0 diff --git a/scripts/lifecycle_dogfood.py b/scripts/lifecycle_dogfood.py index eb246ca2..3020cba2 100644 --- a/scripts/lifecycle_dogfood.py +++ b/scripts/lifecycle_dogfood.py @@ -229,6 +229,11 @@ def __init__(self, root: Path) -> None: def commit(self, message: str) -> None: git(self.repo, "add", "-A") + # The repository ignores `.ai-native/` (its own local lifecycle state); + # in this scratch clone that state IS the governed material, so it is + # force-added. Without this the anchor was never committed and + # `verified_anchor` counted zero commits touching it (#137). + git(self.repo, "add", "-f", "-A", ".ai-native") pending = subprocess.run(["git", "-C", str(self.repo), "status", "--porcelain"], check=True, capture_output=True, text=True) if pending.stdout.strip(): diff --git a/scripts/lifecycle_non_vacuity.py b/scripts/lifecycle_non_vacuity.py index 0ce2a115..80fcfd1c 100644 --- a/scripts/lifecycle_non_vacuity.py +++ b/scripts/lifecycle_non_vacuity.py @@ -154,13 +154,11 @@ class Case: guards="reversing an update must remove what it created, not only restore " "what it replaced", edits=(Edit("ainative/lifecycle/transaction.py", - " elif record.get(\"action\") == plannerlib.CREATE and target.is_file():\n" - " # Created by this transaction, so there is nothing to restore: the\n" - " # previous state did not have it. Leaving it behind is what made\n" - " # `update rollback` produce a v1 project holding v2's new files.\n" - " target.unlink(missing_ok=True)\n" - " removed.append(path)\n", - " elif False:\n pass\n"),), + " elif (record.get(\"action\") in (plannerlib.CREATE, " + "plannerlib.REGION_WRITE,\n" + " plannerlib.HOOK_WRITE)\n" + " and target.is_file()):\n", + " elif False:\n"),), test=("tests.test_lifecycle_update.UpdateRecovery" ".test_rollback_also_removes_the_files_the_update_created"), ), diff --git a/scripts/machine_lifecycle.py b/scripts/machine_lifecycle.py new file mode 100644 index 00000000..dce70741 --- /dev/null +++ b/scripts/machine_lifecycle.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Machine-level ownership: what the global installer wrote, and its reversal. + +`scripts/install_agents.py` copies blocks into harness files, links skills into +`~/.agents` and `~/.claude`, and renders two plugin files. Until now nothing +recorded *what* it had written, so the only available uninstall was manual and +the user had no way to tell an AI Native file from their own. + +This module owns one manifest - `~/.ai-native/machine.json` - and one reversal +rule: + +* a **link** is ours when it resolves to the recorded source; removing it never + touches the source (a symlink or junction is removed, not followed); +* a **rendered file** is ours when its bytes still hash to the recorded digest; + a file the user edited is preserved and reported; +* a **block** is ours while its markers are present (the markers are the + ownership proof, as everywhere else in this stack); user text around the + block is preserved, and a file that becomes empty is removed. + +A file we never wrote is never touched, and a file we wrote that the user has +since changed is preserved, not deleted. The manifest itself is deleted only +after a successful uninstall. +""" + +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +SCHEMA_VERSION = 1 +MANIFEST_RELATIVE = Path(".ai-native") / "machine.json" + +# Directories that exist on a machine whether or not this stack touched them. +# The uninstall prunes empty directories it emptied, but stops here: an empty +# `~/.config` is not ours to remove just because it is empty. +STOP_DIRECTORY_NAMES = frozenset({".config", ".local", ".cache", ".agents", + ".claude", ".codex", ".cursor", ".gemini", + ".mavis", ".ai-native"}) + + +class MachineLifecycleError(RuntimeError): + """The manifest is unreadable; refusing rather than guessing.""" + + +def manifest_path(home: Path) -> Path: + return Path(home) / MANIFEST_RELATIVE + + +def _digest_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def digest_file(path: Path) -> str | None: + try: + return _digest_bytes(path.read_bytes()) + except OSError: + return None + + +def load(home: Path) -> dict | None: + path = manifest_path(home) + if not path.is_file(): + return None + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise MachineLifecycleError(f"cannot read {path}: {error}") from error + if not isinstance(payload, dict) or not isinstance(payload.get("assets"), list): + raise MachineLifecycleError(f"{path} is not a machine manifest") + return payload + + +def save(home: Path, *, version: str, assets: list[dict]) -> Path: + path = manifest_path(home) + path.parent.mkdir(parents=True, exist_ok=True) + record = { + "schema_version": SCHEMA_VERSION, + "installed_by": "scripts/install_agents.py", + "stack_version": version, + "updated_at": datetime.now(timezone.utc).isoformat(), + "assets": sorted(assets, key=lambda item: (item.get("kind", ""), + item.get("path", ""))), + } + path.write_text(json.dumps(record, indent=2, sort_keys=True) + "\n", + encoding="utf-8") + return path + + +def _prune_empty_parents(target: Path, home: Path) -> None: + """Remove the directories an uninstall emptied, and only those.""" + + current = target.parent + while True: + if current == home or current.name in STOP_DIRECTORY_NAMES: + return + try: + if not current.is_dir(): + return + next(current.iterdir()) + return # not empty: something else lives here + except StopIteration: + try: + current.rmdir() + except OSError: + return + current = current.parent + + +def _asset_path(home: Path, asset: dict) -> Path | None: + relative = asset.get("path") + if not isinstance(relative, str) or not relative: + return None + candidate = Path(home) / relative + return candidate + + +def _remove_link(target: Path, source: Path, *, dry_run: bool = False) -> str: + """REMOVED, MISSING or PRESERVED - never follows the link.""" + + if not target.exists() and not target.is_symlink(): + return "MISSING" + try: + if target.resolve() != source: + return "PRESERVED" + except OSError: + return "PRESERVED" + if dry_run: + return "REMOVED" + try: + if target.is_symlink(): + target.unlink() + return "REMOVED" + if target.is_dir(): + # A Windows junction: remove the reparse point, never the source. + target.rmdir() + return "REMOVED" + target.unlink() + return "REMOVED" + except OSError: + return "PRESERVED" + + +def _remove_render(target: Path, recorded: str | None, *, dry_run: bool = False) -> str: + if not target.is_file(): + return "MISSING" + if digest_file(target) != recorded: + return "PRESERVED" # the user edited it; it is theirs now + if dry_run: + return "REMOVED" + try: + target.unlink() + return "REMOVED" + except OSError: + return "PRESERVED" + + +def _remove_block(target: Path, begin: str, end: str, *, dry_run: bool = False) -> str: + if not target.is_file(): + return "MISSING" + try: + text = target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return "PRESERVED" + begin_count = text.count(begin) + end_count = text.count(end) + if begin_count == 0 and end_count == 0: + return "MISSING" + if begin_count != 1 or end_count != 1: + return "PRESERVED" # malformed: not a block this code wrote + start = text.find(begin) + finish = text.find(end, start + len(begin)) + if finish < 0: + return "PRESERVED" + finish += len(end) + remaining = (text[:start] + text[finish:]).lstrip("\n") + if dry_run: + return "REMOVED" + if not remaining.strip(): + try: + target.unlink() + return "REMOVED" + except OSError: + return "PRESERVED" + try: + target.write_text(remaining if remaining.endswith("\n") else remaining + "\n", + encoding="utf-8") + return "REMOVED" # the block is removed; the file stays + except OSError: + return "PRESERVED" + + +def uninstall(home: Path, *, dry_run: bool = False) -> dict: + """Reverse the recorded machine installation. Never guesses beyond the file.""" + + home = Path(home) + manifest = load(home) + if manifest is None: + return {"operation": "machine uninstall", "dry_run": dry_run, + "removed": [], "preserved": [], "missing": [], + "detail": "no machine manifest: nothing is recorded as ours"} + + removed: list[str] = [] + preserved: list[str] = [] + missing: list[str] = [] + block_candidates: list[tuple[Path, str]] = [] + for asset in manifest["assets"]: + target = _asset_path(home, asset) + if target is None: + preserved.append(str(asset)) + continue + try: + relative = target.relative_to(home).as_posix() + except ValueError: + relative = str(target) + kind = asset.get("kind") + if dry_run: + # Describe, do not act: the reported class is what a real run would do. + if kind == "link": + outcome = _remove_link(target, Path(str(asset.get("source", ""))), + dry_run=True) + elif kind == "rendered": + outcome = _remove_render(target, asset.get("digest"), dry_run=True) + elif kind == "block": + outcome = _remove_block(target, str(asset.get("begin", "")), + str(asset.get("end", "")), dry_run=True) + block_candidates.append((target, str(asset.get("preamble", "")))) + else: + outcome = "PRESERVED" + elif kind == "link": + outcome = _remove_link(target, Path(str(asset.get("source", "")))) + elif kind == "rendered": + outcome = _remove_render(target, asset.get("digest")) + elif kind == "block": + outcome = _remove_block(target, str(asset.get("begin", "")), + str(asset.get("end", ""))) + block_candidates.append((target, str(asset.get("preamble", "")))) + else: + outcome = "PRESERVED" + if outcome == "REMOVED": + removed.append(relative) + if not dry_run: + _prune_empty_parents(target, home) + elif outcome == "MISSING": + missing.append(relative) + else: + preserved.append(relative) + + # A file whose only remaining content is the preamble this installer wrote + # was ours entirely; leaving it behind is residue a user cannot classify. + seen_candidates: set[str] = set() + for target, preamble in block_candidates: + key = str(target) + if key in seen_candidates or not target.is_file(): + continue + seen_candidates.add(key) + try: + content = target.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + continue + stripped = content.strip() + if stripped and stripped != preamble.strip(): + continue # user content remains: the file stays + if not dry_run: + try: + target.unlink() + except OSError: + continue + _prune_empty_parents(target, home) + try: + relative = target.relative_to(home).as_posix() + except ValueError: + relative = str(target) + if relative not in removed: + removed.append(relative) + + record = {"operation": "machine uninstall", "dry_run": dry_run, + "removed": sorted(removed), "preserved": sorted(preserved), + "missing": sorted(missing), "stack_version": manifest.get("stack_version")} + if not dry_run: + try: + manifest_path(home).unlink(missing_ok=True) + except OSError: + record["manifest"] = "could not be removed; re-run uninstall" + return record + + +__all__ = ["SCHEMA_VERSION", "MANIFEST_RELATIVE", "MachineLifecycleError", + "manifest_path", "load", "save", "uninstall", "digest_file"] \ No newline at end of file diff --git a/scripts/tests/test_machine_lifecycle.py b/scripts/tests/test_machine_lifecycle.py new file mode 100644 index 00000000..a17b239c --- /dev/null +++ b/scripts/tests/test_machine_lifecycle.py @@ -0,0 +1,95 @@ +"""Machine lifecycle: what a global install owns, and how it is reversed. + +The installer wrote into six harness configurations and several skill trees +without recording what it had written, so no automatic uninstall existed and a +user could not tell an AI Native file from their own. These tests pin the +record and the reversal rule: recorded and unmodified assets are removed, +user files and user-modified assets are preserved, and a dry run changes +nothing. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + +STACK = Path(__file__).resolve().parents[2] +INSTALL = STACK / "scripts" / "install_agents.py" + + +def _run(home: Path, *args: str) -> subprocess.CompletedProcess: + environment = os.environ.copy() + for name in ("OBSIDIAN_VAULT", "OBSIDIAN_PROJECT_SLUG"): + environment.pop(name, None) + return subprocess.run([sys.executable, str(INSTALL), "--home", str(home), *args], + capture_output=True, text=True, env=environment) + + +class MachineLifecycle(unittest.TestCase): + + def setUp(self): + self.home = Path(tempfile.mkdtemp(prefix="machine-test-")) + self.addCleanup(lambda: __import__("shutil").rmtree(self.home, ignore_errors=True)) + completed = _run(self.home) + self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr) + + def manifest(self) -> dict: + return json.loads((self.home / ".ai-native" / "machine.json") + .read_text(encoding="utf-8")) + + def test_the_install_records_what_it_wrote(self): + record = self.manifest() + kinds = {asset["kind"] for asset in record["assets"]} + self.assertTrue({"link", "block", "rendered"} <= kinds, kinds) + self.assertGreater(len(record["assets"]), 20) + self.assertEqual(record["schema_version"], 1) + + def test_a_dry_run_changes_nothing(self): + manifest = (self.home / ".ai-native" / "machine.json").read_bytes() + claude = (self.home / ".claude" / "CLAUDE.md").read_bytes() + completed = _run(self.home, "--uninstall", "--dry-run") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual((self.home / ".ai-native" / "machine.json").read_bytes(), manifest) + self.assertEqual((self.home / ".claude" / "CLAUDE.md").read_bytes(), claude) + + def test_user_files_survive_the_uninstall(self): + user_skill = self.home / ".claude" / "skills" / "my-own-skill" / "SKILL.md" + user_skill.parent.mkdir(parents=True, exist_ok=True) + user_skill.write_text("# mine\n", encoding="utf-8") + notes = self.home / ".claude" / "MY-NOTES.md" + notes.write_text("# private notes\n", encoding="utf-8") + + completed = _run(self.home, "--uninstall") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertTrue(user_skill.is_file()) + self.assertTrue(notes.is_file()) + self.assertFalse((self.home / ".ai-native" / "machine.json").exists()) + + def test_a_user_modified_rendered_file_is_preserved(self): + target = self.home / ".config" / "opencode" / "plugins" / "ai-native-dev-stack.ts" + target.write_text("// edited by the user\n", encoding="utf-8") + completed = _run(self.home, "--uninstall") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertTrue(target.is_file()) + self.assertIn("PRESERVE", completed.stdout) + + def test_the_second_uninstall_reports_nothing_recorded(self): + _run(self.home, "--uninstall") + completed = _run(self.home, "--uninstall") + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertIn("nothing is recorded", completed.stdout) + + def test_a_malformed_manifest_is_refused(self): + (self.home / ".ai-native" / "machine.json").write_text("{not json", encoding="utf-8") + completed = _run(self.home, "--uninstall") + self.assertEqual(completed.returncode, 2) + self.assertIn("ERROR", completed.stderr) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file From b718da4aebe8776f90ada46753efab9d6106edf1 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:01:09 +0200 Subject: [PATCH 10/27] docs(release): v2.3.0 - support docs, machine lifecycle docs, executable first runs - SUPPORT.md (support matrix, reporting path, known limitations) and CODE_OF_CONDUCT.md - README EN/FR: Verified first run is trust init + bootstrap; the PostToolUse hook is configured by init, not by hand; machine integration records a manifest and has a real uninstall; non-git behaviour documented - UPDATING.md and docs/DISTRIBUTION-LIFECYCLE.md: machine update/uninstall, the claude-hook component, protocol v2 containment - version bump 2.3.0 across VERSION, package, AGENTS.md header and README pins; CHANGELOG; scope figures re-measured - both E2E scripts now build from a hermetic copy of the tree: a developer's stale build/ cache made pip wheel fail with WinError 183 and could contaminate the artifact under test --- AGENTS.md | 12 +-- CHANGELOG.md | 86 +++++++++++++++++++ CODE_OF_CONDUCT.md | 49 +++++++++++ README.fr.md | 27 +++++- README.md | 28 ++++-- SUPPORT.md | 58 +++++++++++++ UPDATING.md | 16 ++++ VERSION | 2 +- ainative/__init__.py | 2 +- docs/DISTRIBUTION-LIFECYCLE.md | 29 +++++++ docs/VERIFIED-WORK-PLANE.md | 1 + .../knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md | 2 +- scripts/check_personal_paths.py | 6 ++ scripts/lifecycle_upgrade_e2e.py | 5 +- scripts/verified_first_run_e2e.py | 9 +- 15 files changed, 312 insertions(+), 20 deletions(-) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 SUPPORT.md diff --git a/AGENTS.md b/AGENTS.md index 97f2d62a..5a5c6002 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + @@ -225,16 +225,16 @@ Central unread modules (>5 incoming imports): none ### This project — ai-native-dev-stack -Measured 2026-09-13 (v2.2.2 corrective: release version chain, runtime freshness, stale update cache, anti-debt secret fingerprints, CI coverage) via `git ls-files` (image excluded). Re-measure +Measured 2026-09-13 (v2.3.0: Verified onboarding, hook wiring, machine lifecycle, protocol v2, whole-stack doctor) via `git ls-files` (image excluded). Re-measure with `python3 scripts/measure_scope.py`; CI fails when these figures drift. | Scope | Tokens (÷4) | Files | Strategy | |---|---|---|---| -| Core stack (excl. anti-debt) | ~736 270 | 438 | **Layered read** — cartography first then targeted reads | -| Anti-debt agent | ~133 280 | 119 | Read its `AI_CONTEXT.md` and ADRs before its sources | -| Whole repo | ~869 550 | 557 | **Multi-phase workflow** — never a single direct read | +| Core stack (excl. anti-debt) | ~787 077 | 462 | **Layered read** — cartography first then targeted reads | +| Anti-debt agent | ~133 902 | 117 | Read its `AI_CONTEXT.md` and ADRs before its sources | +| Whole repo | ~920 980 | 579 | **Multi-phase workflow** — never a single direct read | -Do **not** read the whole repo in one pass: at ~870k tokens it does not fit, +Do **not** read the whole repo in one pass: at ~920k tokens it does not fit, and the strategy table above applies in full. Pick the scope the task needs — most work touches only one of the three halves below. diff --git a/CHANGELOG.md b/CHANGELOG.md index 714c33a9..078f842f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,92 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.3.0] - 2026-09-13 + +Minor release: the product surface grows (Verified onboarding, machine +lifecycle, whole-stack diagnostics, a versioned update protocol) without +breaking the documented Standard flow. A stranger can now install, initialize, +diagnose, update, roll back and uninstall — and a Verified user can scaffold and +bootstrap trust — without reading the source. + +### Added + +- **Verified onboarding.** `ainative trust init` writes a minimal approval-root + and policy scaffold (validated by the same code bootstrap uses, claiming no + authority); `ainative trust bootstrap` stays the explicit ceremony. One + schema owner (`trust_schema.py`) validates both documents before anything + reads them. `scripts/verified_first_run_e2e.py` drives the whole governed + workflow — init, trust init, bootstrap, work admit/new, verify, converge to + CONVERGED — from a fresh wheel and venv, on Linux, Windows and macOS. +- **The hook is configured by `init`.** A new `claude-hook` component merges + exactly one owned `PostToolUse` entry into `.claude/settings.json`: + user keys and hook groups are preserved, a re-init never duplicates it, + uninstall/rollback remove only the owned entry, and an unparsable file is + refused rather than rewritten. +- **Whole-stack `doctor`.** Beside the lifecycle diagnosis it now reports the + Python, Git (repository/HEAD/state), Node, the harness hook, harness + integration, the vault and its REST API when configured, Graphify, the + machine manifest and the Trust anchor — each as OK / ABSENT_OPTIONAL / + DEGRADED / FAIL with its impact. Missing automation fails; optional tools do + not. +- **Machine lifecycle.** Every global install records + `~/.ai-native/machine.json` (source or digest per asset), and + `scripts/install_agents.py --uninstall [--dry-run]` reverses exactly that + record: user files and user-modified assets are preserved, file ownership is + provable, and the manifest is removed only after success. +- **Versioned update protocol v2.** Bundles are now + `ainative-lifecycle-v2-.zip` (protocol document + payload under + `stack/`). Runtimes up to v2.2.2 cannot consume such a bundle even from a + mirror — the layout refuses them. v2.2.2 refuses at the runtime gate before + downloading. The documented path is unchanged: upgrade the CLI first. +- **Authenticated update checks.** `GITHUB_TOKEN`/`GH_TOKEN` is sent as a + bearer header when present (never logged, never cached); 403/429 answers are + reported as a rate-limit diagnosis with the remedy. + `ainative update check --strict` exits non-zero when the source could not be + consulted — the default still exits 0, because the answer is what it is. +- **Atomic, attested releases.** The release workflow builds everything, gates + every artifact, attests build provenance + (`gh attestation verify -R Rwanbt/ai-native-dev-stack`), creates the + release as a draft, uploads without `--clobber`, verifies the published + names/sizes/digests, and only then publishes. A new `publish-pypi.yml` + publishes the wheel and sdist through PyPI Trusted Publishing (OIDC); the + one-time PyPI setup is documented in `docs/RELEASING.md`. +- **Machine-path gate.** `scripts/check_personal_paths.py` refuses any tracked + file carrying a user profile, home directory or the maintainer's account in a + path, with a reasoned allowlist for historical evidence; the mutation test + proves it blocks. +- **Docs for strangers.** `SUPPORT.md` (what is supported, how to report, known + limitations), `CODE_OF_CONDUCT.md`, and `docs/RELEASING.md`. + +### Changed + +- **Non-Git projects.** Standard installs with an explicit notice and a + DEGRADED doctor report (Knowledge persistence and provenance need a + repository); Verified refuses with `GIT_REPOSITORY_REQUIRED`. +- **Truthful plan vocabulary.** `BLOCK_WRITE`/`BLOCK_REMOVE` (which read as + "blocked" while the operation proceeded) are replaced by + `REGION_WRITE`/`REGION_REMOVE` and `HOOK_WRITE`/`HOOK_REMOVE`; older journals + remain readable. +- **Knowledge works on a fresh install (#138).** The managed `.gitignore` + region now carries `.ai-native/state/`, and refusals print the exact remedy. + Knowledge state and the trust anchor are owner-only on POSIX. +- `lifecycle_dogfood.py` runs again (#137) and immediately caught a stale + non-vacuity anchor, fixed here. + +### Fixed + +- **No more tracebacks for invalid trusted input.** `ainative trust bootstrap` + with `{}` raised `KeyError` and exited 1 (the NOT_CONVERGED code). Validation + now precedes use; refusals print a stable code on stderr and the same record + as JSON on stdout, always exit 2. +- Machine-specific paths removed from distributed files: the committed + Mavis-generated OpenCode artifacts are gone, the enforcement script carries no + personal defaults, and the maintainer's email left the fixtures. +- `rendered_file` writes bytes, so a recorded digest matches the file on + Windows; the machine uninstall's dry run is pure (it no longer classifies by + mutating). + + ## [2.2.2] - 2026-09-13 Corrective release closing the post-v2.2.1 audit. Two architectural diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..ad497a52 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,49 @@ +# Contributor Code of Conduct + +## Our pledge + +We as members, contributors, and leaders pledge to make participation in this +project a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic +status, nationality, personal appearance, race, religion, or sexual identity +and orientation. + +## Our standards + +Examples of behavior that contributes to a positive environment: + +- Demonstrating empathy and kindness toward other people; +- Being respectful of differing opinions, viewpoints, and experiences; +- Giving and gracefully accepting constructive feedback; +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience; +- Focusing on what is best for the project and its users. + +Examples of unacceptable behavior: + +- The use of sexualized language or imagery, and sexual attention or advances + of any kind; +- Trolling, insulting or derogatory comments, and personal or political + attacks; +- Public or private harassment; +- Publishing others' private information, such as a physical or email + address, without their explicit permission; +- Other conduct which could reasonably be considered inappropriate in a + professional setting. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the maintainer at the contact address on the GitHub profile +(`@Rwanbt`). All complaints will be reviewed and investigated promptly and +fairly, and the privacy and security of the reporter will be respected. + +The maintainer may take any action they deem appropriate, up to and including +a permanent ban from participation, and will state the reason for a decision +to the parties involved. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), +version 2.1. \ No newline at end of file diff --git a/README.fr.md b/README.fr.md index e7e268df..4e8491e7 100644 --- a/README.fr.md +++ b/README.fr.md @@ -416,7 +416,7 @@ refuse une cible qui ne vient pas de lui (`CLI_UPDATE_REQUIRED`, avant tout tél toute écriture) : ```bash -pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.3.0" cd votre-projet && ainative update ``` @@ -483,6 +483,12 @@ Mavis, et le plugin OpenCode — tout cela s'installe avec `python scripts/install_agents.py` (voir *Installer la méthode sur la machine* ci-dessous). Garder les deux séparés, c'est ce qui rend les deux réversibles. +Hors d'un dépôt Git, Standard s'installe avec une degradation documentee — +persistance Knowledge et provenance indisponibles jusqu'a `git init` — et +`ainative doctor` le signale. Verified refuse l'installation +(`GIT_REPOSITORY_REQUIRED`) : ses garanties se resolvent contre un depot qui +n'existerait pas. + --- ## Le Verified Work Plane @@ -492,7 +498,13 @@ Il décide la convergence à partir de contrats commités et de vérifications e jamais à partir d'un récit, et jamais à partir de ce que l'appelant fournit. pip install . - ainative trust bootstrap --repo . --approval-root root.json --policy policy.json --by "vous" + ainative init --profile verified + ainative trust init --repo . --by "vous" # ecrit le scaffold, sans autorite + ainative trust bootstrap --repo . \ + --approval-root .ai-native/trust/setup/approval-root.json \ + --policy .ai-native/trust/setup/policy.json --by "vous" \ + --predicate recorded_owner_ack + # committez l'ancre, puis declarez et verifiez le travail : ainative work admit .ai-native/work/w1 --repo . --by "vous" --artifact ... ainative work new .ai-native/work/w1 --artifact ... ainative converge --work .ai-native/work/w1 --repo . @@ -643,7 +655,9 @@ Ensuite : jamais une copie (`@/chemin/absolu/AGENTS.md`). 2. Éditer `tools/ai_docs/config.sh` (coffre Obsidian, binaire graphify) — ce fichier vous appartient, l'installeur ne l'écrase jamais. -3. Enregistrer le hook PostToolUse — voir `.ai-native/templates/settings_hook_example.json`. +3. Le hook PostToolUse est deja configure : `ainative init` a fusionne une + entree possedee dans `.claude/settings.json` sans toucher au reste du + fichier, et `ainative doctor` verifie l'entree et sa cible. 4. Écrire un `AI_CONTEXT.md` par module — voir `.ai-native/templates/AI_CONTEXT_template.md`. 5. Vérifier : `ainative status`, puis `/verify-ai-docs`. @@ -651,7 +665,12 @@ Ensuite : Installe règles, skills et agents dans **chaque CLI IA détecté** — Claude Code, Codex, OpenCode, Cursor, MiniMax/Mavis — sous forme de **liens** et non de -copies, pour qu'un `git pull` mette tout à jour d'un coup. +copies, pour qu'un `git pull` mette tout à jour d'un coup. L'installeur +enregistre tout ce qu'il écrit dans `~/.ai-native/machine.json` (source ou +empreinte par asset), et `python scripts/install_agents.py --uninstall` défait +exactement cet enregistrement : fichiers utilisateur et fichiers modifiés +préservés, `--dry-run` pour prévisualiser. L'installation est idempotente — +relancez-la pour mettre à jour. ```bash bash scripts/setup-agents.sh # Linux / macOS / Git Bash diff --git a/README.md b/README.md index 6e165237..0ee6f52f 100644 --- a/README.md +++ b/README.md @@ -410,7 +410,7 @@ refuses a target it did not ship with (`CLI_UPDATE_REQUIRED`, before any downloa and any write): ```bash -pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.2.2" +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.3.0" cd your-project && ainative update ``` @@ -483,6 +483,12 @@ Mavis, and the OpenCode plugin — all installed by `python scripts/install_agents.py` (see *Whole-stack transfer* below). Keeping the two apart is what makes both reversible. +Outside a Git repository, Standard installs with a documented degradation — +Knowledge persistence and provenance are unavailable until `git init` — and +`ainative doctor` reports it. Verified refuses to install at all +(`GIT_REPOSITORY_REQUIRED`): its guarantees resolve against a repository that +would not exist. + --- ## The Verified Work Plane @@ -493,9 +499,16 @@ verifications — never from narrative, and never from anything the caller hands in. pip install . - ainative trust bootstrap --repo . --approval-root root.json --policy policy.json --by "you" + ainative init --profile verified + ainative trust init --repo . --by "you" # writes the scaffold, no authority + ainative trust bootstrap --repo . \ + --approval-root .ai-native/trust/setup/approval-root.json \ + --policy .ai-native/trust/setup/policy.json --by "you" \ + --predicate recorded_owner_ack + # commit the anchor, then declare and verify work: ainative work admit .ai-native/work/w1 --repo . --by "you" --artifact ... ainative work new .ai-native/work/w1 --artifact ... + ainative verify --work .ai-native/work/w1 --repo . --verification ainative converge --work .ai-native/work/w1 --repo . Exit codes: 0 CONVERGED, 1 NOT_CONVERGED, 2 INVALID, 3 INTERNAL_ERROR. @@ -588,8 +601,9 @@ ainative init --profile standard --dry-run # see the plan first, change nothin # never overwrites it afterwards) # Edit tools/ai_docs/config.sh: OBSIDIAN_VAULT, GRAPHIFY_BIN, CLAUDE_MEMORY_KEY -# 3. Register the PostToolUse hook in .claude/settings.json -# (see .ai-native/templates/settings_hook_example.json) +# 3. The PostToolUse hook is already configured: `ainative init` merged one +# owned entry into .claude/settings.json without touching the rest of the +# file, and `ainative doctor` verifies the entry and its target. # 4. Write AI_CONTEXT.md for each major module # (see .ai-native/templates/AI_CONTEXT_template.md) @@ -654,7 +668,11 @@ python scripts/install_agents.py --dry-run Skills are installed into every agent root the stack knows about — Claude Code (`~/.claude/skills`) and the cross-CLI `~/.agents/skills` used by Codex, OpenCode and Cursor — as links, not copies, so a `git pull` updates every CLI -at once. +at once. The installer records everything it writes in +`~/.ai-native/machine.json` (source or digest per asset), and +`python scripts/install_agents.py --uninstall` reverses exactly that record: +user files and user-edited files are preserved, and `--dry-run` shows the plan +first. The install itself is idempotent — re-run it to update. The single source of the engineering method is [`AGENTS.md`](AGENTS.md) — every tool config references it instead of re-stating the rules, so the configs never diff --git a/SUPPORT.md b/SUPPORT.md new file mode 100644 index 00000000..0e685713 --- /dev/null +++ b/SUPPORT.md @@ -0,0 +1,58 @@ +# Support + +## What is supported + +| Surface | Supported | +|---|---| +| Installation | PyPI (`pip install ainative-dev-stack==`), pinned git tag, or a checkout + `install.py` | +| Python | 3.11+ for the lifecycle CLI; the AI-docs tooling installed into a project runs on 3.8+ | +| Operating systems | Linux, macOS and Windows (CI exercises all three) | +| Profiles | Standard; Verified (adds governed Work Contracts and deterministic verification) | +| Harnesses | Claude Code, Codex, OpenCode, Cursor, Gemini CLI, MiniMax/Mavis — for the shared method, skills and hooks | + +Only the latest release is supported; fixes ship as a new patch version. + +## Before asking for help + +1. `ainative doctor` — prints the lifecycle health, the Knowledge status and + the environment (Git, Python, the harness hook, optional tools, the vault + when configured). Its output is the first thing any bug report needs. +2. `ainative status` — what is installed and its health. +3. `ainative update check` — whether a newer release exists, and whether the + CLI must be upgraded first (`CLI_UPDATE_REQUIRED`). +4. The guides: `docs/DISTRIBUTION-LIFECYCLE.md`, `docs/VERIFIED-WORK-PLANE.md`, + `docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md`, `docs/MULTIVAULT-OPERATOR-GUIDE.md`, + `UPDATING.md`. + +## Reporting a bug + +Open a GitHub issue with: + +- the exact command you ran and its output (`ainative doctor --json` if the + command involves the lifecycle); +- your OS, Python version and the installed stack version + (`ainative --version`); +- what you expected and what happened. + +Do **not** include secrets, tokens, credentials, personal file contents or the +contents of `~/.claude/`, your vault, or `.ai-native/` directories beyond what +the command printed. Redact paths if they contain your name. + +Security issues follow `SECURITY.md` — a private report, never a public issue. + +## Expected response + +This is a single-maintainer project: there is no response-time SLA and none is +promised. Reports are triaged against the reproduced facts; a confirmed +security or data-loss defect takes precedence over features. + +## Known limitations (honest list) + +- Runtimes older than v2.2.2 cannot consume protocol v2 releases; upgrade the + CLI first (README, UPDATING.md). +- `SHA256SUMS` proves integrity, not a human signature; releases carry GitHub + build-provenance attestations, and #24 tracks stronger signing. +- Multi-Vault is **GUARDED**, not ENFORCED: it does not claim protection from a + malicious process running as the same OS user outside governed paths. +- Knowledge canonical auto-promotion is intentionally not shipped (K5 = STOP). +- Workspace/global multi-user machine ACL isolation is not claimed or tested. \ No newline at end of file diff --git a/UPDATING.md b/UPDATING.md index b3687a7e..c44ecb4c 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -18,6 +18,22 @@ The full model — ownership, transactions, rollback, security boundaries — is --- +## Updating or removing the machine integration + +Global integration (method blocks, skill links, hooks, plugins) is updated by +re-running the installer from a stack checkout — it is idempotent, and it +records what it wrote in `~/.ai-native/machine.json`: + +```bash +python scripts/install_agents.py # install or update +python scripts/install_agents.py --check # verify +python scripts/install_agents.py --uninstall --dry-run # preview removal +python scripts/install_agents.py --uninstall # remove only what it owns +``` + +The uninstall removes recorded, unmodified assets and preserves everything +else — including a managed file you edited since the install. + ## Updating a project — `ainative update` The runtime that applies an update is the policy the release ships: the diff --git a/VERSION b/VERSION index b1b25a5f..276cbf9e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.2.2 +2.3.0 diff --git a/ainative/__init__.py b/ainative/__init__.py index 9708a50c..5b18f1ff 100644 --- a/ainative/__init__.py +++ b/ainative/__init__.py @@ -9,6 +9,6 @@ from __future__ import annotations -__version__ = "2.2.2" +__version__ = "2.3.0" __all__ = ["__version__"] \ No newline at end of file diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 26505c5b..33212123 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -116,6 +116,7 @@ Components shipped today: | `skills-agents` | standard | tree | MANAGED_IMMUTABLE | `.agents/skills/` | | `machine-config` | standard | template | USER_DATA | `tools/ai_docs/config.sh` | | `gitignore-entry` | standard | external_block | EXTERNAL_CONFIG | `.gitignore` | +| `claude-hook` | standard | json_hook | EXTERNAL_CONFIG | `.claude/settings.json` | | `verified-workplane` | verified | marker | MANAGED_IMMUTABLE | `.ai-native/lifecycle/verified.json` | | `verified-guide` | verified | file | MANAGED_IMMUTABLE | `.ai-native/docs/VERIFIED-WORK-PLANE.md` | | `verified-data` | verified | data_root | USER_DATA | `.ai-native/{trust,work,runs}` | @@ -643,6 +644,7 @@ reinterpreted by the dispatcher. ├── tools/ai_docs/ MANAGED_IMMUTABLE │ └── config.sh USER_DATA (never overwritten) ├── .claude/skills/ MANAGED_IMMUTABLE +├── .claude/settings.json EXTERNAL_CONFIG (one owned hook entry) ├── .agents/skills/ MANAGED_IMMUTABLE └── .ai-native/ ├── templates/ MANAGED_IMMUTABLE @@ -656,3 +658,30 @@ reinterpreted by the dispatcher. ├── update-cache.json the cached check └── lifecycle.lock held only during a mutation ``` + +--- + +## 17. The machine surface (global integration) + +`ainative init` is per project. Installing the shared method, the skills and +the harness hooks into a *machine* is a different surface, owned by +`scripts/install_agents.py` from a stack checkout: + +```bash +python scripts/install_agents.py # install or update (idempotent) +python scripts/install_agents.py --check # verify what exists +python scripts/install_agents.py --dry-run # preview +python scripts/install_agents.py --uninstall [--dry-run] +``` + +Every run records what it wrote in `~/.ai-native/machine.json`: each link with +its source, each managed block with its markers and preamble, each rendered +file with its digest. The uninstall reverses exactly that record — +links/junctions are removed and never followed, a rendered file is removed only +while its bytes match the recorded digest, a block is removed only while its +markers are present — and everything else (user skills, user agents, user +edits) is preserved and reported. `ainative doctor` reads the manifest to +report whether machine integration is present. + +The machine surface has no project ownership state and no transaction journal; +re-running the installer *is* the update path. diff --git a/docs/VERIFIED-WORK-PLANE.md b/docs/VERIFIED-WORK-PLANE.md index 0f823881..7f3f8fd3 100644 --- a/docs/VERIFIED-WORK-PLANE.md +++ b/docs/VERIFIED-WORK-PLANE.md @@ -45,6 +45,7 @@ profile loads none of this package. Bootstrap the project's trust anchor. This is a privileged ceremony — see "What it cannot verify" below. + ainative trust init --repo . --by "you" # scaffold a root + policy (no authority) ainative trust bootstrap --repo . \ --approval-root root.json --policy policy.json \ --by "your name" --predicate recorded_owner_ack diff --git a/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md b/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md index 3f88e2c5..c35a6142 100644 --- a/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md +++ b/docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md @@ -2,7 +2,7 @@ STATUS: HISTORICAL INITIAL AUDIT + FINAL PARITY RECORD -Current qualified release candidate: v2.2.2 +Current qualified release candidate: v2.3.0 Initial baseline: `dev` @ a4dfe35 (K1-K4 owners). Legacy reference: `knowledge-lifecycle` @ 9eb5422, archived as tag `archive/knowledge-lifecycle-final`; the branch was deleted after the convergence. diff --git a/scripts/check_personal_paths.py b/scripts/check_personal_paths.py index 51adabd1..4d5c83b1 100644 --- a/scripts/check_personal_paths.py +++ b/scripts/check_personal_paths.py @@ -44,6 +44,12 @@ # Files whose bytes are the historical record. Rewriting the path inside a # qualification log would change evidence; the reason is the allowlist entry. ALLOWED_FILES = { + # The gate itself defines the forbidden patterns; its own source cannot be + # expected to avoid quoting them. + "scripts/check_personal_paths.py": + "defines the forbidden shapes; the literals are regex sources, not paths", + "tests/test_personal_paths_gate.py": + "mutation fixtures: deliberately invalid paths the gate must refuse", "docs/qualification/claude-code.json": "historical qualification evidence: the interpreter path is part of the recorded run", "docs/qualification/codex-desktop.json": diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index 1f7089c5..311d66cb 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -194,7 +194,10 @@ def main() -> int: if not wheel_n.is_file(): raise Failure(f"wheel not found: {wheel_n}") else: - wheel_n = build_wheel(REPO, workspace / "dist-n") + # Hermetic: a stale developer build/ cache could both fail the + # build and contaminate the artifact under test. + source = relabel_tree(REPO, workspace / "source-n", from_version) + wheel_n = build_wheel(source, workspace / "dist-n") run([venv_python, "-m", "pip", "install", "--disable-pip-version-check", "-q", "--no-deps", wheel_n]) ok(f"runtime N {from_version} installed from {wheel_n.name}") diff --git a/scripts/verified_first_run_e2e.py b/scripts/verified_first_run_e2e.py index 716de19b..6c813385 100644 --- a/scripts/verified_first_run_e2e.py +++ b/scripts/verified_first_run_e2e.py @@ -137,9 +137,16 @@ def main() -> int: if not wheel.is_file(): raise Failure(f"wheel not found: {wheel}") else: + # Build from a hermetic copy: a developer's `build/` cache made + # `pip wheel` fail with WinError 183 and, worse, could smuggle + # stale artifacts into what "the fresh wheel" means. + source = workspace / "source" + shutil.copytree(REPO, source, ignore=shutil.ignore_patterns( + ".git", "__pycache__", "*.pyc", ".pytest_cache", "build", "dist", + "*.egg-info", ".ai-native", ".gstack", "node_modules", ".venv", "venv")) dist = workspace / "dist" run([sys.executable, "-m", "pip", "wheel", "--disable-pip-version-check", - "--no-deps", "--wheel-dir", dist, REPO]) + "--no-deps", "--wheel-dir", dist, source]) wheels = sorted(dist.glob("*.whl")) if len(wheels) != 1: raise Failure(f"expected one wheel, found {wheels}") From 5bad34dae5ee5aaa3c517f34234623aa7b306981 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:08:22 +0200 Subject: [PATCH 11/27] fix(lifecycle): managed files get umask modes; clean-install works in a Git project - write_atomic/write_bytes_atomic apply the umask-compatible mode after replace: NamedTemporaryFile's 0600 leaked into every managed project file on POSIX (private state still calls write_atomic_private explicitly) - lifecycle_clean_install.py initialises the fresh project as a Git repository (Verified refuses without one) and gains a non-Git leg proving the documented policy: Standard installs with a notice, Verified refuses with zero writes - the non-Git leg isolates Git discovery with GIT_CEILING_DIRECTORIES so a developer's enclosing repository cannot change the result --- ainative/lifecycle/state.py | 22 ++++++++++++++++++++++ scripts/lifecycle_clean_install.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/ainative/lifecycle/state.py b/ainative/lifecycle/state.py index b1cdd207..c680e1c3 100644 --- a/ainative/lifecycle/state.py +++ b/ainative/lifecycle/state.py @@ -257,6 +257,26 @@ def load(project: Path) -> InstallState | None: return InstallState.from_record(payload) +def _apply_umask_mode(path: Path) -> None: + """Give a written file the mode a plain create would have produced. + + `NamedTemporaryFile` creates 0600, and `os.replace` keeps that mode, so + every managed project file used to land owner-only on POSIX. Project files + are not secrets; a shared checkout expects group/other read (0644 under the + usual umask). Private state calls `write_atomic_private` explicitly. + Windows has no mode bits here and is left alone. + """ + + if os.name == "nt": + return + current = os.umask(0) + os.umask(current) + try: + os.chmod(path, 0o666 & ~current) + except OSError: + return + + def write_atomic(path: Path, payload: str) -> None: """Write via a same-directory temporary file and one rename. @@ -274,6 +294,7 @@ def write_atomic(path: Path, payload: str) -> None: handle.flush() os.fsync(handle.fileno()) os.replace(handle.name, path) + _apply_umask_mode(path) except BaseException: Path(handle.name).unlink(missing_ok=True) raise @@ -290,6 +311,7 @@ def write_bytes_atomic(path: Path, payload: bytes) -> None: handle.flush() os.fsync(handle.fileno()) os.replace(handle.name, path) + _apply_umask_mode(path) except BaseException: Path(handle.name).unlink(missing_ok=True) raise diff --git a/scripts/lifecycle_clean_install.py b/scripts/lifecycle_clean_install.py index c7d0f14b..a54c2321 100644 --- a/scripts/lifecycle_clean_install.py +++ b/scripts/lifecycle_clean_install.py @@ -204,6 +204,26 @@ def check_uninstall(ainative: Path, project: Path, cwd: Path, env: dict) -> None require((project / "src" / "app.py").is_file(), "purge removed user source") +def check_non_git_policy(ainative: Path, root: Path, env: dict) -> None: + """Standard degrades with a notice; Verified refuses. Both are policies.""" + + print("[3c] outside Git: Standard installs with a notice, Verified refuses") + plain = root / "plain-project" + (plain / "src").mkdir(parents=True) + # On a developer machine the temp root can sit inside an enclosing + # repository (a home directory under Git); the ceiling makes the child Git + # stop looking above `root`, so the test sees a real non-repository. + isolated = {**(env or {}), "GIT_CEILING_DIRECTORIES": str(root)} + completed = run([ainative, "init", "--profile", "standard", "--project", plain], + env=isolated) + require("not inside a Git repository" in completed.stdout, + "a non-Git Standard install did not disclose the degradation") + run([ainative, "init", "--profile", "verified", "--project", plain], + env=isolated, expect=2) + require(not (plain / ".ai-native" / "lifecycle" / "verified.json").exists(), + "a refused Verified install wrote its marker anyway") + + def check_multivault_wheel(ainative: Path, project: Path, cwd: Path, env: dict) -> None: print("[3b] the wheel ships ainative.multivault and its CLI answers") python = ainative.parent / ("python.exe" if sys.platform.startswith("win") else "python") @@ -352,12 +372,20 @@ def main() -> int: (project / "src").mkdir(parents=True) (project / "src" / "app.py").write_text("print('hello')\n", encoding="utf-8") (project / "NOTES.md").write_text("my own notes\n", encoding="utf-8") + # A real project is a Git repository: Verified refuses to install without + # one (provenance), and the subprocess environment has to be clean. try: ainative, _ = build_and_install(root) env = clean_environment() + run(["git", "-C", str(project), "init", "-q"], env=env) + run(["git", "-C", str(project), "-c", "user.email=ci@example.invalid", + "-c", "user.name=CI", "add", "-A"], env=env) + run(["git", "-C", str(project), "-c", "user.email=ci@example.invalid", + "-c", "user.name=CI", "commit", "-qm", "init"], env=env) check_versions(ainative, root, env) check_install(ainative, project, root, env) + check_non_git_policy(ainative, root, env) check_version_invariants(ainative, project, root, env) check_multivault_wheel(ainative, project, root, env) check_multivault_exec_sync(ainative, root, root, env) From f18e6b3c0be1ff45d358009828330b7162475c14 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:09:51 +0200 Subject: [PATCH 12/27] fix(clean-install): the payload/checkout comparison ignores the project's own .git --- scripts/lifecycle_clean_install.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/lifecycle_clean_install.py b/scripts/lifecycle_clean_install.py index a54c2321..460a1c46 100644 --- a/scripts/lifecycle_clean_install.py +++ b/scripts/lifecycle_clean_install.py @@ -34,7 +34,8 @@ # are per-transaction. VOLATILE = (".ai-native/lifecycle/state.json", ".ai-native/lifecycle/transactions", - ".ai-native/lifecycle/backups") + ".ai-native/lifecycle/backups", + ".git") class Failure(SystemExit): From 0928a2e119862eb410b29ea7e666530ee828aeb5 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:17:05 +0200 Subject: [PATCH 13/27] fix(hooks): the PostToolUse wrapper reads stdin through the pipeline first [Console]::In.ReadToEnd() returned empty on the Windows CI runners while \ held the payload (and the reverse was observed locally); whichever has the JSON wins, and a skip is reported on stderr instead of staying silent. The same fix is applied to the shared global hook wrapper. --- hooks/posttool-ai-summary/run_hook.ps1 | 43 ++++++++++++++++---------- tools/ai_docs/run_hook.ps1 | 29 +++++++++++------ 2 files changed, 47 insertions(+), 25 deletions(-) diff --git a/hooks/posttool-ai-summary/run_hook.ps1 b/hooks/posttool-ai-summary/run_hook.ps1 index 17f3c0fd..64f24950 100644 --- a/hooks/posttool-ai-summary/run_hook.ps1 +++ b/hooks/posttool-ai-summary/run_hook.ps1 @@ -1,23 +1,34 @@ -# run_hook.ps1 — PostToolUse AI_SUMMARY Generator (Windows natif) -# Wrapper PowerShell pour le hook PostToolUse. - -$INPUT = Get-Content -Raw +# run_hook.ps1 - PostToolUse wrapper (Windows native). +# Reads one hook payload from stdin and delegates to update_on_edit.py next to +# this script. Always exits 0 so it never blocks the editor. +# +# stdin read order: the PowerShell pipeline enumerator first, [Console]::In as +# a fallback. With redirected input the two disagree depending on whether the +# process has a console (a local terminal vs a CI service): `[Console]::In` +# returned empty on the runners while `$input` held the payload, and the +# reverse was observed elsewhere. Whichever has the JSON wins; if neither +# does, the skip is reported on stderr instead of staying silent. +$ErrorActionPreference = "SilentlyContinue" +$PAYLOAD = $input | Out-String +if (-not $PAYLOAD.Trim()) { + $PAYLOAD = [Console]::In.ReadToEnd() +} $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path -$STACK_ROOT = (Resolve-Path "$SCRIPT_DIR\..\..\..").Path -$UPDATE_SCRIPT = Join-Path $STACK_ROOT "tools\ai_docs\update_on_edit.py" +$UPDATE_SCRIPT = Join-Path $SCRIPT_DIR "update_on_edit.py" + +if (-not $PAYLOAD.Trim()) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook received no payload on stdin") + exit 0 +} -# Trouver un Python $PY = $null -foreach ($p in @("python", "python3", "py", "C:\Python312\python.exe", "C:\Python311\python.exe")) { - $pyCmd = Get-Command $p -ErrorAction SilentlyContinue - if ($pyCmd) { $PY = $pyCmd.Source; break } +foreach ($candidate in @("python", "python3", "py")) { + $found = Get-Command $candidate -ErrorAction SilentlyContinue + if ($found) { $PY = $found.Source; break } } if ($PY -and (Test-Path $UPDATE_SCRIPT)) { - $result = @($INPUT | & $PY $UPDATE_SCRIPT 2>&1 | Out-String) - if ($result) { Write-Output $result } -} else { - # Silent skip + $env:PYTHONIOENCODING = "utf-8" + $PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output } - -exit 0 +exit 0 \ No newline at end of file diff --git a/tools/ai_docs/run_hook.ps1 b/tools/ai_docs/run_hook.ps1 index d3d09572..64f24950 100644 --- a/tools/ai_docs/run_hook.ps1 +++ b/tools/ai_docs/run_hook.ps1 @@ -1,22 +1,33 @@ -# run_hook.ps1 - Claude Code PostToolUse wrapper (Windows native). -# Reads one hook payload from redirected stdin and delegates to -# update_on_edit.py next to this script. Always exits 0 so it never blocks the -# editor; when no usable Python is found it skips silently rather than failing -# the tool call. -# [Console]::In, not Get-Content: PowerShell 5.1's Get-Content has a mandatory -# -Path and does not read redirected stdin on its own. +# run_hook.ps1 - PostToolUse wrapper (Windows native). +# Reads one hook payload from stdin and delegates to update_on_edit.py next to +# this script. Always exits 0 so it never blocks the editor. +# +# stdin read order: the PowerShell pipeline enumerator first, [Console]::In as +# a fallback. With redirected input the two disagree depending on whether the +# process has a console (a local terminal vs a CI service): `[Console]::In` +# returned empty on the runners while `$input` held the payload, and the +# reverse was observed elsewhere. Whichever has the JSON wins; if neither +# does, the skip is reported on stderr instead of staying silent. $ErrorActionPreference = "SilentlyContinue" -$PAYLOAD = [Console]::In.ReadToEnd() +$PAYLOAD = $input | Out-String +if (-not $PAYLOAD.Trim()) { + $PAYLOAD = [Console]::In.ReadToEnd() +} $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path $UPDATE_SCRIPT = Join-Path $SCRIPT_DIR "update_on_edit.py" +if (-not $PAYLOAD.Trim()) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook received no payload on stdin") + exit 0 +} + $PY = $null foreach ($candidate in @("python", "python3", "py")) { $found = Get-Command $candidate -ErrorAction SilentlyContinue if ($found) { $PY = $found.Source; break } } -if ($PY -and (Test-Path $UPDATE_SCRIPT) -and $PAYLOAD) { +if ($PY -and (Test-Path $UPDATE_SCRIPT)) { $env:PYTHONIOENCODING = "utf-8" $PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output } From d30e072d59152135e6bb5fbe81b74829d1c7ac18 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:24:57 +0200 Subject: [PATCH 14/27] fix(hooks): the wrapper reports why it skipped instead of exiting silently --- hooks/posttool-ai-summary/run_hook.ps1 | 20 ++++++++++++++++---- tests/test_lifecycle_hook_config.py | 7 +++++-- tools/ai_docs/run_hook.ps1 | 20 ++++++++++++++++---- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/hooks/posttool-ai-summary/run_hook.ps1 b/hooks/posttool-ai-summary/run_hook.ps1 index 64f24950..e540e42b 100644 --- a/hooks/posttool-ai-summary/run_hook.ps1 +++ b/hooks/posttool-ai-summary/run_hook.ps1 @@ -24,11 +24,23 @@ if (-not $PAYLOAD.Trim()) { $PY = $null foreach ($candidate in @("python", "python3", "py")) { $found = Get-Command $candidate -ErrorAction SilentlyContinue - if ($found) { $PY = $found.Source; break } + if (-not $found) { continue } + # The WindowsApps entries are execution aliases for the Store stub: they + # "exist", then do nothing and exit 0. A hook that silently does nothing + # is the failure this prevents. + if ($found.Source -like "*\WindowsApps\*") { continue } + $PY = $found.Source + break } -if ($PY -and (Test-Path $UPDATE_SCRIPT)) { - $env:PYTHONIOENCODING = "utf-8" - $PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output +if (-not $PY) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no usable Python on PATH") + exit 0 +} +if (-not (Test-Path $UPDATE_SCRIPT)) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no update_on_edit.py at $UPDATE_SCRIPT") + exit 0 } +$env:PYTHONIOENCODING = "utf-8" +$PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output exit 0 \ No newline at end of file diff --git a/tests/test_lifecycle_hook_config.py b/tests/test_lifecycle_hook_config.py index f8406f6f..87e54c8d 100644 --- a/tests/test_lifecycle_hook_config.py +++ b/tests/test_lifecycle_hook_config.py @@ -157,9 +157,12 @@ def test_the_installed_wrapper_regenerates_a_summary(self): else: completed = subprocess.run(["bash", str(wrapper)], input=payload, capture_output=True, text=True, timeout=180) - self.assertEqual(completed.returncode, 0, completed.stderr) + diagnostic = (f"rc={completed.returncode} " + f"stdout={completed.stdout[-600:]!r} " + f"stderr={completed.stderr[-600:]!r}") + self.assertEqual(completed.returncode, 0, diagnostic) summary = module / "AI_SUMMARY.md" - self.assertTrue(summary.is_file(), completed.stderr) + self.assertTrue(summary.is_file(), diagnostic) self.assertIn("AI_SUMMARY", read_text(summary)) diff --git a/tools/ai_docs/run_hook.ps1 b/tools/ai_docs/run_hook.ps1 index 64f24950..e540e42b 100644 --- a/tools/ai_docs/run_hook.ps1 +++ b/tools/ai_docs/run_hook.ps1 @@ -24,11 +24,23 @@ if (-not $PAYLOAD.Trim()) { $PY = $null foreach ($candidate in @("python", "python3", "py")) { $found = Get-Command $candidate -ErrorAction SilentlyContinue - if ($found) { $PY = $found.Source; break } + if (-not $found) { continue } + # The WindowsApps entries are execution aliases for the Store stub: they + # "exist", then do nothing and exit 0. A hook that silently does nothing + # is the failure this prevents. + if ($found.Source -like "*\WindowsApps\*") { continue } + $PY = $found.Source + break } -if ($PY -and (Test-Path $UPDATE_SCRIPT)) { - $env:PYTHONIOENCODING = "utf-8" - $PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output +if (-not $PY) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no usable Python on PATH") + exit 0 +} +if (-not (Test-Path $UPDATE_SCRIPT)) { + [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no update_on_edit.py at $UPDATE_SCRIPT") + exit 0 } +$env:PYTHONIOENCODING = "utf-8" +$PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output exit 0 \ No newline at end of file From 2f0492e67abe3a7d6aba70be96bd16146b4a40d7 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:33:36 +0200 Subject: [PATCH 15/27] fix(hooks): hand the payload to Python by file, with visible skips PowerShell's pipe to a native executable is not byte-stable across host shapes (the console-less runners delivered an empty or mangled stream). The wrapper now tries pipeline, raw stdin handle and Console.In in turn, writes the exact bytes to a temp file, and calls update_on_edit.py --payload-file; the script reports an empty or non-JSON payload on stderr instead of skipping silently. Get-Command matches are enumerated (-All) so a WindowsApps Store alias can never win over a real interpreter, the global hook keeps its own stack-root resolution, and the test makes the running interpreter visible on PATH. --- hooks/posttool-ai-summary/run_hook.ps1 | 67 ++++++++++++++++--------- tests/test_lifecycle_hook_config.py | 12 ++++- tools/ai_docs/run_hook.ps1 | 68 +++++++++++++++++++------- tools/ai_docs/update_on_edit.py | 33 +++++++++++-- 4 files changed, 133 insertions(+), 47 deletions(-) diff --git a/hooks/posttool-ai-summary/run_hook.ps1 b/hooks/posttool-ai-summary/run_hook.ps1 index e540e42b..99c1a993 100644 --- a/hooks/posttool-ai-summary/run_hook.ps1 +++ b/hooks/posttool-ai-summary/run_hook.ps1 @@ -1,46 +1,69 @@ -# run_hook.ps1 - PostToolUse wrapper (Windows native). -# Reads one hook payload from stdin and delegates to update_on_edit.py next to -# this script. Always exits 0 so it never blocks the editor. +# run_hook.ps1 - PostToolUse AI_SUMMARY generator (Windows native, global hook). +# Resolves the stack root from this file's location (hooks/posttool-ai-summary/), +# reads one hook payload from stdin, and delegates to the stack's +# tools/ai_docs/update_on_edit.py. Always exits 0 so it never blocks the editor. # -# stdin read order: the PowerShell pipeline enumerator first, [Console]::In as -# a fallback. With redirected input the two disagree depending on whether the -# process has a console (a local terminal vs a CI service): `[Console]::In` -# returned empty on the runners while `$input` held the payload, and the -# reverse was observed elsewhere. Whichever has the JSON wins; if neither -# does, the skip is reported on stderr instead of staying silent. +# Windows gives a redirected stdin to PowerShell in several shapes, and which +# one carries the bytes depends on whether the process has a console (a local +# terminal vs a CI service). The wrapper therefore tries every stream in turn, +# hands the exact bytes to Python through a temp file (PowerShell's pipe to a +# native executable is not byte-stable across host shapes), and reports a skip +# on stderr instead of staying silent. $ErrorActionPreference = "SilentlyContinue" -$PAYLOAD = $input | Out-String -if (-not $PAYLOAD.Trim()) { - $PAYLOAD = [Console]::In.ReadToEnd() + +function Read-HookPayload { + $text = $input | Out-String + if ($text.Trim()) { return $text } + try { + $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput()) + $text = $reader.ReadToEnd() + if ($text.Trim()) { return $text } + } catch { } + try { + $text = [Console]::In.ReadToEnd() + if ($text.Trim()) { return $text } + } catch { } + return "" } + +$PAYLOAD = Read-HookPayload $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path -$UPDATE_SCRIPT = Join-Path $SCRIPT_DIR "update_on_edit.py" +$STACK_ROOT = (Resolve-Path (Join-Path $SCRIPT_DIR "..\..")).Path +$UPDATE_SCRIPT = Join-Path $STACK_ROOT "tools\ai_docs\update_on_edit.py" if (-not $PAYLOAD.Trim()) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook received no payload on stdin") + [Console]::Error.Flush() exit 0 } $PY = $null foreach ($candidate in @("python", "python3", "py")) { - $found = Get-Command $candidate -ErrorAction SilentlyContinue - if (-not $found) { continue } - # The WindowsApps entries are execution aliases for the Store stub: they - # "exist", then do nothing and exit 0. A hook that silently does nothing - # is the failure this prevents. - if ($found.Source -like "*\WindowsApps\*") { continue } - $PY = $found.Source - break + foreach ($found in @(Get-Command $candidate -All -ErrorAction SilentlyContinue)) { + $path = $found.Source + if (-not $path) { continue } + if ($path -like "*\WindowsApps\*") { continue } + if ($path -like "*.exe" -and (Test-Path $path)) { $PY = $path; break } + } + if ($PY) { break } } if (-not $PY) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no usable Python on PATH") + [Console]::Error.Flush() exit 0 } if (-not (Test-Path $UPDATE_SCRIPT)) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no update_on_edit.py at $UPDATE_SCRIPT") + [Console]::Error.Flush() exit 0 } $env:PYTHONIOENCODING = "utf-8" -$PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output +$PAYLOAD_FILE = Join-Path ([System.IO.Path]::GetTempPath()) ("ai_docs_hook_" + [System.Guid]::NewGuid().ToString("N") + ".json") +try { + [System.IO.File]::WriteAllText($PAYLOAD_FILE, $PAYLOAD, (New-Object System.Text.UTF8Encoding($false))) + & $PY $UPDATE_SCRIPT --payload-file $PAYLOAD_FILE 2>&1 | Out-String | Write-Output +} finally { + Remove-Item -LiteralPath $PAYLOAD_FILE -ErrorAction SilentlyContinue +} exit 0 \ No newline at end of file diff --git a/tests/test_lifecycle_hook_config.py b/tests/test_lifecycle_hook_config.py index 87e54c8d..a8bd2f76 100644 --- a/tests/test_lifecycle_hook_config.py +++ b/tests/test_lifecycle_hook_config.py @@ -149,14 +149,22 @@ def test_the_installed_wrapper_regenerates_a_summary(self): payload = json.dumps({"tool_name": "Edit", "tool_input": {"file_path": str(module / "code.py")}}) wrapper = self.project / hookslib.wrapper_relative() + # The wrapper searches PATH like a real session does. This interpreter + # is the one running the suite; making it visible keeps the test about + # the hook, not about the developer's PATH layout. + environment = {**os.environ, + "PATH": str(Path(sys.executable).parent) + os.pathsep + + os.environ.get("PATH", "")} if os.name == "nt": completed = subprocess.run( ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", str(wrapper)], - input=payload, capture_output=True, text=True, timeout=180) + input=payload, capture_output=True, text=True, timeout=180, + env=environment) else: completed = subprocess.run(["bash", str(wrapper)], input=payload, - capture_output=True, text=True, timeout=180) + capture_output=True, text=True, timeout=180, + env=environment) diagnostic = (f"rc={completed.returncode} " f"stdout={completed.stdout[-600:]!r} " f"stderr={completed.stderr[-600:]!r}") diff --git a/tools/ai_docs/run_hook.ps1 b/tools/ai_docs/run_hook.ps1 index e540e42b..bb98cd59 100644 --- a/tools/ai_docs/run_hook.ps1 +++ b/tools/ai_docs/run_hook.ps1 @@ -2,45 +2,77 @@ # Reads one hook payload from stdin and delegates to update_on_edit.py next to # this script. Always exits 0 so it never blocks the editor. # -# stdin read order: the PowerShell pipeline enumerator first, [Console]::In as -# a fallback. With redirected input the two disagree depending on whether the -# process has a console (a local terminal vs a CI service): `[Console]::In` -# returned empty on the runners while `$input` held the payload, and the -# reverse was observed elsewhere. Whichever has the JSON wins; if neither -# does, the skip is reported on stderr instead of staying silent. +# Windows gives a redirected stdin to PowerShell in several shapes, and which +# one carries the bytes depends on whether the process has a console (a local +# terminal vs a CI service): the pipeline enumerator held the payload on the +# runners while [Console]::In was empty, and both were observed the other way +# around locally. The wrapper therefore tries every stream in turn and uses +# whichever has the JSON. A skip is reported on stderr, never silent. + $ErrorActionPreference = "SilentlyContinue" -$PAYLOAD = $input | Out-String -if (-not $PAYLOAD.Trim()) { - $PAYLOAD = [Console]::In.ReadToEnd() + +function Read-HookPayload { + # 1. The pipeline enumerator: PowerShell buffers redirected stdin for it. + $text = $input | Out-String + if ($text.Trim()) { return $text } + # 2. The raw standard-input handle, before Console.In wraps it. + try { + $reader = New-Object System.IO.StreamReader([Console]::OpenStandardInput()) + $text = $reader.ReadToEnd() + if ($text.Trim()) { return $text } + } catch { } + # 3. The console reader, for hosts where only that one is wired. + try { + $text = [Console]::In.ReadToEnd() + if ($text.Trim()) { return $text } + } catch { } + return "" } + +$PAYLOAD = Read-HookPayload $SCRIPT_DIR = Split-Path -Parent $MyInvocation.MyCommand.Path $UPDATE_SCRIPT = Join-Path $SCRIPT_DIR "update_on_edit.py" if (-not $PAYLOAD.Trim()) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook received no payload on stdin") + [Console]::Error.Flush() exit 0 } $PY = $null foreach ($candidate in @("python", "python3", "py")) { - $found = Get-Command $candidate -ErrorAction SilentlyContinue - if (-not $found) { continue } - # The WindowsApps entries are execution aliases for the Store stub: they - # "exist", then do nothing and exit 0. A hook that silently does nothing - # is the failure this prevents. - if ($found.Source -like "*\WindowsApps\*") { continue } - $PY = $found.Source - break + # `Get-Command` can return several matches (the real interpreter and the + # WindowsApps execution alias); taking `.Source` off the array yields + # nothing, and taking the alias yields a Store stub that exits 0 without + # running anything. Every match is considered, and only a real .exe wins. + foreach ($found in @(Get-Command $candidate -All -ErrorAction SilentlyContinue)) { + $path = $found.Source + if (-not $path) { continue } + if ($path -like "*\WindowsApps\*") { continue } + if ($path -like "*.exe" -and (Test-Path $path)) { $PY = $path; break } + } + if ($PY) { break } } if (-not $PY) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no usable Python on PATH") + [Console]::Error.Flush() exit 0 } if (-not (Test-Path $UPDATE_SCRIPT)) { [Console]::Error.WriteLine("[ai_docs] PostToolUse hook found no update_on_edit.py at $UPDATE_SCRIPT") + [Console]::Error.Flush() exit 0 } $env:PYTHONIOENCODING = "utf-8" -$PAYLOAD | & $PY $UPDATE_SCRIPT 2>&1 | Out-String | Write-Output +# Payload by file, not by pipe: PowerShell's pipe to a native executable is +# not byte-stable across host shapes (console-less runners mangled it), and +# the exact bytes are what the JSON parse needs. +$PAYLOAD_FILE = Join-Path ([System.IO.Path]::GetTempPath()) ("ai_docs_hook_" + [System.Guid]::NewGuid().ToString("N") + ".json") +try { + [System.IO.File]::WriteAllText($PAYLOAD_FILE, $PAYLOAD, (New-Object System.Text.UTF8Encoding($false))) + & $PY $UPDATE_SCRIPT --payload-file $PAYLOAD_FILE 2>&1 | Out-String | Write-Output +} finally { + Remove-Item -LiteralPath $PAYLOAD_FILE -ErrorAction SilentlyContinue +} exit 0 \ No newline at end of file diff --git a/tools/ai_docs/update_on_edit.py b/tools/ai_docs/update_on_edit.py index 92a68995..f1c22757 100644 --- a/tools/ai_docs/update_on_edit.py +++ b/tools/ai_docs/update_on_edit.py @@ -67,14 +67,37 @@ def check_context_freshness(module_dir: Path) -> None: +def _read_payload() -> str: + """The hook payload: from --payload-file, or from stdin. + + The file route exists because PowerShell's pipe to a native executable is + not byte-stable across host shapes; the wrapper writes the exact bytes it + received and passes the path, so this script never depends on that pipe. + """ + + if "--payload-file" in sys.argv: + index = sys.argv.index("--payload-file") + try: + path = Path(sys.argv[index + 1]) + return path.read_text(encoding="utf-8-sig", errors="replace") + except (IndexError, OSError) as error: + print(f"[ai_docs] hook payload file unreadable: {error}", file=sys.stderr) + return "" + return sys.stdin.read() + + def main() -> int: + raw = _read_payload().lstrip("\ufeff") + if not raw.strip(): + print("[ai_docs] hook received an empty payload", file=sys.stderr) + return 0 try: - raw = sys.stdin.read() - if not raw.strip(): - return 0 data = json.loads(raw) - except (json.JSONDecodeError, Exception): - return 0 # not a JSON hook event — skip silently + except ValueError as error: + preview = raw[:120].replace("\n", " ") + print(f"[ai_docs] hook payload is not JSON ({error}); got: {preview!r}", + file=sys.stderr) + return 0 # Claude Code PostToolUse payload: { tool_name, tool_input, tool_response } tool_input = data.get("tool_input", data) From b965ca92917f3f5b3db7b35f5b7dc7d58a6e8687 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 14:55:16 +0200 Subject: [PATCH 16/27] fix(release): the published-asset report never lands inside dist The v2.3.0 release workflow aborted after every asset had uploaded and attested: the verification step wrote its assets.json into dist/, and the checker counted it as an unbuilt extra asset. The report now lives in RUNNER_TEMP, the checker excludes its own report file defensively, and the regression is pinned by test. --- .github/workflows/release-assets.yml | 7 +++++-- scripts/check_published_assets.py | 19 ++++++++++++++----- tests/test_release_assets_gate.py | 12 ++++++++++++ 3 files changed, 31 insertions(+), 7 deletions(-) diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 81cf8ea5..03b2e9bb 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -75,6 +75,9 @@ jobs: GH_TOKEN: ${{ github.token }} run: | set -e - gh release view "${GITHUB_REF_NAME}" --json assets > dist/assets.json - python scripts/check_published_assets.py --dist dist --assets-json dist/assets.json + # The report lives outside dist: a JSON file written into the + # artifact directory was counted as an unbuilt extra asset and + # failed the publish after every asset had uploaded correctly. + gh release view "${GITHUB_REF_NAME}" --json assets > "${RUNNER_TEMP}/assets.json" + python scripts/check_published_assets.py --dist dist --assets-json "${RUNNER_TEMP}/assets.json" gh release edit "${GITHUB_REF_NAME}" --draft=false \ No newline at end of file diff --git a/scripts/check_published_assets.py b/scripts/check_published_assets.py index ff5c2950..adb3b5c8 100644 --- a/scripts/check_published_assets.py +++ b/scripts/check_published_assets.py @@ -27,10 +27,18 @@ class PublishedAssetMismatch(RuntimeError): """The published set does not match the built set.""" -def local_assets(dist: Path) -> dict[str, dict]: +def local_assets(dist: Path, *, ignored: Path | None = None) -> dict[str, dict]: + """Every file in dist that could be an asset. + + `ignored` is the report file this check itself may have been pointed at; + when a workflow writes it inside dist, counting it as a built artifact + failed the release after every asset had been uploaded correctly. + """ + + ignored_name = ignored.name if ignored is not None else None assets: dict[str, dict] = {} for path in sorted(dist.iterdir()): - if not path.is_file(): + if not path.is_file() or path.name == ignored_name: continue payload = path.read_bytes() assets[path.name] = {"size": len(payload), @@ -40,8 +48,9 @@ def local_assets(dist: Path) -> dict[str, dict]: return assets -def compare(dist: Path, published: list) -> list[str]: - expected = local_assets(dist) +def compare(dist: Path, published: list, *, + assets_json: Path | None = None) -> list[str]: + expected = local_assets(dist, ignored=assets_json) seen: dict[str, dict] = {} for asset in published: if not isinstance(asset, dict) or not isinstance(asset.get("name"), str): @@ -85,7 +94,7 @@ def main() -> int: print("PUBLISHED_ASSETS_INVALID: no asset list in the JSON", file=sys.stderr) return 1 try: - checked = compare(args.dist, published) + checked = compare(args.dist, published, assets_json=args.assets_json) except PublishedAssetMismatch as refusal: print(f"PUBLISHED_ASSETS_MISMATCH: {refusal}", file=sys.stderr) return 1 diff --git a/tests/test_release_assets_gate.py b/tests/test_release_assets_gate.py index a135e298..8bf13054 100644 --- a/tests/test_release_assets_gate.py +++ b/tests/test_release_assets_gate.py @@ -114,6 +114,18 @@ def test_a_missing_asset_is_refused(self): with self.assertRaises(published.PublishedAssetMismatch): published.compare(dist, assets) + def test_a_report_file_inside_dist_is_not_counted_as_an_asset(self): + directory, dist = self.dist() + self.addCleanup(directory.cleanup) + board = self.published(dist) # computed before the report exists + report = dist / "assets.json" + report.write_text("{}", encoding="utf-8") + # Without the exclusion this raised "assets on the release that were + # not built: ['assets.json']" - the v2.3.0 release abort. + checked = published.compare(dist, board, assets_json=report) + self.assertEqual(len(checked), 2) + self.assertNotIn("assets.json", checked) + def test_a_size_or_digest_mismatch_is_refused(self): directory, dist = self.dist() self.addCleanup(directory.cleanup) From c36f218fd2fbe66ba5786144405e0abf8cca1cdd Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Sun, 13 Sep 2026 21:13:25 +0200 Subject: [PATCH 17/27] release: 2.4.2 --- AGENTS.md | 2 +- CHANGELOG.md | 5 +++++ VERSION | 2 +- ainative/__init__.py | 2 +- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2471f31d..130c0e95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + diff --git a/CHANGELOG.md b/CHANGELOG.md index 303806c7..79134f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,11 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.4.2] - 2026-09-13 + +Patch release: the governed Multi-Vault push path now enforces the approved +remote's `allowed_refs`, closing the authorization asymmetry with fetch (#147). + ### Fixed - **Multi-Vault governed push enforces the approved remote's `allowed_refs`** diff --git a/VERSION b/VERSION index 005119ba..8e8299dc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.1 +2.4.2 diff --git a/ainative/__init__.py b/ainative/__init__.py index 71a72c33..9126f0fa 100644 --- a/ainative/__init__.py +++ b/ainative/__init__.py @@ -9,6 +9,6 @@ from __future__ import annotations -__version__ = "2.4.1" +__version__ = "2.4.2" __all__ = ["__version__"] \ No newline at end of file From 81af07b13dfccfd1ffd17c9cdfea0c9cb63f3f54 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Mon, 14 Sep 2026 01:18:07 +0200 Subject: [PATCH 18/27] release: 2.4.3 Ships the #153 OpenCode plugin runtime fix and closes the published-artifact gap it exposed: the staged payload carried no adapters/ tree, so a wheel install rendered no plugin (visible SKIP) and the documented machine integration existed for checkout users only. - adapters/ (OpenCode + Claude Code) joins the wheel and sdist payload, so `ainative machine init` renders the plugin from the installed distribution. - A durable runtime gate (the opencode-plugin CI job, inside the Production Gate) loads the rendered plugin in a real runtime with no global Bun, drives edit/write, refuses a file over the blocking limit, reads that limit from conventions.json, and proves AI_SUMMARY.md is regenerated. - The same gate runs against the plugin rendered by a wheel installed in a fresh venv, so the published artifact is what is tested. - Version labels move together (VERSION, ainative.__version__, AGENTS.md stack-version, scope table); the READMEs, UPDATING, RELEASING, PUBLIC-PILOT and the Multi-Vault operator guide pin v2.4.3. --- .github/workflows/ci.yml | 32 +- AGENTS.md | 10 +- CHANGELOG.md | 41 +++ MANIFEST.in | 1 + PORTABILITY.md | 2 +- README.fr.md | 4 +- README.md | 6 +- UPDATING.md | 2 +- VERSION | 2 +- _payload_staging.py | 10 +- ainative/__init__.py | 2 +- docs/MULTIVAULT-OPERATOR-GUIDE.md | 6 +- docs/PUBLIC-PILOT.md | 4 +- docs/RELEASING.md | 16 +- scripts/harness/opencode-plugin-runtime.mjs | 73 +++++ scripts/opencode_plugin_published_e2e.py | 175 +++++++++++ scripts/opencode_plugin_runtime_e2e.py | 314 ++++++++++++++++++++ 17 files changed, 668 insertions(+), 32 deletions(-) create mode 100644 scripts/harness/opencode-plugin-runtime.mjs create mode 100644 scripts/opencode_plugin_published_e2e.py create mode 100644 scripts/opencode_plugin_runtime_e2e.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc1c81d..1ea47d9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -738,6 +738,34 @@ jobs: # Job Object probe skips itself on other platforms. run: python -m unittest discover -s tests/mv00 -p "test_*.py" -v + opencode-plugin: + name: OpenCode plugin runtime (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7 + with: + python-version: "3.11" + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 + with: + node-version: "22" + + # #153 was a runtime incompatibility — a global `Bun.file` and a callable + # Bun shell the opencode runtime does not provide — so reading the + # template proves nothing. The first step renders it and drives the real + # hooks (edit/write, LOC refusal, AI_SUMMARY regeneration); the second + # builds the wheel, installs it in a fresh venv, and proves the published + # distribution renders the same working plugin from its own payload. + - name: The rendered plugin loads, gates edit/write and regenerates AI_SUMMARY + run: python scripts/opencode_plugin_runtime_e2e.py + + - name: A wheel install renders a working plugin from its own payload + run: python scripts/opencode_plugin_published_e2e.py + production-gate: name: Production Gate # The one required status on main. It runs even when a needed job failed — @@ -749,7 +777,7 @@ jobs: needs: [test, conventions, loc-gate, vault-v4-protocol, workplane-v2, lifecycle, upgrade-e2e, verified-first-run, knowledge-b1, knowledge-b2, clean-install, installers, colleague-e2e, - vault-sync, hooks, multivault-unit] + vault-sync, hooks, multivault-unit, opencode-plugin] runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 @@ -759,5 +787,5 @@ jobs: - name: Every blocking job must have succeeded env: PRODUCTION_GATE_NEEDS: ${{ toJSON(needs) }} - PRODUCTION_GATE_EXPECTED: test conventions loc-gate vault-v4-protocol workplane-v2 lifecycle upgrade-e2e verified-first-run knowledge-b1 knowledge-b2 clean-install installers colleague-e2e vault-sync hooks multivault-unit + PRODUCTION_GATE_EXPECTED: test conventions loc-gate vault-v4-protocol workplane-v2 lifecycle upgrade-e2e verified-first-run knowledge-b1 knowledge-b2 clean-install installers colleague-e2e vault-sync hooks multivault-unit opencode-plugin run: python scripts/ci_production_gate.py diff --git a/AGENTS.md b/AGENTS.md index 130c0e95..f9b710da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Universal Engineering Rules - + @@ -225,16 +225,16 @@ Central unread modules (>5 incoming imports): none ### This project — ai-native-dev-stack -Measured 2026-09-13 (v2.3.0: Verified onboarding, hook wiring, machine lifecycle, protocol v2, whole-stack doctor) via `git ls-files` (image excluded). Re-measure +Measured 2026-09-14 (v2.4.3: OpenCode plugin runtime fix, adapters in the published payload, opencode-plugin CI gate) via `git ls-files` (image excluded). Re-measure with `python3 scripts/measure_scope.py`; CI fails when these figures drift. | Scope | Tokens (÷4) | Files | Strategy | |---|---|---|---| -| Core stack (excl. anti-debt) | ~787 077 | 462 | **Layered read** — cartography first then targeted reads | +| Core stack (excl. anti-debt) | ~824 494 | 477 | **Layered read** — cartography first then targeted reads | | Anti-debt agent | ~133 902 | 117 | Read its `AI_CONTEXT.md` and ADRs before its sources | -| Whole repo | ~920 980 | 579 | **Multi-phase workflow** — never a single direct read | +| Whole repo | ~958 397 | 594 | **Multi-phase workflow** — never a single direct read | -Do **not** read the whole repo in one pass: at ~920k tokens it does not fit, +Do **not** read the whole repo in one pass: at ~958k tokens it does not fit, and the strategy table above applies in full. Pick the scope the task needs — most work touches only one of the three halves below. diff --git a/CHANGELOG.md b/CHANGELOG.md index 79134f4e..d84bcce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,47 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +## [2.4.3] - 2026-09-14 + +Patch release: the OpenCode plugin is runtime-compatible and ships in the +published distribution, so `ainative machine init` renders a working plugin +from an installed wheel instead of skipping it. + +### Fixed + +- **The OpenCode LOC-gate plugin no longer depends on a Bun runtime** (#153): + the template read the edited file through `Bun.file` — init crashed with + `Bun.file is not defined`, so every edit and write failed — and ran + `update_on_edit.py` through the Bun shell — `$ is not a function` after every + successful edit, so AI summaries were never regenerated. It now uses + `readFile` from `node:fs/promises` and `spawn` from `node:child_process`; + semantics are unchanged (LOC gate, Python probe, summary regeneration). +- **The published distribution carries the harness adapters**: the staged + payload shipped no `adapters/` tree, so a wheel install skipped the plugin + with a visible `SKIP` and the documented OpenCode integration existed for + checkout users only. The wheel and the lifecycle bundle now render the + OpenCode plugin (and the Claude Code adapter) from their own payload. + +### Added + +- **A durable OpenCode runtime gate** (the `opencode-plugin` CI job, inside the + Production Gate): it renders the plugin, loads it in a real runtime with no + global `Bun`, drives the `tool.execute.before` / `tool.execute.after` hooks + for edit and write, proves the blocking LOC limit comes from + `conventions.json`, and asserts `AI_SUMMARY.md` is regenerated. A second step + installs the built wheel in a fresh venv and runs the same gate against the + plugin `ainative machine init` rendered from the installed payload. + +### Documentation + +- The Multi-Vault operator guide's real-machine procedure names the pinned + release, and the install pins in both READMEs, `UPDATING.md` and + `docs/RELEASING.md` move to v2.4.3. + +### Maintenance + +- `actions/setup-python` moves to its current major in the CI workflows (#135). + ## [2.4.2] - 2026-09-13 Patch release: the governed Multi-Vault push path now enforces the approved diff --git a/MANIFEST.in b/MANIFEST.in index 2dcbde39..91875586 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -8,6 +8,7 @@ include _build_backend.py include _payload_staging.py include conventions.json include docs/VERIFIED-WORK-PLANE.md +recursive-include adapters * recursive-include skills * recursive-include templates * recursive-include tools/ai_docs * \ No newline at end of file diff --git a/PORTABILITY.md b/PORTABILITY.md index 2c94d6f0..3224d9be 100644 --- a/PORTABILITY.md +++ b/PORTABILITY.md @@ -116,7 +116,7 @@ Then set the machine-local values: - **Method** — the Linux installer creates `~/.config/opencode/AGENTS.md`, which OpenCode loads globally. - **Skills / agent** — shared skills are linked under `~/.agents/skills/`; the `anti-debt` subagent is linked under `~/.config/opencode/agents/`. -- **Hooks** — `~/.config/opencode/plugins/ai-native-dev-stack.ts` installs a native plugin. It blocks edits above 1500 LOC and regenerates AI summaries after edits without changing `opencode.json`. +- **Hooks** — `ainative machine init` renders `~/.config/opencode/plugins/ai-native-dev-stack.ts` from the installed distribution. It blocks edits above the blocking LOC limit in `conventions.json`, and regenerates AI summaries after edits without changing `opencode.json`. The plugin needs no Bun runtime (it uses `node:fs/promises` and `node:child_process`), and the `opencode-plugin` CI gate executes the installed plugin — edit, write, refusal, summary regeneration — on every change. - Restart OpenCode after installation because rules, skills, agents, and plugins are loaded at startup. --- diff --git a/README.fr.md b/README.fr.md index 77878422..f69bd472 100644 --- a/README.fr.md +++ b/README.fr.md @@ -592,11 +592,11 @@ OpenCode et Cursor), pose `AGENTS.md` et `conventions.json`. # Relancez la ligne d'installation quand une release change le runtime du # lifecycle ; `ainative update` vous le dira (CLI_UPDATE_REQUIRED). # -# PyPI (ainative-dev-stack==2.4.0) est cable mais pas encore publie : il +# PyPI (ainative-dev-stack==2.4.3) est cable mais pas encore publie : il # manque une configuration Trusted Publisher unique sur le compte PyPI # (docs/RELEASING.md). D'ici la, la release GitHub epinglee ci-dessous est # l'installation supportee. -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.0" # release epinglee (reproductible) +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" # release epinglee (reproductible) cd /chemin/vers/votre-projet ainative setup # guide : profil, integration machine, doctor diff --git a/README.md b/README.md index e2388481..ccd72655 100644 --- a/README.md +++ b/README.md @@ -587,11 +587,11 @@ ainative doctor # includes the Knowledge section # Re-run the install line when a release changes the lifecycle runtime; # `ainative update` tells you when (CLI_UPDATE_REQUIRED). # -# PyPI (ainative-dev-stack==2.4.0) is wired but not yet published: it needs +# PyPI (ainative-dev-stack==2.4.3) is wired but not yet published: it needs # a one-time Trusted Publisher setup on the PyPI account (docs/RELEASING.md, # "PyPI"), so until then the pinned GitHub release below is the supported # install. -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.0" # pinned release (reproducible) +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" # pinned release (reproducible) cd your-project ainative setup # guided: profile, machine integration, doctor @@ -644,7 +644,7 @@ cp tools/ai_docs/config.sh.example tools/ai_docs/config.sh # Fill in Obsidian vault path, Python path, graphify binary # 4. Install the CLI; it configures the hook — no hand-edited JSON: -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.0" +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" ainative init --profile standard # merges ONE owned PostToolUse entry into .claude/settings.json # (hand-registered before the lifecycle existed? init adopts it idempotently.) diff --git a/UPDATING.md b/UPDATING.md index cf760d63..138f7e89 100644 --- a/UPDATING.md +++ b/UPDATING.md @@ -49,7 +49,7 @@ update each project. `ainative update` refuses the other order with ```bash # 1. the CLI / runtime -pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.0" +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" # 2. each project ainative update check # is there anything newer? (works with any runtime) diff --git a/VERSION b/VERSION index 8e8299dc..6550da69 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.4.2 +2.4.3 \ No newline at end of file diff --git a/_payload_staging.py b/_payload_staging.py index 81cffdfe..fd38589f 100644 --- a/_payload_staging.py +++ b/_payload_staging.py @@ -25,9 +25,13 @@ PAYLOAD_DIRNAME = Path("ainative") / "_payload" -# What a project install needs, and nothing more. The wheel does not carry the -# test suite, the docs archive or the anti-debt agent. -PAYLOAD_TREES = ("skills", "tools/ai_docs", "templates") +# What a project install needs, plus the harness adapters `ainative machine +# init` renders. The adapters belong in the distribution: when the payload did +# not carry them, a wheel install skipped the OpenCode plugin with a visible +# SKIP, so the documented machine integration existed for checkout users only +# (#153 follow-up). The wheel still does not carry the test suite, the docs +# archive or the anti-debt agent. +PAYLOAD_TREES = ("skills", "adapters", "tools/ai_docs", "templates") PAYLOAD_FILES = ("AGENTS.md", "VERSION", "conventions.json", "docs/VERIFIED-WORK-PLANE.md") diff --git a/ainative/__init__.py b/ainative/__init__.py index 9126f0fa..e0788076 100644 --- a/ainative/__init__.py +++ b/ainative/__init__.py @@ -9,6 +9,6 @@ from __future__ import annotations -__version__ = "2.4.2" +__version__ = "2.4.3" __all__ = ["__version__"] \ No newline at end of file diff --git a/docs/MULTIVAULT-OPERATOR-GUIDE.md b/docs/MULTIVAULT-OPERATOR-GUIDE.md index 4def8350..53eea379 100644 --- a/docs/MULTIVAULT-OPERATOR-GUIDE.md +++ b/docs/MULTIVAULT-OPERATOR-GUIDE.md @@ -59,7 +59,7 @@ history only. ENFORCED-DIAGNOSTIC and ENFORCED-AUTHENTICATED are not available. 1. Install and initialize the stack (pinned release; see README for the current version): ```powershell - pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.2" + pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" ainative machine init # installs the method and assets for every harness ainative machine doctor # healthy, or exit 1 naming the failing asset ``` @@ -92,8 +92,8 @@ history only. ENFORCED-DIAGNOSTIC and ENFORCED-AUTHENTICATED are not available. invalidates the binding (`DENY_ROOT_STALE` / `DENY_CHECKOUT_STALE`): re-bind after the move. 4. Governed `exec` and `sync` (usage notes): - - `exec`: place the approved command after the options **without** a `--` separator; with - 2.4.2 the separator is captured by argv and the spawn fails with a file-not-found error. + - `exec`: place the approved command after the options **without** a `--` separator; the + current CLI captures the separator into argv and the spawn fails with a file-not-found error. - `sync`: the Git environment is positive-only (no ambient credentials or proxies). A fetch from a private remote without dedicated credential material is denied by design (`AINATIVE_FETCH_FAILED`); `file://` and public remotes work. Force pushes and ref diff --git a/docs/PUBLIC-PILOT.md b/docs/PUBLIC-PILOT.md index 422e27b8..a3e44792 100644 --- a/docs/PUBLIC-PILOT.md +++ b/docs/PUBLIC-PILOT.md @@ -25,7 +25,7 @@ source pilot-venv/bin/activate # Windows (PowerShell): pilot-venv\Scripts\Activate.ps1 -pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.0" +pip install "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2.4.3" ainative --version ``` @@ -33,7 +33,7 @@ The last command prints the lifecycle version, the state schema and the stack version. **Pass:** it prints them without an error. Note the versions in the feedback form. -PyPI (`pip install ainative-dev-stack==2.4.0`) is not published yet — the +PyPI (`pip install ainative-dev-stack==2.4.3`) is not published yet — the one-time trusted-publisher setup on the PyPI account is still pending. The pinned release above is the supported install for this pilot. diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 3f05a084..cc6ddf81 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -19,8 +19,8 @@ reminder; the gates fail closed and none of them fixes anything silently. ```bash git checkout main && git pull --ff-only -git tag v2.3.0 -git push origin v2.3.0 +git tag v2.4.3 +git push origin v2.4.3 ``` The **Release assets** workflow then runs, in order: @@ -69,29 +69,29 @@ workflow token, and GitHub does not let one workflow's token trigger another exists, upload an already-published release with: ```bash -gh workflow run publish-pypi.yml -f tag=v2.4.0 +gh workflow run publish-pypi.yml -f tag=v2.4.3 ``` ```bash # what a user then runs -pip install ainative-dev-stack==2.4.0 +pip install ainative-dev-stack==2.4.3 # or, isolated: -pipx install ainative-dev-stack==2.4.0 +pipx install ainative-dev-stack==2.4.3 ``` ## Verifying what was published ```bash # integrity: the bytes match the published sums -gh release download v2.4.0 --dir dist +gh release download v2.4.3 --dir dist cd dist && sha256sum -c SHA256SUMS # provenance: which workflow, repository and commit produced them -gh attestation verify dist/ainative_dev_stack-2.4.0-py3-none-any.whl -R Rwanbt/ai-native-dev-stack +gh attestation verify dist/ainative_dev_stack-2.4.3-py3-none-any.whl -R Rwanbt/ai-native-dev-stack # PyPI provenance (attestations are published with the files) python -m pip install --upgrade pypi-attestations -python -m pypi_attestations verify dist/ainative_dev_stack-2.4.0-py3-none-any.whl +python -m pypi_attestations verify dist/ainative_dev_stack-2.4.3-py3-none-any.whl ``` `SHA256SUMS` alone proves integrity against the source that published it; it diff --git a/scripts/harness/opencode-plugin-runtime.mjs b/scripts/harness/opencode-plugin-runtime.mjs new file mode 100644 index 00000000..8ca7233f --- /dev/null +++ b/scripts/harness/opencode-plugin-runtime.mjs @@ -0,0 +1,73 @@ +// Runtime harness for the OpenCode plugin (#153). +// +// The plugin bug this exists to catch — a global `Bun.file` and a Bun-shell `$` +// that the opencode runtime does not provide — only appears when the real file +// is loaded by a real JavaScript runtime and its hooks are driven. So this +// harness imports the *rendered* plugin, builds it with a workspace, then runs +// the step list it was given through `tool.execute.before` / `tool.execute.after`. +// +// It asserts nothing: the plan and the assertions live in the Python driver +// (scripts/opencode_plugin_runtime_e2e.py), so a failure message names the +// property, not a stack trace. The report is the only thing printed to stdout; +// the driver parses it. Node warnings and errors go to stderr untouched. +// +// Usage: node --experimental-strip-types opencode-plugin-runtime.mjs +// Plan: {"plugin": "", "workspace": "", +// "steps": [{"id", "hook", "input", "output"}, ...]} + +import { pathToFileURL } from "node:url" +import { readFile } from "node:fs/promises" + +const plan = JSON.parse(await readFile(process.argv[2], "utf8")) +const report = { loaded: false, export: null, hooks: [], steps: [] } + +const emit = (failed) => { + console.log(JSON.stringify(report)) + process.exit(failed ? 1 : 0) +} + +// The #153 failure mode was a runtime with no global `Bun`. Recording what the +// runtime actually provides makes the absence part of the evidence rather than +// an assumption about the host. +report.globalBun = typeof Bun + +let module +try { + module = await import(pathToFileURL(plan.plugin).href) +} catch (error) { + report.loadError = String((error && error.stack) || error) + emit(true) +} + +const factory = module.AiNativeDevStack + ?? Object.values(module).find((value) => typeof value === "function") +if (typeof factory !== "function") { + report.loadError = "the rendered module exports no plugin factory" + emit(true) +} +report.export = "AiNativeDevStack" in module ? "AiNativeDevStack" : "anonymous" + +let hooks +try { + hooks = await factory({ directory: plan.workspace }) +} catch (error) { + report.loadError = String((error && error.stack) || error) + emit(true) +} +report.loaded = true +report.hooks = Object.keys(hooks).sort() + +for (const step of plan.steps) { + const entry = { id: step.id, ok: true } + try { + const hook = hooks[step.hook] + if (typeof hook !== "function") throw new Error(`the plugin exposes no ${step.hook}`) + await hook(step.input ?? {}, step.output ?? {}) + } catch (error) { + entry.ok = false + entry.error = String((error && error.message) || error) + } + report.steps.push(entry) +} + +emit(false) diff --git a/scripts/opencode_plugin_published_e2e.py b/scripts/opencode_plugin_published_e2e.py new file mode 100644 index 00000000..b30fc0cc --- /dev/null +++ b/scripts/opencode_plugin_published_e2e.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 +"""Published-artifact gate: the OpenCode plugin, rendered by the real wheel. + +The source-level runtime gate (scripts/opencode_plugin_runtime_e2e.py) proves the +template is runtime-compatible. It cannot prove that a user who installs the +published wheel gets a working plugin at all: before this gate, the staged +payload carried no `adapters/` tree, so `ainative machine init` skipped the +plugin with a visible SKIP and the documented OpenCode integration existed for +checkout users only. + +So this gate never reads the checkout's template. It builds the wheel (or takes +`--wheel` — the released bytes, for a post-release run), installs it into a +fresh venv, runs `ainative machine init` against a throwaway home, then hands +the *installed* plugin to the runtime driver. The plugin must come out of the +venv's own payload; a rendered copy pointing back at this checkout fails the +gate. + +Usage: + python scripts/opencode_plugin_published_e2e.py + python scripts/opencode_plugin_published_e2e.py --wheel dist/ainative_dev_stack-2.4.3-py3-none-any.whl + python scripts/opencode_plugin_published_e2e.py --keep +""" + +from __future__ import annotations + +import argparse +import json +import os +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +RUNTIME_GATE = REPO / "scripts" / "opencode_plugin_runtime_e2e.py" +PLUGIN_RELATIVE = Path(".config") / "opencode" / "plugins" / "ai-native-dev-stack.ts" +STEP_TIMEOUT_SECONDS = 900 + + +class Failure(SystemExit): + pass + + +def run(command: list[str], *, cwd: Path | None = None, env: dict | None = None, + expect: int = 0) -> subprocess.CompletedProcess: + completed = subprocess.run([str(item) for item in command], + cwd=str(cwd) if cwd else None, env=env, + capture_output=True, text=True, + stdin=subprocess.DEVNULL, timeout=STEP_TIMEOUT_SECONDS) + if completed.returncode != expect: + print(completed.stdout[-2000:]) + print(completed.stderr[-2000:], file=sys.stderr) + raise Failure(f"expected exit {expect}, got {completed.returncode}: {command}") + return completed + + +def require(condition: bool, message: str) -> None: + if not condition: + raise Failure(f"FAILED: {message}") + print(f" [PASS] {message}") + + +def clean_environment() -> dict: + return {key: value for key, value in os.environ.items() + if key not in ("PYTHONPATH", "AINATIVE_STACK_SOURCE", "OBSIDIAN_VAULT", + "OBSIDIAN_PROJECT_SLUG")} + + +def build_wheel(root: Path) -> Path: + print("[1] build the wheel the user would install") + distribution = root / "dist" + run([sys.executable, "-m", "pip", "install", "--disable-pip-version-check", "-q", + "build"]) + run([sys.executable, "-m", "build", "--wheel", "--outdir", distribution], cwd=REPO) + wheel = next(distribution.glob("*.whl")) + print(f" wheel: {wheel.name}") + return wheel + + +def install(wheel: Path, root: Path) -> tuple[Path, Path]: + print("[2] install it into a fresh venv") + venv = root / "venv" + run([sys.executable, "-m", "venv", venv]) + scripts = venv / ("Scripts" if os.name == "nt" else "bin") + python = scripts / ("python.exe" if os.name == "nt" else "python") + ainative = scripts / ("ainative.exe" if os.name == "nt" else "ainative") + run([python, "-m", "pip", "install", "--disable-pip-version-check", "-q", wheel]) + require(ainative.is_file(), "the wheel installs the ainative console script") + return ainative, python + + +def machine_init(ainative: Path, home: Path, env: dict) -> Path: + print("[3] ainative machine init renders the plugin from the installed payload") + completed = run([ainative, "machine", "init", "--home", home, "--json"], env=env) + report = json.loads(completed.stdout) + require(report.get("changes", 0) > 0, "the machine init recorded changes") + require(report.get("errors", 1) == 0, "the machine init reported no error") + require(str(home.resolve()) in str(report.get("manifest")), + "the machine manifest was written under the throwaway home") + + plugin = home / PLUGIN_RELATIVE + require(plugin.is_file(), f"the installed distribution renders {PLUGIN_RELATIVE}") + return plugin + + +def assert_plugin_comes_from_the_wheel(plugin: Path, venv: Path) -> None: + print("[4] the rendered plugin resolves to the installed payload, not this checkout") + text = plugin.read_text(encoding="utf-8") + require("readFile" in text and "node:fs/promises" in text + and "node:child_process" in text, + "the installed plugin carries the runtime-compatible implementation") + require("Bun." not in text and "await $(" not in text, + "the installed plugin carries no Bun runtime dependency") + root_line = next((line for line in text.splitlines() + if line.startswith("const STACK_ROOT = ")), "") + require(root_line != "", "the installed plugin declares its stack root") + stack_root = json.loads(root_line.split("=", 1)[1].strip()) + require(Path(stack_root).is_relative_to(venv.resolve()), + f"the plugin's stack root is inside the venv ({stack_root})") + require(not Path(stack_root).is_relative_to(REPO.resolve()), + "the plugin's stack root is not this checkout") + require((Path(stack_root) / "adapters" / "opencode" / "ai-native-dev-stack.ts").is_file(), + "the installed payload carries the plugin template it was rendered from") + + +def machine_doctor(ainative: Path, home: Path, env: dict) -> None: + print("[5] ainative machine doctor accepts the recorded integration") + completed = run([ainative, "machine", "doctor", "--home", home, "--json"], env=env) + report = json.loads(completed.stdout) + require(report.get("healthy") is True, "the machine doctor verdict is healthy") + + +def runtime_gate(plugin: Path, python: Path, env: dict) -> None: + print("[6] the installed plugin passes the runtime gate (edit/write, AI_SUMMARY)") + completed = run([sys.executable, RUNTIME_GATE, "--plugin", plugin, + "--python-exe", python], env=env) + require("8/8 checks passed" in completed.stdout, + "the installed plugin passes all 8 runtime checks") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--wheel", type=Path, default=None, + help="install this wheel instead of building one " + "(the released bytes, for a post-release run)") + parser.add_argument("--keep", action="store_true", help="keep the workspace") + args = parser.parse_args() + + root = Path(tempfile.mkdtemp(prefix="opencode-published-")) + home = root / "home" + home.mkdir() + env = {**clean_environment(), "HOME": str(home), "USERPROFILE": str(home), + "AINATIVE_NO_UPDATE_CHECK": "1", "PYTHONIOENCODING": "utf-8"} + try: + wheel = args.wheel if args.wheel is not None else build_wheel(root) + require(wheel.is_file(), f"the wheel to install exists: {wheel}") + ainative, python = install(wheel, root) + plugin = machine_init(ainative, home, env) + assert_plugin_comes_from_the_wheel(plugin, root / "venv") + machine_doctor(ainative, home, env) + runtime_gate(plugin, python, env) + finally: + if args.keep: + print(f"workspace kept at {root}") + else: + shutil.rmtree(root, ignore_errors=True) + + print("\nPUBLISHED OPENCODE PLUGIN: all gates passed.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/opencode_plugin_runtime_e2e.py b/scripts/opencode_plugin_runtime_e2e.py new file mode 100644 index 00000000..98f21e0b --- /dev/null +++ b/scripts/opencode_plugin_runtime_e2e.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""OpenCode plugin runtime gate: render the plugin, execute it, drive edit/write. + +#153 was a runtime bug, not a unit-test bug: the plugin crashed at load with +`Bun.file is not defined`, and after every successful edit with +`$ is not a function`, so `update_on_edit.py` never ran. Tests that read the +template cannot catch either — only a runtime that loads the *rendered* file, +builds the plugin, and calls its hooks can. + +This driver runs when the distribution is a checkout; the published path is +`scripts/opencode_plugin_published_e2e.py`, which points `--plugin` at the file +`ainative machine init` rendered from the installed wheel. Eight checks: + + 1. the rendered plugin loads in a real runtime (global `Bun` is undefined) + 2. `edit`: before allows, after regenerates AI_SUMMARY.md + 3. `write`: before allows, after regenerates AI_SUMMARY.md + 4. an edit above the blocking LOC limit is refused, with the gate's message + 5. the blocking limit comes from conventions.json, not a hardcoded number + 6. an unreadable (typically new) path is not gated + 7. without a Python interpreter the after-hook skips, breaking no edit + 8. the runtime contract holds: node: imports, no Bun/`$` runtime dependency + +Usage: + python scripts/opencode_plugin_runtime_e2e.py + python scripts/opencode_plugin_runtime_e2e.py --node /path/to/node + python scripts/opencode_plugin_runtime_e2e.py --plugin \\ + --python-exe # the published-artifact path +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +HARNESS = Path(__file__).resolve().parent / "harness" / "opencode-plugin-runtime.mjs" +TEMPLATE_RELATIVE = Path("adapters/opencode/ai-native-dev-stack.ts") +UPDATE_SCRIPT_RELATIVE = Path("tools/ai_docs/update_on_edit.py") +_STACK_ROOT_LITERAL = re.compile(r"^const STACK_ROOT = (.+)$", re.M) +FORBIDDEN_RUNTIME = ("Bun.", "Bun(", "await $(", "require(\"bun\")", "require('bun')") +REQUIRED_IMPORTS = ("node:fs/promises", "node:child_process") +FALLBACK_BLOCKING_LOC = 1500 +STEP_TIMEOUT_SECONDS = 180 +CHECKS = 8 + + +class Failure(SystemExit): + pass + + +def check(number: int, description: str, condition: bool, detail: str = "") -> None: + status = "PASS" if condition else "FAIL" + print(f" [{status}] {number}/{CHECKS} {description}") + if not condition: + raise Failure(f"FAILED {number}/{CHECKS}: {description}" + + (f"\n {detail}" if detail else "")) + + +def run(command: list[str], *, env: dict | None = None, + input_text: str | None = None) -> subprocess.CompletedProcess: + return subprocess.run([str(item) for item in command], env=env, + input=input_text, capture_output=True, text=True, + timeout=STEP_TIMEOUT_SECONDS) + + +def node_command(node: str) -> list[str]: + """Node 22.6+ needs --experimental-strip-types for .ts; 23.6+ defaults it.""" + + probe = run([node, "--experimental-strip-types", "-e", "0"]) + if probe.returncode == 0: + return [node, "--experimental-strip-types"] + return [node] + + +def render_plugin(template: Path, stack_root: Path, destination: Path) -> str: + text = template.read_text(encoding="utf-8") + rendered = text.replace("{{STACK_ROOT_JSON}}", json.dumps(str(stack_root))) + if "{{STACK_ROOT_JSON}}" in rendered: + raise Failure(f"{template} still carries an unrendered placeholder") + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(rendered, encoding="utf-8") + return rendered + + +def embedded_stack_root(plugin: Path) -> str: + match = _STACK_ROOT_LITERAL.search(plugin.read_text(encoding="utf-8")) + if match is None: + raise Failure(f"{plugin} carries no STACK_ROOT literal; not a rendered plugin") + return json.loads(match.group(1)) + + +def blocking_loc(stack_root: Path) -> int: + try: + conventions = json.loads((stack_root / "conventions.json").read_text(encoding="utf-8")) + value = conventions.get("file_size", {}).get("blocking") + return int(value) if isinstance(value, int) and value > 0 else FALLBACK_BLOCKING_LOC + except (OSError, ValueError): + return FALLBACK_BLOCKING_LOC + + +def hook_step(step_id: str, hook: str, tool: str, call_id: str, file_path: Path) -> dict: + return {"id": step_id, "hook": hook, + "input": {"tool": tool, "callID": call_id}, + "output": {"args": {"filePath": str(file_path)}}} + + +def execute_plan(prefix: list[str], plugin: Path, workspace: Path, + steps: list[dict], env: dict) -> dict: + plan_file = workspace / "plan.json" + plan_file.write_text(json.dumps({"plugin": str(plugin), "workspace": str(workspace), + "steps": steps}), encoding="utf-8") + completed = run([*prefix, str(HARNESS), str(plan_file)], env=env) + try: + return json.loads(completed.stdout) + except ValueError: + raise Failure("the runtime harness produced no JSON report\n" + f"stdout:\n{completed.stdout[-1500:]}\n" + f"stderr:\n{completed.stderr[-1500:]}") from None + + +def write_module(workspace: Path, name: str = "main") -> Path: + module = workspace / "src" + module.mkdir(parents=True, exist_ok=True) + (module / "AI_CONTEXT.md").write_text( + "# src — harness module\n\nPurpose: exercise the plugin hooks.\n", encoding="utf-8") + source = module / f"{name}.py" + source.write_text("def greet(name):\n return f\"hello {name}\"\n", encoding="utf-8") + return source + + +def step_ok(report: dict, step_id: str) -> dict: + for entry in report.get("steps", []): + if entry["id"] == step_id: + return entry + raise Failure(f"the harness reported no step {step_id!r}: {report}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--node", default=shutil.which("node") or "node", + help="the node executable (default: on PATH)") + parser.add_argument("--stack-root", type=Path, default=REPO, + help="distribution root holding the template (default: this checkout)") + parser.add_argument("--plugin", type=Path, default=None, + help="an already-rendered plugin (the published artifact); " + "the stack root is read from its STACK_ROOT literal") + parser.add_argument("--python-exe", type=Path, default=None, + help="a Python whose directory is prepended to PATH for the hooks") + parser.add_argument("--keep", action="store_true", help="keep the workspace") + args = parser.parse_args() + + if not HARNESS.is_file(): + raise Failure(f"the runtime harness is missing: {HARNESS}") + prefix = node_command(args.node) + root = Path(tempfile.mkdtemp(prefix="opencode-runtime-")) + try: + return qualify(prefix, root, args) + finally: + if args.keep: + print(f"workspace kept at {root}") + else: + shutil.rmtree(root, ignore_errors=True) + + +def qualify(prefix: list[str], root: Path, args) -> int: + print("OpenCode plugin runtime gate") + + env = {**os.environ, "PYTHONIOENCODING": "utf-8"} + if args.python_exe is not None: + env["PATH"] = str(Path(args.python_exe).parent) + os.pathsep + env.get("PATH", "") + + if args.plugin is not None: + plugin = args.plugin.resolve() + if not plugin.is_file(): + raise Failure(f"the installed plugin does not exist: {plugin}") + stack_root = Path(embedded_stack_root(plugin)) + template = None + else: + stack_root = args.stack_root.resolve() + template = stack_root / TEMPLATE_RELATIVE + if not template.is_file(): + raise Failure(f"the distribution carries no plugin template: {template}") + plugin = root / "rendered" / "ai-native-dev-stack.ts" + plugin.parent.mkdir(parents=True, exist_ok=True) + render_plugin(template, stack_root, plugin) + + print(f" plugin: {plugin}") + print(f" stack root: {stack_root}") + + # 1 — the rendered file loads in a real runtime, which has no global Bun. + workspace = root / "workspace" + workspace.mkdir() + report = execute_plan(prefix, plugin, workspace, [], env) + check(1, "the rendered plugin loads in a real runtime (global Bun is undefined)", + report.get("loaded") is True + and report.get("globalBun") == "undefined" + and report.get("hooks") == ["tool.execute.after", "tool.execute.before"], + json.dumps(report)[:1200]) + + # 2 — edit: before allows, after runs update_on_edit.py and regenerates. + source = write_module(workspace) + summary = source.parent / "AI_SUMMARY.md" + steps = [hook_step("edit-before", "tool.execute.before", "edit", "c1", source), + hook_step("edit-after", "tool.execute.after", "edit", "c1", source)] + report = execute_plan(prefix, plugin, workspace, steps, env) + first = summary.read_text(encoding="utf-8") if summary.is_file() else "" + first_ok = (step_ok(report, "edit-before")["ok"] and step_ok(report, "edit-after")["ok"] + and "greet" in first) + + source.write_text("def greet(name):\n return f\"hello {name}\"\n\n\n" + "def farewell(name):\n return f\"bye {name}\"\n", encoding="utf-8") + steps = [hook_step("edit2-before", "tool.execute.before", "edit", "c2", source), + hook_step("edit2-after", "tool.execute.after", "edit", "c2", source)] + report = execute_plan(prefix, plugin, workspace, steps, env) + second = summary.read_text(encoding="utf-8") if summary.is_file() else "" + check(2, "edit: before allows, after regenerates AI_SUMMARY.md", + first_ok and step_ok(report, "edit2-before")["ok"] + and step_ok(report, "edit2-after")["ok"] + and "farewell" in second and second != first, + json.dumps(report)[:1200]) + + # 3 — write follows the same lifecycle. + helper = source.parent / "helper.py" + helper.write_text("def assist(value):\n return value + 1\n", encoding="utf-8") + steps = [hook_step("write-before", "tool.execute.before", "write", "w1", helper), + hook_step("write-after", "tool.execute.after", "write", "w1", helper)] + report = execute_plan(prefix, plugin, workspace, steps, env) + written = summary.read_text(encoding="utf-8") if summary.is_file() else "" + check(3, "write: before allows, after regenerates AI_SUMMARY.md", + step_ok(report, "write-before")["ok"] and step_ok(report, "write-after")["ok"] + and "assist" in written, + json.dumps(report)[:1200]) + + # 4 — the blocking limit refuses the edit and names the gate. + limit = blocking_loc(stack_root) + big = source.parent / "big.py" + big.write_text("value = 1\n" * (limit + 1), encoding="utf-8") + steps = [hook_step("gate", "tool.execute.before", "edit", "b1", big)] + report = execute_plan(prefix, plugin, workspace, steps, env) + refusal = step_ok(report, "gate") + check(4, f"an edit above the blocking LOC limit ({limit}) is refused", + refusal["ok"] is False and "LOC gate" in refusal.get("error", "") + and str(limit) in refusal.get("error", ""), + json.dumps(report)[:1200]) + + # 5 — the limit is read from conventions.json at runtime, not hardcoded. + synthetic_root = root / "synthetic-stack" + synthetic_root.mkdir() + (synthetic_root / "conventions.json").write_text( + json.dumps({"file_size": {"blocking": 10}}), encoding="utf-8") + synthetic_plugin = root / "rendered" / "synthetic.ts" + render_plugin(template if template else stack_root / TEMPLATE_RELATIVE, + synthetic_root, synthetic_plugin) + synthetic_workspace = root / "synthetic-workspace" + synthetic_workspace.mkdir() + small = synthetic_workspace / "small.py" + small.write_text("value = 1\n" * 5, encoding="utf-8") + over = synthetic_workspace / "over.py" + over.write_text("value = 1\n" * 11, encoding="utf-8") + steps = [hook_step("config-small", "tool.execute.before", "edit", "s1", small), + hook_step("config-over", "tool.execute.before", "edit", "s2", over)] + report = execute_plan(prefix, synthetic_plugin, synthetic_workspace, steps, env) + over_entry = step_ok(report, "config-over") + check(5, "the blocking limit comes from conventions.json (10, not the fallback)", + step_ok(report, "config-small")["ok"] is True + and over_entry["ok"] is False and "10" in over_entry.get("error", ""), + json.dumps(report)[:1200]) + + # 6 — a path that does not exist yet (a new file) is not gated. + steps = [hook_step("new-file", "tool.execute.before", "write", "n1", + workspace / "src" / "not-written-yet.py")] + report = execute_plan(prefix, plugin, workspace, steps, env) + check(6, "an unreadable (typically new) path is not gated", + step_ok(report, "new-file")["ok"] is True, json.dumps(report)[:1200]) + + # 7 — no Python anywhere: the after-hook skips and the edit still succeeds. + barren = root / "no-python" + barren.mkdir() + no_python_env = {**env, "PATH": str(barren)} + before_bytes = summary.read_bytes() + steps = [hook_step("nopy-before", "tool.execute.before", "edit", "p1", source), + hook_step("nopy-after", "tool.execute.after", "edit", "p1", source)] + report = execute_plan(prefix, plugin, workspace, steps, no_python_env) + check(7, "without a Python interpreter the after-hook skips, breaking no edit", + step_ok(report, "nopy-before")["ok"] and step_ok(report, "nopy-after")["ok"] + and summary.read_bytes() == before_bytes, + json.dumps(report)[:1200]) + + # 8 — the runtime contract, on the exact bytes the runtime loads. + text = plugin.read_text(encoding="utf-8") + forbidden = [pattern for pattern in FORBIDDEN_RUNTIME if pattern in text] + missing = [name for name in REQUIRED_IMPORTS if name not in text] + check(8, "the runtime contract holds: node: imports, no Bun/$ runtime dependency", + not forbidden and not missing, + f"forbidden={forbidden} missing={missing}") + + print(f"\nOPENCODE PLUGIN RUNTIME: {CHECKS}/{CHECKS} checks passed") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Failure as refusal: + print(str(refusal), file=sys.stderr) + raise SystemExit(1) from None From de8bb6b4f01869b71dec5279b4a3fd00ae0d3500 Mon Sep 17 00:00:00 2001 From: Rwanbt Date: Mon, 14 Sep 2026 01:20:47 +0200 Subject: [PATCH 19/27] fix(opencode-e2e): compare resolved manifest paths, not Windows short names --- scripts/opencode_plugin_published_e2e.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/opencode_plugin_published_e2e.py b/scripts/opencode_plugin_published_e2e.py index b30fc0cc..fec6845f 100644 --- a/scripts/opencode_plugin_published_e2e.py +++ b/scripts/opencode_plugin_published_e2e.py @@ -96,7 +96,11 @@ def machine_init(ainative: Path, home: Path, env: dict) -> Path: report = json.loads(completed.stdout) require(report.get("changes", 0) > 0, "the machine init recorded changes") require(report.get("errors", 1) == 0, "the machine init reported no error") - require(str(home.resolve()) in str(report.get("manifest")), + # The manifest path in the report may use the platform's short or long form + # (Windows CI runs under RUNNER~1); compare resolved locations, not strings. + manifest = report.get("manifest") + require(isinstance(manifest, str) and Path(manifest).is_file() + and Path(manifest).resolve().parent == (home / ".ai-native").resolve(), "the machine manifest was written under the throwaway home") plugin = home / PLUGIN_RELATIVE @@ -151,6 +155,9 @@ def main() -> int: root = Path(tempfile.mkdtemp(prefix="opencode-published-")) home = root / "home" home.mkdir() + # Resolve once: Windows temp paths can be 8.3 short names, and comparing a + # short form with a resolved one made a passing install look wrong on CI. + home = home.resolve() env = {**clean_environment(), "HOME": str(home), "USERPROFILE": str(home), "AINATIVE_NO_UPDATE_CHECK": "1", "PYTHONIOENCODING": "utf-8"} try: From b2daefc60de6c358a44ec45f5dd61a950461e927 Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 17:47:49 +0200 Subject: [PATCH 20/27] fix(lifecycle): confine release credentials to the configured origin (#158) (#167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release transport attached GITHUB_TOKEN/GH_TOKEN to every request it made — including an artifact URL named by the metadata of a custom AINATIVE_UPDATE_URL — and urllib's redirect handler copied Authorization to any redirect target (both reproduced before changing production code). A new transport module follows redirects itself, one policy-checked hop at a time: release metadata may not leave its own origin, an artifact redirect is followed anonymously with every credential recomputed per hop, https -> http downgrades and URLs carrying userinfo are refused, HTTPS is required throughout, and redirect chains are bounded at five. GitHub private release assets are fetched through the release asset API URL (Accept: application/octet-stream); the 302 to the CDN is stripped of credentials. The credential origin is endpoint configuration (auth_origin), never derived from the URL being fetched. AINATIVE_UPDATE_URL is anonymous by construction: it can no longer inherit the provider token. Documented as a breaking behavior change in the CHANGELOG and the distribution lifecycle documentation. Refs #158 --- CHANGELOG.md | 21 ++ ainative/lifecycle/provider.py | 114 +++++--- ainative/lifecycle/transport.py | 231 ++++++++++++++++ docs/DISTRIBUTION-LIFECYCLE.md | 30 +++ tests/test_lifecycle_cli.py | 13 +- tests/test_lifecycle_update_integrity.py | 1 + tests/test_lifecycle_update_transport.py | 322 +++++++++++++++++++---- 7 files changed, 634 insertions(+), 98 deletions(-) create mode 100644 ainative/lifecycle/transport.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d84bcce6..0afd9350 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,27 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Security + +- **Release transport confines provider credentials to their origin** (#158): + the update check attached `GITHUB_TOKEN`/`GH_TOKEN` to every request it made — + including an artifact URL named by the metadata of a custom + `AINATIVE_UPDATE_URL` — and urllib's redirect handling copied the header to + any redirect target. A private opener now follows redirects one + policy-checked hop at a time: release metadata must stay on its own origin, + an artifact redirect is followed anonymously, `https → http` downgrades and + URLs carrying userinfo are refused, and redirect chains are bounded. Private + GitHub release assets are fetched through the release asset API + (`Accept: application/octet-stream`); the `302` to the CDN is followed with + every credential stripped. + +### Breaking behavior + +- **`AINATIVE_UPDATE_URL` is anonymous only** (#158): a custom release source no + longer inherits `GITHUB_TOKEN`/`GH_TOKEN`. The environment token is sent only + to `api.github.com`; a custom source must serve its metadata anonymously. + Conflicting or unusable update sources are refused rather than falling back. + ## [2.4.3] - 2026-09-14 Patch release: the OpenCode plugin is runtime-compatible and ships in the diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index 9ec4b3d0..7db9da35 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -23,11 +23,10 @@ import os import re import shutil -import urllib.error -import urllib.request from dataclasses import dataclass from pathlib import Path, PurePosixPath +from . import transport as transportlib from . import version as versionlib from .digest import digest_bytes from .errors import LifecycleError @@ -77,7 +76,7 @@ def protocol_document(version: str) -> dict: # Short on purpose: an update check runs in the background of a status command, # and a user must never wait on a slow endpoint to be told their profile. -NETWORK_TIMEOUT_SECONDS = 5 +NETWORK_TIMEOUT_SECONDS = transportlib.NETWORK_TIMEOUT_SECONDS MAX_METADATA_BYTES = 1 << 20 # 1 MiB of release JSON is already absurd MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB @@ -185,43 +184,62 @@ class ReleaseApiProvider(UpdateProvider): Everything read here is attacker-influenceable in the sense that matters: it arrives over the network. So the size is bounded before it is parsed, the - version must be SemVer, the archive URL must be HTTPS, and the bundle's - SHA-256 must be published by the source — `_select_asset` refuses anything - less, which is what makes `verify_archive` a comparison rather than a - computation. + version must be SemVer, the bundle's SHA-256 must be published by the source + — `_select_asset` refuses anything less, which is what makes + `verify_archive` a comparison rather than a computation — and every request + goes through `transport.get`, which sends a credential only to the origin + the endpoint config declares (PR-0A, #158). + + The selector decides which of two endpoints this is: + + * the built-in GitHub.com endpoint — credential origin `api.github.com`, so + metadata and the asset API are authenticated and a `302` to the CDN is + followed anonymously; + * the anonymous endpoint — any URL supplied as the constructor argument or + through `AINATIVE_UPDATE_URL`. It never receives a credential: its + metadata may name any artifact URL, and a custom source must not be able + to obtain the user's provider token by naming one. """ name = "release-api" - def __init__(self, url: str | None = None) -> None: - self.url = url or os.environ.get(RELEASE_URL_ENV) or DEFAULT_RELEASE_URL + def __init__(self, url: str | None = None, *, + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None) -> None: + if endpoint is not None: + self.endpoint = endpoint + self.url = url or endpoint.api_base_url + return + override = (url if url is not None + else os.environ.get(RELEASE_URL_ENV) or "").strip() + if override: + self.endpoint = transportlib.anonymous_endpoint(override) + self.url = override + else: + self.endpoint = transportlib.GITHUB_ENDPOINT + self.url = DEFAULT_RELEASE_URL + + def _token(self) -> str: + """The credential this endpoint may use. Anonymous endpoints never do.""" + + if self.endpoint.auth_origin is None: + return "" + return _update_token() def _get(self, url: str, limit: int) -> bytes: - if not url.lower().startswith("https://"): - raise LifecycleError("UPDATE_CHECK_FAILED", f"refusing a non-HTTPS source: {url}") - headers = {"User-Agent": "ainative-lifecycle", - "Accept": "application/vnd.github+json"} - token = _update_token() - if token: - headers["Authorization"] = f"Bearer {token}" - request = urllib.request.Request(url, headers=headers) - try: - with urllib.request.urlopen(request, timeout=NETWORK_TIMEOUT_SECONDS) as response: - payload = response.read(limit + 1) - except urllib.error.HTTPError as error: - # The status is the diagnosis; the message never echoes headers. - detail = ("the release source rate-limited this check; set GITHUB_TOKEN " - "or GH_TOKEN to raise the limit" - if error.code in (403, 429) - else f"the release source answered HTTP {error.code}") - raise LifecycleError("UPDATE_CHECK_FAILED", detail) from error - except (urllib.error.URLError, OSError, ValueError) as error: - raise LifecycleError("UPDATE_CHECK_FAILED", - f"cannot reach the release source: {error}") from error - if len(payload) > limit: - raise LifecycleError("UPDATE_INTEGRITY_FAILED", - f"response from {url} exceeds {limit} bytes") - return payload + """Fetch the release document; redirects may not leave its origin.""" + + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + + def _fetch_artifact(self, url: str, limit: int) -> bytes: + """Fetch release bytes; a cross-origin redirect is followed anonymously.""" + + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) def latest(self, channel: str) -> Release: raw = self._get(self.url, MAX_METADATA_BYTES) @@ -248,7 +266,25 @@ def latest(self, channel: str) -> Release: def fetch(self, release: Release) -> bytes: if not release.url: raise LifecycleError("UPDATE_UNAVAILABLE", "release declares no archive") - return self._get(release.url, MAX_ARCHIVE_BYTES) + return self._fetch_artifact(release.url, MAX_ARCHIVE_BYTES) + + +def _asset_url(asset: dict) -> str | None: + """The URL this stack fetches an asset from, or None when it publishes none. + + GitHub serves the asset API URL (`assets[].url`) and, for a private + repository, a `browser_download_url` that only anonymizes for public + assets. The API URL is also the only one the transport will authenticate, + because it lives on the configured API origin; the browser URL stays as the + fallback so a GitHub-API-compatible mirror that publishes only that field + keeps working. + """ + + for key in ("url", "browser_download_url"): + value = asset.get(key) + if isinstance(value, str) and value.lower().startswith("https://"): + return value + return None def _select_asset(document: dict, expected_version: str) -> tuple[str | None, str | None]: @@ -272,10 +308,11 @@ def _select_asset(document: dict, expected_version: str) -> tuple[str | None, st if not isinstance(asset, dict): continue name = str(asset.get("name", "")) - url = asset.get("browser_download_url") if not (name.startswith(LIFECYCLE_BUNDLE_PREFIX) - and name.endswith(LIFECYCLE_BUNDLE_SUFFIX) - and isinstance(url, str)): + and name.endswith(LIFECYCLE_BUNDLE_SUFFIX)): + continue + url = _asset_url(asset) + if url is None: continue if name != expected: raise LifecycleError( @@ -351,4 +388,5 @@ def copy_tree(source: Path, destination: Path) -> None: "PROVIDER_ENV", "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "DEFAULT_RELEASE_URL", "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", + "ReleaseProviderEndpointConfig", ] \ No newline at end of file diff --git a/ainative/lifecycle/transport.py b/ainative/lifecycle/transport.py new file mode 100644 index 00000000..193491a7 --- /dev/null +++ b/ainative/lifecycle/transport.py @@ -0,0 +1,231 @@ +"""HTTP transport for release metadata and artifacts: credential confinement. + +urllib follows redirects by copying every request header - `Authorization` +included - to the redirect target, whatever its origin. A release endpoint that +answers `302` can therefore hand the user's token to any host, and a custom +source can name an artifact URL that the fetcher will authenticate. Both are +credential exfiltration behind a legitimate-looking request, and neither shows +up in a test that only walks the happy path (PR-0A, issue #158). + +This module owns one rule and enforces it on every hop: a credential is sent +only to the origin it belongs to. Everything else follows from the two kinds of +request the update path makes: + +* **metadata** - the release document. It must stay on its own origin; a + redirect away from it is refused outright. +* **artifact** - the release bytes. They may legitimately live on a CDN (GitHub + answers `302` to `objects.githubusercontent.com`), so a cross-origin redirect + is followed - anonymously. The credential is recomputed per hop, so it can + never cross an origin boundary. + +HTTPS is mandatory on every hop; an `https -> http` downgrade is refused, and a +URL carrying userinfo is refused. Redirects and bodies are bounded: an update +check runs in the background of a status command and must fail fast, never +loop, and never read an unbounded body. +""" + +from __future__ import annotations + +import urllib.error +import urllib.parse +import urllib.request +from dataclasses import dataclass + +from .errors import LifecycleError + +NETWORK_TIMEOUT_SECONDS = 5 + +# A metadata document and an artifact pass through at most one legitimately +# chained redirect today (API -> object storage); five leaves room for a mirror +# without becoming a way to make the updater walk an arbitrary chain. +MAX_REDIRECTS = 5 + +USER_AGENT = "ainative-lifecycle" +ACCEPT_GITHUB_JSON = "application/vnd.github+json" +ACCEPT_OCTET_STREAM = "application/octet-stream" + +GITHUB_PROVIDER = "github" +GITHUB_API_BASE_URL = "https://api.github.com" +GITHUB_API_VERSION = "2022-11-28" +ANONYMOUS_PROVIDER = "anonymous-url" +ENVIRONMENT_CREDENTIAL_SOURCE = "environment" + +METADATA = "metadata" +ARTIFACT = "artifact" +_REDIRECT_CODES = frozenset({301, 302, 303, 307, 308}) +_HTTPS_PORT = 443 + + +@dataclass(frozen=True) +class ReleaseProviderEndpointConfig: + """Where a provider's requests go, and where its credential may go. + + `auth_origin` is the only origin ever allowed to receive a credential, and + it is configuration - never derived from the URL being fetched, or a + malicious response could widen its own trust. `credential_source` names + where the credential comes from and is resolved by the provider, not here. + An endpoint with `auth_origin=None` is anonymous by construction: no + credential can be attached whatever the environment holds. + """ + + provider: str + api_base_url: str + auth_origin: str | None = None + api_version: str = "" + credential_source: str = "" + + +GITHUB_ENDPOINT = ReleaseProviderEndpointConfig( + provider=GITHUB_PROVIDER, + api_base_url=GITHUB_API_BASE_URL, + auth_origin=GITHUB_API_BASE_URL, + api_version=GITHUB_API_VERSION, + credential_source=ENVIRONMENT_CREDENTIAL_SOURCE, +) + + +def anonymous_endpoint(url: str) -> ReleaseProviderEndpointConfig: + """The anonymous selector's endpoint: a URL, and no credential, ever.""" + + return ReleaseProviderEndpointConfig(provider=ANONYMOUS_PROVIDER, api_base_url=url) + + +def _origin_of(url: str) -> str: + """`https://host[:port]` for a request URL, or a refusal. + + A refused URL never reaches a socket, so a configuration mistake fails as a + message rather than as a request to the wrong place. + """ + + try: + parts = urllib.parse.urlsplit(url) + port = parts.port + except ValueError as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release URL is unreadable: {error}") from error + if parts.scheme != "https": + raise LifecycleError("UPDATE_CHECK_FAILED", + f"refusing a non-HTTPS release URL: {url}") + if parts.username is not None or parts.password is not None: + raise LifecycleError("UPDATE_CHECK_FAILED", + "refusing a release URL carrying embedded credentials") + host = parts.hostname + if not host: + raise LifecycleError("UPDATE_CHECK_FAILED", f"release URL names no host: {url}") + if ":" in host: + host = f"[{host}]" + if port is not None and port != _HTTPS_PORT: + return f"https://{host}:{port}" + return f"https://{host}" + + +def _may_receive(endpoint: ReleaseProviderEndpointConfig, origin: str) -> bool: + """True only for the configured credential origin. A bad config never grants.""" + + if endpoint.auth_origin is None: + return False + try: + return _origin_of(endpoint.auth_origin) == origin + except LifecycleError: + return False + + +def _request_headers(endpoint: ReleaseProviderEndpointConfig, origin: str, *, + accept: str, token: str) -> dict: + headers = {"User-Agent": USER_AGENT, "Accept": accept} + if endpoint.api_version: + headers["X-GitHub-Api-Version"] = endpoint.api_version + if token and _may_receive(endpoint, origin): + headers["Authorization"] = f"Bearer {token}" + return headers + + +class _NoRedirectHandler(urllib.request.HTTPRedirectHandler): + """Never redirects. `get()` follows the chain itself, one policy-checked hop + at a time; urllib's handler would copy `Authorization` to the target.""" + + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def _send(request: urllib.request.Request): + """One HTTP exchange, never a redirect followed by urllib.""" + + opener = urllib.request.build_opener(_NoRedirectHandler) + return opener.open(request, timeout=NETWORK_TIMEOUT_SECONDS) + + +def _redirect_target(current: str, error: urllib.error.HTTPError) -> str: + headers = error.headers + location = headers.get("Location") if headers is not None else None + if not location: + raise LifecycleError("UPDATE_CHECK_FAILED", + "release source redirected without a Location header") + target = urllib.parse.urljoin(current, location) + _origin_of(target) # refuses non-HTTPS, userinfo, or a missing host + return target + + +def _http_refusal(error: urllib.error.HTTPError) -> LifecycleError: + # The status is the diagnosis; the message never echoes headers. + detail = ("the release source rate-limited this check; set GITHUB_TOKEN " + "or GH_TOKEN to raise the limit" + if error.code in (403, 429) + else f"the release source answered HTTP {error.code}") + return LifecycleError("UPDATE_CHECK_FAILED", detail) + + +def get(url: str, *, limit: int, endpoint: ReleaseProviderEndpointConfig, + kind: str, accept: str, token: str = "") -> bytes: + """Fetch release metadata or artifact bytes under the confinement rule. + + Metadata may only redirect within its own origin. Artifacts may redirect + across origins, and every hop recomputes its headers, so a credential is + attached exactly at `auth_origin` - the CDN hop after a GitHub `302` is + anonymous by construction, not by a special case. + """ + + if kind not in (METADATA, ARTIFACT): + raise ValueError(f"unknown release request kind {kind!r}") + metadata_origin = _origin_of(url) if kind == METADATA else None + current = url + headers = _request_headers(endpoint, _origin_of(current), accept=accept, token=token) + + for _hop in range(MAX_REDIRECTS + 1): + request = urllib.request.Request(current, headers=dict(headers), method="GET") + try: + with _send(request) as response: + payload = response.read(limit + 1) + except urllib.error.HTTPError as error: + if error.code not in _REDIRECT_CODES: + raise _http_refusal(error) from error + target = _redirect_target(current, error) + if kind == METADATA and _origin_of(target) != metadata_origin: + raise LifecycleError( + "UPDATE_CHECK_FAILED", + f"release metadata redirects to another origin ({_origin_of(target)}); " + "refusing to follow") from error + current = target + headers = _request_headers(endpoint, _origin_of(current), accept=accept, + token=token) + continue + except (urllib.error.URLError, OSError, ValueError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"cannot reach the release source: {error}") from error + if len(payload) > limit: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"response from {current} exceeds {limit} bytes") + return payload + + raise LifecycleError( + "UPDATE_CHECK_FAILED", + f"release source redirects more than {MAX_REDIRECTS} times; refusing to follow") + + +__all__ = [ + "ReleaseProviderEndpointConfig", "GITHUB_ENDPOINT", "anonymous_endpoint", + "get", "METADATA", "ARTIFACT", "MAX_REDIRECTS", "NETWORK_TIMEOUT_SECONDS", + "USER_AGENT", "ACCEPT_GITHUB_JSON", "ACCEPT_OCTET_STREAM", + "GITHUB_PROVIDER", "GITHUB_API_BASE_URL", "GITHUB_API_VERSION", + "ANONYMOUS_PROVIDER", "ENVIRONMENT_CREDENTIAL_SOURCE", +] diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 33212123..f7004112 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -371,6 +371,36 @@ anonymous rate limit); the token is never logged, never written to the cache, and never included in an error message. A 403/429 answer is reported as a rate-limit diagnosis with the same remedy. +### Sources and credential confinement + +The source is selected by one environment variable, with no discovery: + +```text +AINATIVE_UPDATE_PROVIDER=local + AINATIVE_UPDATE_LOCAL_DIR → local mirror +AINATIVE_UPDATE_PROVIDER=github (default) → official releases +AINATIVE_UPDATE_URL → custom release document +``` + +`AINATIVE_UPDATE_URL` names a custom HTTPS source whose release document is +GitHub-Release-API-compatible. It is **anonymous by construction**: it never +receives `GITHUB_TOKEN`, `GH_TOKEN` or any other provider credential, because +its metadata may name any artifact URL and that must not be enough to obtain a +token. The credential is sent to exactly one origin — `api.github.com`, the +built-in endpoint's declared `auth_origin` — and nowhere else, on any hop: + +- release metadata may only redirect within its own origin; a cross-origin + metadata redirect is refused; +- a release artifact may redirect across origins (GitHub answers `302` to its + CDN): the redirect is followed anonymously, with every credential stripped; +- private GitHub release assets are fetched through the release asset API with + `Accept: application/octet-stream`; the browser download URL is never + authenticated; +- `https → http` downgrades, URLs carrying `user:password@`, and redirect + chains beyond the bound are refused before a socket is opened. + +There is no unverified fallback: a source that cannot publish the bundle's +SHA-256 is refused rather than trusted. + `ainative update check` exits 0 whatever the answer: `exit 0` means "the answer is what it is", never "the source was reachable". A CI gate that needs the second statement runs `ainative update check --strict`, which exits 1 on diff --git a/tests/test_lifecycle_cli.py b/tests/test_lifecycle_cli.py index b843b9b4..d2c2a340 100644 --- a/tests/test_lifecycle_cli.py +++ b/tests/test_lifecycle_cli.py @@ -189,16 +189,23 @@ def test_a_verified_command_never_triggers_an_update_check(self): import urllib.request from ainative import cli as clilib + from ainative.lifecycle import transport as transportlib calls: list[str] = [] def refuse(*args, **kwargs): - calls.append("urlopen") + calls.append("network") raise AssertionError("an authority command reached the network") - original = urllib.request.urlopen + # Both seams: the release transport opens its own opener (so patching + # `urlopen` alone no longer intercepts it), and a stray call elsewhere + # would still go through the module function. + original_urlopen = urllib.request.urlopen + original_send = transportlib._send urllib.request.urlopen = refuse - self.addCleanup(setattr, urllib.request, "urlopen", original) + transportlib._send = refuse + self.addCleanup(setattr, urllib.request, "urlopen", original_urlopen) + self.addCleanup(setattr, transportlib, "_send", original_send) for command in (["trust", "show", "--repo", str(self.project)], ["work", "--help"], ["converge", "--help"]): diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index b56bb634..564dc613 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -81,6 +81,7 @@ def fake_get(url: str, limit: int) -> bytes: return responses[url] provider._get = fake_get + provider._fetch_artifact = fake_get return provider def snapshot(self) -> dict[str, str]: diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py index f77b1c41..df01e4ef 100644 --- a/tests/test_lifecycle_update_transport.py +++ b/tests/test_lifecycle_update_transport.py @@ -1,10 +1,19 @@ -"""Update transport: authenticated checks, rate-limit reporting, protocol v2. - -The anonymous GitHub check is rate-limited per IP, and many users share one -NAT; a `GITHUB_TOKEN`/`GH_TOKEN` must be usable without ever being logged or -persisted. A legacy runtime (<= v2.2.2) must not be able to consume a protocol -v2 bundle even when a mirror names the file for it - the *layout* refuses it, -not just the official asset selector. +"""Update transport: credential confinement, authenticated checks, protocol v2. + +The token an environment offers (`GITHUB_TOKEN`/`GH_TOKEN`) exists to raise the +anonymous GitHub API rate limit. Before this suite existed, `_get()` attached it +to every request it made - including an artifact URL named by the release +metadata of a custom `AINATIVE_UPDATE_URL` - and urllib's redirect handler +copied `Authorization` to any redirect target. Any custom source could therefore +collect the user's provider token, and a legitimate-looking `302` could hand it +to an unrelated host. + +These tests pin the confinement rule instead: a credential is sent only to the +origin it belongs to, never across an origin boundary, never to the anonymous +`AINATIVE_UPDATE_URL` selector, and never after an `https -> http` downgrade. +The protocol v2 containment tests at the end are unchanged: a legacy runtime +(<= v2.2.2) must not be able to consume a protocol v2 bundle even when a mirror +names the file for it - the *layout* refuses it, not just the asset selector. """ from __future__ import annotations @@ -13,89 +22,288 @@ import os import tempfile import unittest +import urllib.error +import urllib.request import zipfile from pathlib import Path from unittest import mock +from urllib.parse import urlsplit -from tests.lifecycle_support import LifecycleTestCase, write_text +from tests.lifecycle_support import LifecycleTestCase from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import transport as transportlib from ainative.lifecycle import updater as updaterlib from ainative.lifecycle.errors import LifecycleError +TOKEN = "ghp_" + "x" * 36 +APPROVED_ORIGIN = "https://api.github.com" +ASSET_API_URL = "https://api.github.com/repos/o/r/releases/assets/5" +BUNDLE_NAME = "ainative-lifecycle-v2-2.0.0.zip" -class AuthenticatedChecks(unittest.TestCase): - def tearDown(self): - for name in providerlib.TOKEN_ENVS: - os.environ.pop(name, None) - super().tearDown() +def origin(url: str) -> str: + parts = urlsplit(url) + return f"{parts.scheme}://{parts.netloc}" - def test_a_token_in_the_environment_is_sent_as_a_header(self): - os.environ["GITHUB_TOKEN"] = "ghp_" + "x" * 36 - captured = {} - class Response: - status = 200 +class FakeResponse: + """The part of an HTTP response this transport consumes, and nothing more.""" - def read(self, limit): - return b"{}" + def __init__(self, payload: bytes) -> None: + self.payload = payload - def __enter__(self): - return self + def read(self, limit: int) -> bytes: + return self.payload[:limit] - def __exit__(self, *args): - return False + def __enter__(self) -> "FakeResponse": + return self - def fake_urlopen(request, timeout=None): - captured["headers"] = dict(request.headers) - return Response() + def __exit__(self, *args: object) -> bool: + return False - with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): - providerlib.ReleaseApiProvider("https://example.invalid")._get( - "https://example.invalid", 100) - self.assertEqual(captured["headers"].get("Authorization"), - "Bearer ghp_" + "x" * 36) - def test_no_token_means_no_authorization_header(self): - captured = {} +class ScriptedTransport: + """Answers a scripted hop table; records (url, Authorization, Accept) per hop. + + A hop is either the bytes to serve or `("redirect", location)`. An + unscripted URL raises: a request the test did not anticipate is a failure, + not a silent network attempt. + """ + + def __init__(self, hops: dict) -> None: + self.hops = hops + self.observed: list[tuple[str, str | None, str | None]] = [] + + def send(self, request: urllib.request.Request): + url = request.full_url + self.observed.append((url, request.get_header("Authorization"), + request.get_header("Accept"))) + if url not in self.hops: + raise AssertionError(f"unexpected request: {url}") + answer = self.hops[url] + if isinstance(answer, tuple) and answer[0] == "redirect": + raise urllib.error.HTTPError(url, 302, "Found", + {"Location": answer[1]}, None) + return FakeResponse(answer) + + def authorizations(self) -> list[tuple[str, str | None]]: + return [(origin(url), authorization) for url, authorization, _ in self.observed] + + +class CredentialConfinement(unittest.TestCase): + """One rule: a credential goes exactly where it belongs, and nowhere else.""" + + def setUp(self) -> None: + for name in (*providerlib.TOKEN_ENVS, providerlib.RELEASE_URL_ENV, "GITLAB_TOKEN"): + previous = os.environ.pop(name, None) + if previous is not None: + self.addCleanup(os.environ.__setitem__, name, previous) - class Response: - status = 200 + def get(self, provider, server: ScriptedTransport, url: str, + limit: int = 1_000_000) -> bytes: + with mock.patch.object(transportlib, "_send", server.send): + return provider._get(url, limit) - def read(self, limit): - return b"{}" + # --- where the credential may go ------------------------------------ - def __enter__(self): - return self + def test_the_trusted_github_api_receives_the_bearer(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({providerlib.DEFAULT_RELEASE_URL: b"{}"}) + self.assertEqual(self.get(provider, server, providerlib.DEFAULT_RELEASE_URL), b"{}") + self.assertEqual(server.observed[0][1], f"Bearer {TOKEN}") - def __exit__(self, *args): - return False + def test_no_token_means_no_authorization_header(self): + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({providerlib.DEFAULT_RELEASE_URL: b"{}"}) + self.get(provider, server, providerlib.DEFAULT_RELEASE_URL) + self.assertIsNone(server.observed[0][1]) + + def test_a_custom_url_never_receives_the_bearer(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider("https://evil.example/releases/latest") + server = ScriptedTransport({"https://evil.example/releases/latest": b"{}"}) + self.get(provider, server, provider.url) + self.assertIsNone(server.observed[0][1], + "a custom source received the provider credential") + + def test_ainative_update_url_is_anonymous_even_at_the_api_host(self): + os.environ["GITHUB_TOKEN"] = TOKEN + os.environ["GH_TOKEN"] = TOKEN + os.environ["GITLAB_TOKEN"] = TOKEN + url = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" + os.environ[providerlib.RELEASE_URL_ENV] = url + provider = providerlib.ReleaseApiProvider() + self.assertEqual(provider.url, url) + server = ScriptedTransport({url: b"{}"}) + self.get(provider, server, url) + self.assertIsNone(server.observed[0][1], + "AINATIVE_UPDATE_URL must never carry a provider credential") + + # --- URLs the transport refuses outright ----------------------------- + + def test_a_url_with_embedded_credentials_is_refused(self): + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, "https://user:secret@api.github.com/releases/latest") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertNotIn("secret", raised.exception.message) - def fake_urlopen(request, timeout=None): - captured["headers"] = dict(request.headers) - return Response() + def test_a_non_https_url_is_refused(self): + provider = providerlib.ReleaseApiProvider("http://example.invalid/releases") + with self.assertRaises(LifecycleError) as raised: + provider.latest("stable") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") - with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): - providerlib.ReleaseApiProvider("https://example.invalid")._get( - "https://example.invalid", 100) - self.assertNotIn("Authorization", captured["headers"]) + # --- redirects -------------------------------------------------------- + + def test_a_same_origin_metadata_redirect_keeps_the_bearer(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + first = providerlib.DEFAULT_RELEASE_URL + second = "https://api.github.com/repos/x/y/releases/1234" + server = ScriptedTransport({first: ("redirect", "/repos/x/y/releases/1234"), + second: b'{"ok": true}'}) + self.assertEqual(self.get(provider, server, first), b'{"ok": true}') + self.assertEqual(server.authorizations(), + [(APPROVED_ORIGIN, f"Bearer {TOKEN}"), + (APPROVED_ORIGIN, f"Bearer {TOKEN}")]) + + def test_a_cross_origin_metadata_redirect_is_refused(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({ + providerlib.DEFAULT_RELEASE_URL: ("redirect", "https://evil.example/metadata")}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, providerlib.DEFAULT_RELEASE_URL) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertEqual(len(server.observed), 1, "the redirect target was contacted") + + def test_an_https_to_http_redirect_is_refused(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({ + providerlib.DEFAULT_RELEASE_URL: ("redirect", "http://mirror.example/releases")}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, providerlib.DEFAULT_RELEASE_URL) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertEqual(len(server.observed), 1, "a downgraded hop was attempted") + + def test_a_redirect_target_with_embedded_credentials_is_refused(self): + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({ + providerlib.DEFAULT_RELEASE_URL: + ("redirect", "https://user:secret@api.github.com/releases/2")}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, providerlib.DEFAULT_RELEASE_URL) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertEqual(len(server.observed), 1) + + def test_a_redirect_loop_is_refused(self): + provider = providerlib.ReleaseApiProvider() + url = providerlib.DEFAULT_RELEASE_URL + server = ScriptedTransport({url: ("redirect", url)}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, url) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + self.assertEqual(len(server.observed), transportlib.MAX_REDIRECTS + 1) + + def test_an_oversized_response_is_refused(self): + provider = providerlib.ReleaseApiProvider() + server = ScriptedTransport({providerlib.DEFAULT_RELEASE_URL: b"x" * 2_000}) + with self.assertRaises(LifecycleError) as raised: + self.get(provider, server, providerlib.DEFAULT_RELEASE_URL, 1_000) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") def test_a_rate_limited_check_says_so_without_echoing_the_token(self): os.environ["GH_TOKEN"] = "github_pat_secret_value" - error = providerlib.urllib.error.HTTPError( - "https://example.invalid", 403, "Forbidden", {}, None) + provider = providerlib.ReleaseApiProvider() - def fake_urlopen(request, timeout=None): - raise error + def send(request): + raise urllib.error.HTTPError(request.full_url, 403, "Forbidden", {}, None) - with mock.patch.object(providerlib.urllib.request, "urlopen", fake_urlopen): + with mock.patch.object(transportlib, "_send", send): with self.assertRaises(LifecycleError) as raised: - providerlib.ReleaseApiProvider("https://example.invalid")._get( - "https://example.invalid", 100) + provider._get(providerlib.DEFAULT_RELEASE_URL, 100) self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") self.assertIn("rate-limited", raised.exception.message) self.assertNotIn("github_pat_secret_value", raised.exception.message) + # --- artifacts: the CDN hop is anonymous ------------------------------ + + def test_the_asset_api_redirect_to_the_cdn_strips_the_bearer(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + cdn = "https://objects.githubusercontent.com/blob/xyz" + server = ScriptedTransport({ASSET_API_URL: ("redirect", cdn), + cdn: b"payload-bytes"}) + with mock.patch.object(transportlib, "_send", server.send): + payload = provider._fetch_artifact(ASSET_API_URL, 1_000) + self.assertEqual(payload, b"payload-bytes") + self.assertEqual(server.observed[0][1], f"Bearer {TOKEN}") + self.assertEqual(server.observed[0][2], transportlib.ACCEPT_OCTET_STREAM) + self.assertIsNone(server.observed[1][1], + "the CDN received the provider credential") + + # --- the gate: no credential anywhere else ---------------------------- + + def test_no_credential_reaches_a_non_approved_origin_across_the_flow(self): + os.environ["GITHUB_TOKEN"] = TOKEN + provider = providerlib.ReleaseApiProvider() + cdn = "https://objects.githubusercontent.com/blob" + document = {"tag_name": "v2.0.0", "assets": [{ + "name": BUNDLE_NAME, "url": ASSET_API_URL, + "browser_download_url": f"https://github.com/o/r/releases/download/v2.0.0/{BUNDLE_NAME}", + "digest": "sha256:" + "0" * 64}]} + server = ScriptedTransport({ + providerlib.DEFAULT_RELEASE_URL: json.dumps(document).encode(), + ASSET_API_URL: ("redirect", cdn), cdn: b"bundle"}) + with mock.patch.object(transportlib, "_send", server.send): + release = provider.latest("stable") + self.assertEqual(release.url, ASSET_API_URL) + self.assertEqual(provider.fetch(release), b"bundle") + self.assertEqual(len(server.observed), 3, server.observed) + for url, authorization, _accept in server.observed: + if origin(url) == APPROVED_ORIGIN: + self.assertEqual(authorization, f"Bearer {TOKEN}", url) + else: + self.assertIsNone(authorization, f"credential sent to {url}") + + +class GitHubPrivateAssetFlow(unittest.TestCase): + """A private asset is fetched through the asset API, never the browser URL.""" + + def provider(self, document: dict) -> providerlib.ReleaseApiProvider: + provider = providerlib.ReleaseApiProvider() + provider._get = lambda url, limit: json.dumps(document).encode("utf-8") + return provider + + def test_the_asset_api_url_is_selected_when_published(self): + document = {"tag_name": "v2.0.0", "assets": [{ + "name": BUNDLE_NAME, "url": ASSET_API_URL, + "browser_download_url": f"https://github.com/o/r/releases/download/v2.0.0/{BUNDLE_NAME}", + "digest": "sha256:" + "0" * 64}]} + release = self.provider(document).latest("stable") + self.assertEqual(release.url, ASSET_API_URL) + self.assertEqual(release.digest, "0" * 64) + + def test_a_document_without_the_api_url_keeps_the_browser_url(self): + document = {"tag_name": "v2.0.0", "assets": [{ + "name": BUNDLE_NAME, + "browser_download_url": f"https://example.invalid/{BUNDLE_NAME}", + "digest": "sha256:" + "0" * 64}]} + release = self.provider(document).latest("stable") + self.assertEqual(release.url, f"https://example.invalid/{BUNDLE_NAME}") + + def test_a_plain_http_asset_url_is_ignored_not_authenticated(self): + document = {"tag_name": "v2.0.0", "assets": [{ + "name": BUNDLE_NAME, "browser_download_url": "http://evil.example/x.zip", + "digest": "sha256:" + "0" * 64}]} + with self.assertRaises(LifecycleError) as raised: + self.provider(document).latest("stable") + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_METADATA_MISSING") + class ProtocolV2Containment(unittest.TestCase): """The layout, not the selector, is what a legacy runtime cannot consume.""" @@ -177,4 +385,4 @@ def test_without_strict_the_answer_is_exit_zero(self): if __name__ == "__main__": - unittest.main() \ No newline at end of file + unittest.main() From 9fd70dc8b21e11efa2d478204a72cd2f4d71c1d1 Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 17:50:23 +0200 Subject: [PATCH 21/27] docs(adr): ADR-MF-02 - neutral workflow, work authority and claims (Refs #159) (#169) --- ...0018-multiforge-neutral-workflow-claims.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/adr/0018-multiforge-neutral-workflow-claims.md diff --git a/docs/adr/0018-multiforge-neutral-workflow-claims.md b/docs/adr/0018-multiforge-neutral-workflow-claims.md new file mode 100644 index 00000000..a0568199 --- /dev/null +++ b/docs/adr/0018-multiforge-neutral-workflow-claims.md @@ -0,0 +1,195 @@ +# ADR-0018 — Multi-Forge II: provider-neutral workflow, work authority, and claims + +- Status: accepted +- Date: 2026-09-16 +- Alias: ADR-MF-02 +- Materializes: Multi-Forge v1.3.2-final (frozen architecture), §17 and + §45–54. +- Constrains: `docs/FORGE-WORKFLOW.md`, `docs/GITHUB-WORKFLOW.md`, + `docs/GITLAB-WORKFLOW.md`, `templates/AGENTS.md`, `ainative/forge.py`, + `ainative claim-attempt`, `skills/issue-to-implementation/`. +- Does not modify: ADR-0017 (feature/state model); the repository's own + GitHub-based policy for its own maintenance; the Verified authority + architecture; issue #33 (stale-claim lifecycle) and #120 (managed Git guard) + remain independent and unchanged. + +## Context + +The workflow policy is GitHub-shaped in its vocabulary and its mechanics: +`docs/GITHUB-WORKFLOW.md` says "Issue", "PR", "assignment"; the claim +procedure resolves against GitHub-shaped records; the distributed +`AGENTS.md` inherits that vocabulary into every project. Multi-Forge supports +Generic Git, GitHub.com and GitLab.com, and GitLab says "WorkItem" and +"ChangeRequest" — not Issue and PR. + +Two failure modes must not be traded for each other: + +1. **Neutrality without authority.** A generic workflow that cannot say *which* + remote is authoritative silently picks one — origin, upstream, the first + remote — and then mutates the wrong backlog. +2. **Convenience without durability.** A claim is a remote mutation. If the + local record of an attempted claim is lost — no journal, or a journal + written after the POST — a crash leaves an agent unable to tell "my claim + landed" from "my claim never left", and the only safe-looking remedy + (POST again) is how duplicate claims are created. + +The architecture review for the existing policy is closed (see +`docs/GITHUB-WORKFLOW.md`); this ADR does not reopen its decisions, it +generalizes their vocabulary and adds the claim-durability layer. + +## Decision + +### 1. Terminology: Work Authority, WorkItem, ChangeRequest, WorkAuthorityRef + +- **Work Authority** — the remote whose work records are authoritative for a + project: one provider (github, gitlab, or none for Generic Git) plus a + project identity on that provider. +- **WorkItem** — the unit of work on that authority (GitHub Issue, GitLab + WorkItem). +- **ChangeRequest** — the reviewable change (GitHub PR, GitLab MR). +- **WorkAuthorityRef** — the canonical reference: `provider` + stable project + identity. Not a URL, not a remote name, not a hostname guess. + +The distributed policy documents (`docs/FORGE-WORKFLOW.md`) speak only this +vocabulary. `docs/GITHUB-WORKFLOW.md` and `docs/GITLAB-WORKFLOW.md` become +explicit mappings onto it: they name the provider's record kinds, its claim +signals (assignment, comment/note, event identifiers) and its error surfaces. +The repository's own root `AGENTS.md` may remain GitHub-specific: it governs +this repository's own workflow, not the distributed model. + +### 2. Ownership: remote facts belong to the skill/harness; the resolver is pure + +Reading and mutating remote state is done by the harness or skill that holds +the provider's tooling and credentials. `ainative/forge.py` adds exactly one +thing: a **pure local resolver**: + +```text +resolve_observed_work_authority(...) -> WorkAuthorityRef | refusal +``` + +It has no network, no credential lookup, no writes, no provider API calls and +no Multi-Vault push authorization. It consumes facts observed elsewhere +(remote names/URLs, explicit harness declarations, explicit WorkItem or +ChangeRequest references) and returns an authority or a refusal. + +Resolution priority, in order: + +1. an explicit WorkItem/ChangeRequest repository reference; +2. an explicit harness-declared authority; +3. **exactly one** compatible observed candidate; +4. otherwise a refusal: `WORK_AUTHORITY_UNAVAILABLE`, + `WORK_AUTHORITY_AMBIGUOUS`, `WORK_AUTHORITY_MISMATCH`. + +Observed candidates are diagnostic only: an `origin` and an `upstream` that +disagree (a fork) resolve to `AMBIGUOUS`, never to a preference. A remote URL +is evidence, never authority; self-hosted providers are not inferred from a +hostname (PR-3 renders `unknown` rather than guessing). + +### 3. Claim identity is canonical, not local + +A claim is identified by provider-stable facts, never by local formatting: + +```text +principal :principal: +event :: +``` + +V1 event kinds: `comment`, `note`, `assignment`. A "claim event" is the record +of a claim signal; a later assignment never rewrites an earlier comment. All +timestamps are normalized to UTC at observation. Winner ordering is a total +order: `(created_at_utc, canonical_event_identifier)` ascending. An +unorderable pair is `CLAIM_CONFLICT` and every claimant stops — the existing +ADR-0009-era rule, restated in provider-neutral terms. + +### 4. ClaimAttempt journal: durable, local, non-authoritative + +Every claim attempt is journaled before the remote POST: + +```text +.ai-native/state/claim-attempts/ +``` + +The directory is covered by the existing managed `.ai-native/state/` ignore +rule. A journal entry is **local** (never synced), **durable** (written +atomically, fsynced by the existing atomic-write helpers), **non-authoritative** +(it does not decide anything by itself) and **non-Verified** (it is never +evidence for a convergence verdict). + +### 5. The claim mutation algorithm + +```text +READ the WorkItem +READ linked active ChangeRequests (a linked PR/MR is a claim-level conflict) +GENERATE attempt_id +WRITE the PENDING attempt atomically + on local write failure → CLAIM_JOURNAL_UNAVAILABLE, STOP (no POST) +POST exactly one claim signal, carrying a deterministic attempt marker +READ the remote state again +SEARCH for the exact attempt marker +ARBITRATE against every other canonical claim event +persist the outcome in the journal +``` + +Journal-before-POST is the invariant that makes crash recovery possible: an +attempt that crashed after the POST is found by its marker on the remote; an +attempt that crashed before it is provably local-only. + +### 6. Outcomes, failure policy, and recovery + +Outcomes: `CONFIRMED`, `LOST`, `CONFLICT`, `UNCERTAIN`, `ABANDONED`. + +- An unknown POST result is `UNCERTAIN`. It is **never** automatically + re-POSTed: the marker search is the only way to resolve it. +- A corrupt or unreadable journal fails closed (`CLAIM_JOURNAL_UNAVAILABLE` / + `UNCERTAIN`), never "assume it did not happen". +- There are no leases, no heartbeats, no age-based expiration and no + timestamp-inferred abandonment. +- `ABANDONED` is an explicit operator transition. Only after it may a new + attempt be created; abandonment is local only (no remote mutation) and the + record is retained. +- Recovery CLI: `ainative claim-attempt list`, `inspect `, + `abandon --confirm`. + +A normal lifecycle update must leave pending ClaimAttempt files byte-identical +(the journal belongs to the project's state area but is not lifecycle-owned). + +### 7. No remote work API clients + +Neither this layer nor any neutral module calls a provider work API. There is +no GitHub Issue client, no GitLab WorkItem client, no cross-forge backlog +federation, no automatic backlog migration and no bidirectional +GitHub/GitLab synchronization. Those are explicit non-goals of the frozen +architecture, not deferred work. + +## Rejected alternatives + +- **Provider API clients in the neutral layer** (`PyGithub`-style). Rejected: + credentials, dialects and remote authority would enter a layer whose entire + value is purity; the resolver would become untestable without network. +- **A remote claim service or lock service.** Rejected: AGENTS policy forbids + lock services; GitHub/GitLab state is the lock. +- **Claim leases, heartbeats, age-based expiration.** Rejected: they infer + abandonment from time, and time is not authority. Issue #33 stays + independent. +- **Retrying an `UNCERTAIN` POST.** Rejected: a duplicate claim is worse than + a stuck one; the marker search resolves uncertainty without a second POST. +- **Preferring `origin` over `upstream` on a fork.** Rejected: guessing is how + a claim lands on the wrong backlog. +- **Journaling after the POST** (or not at all). Rejected: loses exactly the + fact needed after a crash. +- **Storing claim attempts inside Verified records.** Rejected: the journal is + non-authoritative; a Verified surface must not carry it. + +## Consequences + +- PR-2 (#163) implements the neutral policy documents, the pure resolver, the + identity grammar and the journal; the existing claim-resolution behavior + (ADR-era `claim_resolution.py`) is preserved as the GitHub mapping of the + neutral rule. +- Harnesses/skills keep owning remote mutations and must surface canonical + event identifiers; a harness that cannot is honest degradation (refusal), + not silent guessing. +- The repository's own GitHub workflow keeps working unchanged through the + mapping; nothing about this repository's maintenance changes. +- Distributed policy becomes provider-neutral; `templates/AGENTS.md` speaks + Work Authority; GitHub remains the reference mapping. From f98b6159afd29c25476ded3fe6aecf6fc8b03f68 Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 17:50:29 +0200 Subject: [PATCH 22/27] docs(adr): ADR-MF-03 - release source resolution, manifest V3, providers (Refs #161) (#170) --- ...forge-release-source-manifest-providers.md | 264 ++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 docs/adr/0019-multiforge-release-source-manifest-providers.md diff --git a/docs/adr/0019-multiforge-release-source-manifest-providers.md b/docs/adr/0019-multiforge-release-source-manifest-providers.md new file mode 100644 index 00000000..81edf18f --- /dev/null +++ b/docs/adr/0019-multiforge-release-source-manifest-providers.md @@ -0,0 +1,264 @@ +# ADR-0019 — Multi-Forge III: release source resolution, ReleaseManifest V3, providers + +- Status: accepted +- Date: 2026-09-16 +- Alias: ADR-MF-03 +- Materializes: Multi-Forge v1.3.2-final (frozen architecture), §18–20 and + §33–43, §60–73, including the final addendum (`release_project_ref`, + `AnonymousReleaseApiProvider`). +- Constrains: `ainative/lifecycle/provider.py`, `transport.py`, `updater.py`, + `machine.py`, `ainative/lifecycle/data/`, `docs/RELEASING.md`, the release + gates under `scripts/`, the support matrix. +- Does not modify: ADR-0009 §6 (authority commands never depend on remote + answers); ADR-0017/0018; issue #24 (cryptographic release authority) stays + independent — nothing here claims cryptographic provenance. + +## Context + +The update path knows one remote provider and one mirror format. Multi-Forge +adds GitLab.com and hardens the release contract against a class of failures +the current shape cannot express: + +- a release manifest whose bytes are trusted because the source said so, + instead of being verified against an **external** digest and size; +- an enumeration that silently truncates (endpoint pagination bounds) and + installs the best of what it happened to see; +- duplicate publication (two packages, two assets) resolved by "first match"; +- version identity drifting between candidate, manifest, runtime, artifact, + filename and bundle protocol document; +- a SemVer `+build` suffix that makes two artifacts "the same version" while + being different bytes; +- credentials that follow a redirect to object storage. + +PR-0A (#158) already froze the transport rule — a credential is sent only to +its configured origin, computed per hop, never across an origin boundary. +This ADR freezes everything above that transport: where a source comes from, +what a provider must publish, and what the updater verifies before it trusts +a single declaration. + +## Decision + +### 1. One source resolver + +`resolve_release_source()` is the only function that decides where a release +comes from. `update`, `update check`, `status` and `doctor` all consume it; +there is no second path and no command-specific default. Conflict validation +runs **before** precedence: two selectors that contradict each other are +refused, never ordered. + +### 2. Selector rules (exact, no last-selector-wins) + +```text +AINATIVE_UPDATE_PROVIDER=local + AINATIVE_UPDATE_LOCAL_DIR + → LocalDirectoryProvider +AINATIVE_UPDATE_LOCAL_DIR without explicit provider=local + → UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL mixed with a provider/local selector + → UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL alone + → AnonymousReleaseApiProvider (no credentials) +named provider (e.g. gitlab) + → machine config, else built-in +nothing selected + → machine default_provider, else built-in GitHub.com +``` + +### 3. Machine-scope trust configuration + +Release provider configuration lives at `~/.ai-native/release-providers.json` +— machine scope, one file, reserved provider names refused when they would +shadow a built-in. **No project-local provider trust config exists**: a cloned +repository must never be able to redirect a user's updates or name its own +credential origin. The resolver reports its choice: effective source, +selection reason, authenticated yes/no, auth origin when applicable. Never a +secret value. + +### 4. Endpoint configuration: trust is separated from the secret + +```text +ReleaseProviderEndpointConfig: + provider reserved name ("github", "gitlab", "local", ...) + api_base_url provider API root + auth_origin the ONE origin allowed to receive the credential + api_version API dialect header where the provider requires one + release_project_ref where required (GitLab); numeric ID or namespace path + credential_source where the credential comes from (environment, ...) +``` + +`auth_origin` is configuration. A response can never widen it; a repository +can never set it. An endpoint without an `auth_origin` is anonymous by +construction. + +### 5. Transport split (metadata / artifact), per PR-0A + +- **Metadata** (release documents, file listings): same-origin redirects only; + cross-origin is refused. +- **Artifact** (release bytes): cross-origin redirects are followed — a CDN + hop is legitimate — always anonymously; every credential is recomputed per + hop and never crosses an origin boundary. +- GitHub private assets are fetched through the release asset API with + `Accept: application/octet-stream`; the browser download URL is never + authenticated. +- GitLab object storage: an authenticated API request may redirect to the + object store; all GitLab credentials are stripped, HTTPS is required, and + the fetched bytes are verified against the expected digest. + +### 6. Anonymous release API compatibility + +`AINATIVE_UPDATE_URL` remains HTTPS, anonymous and +GitHub-Release-API-compatible. It is not a ZIP URL, not a generic mirror +index, not arbitrary JSON. If V3 integrity metadata is missing from what it +serves, the update fails closed. Its anonymity is frozen by ADR-MF-03 here and +implemented by PR-0A. + +### 7. ReleaseManifest V3 + +```text +release manifest: schema · protocol · version · channel · + compatibility · artifacts[] · provenance +``` + +- The manifest is fetched and verified **before it is parsed**: its SHA-256 + and size are supplied externally (provider metadata / mirror index). +- A provider enumeration returns `EnumerationResult`. `complete=false` (bounds + reached before exhaustion) refuses with `RELEASE_ENUMERATION_INCOMPLETE`; a + complete, compatible channel with zero candidates refuses with + `RELEASE_NO_CANDIDATE`. Partial results are never used silently. +- V1 compatibility mode is `exact-runtime`: a V1-era consumer applies only a + manifest whose compatibility names the running runtime exactly. + +### 8. SemVer policy for installable releases + +```text +allowed: 1.2.3 1.2.3-rc.1 +rejected: 1.2.3+build1 → RELEASE_BUILD_METADATA_UNSUPPORTED +``` + +Ordering uses an explicit release precedence key (never string order). Two +candidates with the same canonical precedence but conflicting release +identities are refused: `RELEASE_DUPLICATE_VERSION` — "first match" is not a +policy. + +### 9. The trust chain, stated as a sequence + +```text +provider metadata + → manifest SHA-256 + size (external anchor) + → download manifest + → verify size + SHA-256 + → parse manifest + → select the lifecycle artifact + → download artifact + → verify size + SHA-256 +``` + +Trust-bearing artifact declarations are never parsed before the manifest is +verified. There is no path that skips a step "because the source is trusted". + +### 10. The exact version chain + +```text +candidate.version + == manifest.version + == compatibility.runtime_version + == artifact.version + == artifact filename version + == lifecycle-protocol.json.release_version +``` + +Any inequality refuses with `UPDATE_VERSION_MISMATCH`. This chain already +exists for V2 (AUD-201); V3 keeps it and adds `compatibility.runtime_version` +to it. The runtime contract remains exact equality — no compatibility matrix. + +### 11. Providers + +- **GitHub.com** (V1 target): bounded enumeration, release asset metadata, + manifest digest/size anchor, private assets, API version header, + authenticated API requests, anonymous CDN redirects. GHES is not declared. +- **Local directory** (V1 target): `releases.json` must provide the manifest + locator, its size and its SHA-256, and the local provider executes the same + logical verification chain as a network provider. "It is on disk" is not an + integrity argument. +- **GitLab.com** (V1 target): Releases API for discovery/version/presentation + only; the **Generic Package Registry is the canonical distribution and + integrity surface**. One project identity (`release_project_ref`, numeric ID + or namespace path — never a URL) is used for Releases, packages and package + files. Package lookup is exact (`type == generic`, canonical package name, + selected version) and requires exactly one match; duplicate handling refuses. + Pagination enumerates to exhaustion within configured bounds, otherwise + `RELEASE_ENUMERATION_INCOMPLETE`. The manifest file is + `ainative-release-v3.json`; zero matches → `RELEASE_MANIFEST_MISSING`, more + than one → `RELEASE_MANIFEST_AMBIGUOUS`. Integrity anchors are the package + file API's `file_sha256` and `size`; absent → `RELEASE_INTEGRITY_METADATA_MISSING`, + malformed → `RELEASE_INTEGRITY_METADATA_INVALID`. Release Link URLs are + presentation, never integrity roots. + +### 12. Secret patterns are mandatory, not configurable-in-place + +The scanner contract is `extra_secret_patterns=()`; the effective set is +`MANDATORY_SECRET_PATTERNS ∪ operator extras`. Repository configuration may +not remove, replace, disable or shadow a mandatory pattern. The minimum set +includes private key markers, the AWS/GitHub/Slack patterns already supported, +and documented GitLab token prefixes. GitLab Self-Managed qualification +carries an `access_token_prefix` tuple: a custom prefix requires an operator +extra pattern **and** a fixture proving detection, or the tuple stays +unqualified. + +### 13. Qualification is declared, not assumed + +- Always qualify GitLab.com if V1 declares it supported. +- GitLab Self-Managed: qualify if an environment is available; otherwise the + tuple is `UNTESTED` and **not declared supported**. Missing Self-Managed + infrastructure never blocks the V1 release. +- GHES: never declared in V1; a qualification checklist (version, API version, + asset digest availability, private asset behavior, auth origin, redirect + behavior, manifest flow, platform results) is recorded for later. +- Matrix invariant: **declared support ≤ actually qualified support**. + +### 14. V3 publication gate (V2 bridge) + +The first V3 publication requires `V3_BRIDGE_RELEASE=`: the pipeline +verifies the bridge release exists, is published, and carries a V2 lifecycle +bundle; otherwise it blocks the release. A documented manual upgrade path +exists for pre-bridge runtimes. + +### 15. Failure policy + +Any uncertainty — auth origin, redirect origin, manifest digest, artifact +digest, manifest ambiguity, package ambiguity, selector conflict, enumeration +completeness — results in REFUSE UPDATE. Never warning-and-continue. + +## Rejected alternatives + +- **Universal Forge SDK / generic `ForgeHttpClient`.** Rejected: providers + differ in integrity surfaces and identity semantics; one abstraction wide + enough to cover them becomes the place where those differences are blurred. +- **Project-local provider trust config** (`.ai-native/release-providers.json` + in a repository). Rejected: a cloned repo could redirect updates or name a + credential origin. +- **Warning-and-continue on integrity uncertainty.** Rejected: an update that + cannot verify itself is not an update (the #126 rule, at V3 scale). +- **Release Link URLs / browser URLs as integrity roots.** Rejected: they are + presentation endpoints; the package file API carries the anchor. +- **"Pick the highest/inner match" on duplicates.** Rejected: duplicates are a + publication defect; failing closed is the only deterministic outcome. +- **Allowing `+build` metadata.** Rejected: two different artifacts must not + share an installable version identity. +- **A compatibility matrix for the runtime contract.** Rejected: strict + equality has no silent-permission surface; no release has needed one. +- **Claiming GHES / Self-Managed support "by design similarity".** Rejected: + declared support must not exceed qualification. + +## Consequences + +- PR-4 (#165) and PR-5 (#166) implement this ADR; PR-0A (#158) already + implements its transport core; PR-0B (#157) must ship in a V2-compatible + bridge release before any V3 publication. +- Breaking behavior changes, documented in the CHANGELOG: conflicting update + selectors now fail closed; `AINATIVE_UPDATE_URL` is anonymous; `+build` + metadata is unsupported for installable V3 releases; a state migrated to V2 + requires a newer CLI (ADR-0017). +- Provider diagnostics become observable (`source`, `reason`, `authenticated`, + `auth_origin`) without exposing secrets. +- The support matrix is the honest published surface: Generic Git, GitHub.com, + GitLab.com; GitLab Self-Managed and GHES `UNTESTED`, not supported. From a1e1d97a82c5f6affe3fd49d9ddafc30eb1d6511 Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 17:53:42 +0200 Subject: [PATCH 23/27] docs(adr): ADR-MF-01 - Generic Git, features and state V2 (Refs #160) (#168) --- ...7-multiforge-generic-git-features-state.md | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 docs/adr/0017-multiforge-generic-git-features-state.md diff --git a/docs/adr/0017-multiforge-generic-git-features-state.md b/docs/adr/0017-multiforge-generic-git-features-state.md new file mode 100644 index 00000000..e1571229 --- /dev/null +++ b/docs/adr/0017-multiforge-generic-git-features-state.md @@ -0,0 +1,227 @@ +# ADR-0017 — Multi-Forge I: Generic Git, features, and state V2 + +- Status: accepted +- Date: 2026-09-16 +- Alias: ADR-MF-01 +- Materializes: Multi-Forge v1.3.2-final (frozen architecture), §15–16; the + accepted design gate #23. +- Constrains: `ainative/lifecycle/state.py`, `manifest.py`, `planner.py`, + `installer.py`, `ainative/lifecycle/data/profiles.json` and + `components.json`, the feature CLI, `templates/gitlab/`. +- Does not modify: ADR-0009 (ownership and transaction rules are extended, not + reopened); ADR-0001 through ADR-0016; the Verified authority architecture. + +## Context + +The lifecycle layer was built GitHub-shaped without saying so. `standard` +installs a `github-templates` component; the distributed workflow policy is +`docs/GITHUB-WORKFLOW.md`; nothing in the state records which forge a project +works with, because there was only one. + +Multi-Forge makes the base product **Generic Git**: a project with no forge at +all must be fully supported, GitHub.com and GitLab.com become optional +capabilities, and the two must never be active at the same time — two work +forges in one project means two conflicting authorities over the same facts. +Issue #23 raised the same question from the governance side: profiles are a +governance level, while capabilities (Vault, skills, templates, work-issue +templates) are optional and combine freely. The current manifests conflate the +two. + +Three constraints from the existing architecture bound any solution: + +1. **One state file, written last.** ADR-0009 §4: `.ai-native/lifecycle/ + state.json` is the only record of what the stack owns, and a transaction + commits it after every file change, never before. +2. **One transaction system.** The journal, backup, repair and rollback + machinery of `transaction.py` is the only mutation path; a feature switch is + not allowed to invent a second one. +3. **Migrations preserve user data.** A migration must be idempotent, must not + resurrect a previously managed file the user (or a previous uninstall) + removed, and must not adopt a file the stack never wrote. + +## Decision + +### 1. Generic Git is the product; forges add capabilities, not authority + +A project is a Git project first. Nothing in the lifecycle layer may require a +forge, resolve a forge from a remote URL, or reach a forge API. The lifecycle +layer installs files and records ownership; a forge feature only decides which +forge-shaped files (templates, policy mapping) are installed. Work-authority +questions remain owned by the skills/harnesses that already own them (ADR-MF-02). + +### 2. Profiles and features are orthogonal + +A **profile** remains what ADR-0009 made it: a governance level (`standard`, +`verified`) that resolves to a component set, with `verified` extending +`standard`. A **feature** is an optional project-scope capability that can be +enabled or disabled independently of the profile. Neither is defined in terms +of the other: + +```text +profile = standard | verified # governance level +features = forge-github | forge-gitlab # optional capabilities (V1) +``` + +Any future capability that installs files into the project becomes a feature +under this model; the model is not extended to capabilities owned at machine +scope (§9). + +### 3. The feature model + +A feature declares exactly: + +| Field | Meaning | +|---|---| +| `name` | stable ID (`forge-github`, `forge-gitlab`), never localised, never positional | +| `scope` | `project` in V1; a machine-scope entry is not a feature | +| `components[]` | component IDs from `components.json` the feature installs | +| `conflicts[]` | feature names that may not be active at the same time | + +`forge-github` conflicts with `forge-gitlab`, and the manifest marks work-forge +features as such: **at most one work-forge feature may be active**. Zero is +valid (`feature switch none`, the Generic Git shape). A V2 state that names two +work forges is refused with `STATE_CONFLICTING_WORK_FORGE_FEATURES` — at load, +before planning, never by silently picking one. + +### 4. State schema V2 and the single projection + +`state.json` gains `schema_version: 2` and one new array: + +```text +active_features: ["forge-github"] # 0..1 work forge; empty is valid +``` + +Every read path — `doctor`, `status`, `planner`, `feature status`, +`update check` — derives its view through one shared function, +`project_install_state()`. It returns the effective V2 state for a project and +is the only place that knows how to project older schemas: + +- `schema_version: 1` → effective V2 with the **`forge-github` compatibility + default**: a legacy project is a GitHub project until it explicitly switches. + This is a projection, not a write. +- `schema_version: 2` → validated as-is (work-forge conflict refusal above). + +The projection never writes, never caches, and is never bypassed by a command +that wants "just the profile". One owner, or the read paths drift. + +### 5. Migration is state-last, idempotent, and never resurrects absence + +A mutating operation on a V1 project (init, update, feature command) migrates +the state inside the existing transaction: files first, then the V2 state, +then commit. A failure before the commit leaves the V1 state untouched; a +completed commit leaves a complete V2 state — a serialized half-V2 state is +never produced. + +Migration rules: + +- `github-templates` keeps its component ID and its managed-file records. + `gitlab-templates` is added as a new component (`templates/gitlab/` → + `.gitlab/`, individual files, `MANAGED_MUTABLE`, same ownership semantics as + the GitHub templates). +- A previously managed file that is now absent stays absent. Migration does not + re-create it, and no tombstone records its absence: absence is not a fact + worth a permanent file. A later explicit `feature enable` may seed its + feature's files through the normal install path. +- Running the migration twice changes nothing the second time (idempotence is + tested, not assumed). + +### 6. Switching is one lifecycle transaction + +`feature enable`, `feature disable`, `feature switch ` and +`feature switch none` run inside the existing per-project lifecycle lock and +the existing transaction engine, in this order: + +```text +acquire the project lifecycle lock +reload state → project effective V2 +plan → feature deltas + ownership decisions +apply → files +verify +write the V2 state last +commit +``` + +`feature switch X` is `disable current work forge + enable X` as one plan, so +an interruption cannot leave a project with two forge feature sets or with +none where it had one. A conflict (`enable` a feature that conflicts with an +active one) is refused before planning; the remedy is `switch`, and the +refusal says so. + +### 7. CLI surface + +```text +ainative feature status +ainative feature enable +ainative feature disable +ainative feature switch +ainative feature switch none +``` + +`feature status` is read-only and derives from `project_install_state()`. + +### 8. Ownership is inherited, not redefined + +Features add no new ownership kind. The ADR-0009 matrix applies unchanged: +managed-and-unmodified → replace/update allowed; managed-and-modified → +preserve, reported as a conflict; never-managed → never silently adopted; a +user collision → conflict; a removed feature's modified file → preserved. A +round trip (GitHub → GitLab → none → GitHub) loses no user data, and that is an +executable test, not a promise. + +### 9. Machine-scope capabilities are not features + +Harness instruction files (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, …), +machine manifests, vault bindings, and the global skills trees are +machine-scope integration. They stay owned by `ainative machine` / +`scripts/install_agents.py` and do not appear in `active_features`. A +capability spanning both scopes is split: its project files can become a +feature, its machine files stay machine-managed. + +### 10. Dogfood, and what stays GitHub-specific + +This repository's own root `AGENTS.md` is GitHub-specific on purpose (its own +workflow is GitHub's) and is never a target of the generic distributed policy +template; the distributed generic template is a different artifact (the +distributed policy template, `templates/AGENTS.md`, PR-2). Feature machinery +and the `gitlab-templates` addition must not rewrite the stack repository's own +`.github/` files or `AGENTS.md`. + +GHES and GitLab Self-Managed are **not** features in V1 and are not declared +supported: the model leaves room for them, qualification does not exist. + +## Rejected alternatives + +- **Extend profiles into capability bundles** (`standard+forge-gitlab`, …). + Rejected: profiles and capabilities change for different reasons and at + different rates; a matrix of combined profiles multiplies the manifest and + makes "which governance am I under?" unanswerable from the state alone. +- **Per-forge state files** (`forge-github.json`, `forge-gitlab.json`). + Rejected: two files can disagree, and the whole point of one state file + written last is that it cannot. +- **Detect the forge from the Git remote and activate implicitly.** Rejected: + a remote URL is not an authority fact; detection is diagnostic (PR-3), never + a mutation trigger. +- **File tombstones for legacy files that are absent.** Rejected: records a + negative forever, for a state (absence) that is already the truth. +- **A second transaction path for feature switches.** Rejected: ADR-0009's + journal/backup/repair/rollback exists; duplicating it doubles the recovery + surface. +- **Adopting user-created templates as feature files.** Rejected: adoption is + how the stack would later delete or overwrite the user's file. + +## Consequences + +- `state.py` gains schema V2 and `project_install_state()`; every reader is + migrated to it in the same change, so no path reads `active_profile` and + guesses features. +- A state migrated to V2 requires a runtime that understands V2. The existing + schema guard already refuses a newer schema (`INSTALL_STATE_CORRUPTED`: + "upgrade the CLI rather than downgrading state"), so the migration is a + documented breaking behavior change, not a silent one. +- `github-templates` keeps its ID; `gitlab-templates` is new; the GitLab + template files are the GitLab equivalents of the existing GitHub ones. +- PR-1 (#162) implements this ADR; PR-3 (#164) consumes the projection for + observation; ADR-MF-02 (ADR-0018) builds the provider-neutral workflow on + top of the feature/project model. +- The projection is the single owner of legacy semantics; a bug fixed there is + fixed everywhere at once. From 457ab8791b39746372e736637de1b02540724a4f Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 18:02:16 +0200 Subject: [PATCH 24/27] feat(lifecycle): report a future lifecycle protocol as CLI_UPDATE_REQUIRED (#157) (#171) A V2 runtime meeting a V3 publication used to say UPDATE_INTEGRITY_METADATA_MISSING - "no lifecycle bundle to verify" - which sends every user looking for a publishing defect instead of upgrading the CLI. The two situations are now distinct: - a release that publishes a lifecycle bundle or manifest for a protocol newer than this runtime's (ainative-lifecycle-v3-*, ainative-release-v3.json) resolves as a refusal: CLI_UPDATE_REQUIRED with the upgrade command, and `update check` reports it as available with a CLI upgrade required; - a release that publishes no lifecycle bundle at all keeps UPDATE_INTEGRITY_METADATA_MISSING, so a broken publication is never mistaken for future protocol. `upgrade_command` moves to provider.py (a release-source refusal must name the upgrade path, and provider must not import the updater); updater re-exports it. Adds the bridge publication gate: scripts/check_bridge_release.py resolves a declared V3_BRIDGE_RELEASE through the same V2 selection rules a user's updater uses and blocks a V3 publication when the bridge is missing, unpublished, or carries no lifecycle bundle. The release workflow runs it when the repository variable V3_BRIDGE_RELEASE is set (explicit SKIP otherwise). The manual stale-runtime escape path is documented in RELEASING.md and the distribution lifecycle documentation. Characterization: the new tests failed on the pre-change tree with UPDATE_INTEGRITY_METADATA_MISSING / CHECK_FAILED; they pass after the change. Refs #157 --- .github/workflows/release-assets.yml | 14 +++ CHANGELOG.md | 14 +++ ainative/lifecycle/provider.py | 59 ++++++++++++ ainative/lifecycle/updater.py | 23 +++-- docs/DISTRIBUTION-LIFECYCLE.md | 16 ++++ docs/RELEASING.md | 38 +++++++- scripts/check_bridge_release.py | 116 +++++++++++++++++++++++ tests/test_lifecycle_update_transport.py | 80 ++++++++++++++++ tests/test_release_assets_gate.py | 82 ++++++++++++++++ 9 files changed, 430 insertions(+), 12 deletions(-) create mode 100644 scripts/check_bridge_release.py diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml index 03b2e9bb..05dc7d76 100644 --- a/.github/workflows/release-assets.yml +++ b/.github/workflows/release-assets.yml @@ -32,6 +32,20 @@ jobs: # ainative-dev-stack-2.2.1.zip beside a 2.2.2 release (AUD-201), and # every check downstream would still pass. run: python scripts/check_release_versions.py --tag "${GITHUB_REF_NAME}" + - name: V3 bridge gate (declared bridge releases only) + # The first V3 publication requires a V2-compatible bridge release that + # a V2 runtime can actually resolve (#157, PR-0B). Declared as the + # repository variable V3_BRIDGE_RELEASE; V2 releases skip it visibly, + # never silently. + env: + V3_BRIDGE_RELEASE: ${{ vars.V3_BRIDGE_RELEASE }} + run: | + set -e + if [ -z "${V3_BRIDGE_RELEASE}" ]; then + echo "SKIP: no V3_BRIDGE_RELEASE declared (not a V3 publication)" + exit 0 + fi + python scripts/check_bridge_release.py --bridge-version "${V3_BRIDGE_RELEASE}" - run: python -m pip install --disable-pip-version-check -q build - name: Build the wheel and sdist (refuses a two-version tree) # _build_backend refuses to stage when VERSION and ainative.__version__ diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afd9350..187d3f26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,20 @@ project adheres to [Semantic Versioning](https://semver.org/). ## [Unreleased] +### Added + +- **V2 forward bridge: a future lifecycle protocol now says so** (#157): a + release that publishes a newer lifecycle protocol (`ainative-release-v3.json` + manifest or `ainative-lifecycle-v3-*` bundle) is reported as available with a + CLI upgrade required, and `ainative update` refuses it with + `CLI_UPDATE_REQUIRED` before any download or write. A release that publishes + no lifecycle bundle at all keeps `UPDATE_INTEGRITY_METADATA_MISSING`, so a + broken publication is still not mistaken for future protocol. +- **Bridge publication gate** (`scripts/check_bridge_release.py`): the release + workflow verifies a declared `V3_BRIDGE_RELEASE` through the V2 selection + rules before publishing a V3 release; a missing, unpublished or bundle-less + bridge blocks the release. + ### Security - **Release transport confines provider credentials to their origin** (#158): diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index 7db9da35..f77ee3dd 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -72,6 +72,22 @@ def protocol_document(version: str) -> dict: "release_version": version, "payload_root": "stack"} +UPGRADE_COMMAND_TEMPLATE = ( + 'pip install --upgrade ' + '"git+https://github.com/Rwanbt/ai-native-dev-stack.git@v{version}"') + + +def upgrade_command(version: str) -> str: + """The exact command that installs the runtime a target release needs. + + Lives here rather than in `updater` because a release-source refusal (a + future lifecycle protocol, #157) must name the upgrade path, and this + module must not import the updater that consumes it. + """ + + return UPGRADE_COMMAND_TEMPLATE.format(version=version) + + _SHA256 = re.compile(r"^[0-9a-f]{64}$") # Short on purpose: an update check runs in the background of a status command, @@ -287,6 +303,34 @@ def _asset_url(asset: dict) -> str | None: return None +# A release that publishes one of these names declares a lifecycle protocol. +# Comparing that number with this runtime's own is what separates "a newer +# release" from "a broken release" (#157): the first must tell the user to +# upgrade the CLI, the second must keep the integrity refusal. +_LIFECYCLE_ASSET_PATTERNS = ( + re.compile(r"^ainative-lifecycle-v(?P\d+)-.+\.zip$"), + re.compile(r"^ainative-release-v(?P\d+)\.json$"), +) + + +def _future_lifecycle_protocol(assets: object) -> int | None: + """The newest declared lifecycle protocol above this runtime's, or None.""" + + newest = None + for asset in assets if isinstance(assets, list) else []: + if not isinstance(asset, dict): + continue + name = str(asset.get("name", "")) + for pattern in _LIFECYCLE_ASSET_PATTERNS: + match = pattern.match(name) + if match is None: + continue + protocol = int(match.group("protocol")) + if protocol > UPDATE_PROTOCOL_VERSION and (newest is None or protocol > newest): + newest = protocol + return newest + + def _select_asset(document: dict, expected_version: str) -> tuple[str | None, str | None]: """Pick the lifecycle bundle of `expected_version` and its digest, or refuse. @@ -332,6 +376,20 @@ def _select_asset(document: dict, expected_version: str) -> tuple[str | None, st "UPDATE_INTEGRITY_METADATA_MISSING", f"release asset {name!r} carries a malformed sha256 digest") return url, sha + future = _future_lifecycle_protocol(assets) + if future is not None: + raise LifecycleError( + "CLI_UPDATE_REQUIRED", + f"release {expected_version} publishes lifecycle protocol {future}; " + f"this runtime speaks protocol {UPDATE_PROTOCOL_VERSION} and the " + "release must be applied by a newer CLI runtime.\n\n" + "Upgrade the CLI first:\n" + f" {upgrade_command(expected_version)}\n\n" + "Then run:\n" + " ainative update", + target_version=expected_version, protocol=future, + runtime_protocol=UPDATE_PROTOCOL_VERSION, + upgrade_command=upgrade_command(expected_version)) raise LifecycleError( "UPDATE_INTEGRITY_METADATA_MISSING", f"release publishes no {LIFECYCLE_BUNDLE_PREFIX}*{LIFECYCLE_BUNDLE_SUFFIX} " @@ -389,4 +447,5 @@ def copy_tree(source: Path, destination: Path) -> None: "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", "ReleaseProviderEndpointConfig", + "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", ] \ No newline at end of file diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 0b93a842..5f61513c 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -55,9 +55,6 @@ DISABLE_ENV = "AINATIVE_NO_UPDATE_CHECK" STAGED_RELATIVE = statelib.LIFECYCLE_DIRNAME / "staged" -UPGRADE_COMMAND_TEMPLATE = ( - 'pip install --upgrade ' - '"git+https://github.com/Rwanbt/ai-native-dev-stack.git@v{version}"') # A zip that expands to more than this, or holds more entries, is refused before # a single byte is written. Both are classic archive bombs. @@ -116,10 +113,10 @@ def runtime_version() -> str: return __version__ -def upgrade_command(version: str) -> str: - """The exact command that installs the runtime a target release needs.""" - - return UPGRADE_COMMAND_TEMPLATE.format(version=version) +# Moved to `provider`: a release-source refusal (a future lifecycle protocol, +# #157) must name the upgrade path, and provider must not import this module. +# Re-exported here for the callers that have always named it through `updater`. +upgrade_command = providerlib.upgrade_command def _runtime_fields(result: CheckResult) -> CheckResult: @@ -235,6 +232,18 @@ def check(project: Path, *, force: bool = False, allow_network: bool = True, try: release = providerlib.build(channel).latest(channel) except LifecycleError as error: + if error.code == "CLI_UPDATE_REQUIRED": + # A future-protocol release IS available; this runtime cannot + # apply it. The old "no bundle to verify" answer sent users + # looking for a publishing defect instead of upgrading (#157). + target = error.detail.get("target_version") + result = _runtime_fields(CheckResult( + UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, + current, latest=target if isinstance(target, str) else None, + detail=error.message, checked_at=statelib.now())) + if record: + _write_cache(project, result) + return result status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED result = _runtime_fields(CheckResult(status, current, detail=error.message, checked_at=statelib.now())) diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index f7004112..0a01c9ab 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -468,6 +468,22 @@ pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v2. cd your-project && ainative update ``` +**A newer lifecycle protocol, stated precisely.** A release that publishes a +lifecycle bundle or manifest for a protocol newer than the running runtime's +(`ainative-lifecycle-v3-*`, `ainative-release-v3.json`) is not a broken +release: `update check` reports it as available with a CLI upgrade required, +and `ainative update` refuses it with `CLI_UPDATE_REQUIRED`, before any +download or write, naming the upgrade command. A release that publishes no +lifecycle bundle at all keeps its refusal: +`UPDATE_INTEGRITY_METADATA_MISSING`. A runtime older than the bridge release +sees a V3 release as `UPDATE_INTEGRITY_METADATA_MISSING`; its documented +escape path is the same upgrade command, applied manually, then +`ainative update`. Before the first V3 publication the release workflow +verifies the declared `V3_BRIDGE_RELEASE` with +`scripts/check_bridge_release.py`, which resolves it through these same +selection rules and blocks the publication (`BLOCK RELEASE`) when the bridge +cannot be verified. + **The release chain, stated precisely.** For a published release: ``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index cc6ddf81..af9ebcba 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -27,21 +27,49 @@ The **Release assets** workflow then runs, in order: 1. the tag points at `origin/main`; 2. `check_release_versions.py --tag` (tag == VERSION == package == AGENTS.md); -3. wheel, sdist, lifecycle bundle build; the bundle is +3. when `V3_BRIDGE_RELEASE` is declared, the bridge gate resolves it through + the V2 selection rules and blocks a V3 publication without a verifiable + bridge; +4. wheel, sdist, lifecycle bundle build; the bundle is `ainative-lifecycle-v2-.zip`; -4. `check_release_versions.py --dist` re-reads every artifact (filenames, wheel +5. `check_release_versions.py --dist` re-reads every artifact (filenames, wheel METADATA, sdist PKG-INFO, protocol document, payload VERSION); -5. `SHA256SUMS` is written; -6. **build provenance is attested** for every artifact +6. `SHA256SUMS` is written; +7. **build provenance is attested** for every artifact (`actions/attest-build-provenance`): GitHub signs a statement that these bytes came from this workflow, this repository, this commit; -7. the release is created as a **draft**, assets are uploaded (no `--clobber`), +8. the release is created as a **draft**, assets are uploaded (no `--clobber`), `check_published_assets.py` compares the uploaded names, sizes and digests against `dist/`, and only then is the draft published. A failure at any step leaves a draft release (or none) - never a visible release with a partial asset set. +## V2 bridge gate (before the first V3 publication) + +The first V3 release cannot be applied by any runtime older than V3, so every +installed runtime must be able to reach the bridge release first. The bridge is +a normal release that ships the PR-0B bridge: a V2 runtime meeting a future +protocol reports `CLI_UPDATE_REQUIRED` with the upgrade path, instead of +`UPDATE_INTEGRITY_METADATA_MISSING`. + +Before publishing a V3 release, set the repository variable +`V3_BRIDGE_RELEASE` to the bridge version. The release workflow then runs +`scripts/check_bridge_release.py --bridge-version ""`, which resolves +the bridge release through the same selection rules a user's updater uses and +blocks the publication (`BLOCK RELEASE`, exit 1) unless the release exists, is +published, and publishes `ainative-lifecycle-v2-.zip` with a digest. +When the variable is unset (V2 releases), the step reports an explicit SKIP. + +A runtime older than the bridge sees the V3 release as +`UPDATE_INTEGRITY_METADATA_MISSING`; its documented escape path is the same +upgrade command: + +```bash +pip install --upgrade "git+https://github.com/Rwanbt/ai-native-dev-stack.git@v" +cd your-project && ainative update +``` + ## PyPI The **Publish to PyPI** workflow runs when a release is published (or on diff --git a/scripts/check_bridge_release.py b/scripts/check_bridge_release.py new file mode 100644 index 00000000..400c2c4c --- /dev/null +++ b/scripts/check_bridge_release.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Block a V3 publication until its V2 bridge release is verifiable. + +A V3 lifecycle publication cannot be applied by any runtime older than V3. +Every installed runtime must therefore be able to cross to the bridge release +first, and "the bridge exists" must be checked the way a user's updater checks +it — by resolving the release through the V2 selection rules, digest included — +not by trusting a tag name. This is the gate the V3 release pipeline runs +before publishing (PR-0B, #157); a missing, unpublished or bundle-less bridge +release blocks the release. + +The check is read-only: it resolves release metadata and downloads nothing. + +Usage: + V3_BRIDGE_RELEASE=2.5.0 python scripts/check_bridge_release.py + python scripts/check_bridge_release.py --bridge-version 2.5.0 --json + +Exit codes: + 0 the bridge release is verifiable; the publication may proceed + 1 BLOCK RELEASE — missing, unpublished or unusable bridge release + 2 configuration error — no bridge version declared +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] +if str(REPO) not in sys.path: + sys.path.insert(0, str(REPO)) + +from ainative.lifecycle import provider as providerlib # noqa: E402 +from ainative.lifecycle import version as versionlib # noqa: E402 +from ainative.lifecycle.errors import LifecycleError # noqa: E402 + +ENV_VAR = "V3_BRIDGE_RELEASE" +DEFAULT_RELEASE_API = ("https://api.github.com/repos/Rwanbt/" + "ai-native-dev-stack/releases/tags/v{version}") + + +class BridgeReleaseUnverified(Exception): + """The publication must block; the message says exactly why.""" + + +def release_document_url(version: str, template: str = DEFAULT_RELEASE_API) -> str: + return template.format(version=version) + + +def verify_bridge_release(version: str, *, provider=None, + release_api: str = DEFAULT_RELEASE_API) -> dict: + """Resolve `version` as a V2 runtime would, and require a published bundle. + + The resolution walks the production selection path + (`ReleaseProvider.latest`), so "verifiable" means exactly what it means to + a user: a V2 runtime selects this bundle with a published digest. A + future-protocol release is not a bridge — a V2 runtime could not apply it + either (`CLI_UPDATE_REQUIRED`), and that refusal blocks here too. + """ + + parsed = versionlib.parse(version) + if parsed is None: + raise BridgeReleaseUnverified(f"bridge version {version!r} is not SemVer") + provider = provider or providerlib.ReleaseApiProvider( + release_document_url(str(parsed), release_api)) + try: + release = provider.latest("stable") + except LifecycleError as error: + raise BridgeReleaseUnverified( + f"a V2 runtime cannot resolve the bridge release {parsed}: " + f"{error.code}: {error.message}") from error + if release.version != str(parsed): + raise BridgeReleaseUnverified( + f"the release behind {parsed} declares version {release.version!r}") + if not release.digest: + raise BridgeReleaseUnverified( + f"bridge release {parsed} publishes no bundle digest; a runtime " + "cannot verify what it would install") + return {"bridge_version": str(parsed), + "lifecycle_bundle": providerlib.lifecycle_bundle_name(str(parsed)), + "sha256": release.digest, + "source": release.source} + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("--bridge-version", default=os.environ.get(ENV_VAR, ""), + help=f"V2-compatible bridge release; defaults to ${ENV_VAR}") + parser.add_argument("--release-api", default=DEFAULT_RELEASE_API, + help="release document URL template containing {version}") + parser.add_argument("--json", action="store_true") + args = parser.parse_args(argv) + + version = args.bridge_version.strip() + if not version: + print(f"BLOCK RELEASE: declare the bridge release with {ENV_VAR} " + "or --bridge-version", file=sys.stderr) + return 2 + try: + record = verify_bridge_release(version, release_api=args.release_api) + except BridgeReleaseUnverified as error: + print(f"BLOCK RELEASE: {error}", file=sys.stderr) + return 1 + if args.json: + print(json.dumps(record, indent=2, sort_keys=True)) + else: + print(f"bridge release {record['bridge_version']} verified: " + f"{record['lifecycle_bundle']} sha256:{record['sha256'][:12]}...") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py index df01e4ef..6ccaef3c 100644 --- a/tests/test_lifecycle_update_transport.py +++ b/tests/test_lifecycle_update_transport.py @@ -305,6 +305,86 @@ def test_a_plain_http_asset_url_is_ignored_not_authenticated(self): self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_METADATA_MISSING") +class FutureProtocolBridge(LifecycleTestCase): + """A newer lifecycle protocol is a newer release, not a broken one (#157). + + Before this bridge existed, a V2 runtime meeting a V3 publication said + `UPDATE_INTEGRITY_METADATA_MISSING` - "no lifecycle bundle to verify" - + which sends every user looking for a publishing defect instead of upgrading + the CLI. The two situations are now distinct: a future-protocol + publication refuses with `CLI_UPDATE_REQUIRED` and the upgrade path, while + a release that simply publishes no lifecycle bundle keeps the integrity + refusal. + """ + + def v3_document(self, *, tag: str = "v3.0.0", extra_assets: list | None = None) -> bytes: + assets = [ + {"name": "ainative-release-v3.json", + "browser_download_url": "https://example.invalid/ainative-release-v3.json"}, + {"name": f"ainative-lifecycle-v3-{tag.lstrip('v')}.zip", + "browser_download_url": f"https://example.invalid/ainative-lifecycle-v3-{tag.lstrip('v')}.zip"}, + ] + assets.extend(extra_assets or []) + return json.dumps({"tag_name": tag, "assets": assets}).encode("utf-8") + + def provider(self, document: bytes): + provider = providerlib.ReleaseApiProvider("https://example.invalid/releases/latest") + provider._get = lambda url, limit: document + return provider + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def test_a_future_protocol_release_is_refused_as_cli_update_required(self): + with self.assertRaises(LifecycleError) as raised: + self.provider(self.v3_document()).latest("stable") + self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") + self.assertEqual(raised.exception.detail.get("target_version"), "3.0.0") + self.assertIn("Upgrade the CLI first", raised.exception.message) + self.assertIn("@v3.0.0", raised.exception.detail["upgrade_command"]) + + def test_a_release_publishing_nothing_consumable_keeps_the_integrity_refusal(self): + document = json.dumps({"tag_name": "v3.0.0", "assets": [ + {"name": "notes.txt", "browser_download_url": "https://example.invalid/notes.txt"}]} + ).encode("utf-8") + with self.assertRaises(LifecycleError) as raised: + self.provider(document).latest("stable") + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_METADATA_MISSING") + + def test_a_bridge_style_release_stays_selectable_by_the_v2_runtime(self): + """A release carrying both a V2 bundle and V3 markers is still a V2 release.""" + + bundle = {"name": "ainative-lifecycle-v2-2.5.0.zip", + "browser_download_url": "https://example.invalid/ainative-lifecycle-v2-2.5.0.zip", + "digest": "sha256:" + "a" * 64} + document = self.v3_document(tag="v2.5.0", extra_assets=[bundle]) + release = self.provider(document).latest("stable") + self.assertEqual(release.version, "2.5.0") + self.assertEqual(release.digest, "a" * 64) + + def test_check_reports_a_future_release_as_available_with_a_cli_upgrade(self): + self.install("standard") + provider = self.provider(self.v3_document()) + with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + result = updaterlib.check(self.project, force=True, record=False) + self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) + self.assertEqual(result.latest, "3.0.0") + self.assertFalse(result.runtime_ready) + + def test_apply_refuses_a_future_release_before_any_write(self): + self.install("standard") + provider = self.provider(self.v3_document()) + before = self.snapshot() + with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") + self.assertEqual(self.snapshot(), before, "a refused update touched the project") + + class ProtocolV2Containment(unittest.TestCase): """The layout, not the selector, is what a legacy runtime cannot consume.""" diff --git a/tests/test_release_assets_gate.py b/tests/test_release_assets_gate.py index 8bf13054..6034d1ae 100644 --- a/tests/test_release_assets_gate.py +++ b/tests/test_release_assets_gate.py @@ -13,6 +13,7 @@ from __future__ import annotations +import contextlib import importlib.util import io import json @@ -30,6 +31,9 @@ import check_release_versions as gate # noqa: E402 import check_published_assets as published # noqa: E402 +import check_bridge_release as bridge # noqa: E402 +from ainative.lifecycle import provider as providerlib # noqa: E402 +from ainative.lifecycle.errors import LifecycleError # noqa: E402 def make_wheel(dist: Path, version: str) -> Path: @@ -145,5 +149,83 @@ def test_a_tag_that_does_not_name_the_version_fails(self): gate.check_labels(REPO, "v999.0.0") +class BridgeReleaseGate(unittest.TestCase): + """A V3 publication is blocked until a V2-consumable bridge exists (#157).""" + + class FakeProvider: + def __init__(self, outcome: object) -> None: + self.outcome = outcome + + def latest(self, channel: str): + if isinstance(self.outcome, Exception): + raise self.outcome + return self.outcome + + def release(self, version: str = "2.5.0", digest: str | None = "a" * 64): + return providerlib.Release(version=version, url="https://example.invalid/b.zip", + digest=digest) + + def test_a_verifiable_bridge_release_passes(self): + record = bridge.verify_bridge_release("2.5.0", provider=self.FakeProvider(self.release())) + self.assertEqual(record["bridge_version"], "2.5.0") + self.assertEqual(record["lifecycle_bundle"], "ainative-lifecycle-v2-2.5.0.zip") + self.assertEqual(record["sha256"], "a" * 64) + + def test_a_missing_or_unpublished_bridge_release_blocks(self): + provider = self.FakeProvider(LifecycleError("UPDATE_CHECK_FAILED", "HTTP 404")) + with self.assertRaises(bridge.BridgeReleaseUnverified) as raised: + bridge.verify_bridge_release("2.5.0", provider=provider) + self.assertIn("UPDATE_CHECK_FAILED", str(raised.exception)) + + def test_a_bridge_without_a_lifecycle_bundle_blocks(self): + provider = self.FakeProvider( + LifecycleError("UPDATE_INTEGRITY_METADATA_MISSING", "no bundle")) + with self.assertRaises(bridge.BridgeReleaseUnverified) as raised: + bridge.verify_bridge_release("2.5.0", provider=provider) + self.assertIn("UPDATE_INTEGRITY_METADATA_MISSING", str(raised.exception)) + + def test_a_future_protocol_release_is_not_a_bridge(self): + provider = self.FakeProvider( + LifecycleError("CLI_UPDATE_REQUIRED", "publishes lifecycle protocol 3")) + with self.assertRaises(bridge.BridgeReleaseUnverified) as raised: + bridge.verify_bridge_release("2.5.0", provider=provider) + self.assertIn("CLI_UPDATE_REQUIRED", str(raised.exception)) + + def test_a_release_declaring_another_version_blocks(self): + provider = self.FakeProvider(self.release(version="2.4.9")) + with self.assertRaises(bridge.BridgeReleaseUnverified) as raised: + bridge.verify_bridge_release("2.5.0", provider=provider) + self.assertIn("declares version", str(raised.exception)) + + def test_a_bridge_without_a_published_digest_blocks(self): + provider = self.FakeProvider(self.release(digest=None)) + with self.assertRaises(bridge.BridgeReleaseUnverified): + bridge.verify_bridge_release("2.5.0", provider=provider) + + def test_a_non_semver_bridge_version_blocks(self): + with self.assertRaises(bridge.BridgeReleaseUnverified): + bridge.verify_bridge_release("latest", provider=self.FakeProvider(self.release())) + + def test_a_missing_bridge_version_is_a_configuration_error(self): + stderr = io.StringIO() + with contextlib.redirect_stderr(stderr): + code = bridge.main(["--bridge-version", ""]) + self.assertEqual(code, 2) + self.assertIn("BLOCK RELEASE", stderr.getvalue()) + + def test_the_gate_resolves_through_the_v2_selection_path(self): + """Not a mock of the gate: the real provider, the real selection rules.""" + + document = json.dumps({"tag_name": "v2.5.0", "assets": [ + {"name": "ainative-lifecycle-v2-2.5.0.zip", + "browser_download_url": "https://example.invalid/b.zip", + "digest": "sha256:" + "b" * 64}]}).encode("utf-8") + provider = providerlib.ReleaseApiProvider( + "https://example.invalid/releases/tags/v2.5.0") + provider._get = lambda url, limit: document + record = bridge.verify_bridge_release("2.5.0", provider=provider) + self.assertEqual(record["sha256"], "b" * 64) + + if __name__ == "__main__": unittest.main() \ No newline at end of file From f0101fc70da6761b946bba274a9a49918c65d1da Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 22:04:37 +0200 Subject: [PATCH 25/27] feat: Multi-Forge implementation (features, claims, Release V3, GitLab provider) -> dev (#172) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(lifecycle): feature model, State V2 and the shared projection (#162) ADR-0017 makes profiles (governance) and features (optional project-scope capabilities) orthogonal. This change adds the model and the read side; switching comes next in the lane. - data/features.json: forge-github / forge-gitlab — project scope, symmetric conflicts, work-forge flag, declared legacy_default (no hard-coded default). - manifest: Feature, Distribution.features / legacy_default_feature, Distribution.feature(); the catalogue refuses a machine-scope entry, an asymmetric conflict, a component claimed by two features, an undeclared component reference and an undeclared legacy default. - state: schema V2 with active_features. V1 still loads (no field); a V2 state naming a feature this release does not know refuses as INSTALL_STATE_CORRUPTED (upgrade the CLI, never downgrade the state), and the existing schema guard still refuses a newer schema. - features.project_install_state(): the single projection every read path will share; V1 projects to the declared legacy default; a V2 state activating two work forges refuses as STATE_CONFLICTING_WORK_FORGE_FEATURES. The projection never writes. - features.migrate_state(): stamps the current schema and seeds the legacy default exactly once, inside the existing install transaction (state-last); it plans no file change of its own, so a managed file that is already absent stays absent through the migration. - installer: a fresh state seeds the default feature (a fresh Standard install keeps shipping the GitHub templates, as it always has); a no-op install still saves the state when it was V1, which is what makes the migration persist. - gitlab-templates component and templates/gitlab/ (owned by forge-gitlab; the .gitlab/ equivalents of the GitHub templates). - New refusal codes: STATE_CONFLICTING_WORK_FORGE_FEATURES, FEATURE_UNKNOWN. - CI: tests.test_lifecycle_features runs in the lifecycle job. Verified: 295 lifecycle tests OK (21 new), machine/CLI suites OK, gates (conventions, scope, complexity, LOC) green. Refs #162 * feat(lifecycle): feature switching in one transaction (#162) `ainative feature enable | disable | switch | status` moves a project between optional capabilities with the profile path's guarantees unchanged: project lifecycle lock, reload, project the effective V2 set, plan, apply, verify, state last, commit. A V1 state migrates inside the same transaction. - profiles.json: `github-templates` leaves the standard profile; the feature `forge-github` owns it now (a component belongs to exactly one owner), so `feature switch none` really is Generic Git. - planner: the wanted set is profile components plus the active features' components, computed by one shared function the installer also records from (plan and record cannot drift). Feature files are seeded when a feature is enabled; ordinary maintenance never re-seeds an absent feature file — a file the user removed stays removed until an explicit transition (ADR-0017 §5). - installer.set_features(): the transition semantics. `enable` refuses a conflicting active feature with the remedy (`FEATURE_CONFLICT`, never an implicit disable); `switch` replaces the work forge in one plan; `switch none` removes every work forge and nothing else. A no-op transition changes nothing — including the state bytes — unless it still has something to record (migration, moved set, dropped component records). - feature_cli.py: the command surface, split out of cli.py so the dispatcher stays under its LOC budget; status derives from the shared projection. - New refusal code FEATURE_CONFLICT. - Tests: switching, disable preserves a modified file, absent feature file stays absent through installs and is re-seeded by an explicit transition, the migration fixture (a V1 state whose GitHub template is absent stays absent), a round trip without user-data loss, CLI status/switch. - Docs: DISTRIBUTION-LIFECYCLE §18 and CHANGELOG. Verified: 307 lifecycle tests OK (33 in the features suite), CLI/machine suites OK, gates green (conventions, scope, complexity, LOC — cli.py back under 800), five non-vacuity guards re-proved. Refs #162 * test(lifecycle): feature ownership matrix - a user file is never adopted (#162) * feat(forge): pure work-authority resolver, claim grammar and durable journal (#163) ADR-0018's local half: the harness observes remote facts, this layer records them deterministically and never decides by guessing. - ainative/forge.py: `resolve_observed_work_authority()` — pure (no network, no credentials, no writes, no push authorization). Priority: explicit reference, then harness declaration, then exactly one compatible observed candidate; otherwise WORK_AUTHORITY_UNAVAILABLE / _AMBIGUOUS / _MISMATCH. Only github.com and gitlab.com are read as providers; a self-hosted host is `unknown`, never inferred from its name; a fork is ambiguity, never "prefer origin". - ainative/claims.py: canonical identities (`:principal:`, `::`), UTC-normalized timestamps (a naive one refuses), total winner ordering with CLAIM_CONFLICT for unorderable duplicates, and the ClaimAttempt journal under `.ai-native/state/claim-attempts/` — PENDING written durably before the remote POST, outcomes CONFIRMED/LOST/CONFLICT/UNCERTAIN/ABANDONED, an unwritable or corrupt journal fails closed, abandon is an explicit local operator transition that keeps the record, and an abandoned record is final. - ainative/claim_cli.py + `ainative claim-attempt list|inspect|abandon --confirm`: the recovery surface. No retry path exists by design. - New refusal codes: WORK_AUTHORITY_*, CLAIM_INVALID, CLAIM_CONFLICT, CLAIM_UNCERTAIN, CLAIM_JOURNAL_UNAVAILABLE. - Tests: 27 in tests/test_forge_claims.py, including the invariant that a pending attempt survives a lifecycle update byte-identically and the CLI confirmation flow. CI runs them in the lifecycle job. Verified: 308 lifecycle tests OK, knowledge/machine/CLI suites OK, gates green (conventions, scope, complexity, LOC). Refs #163 * docs(workflow): provider-neutral FORGE-WORKFLOW, GitHub and GitLab mappings (#163) * docs(policy): distribute a provider-neutral AGENTS.md template (#163) The engineering-method component installs templates/AGENTS.md (Work Authority vocabulary, ADR-0018) instead of the repository's own AGENTS.md, which stays GitHub-specific. Identical to the root file except the work-management section; the fixture distribution ships both files. * feat(forge): forge detection, status observation and the doctor extension (#164) `ainative forge detect|status` observe the project's Git remotes and render the Work Authority resolution. Read-only by construction: the only subprocess is `git remote`, the only inputs are the pure resolver, the install state and the claim journal — zero network, zero credentials, zero writes, zero persistent trust. A fork renders as AMBIGUOUS (both candidates shown, no preference for origin) and an unknown host as UNAVAILABLE, both exit 0: detection is a diagnostic, the refusal belongs to the mutation path. - ainative/observation.py: remote reading, `forge_picture()` (resolution as state), `project_view()` (features + forge + unresolved claim attempts) and the doctor text lines. An unreadable claim journal is displayed, not a crash. - ainative/forge_cli.py: the command surface; `status` adds the effective feature set (shared projection) and the unresolved claim count. - Doctor extension: profile/features/forge/claim summary in both outputs, never a credential (`doctor --json` gains features, forge, claim_attempts). - status.py: reports the effective feature set through the same projection (`features`, `features_projected_from_legacy`), closing the read-only parity requirement for `status`. - ainative/cli_support.py: the small shared plumbing (emit/project/report/ plan text) extracted so cli.py stays the dispatcher under its LOC budget and the command modules stop importing render callables through cli.py. - Tests: 7 new observation tests (resolution, fork rendering, unknown host, features+claims in status, byte-identical project across detect/status/ doctor) and a status/projection parity test for a V1 state. Verified: 309 lifecycle tests OK, 139 CLI/knowledge/machine suites OK, gates green (conventions, scope, complexity, LOC: cli.py 757). Refs #164 * feat(release): one source resolver with fail-closed selector conflicts (#165) PR-4A. `resolve_release_source()` (ainative/lifecycle/release_source.py) is now the only function that decides where a release comes from, and `update`, `update check`, `status` and `doctor` all consume it — so the answer cannot differ between commands (ADR-0019 sections 1-3). - Selector rules exactly as frozen: local pair -> mirror; LOCAL_DIR without provider=local -> UPDATE_SOURCE_CONFLICT; URL mixed with any selector -> UPDATE_SOURCE_CONFLICT; URL alone -> anonymous; named provider -> machine config or built-in; nothing -> machine default_provider, else GitHub.com. Validation before precedence: nothing is ordered silently, and the old "unknown provider" refusal is preserved. - Machine scope `~/.ai-native/release-providers.json` (schema 1): reserved names (github/gitlab/local) cannot be redefined, a named provider is anonymous in V1 (a custom auth_origin is refused), a future schema refuses rather than guessing. `default_provider: local` requires a configured directory. New refusal codes: UPDATE_SOURCE_CONFLICT, RELEASE_CONFIG_INVALID. - `provider.build()` delegates to the resolver; the selector constants are re-exported so existing imports keep working. - Observability: `status` and `doctor` display the effective source, the selection reason, authenticated yes/no and the auth origin — never a secret (`describe()` turns a selector conflict into a displayed state, so the diagnostics keep diagnosing while the updater refuses). - Tests: 19 in tests/test_release_source.py (selector matrix, machine config, provider construction, status/doctor parity); CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, 123 update/CLI suites OK, gates green (conventions, scope, complexity 0 findings after splitting the resolver, LOC 0 warnings). Refs #165 * feat(release): Release V3 contract, anchored manifest and exact version chain (#165) PR-4B + PR-4C. ainative/lifecycle/release_v3.py owns the V3 vocabulary and the fail-closed policies around it (ADR-0019 sections 7-10): - Candidate policy: installable versions are SemVer without build metadata (1.2.3, 1.2.3-rc.1); `1.2.3+build1` refuses as RELEASE_BUILD_METADATA_UNSUPPORTED; ordering uses version precedence, never string order; two identities on one version refuse as RELEASE_DUPLICATE_VERSION (never "first match"); an enumeration that stopped at its bounds refuses as RELEASE_ENUMERATION_INCOMPLETE; a complete channel with nothing refuses as RELEASE_NO_CANDIDATE. - The external anchor travels WITH the candidate (provider metadata): manifest sha256 + size are verified BEFORE the document is parsed — RELEASE_INTEGRITY_METADATA_MISSING / _INVALID for absent/malformed anchors, UPDATE_INTEGRITY_FAILED for any mismatch. A hostile manifest can never influence whether its own bytes are trusted. - ReleaseManifest V3: schema, protocol, version, channel, compatibility, artifacts, provenance — strictly validated (plain filenames, exact one lifecycle artifact, canonical versions). A newer protocol refuses as CLI_UPDATE_REQUIRED, mirroring the bridge; anything else is an integrity refusal. - The exact version chain: candidate == manifest == compatibility.runtime_version == artifact == artifact filename == lifecycle-protocol.json release_version, with the broken link named in the refusal detail. `resolve_manifest()` formalizes the order: enumerate -> select -> verify -> parse -> chain. Tests: 32 in tests/test_release_v3.py (SemVer policy, selection, anchors, manifest refusals, every broken chain link, resolution order with a fake provider). CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, complexity 0 findings, LOC 0 warnings, scope within tolerance. Refs #165 * feat(release): V3 providers - GitHub, anonymous document, local mirror (#165) PR-4D + PR-4E. Each provider implements the `release_v3` contract and nothing else: enumerate, fetch the manifest, fetch an artifact. Every trust decision stays in release_v3 — the provider that fetched a manifest never decides whether to believe it (ADR-0019 sections 9-11). - GitHubReleaseProvider: bounded enumeration of the releases API (a full page says `complete=False`, so selection refuses instead of picking the best of what it saw); drafts, non-SemVer tags and releases without an `ainative-release-v3.json` asset are not candidates (a V2-era release is not a broken V3 one); the manifest asset's digest/size travel as the candidate's anchor; artifacts and manifests are fetched through the PR-0A transport (asset API locator preferred, `browser_download_url` fallback, octet-stream, bearer only at `api.github.com`). - AnonymousReleaseApiProvider: `AINATIVE_UPDATE_URL` as one GitHub-shaped release document, never authenticated; a document without a V3 manifest yields no candidate (`RELEASE_NO_CANDIDATE`), never a silent V2 fallback. - LocalReleaseProvider: `releases.json` channels carry the manifest locator with its size and SHA-256; the mirror executes the same logical chain as a network source, traversal-checked, bounded, and never trusted for being on disk. A V2-era channel entry is not a candidate. - `ReleaseCandidate` gains `manifest_locator` (provider-internal, opaque to the contract); `provider.environment_token()` exposes the environment token publicly for the V3 providers under the same origin rule. Tests: 16 in tests/test_release_providers.py, including an end-to-end `resolve_manifest` against the local mirror and against a scripted GitHub server, a tampered-manifest refusal, a bounded-listing refusal and a path-traversal refusal. CI runs them in the lifecycle job. Verified: 309 lifecycle tests OK, 67 release tests OK, complexity 0 findings, LOC 0 warnings, scope within tolerance. Refs #165 * feat(release): wire the updater to the V3 flow with a V2-era fallback (#165) PR-4 completion. `update check` and `update` now resolve through `resolve_v3_candidate()`: the resolved source is enumerated (bounded), the newest V3 candidate of the channel is selected, and the manifest is fetched and anchor-verified only when an update is applied — a check needs the version, nothing more. - A source that hits its enumeration bounds refuses (RELEASE_ENUMERATION_INCOMPLETE): partial results are never a reason to change generation. - A complete channel with no V3 candidate is a V2-era mirror, and the caller speaks the existing V2 path to it (deterministic, disclosed fallback — the bridge-era releases stay consumable). Everything else propagates. - apply(): V3 fetch verifies the artifact against the manifest's own size+SHA-256 before extraction, then the existing transaction applies the payload; the V3 bundle declares lifecycle protocol 3 (`_distribution_root` gained an `expected_protocol`), and a bundle carrying the V2 protocol refuses as UPDATE_VERSION_MISMATCH. - `provider.build_v3()` is the second patchable seam; the four tests that stub the V2 provider now stub it explicitly. Tests: 5 new end-to-end tests on a local V3 mirror (check, apply + rollback, tampered manifest refused with zero writes, protocol mismatch refused, CLI report). 314 lifecycle tests OK; release/CLI suites OK; gates green. Refs #165 * feat(release): GitLab provider, auth scheme per endpoint, purity tests (#166) PR-5 core. The GitLab provider implements the V3 contract (ADR-0019 §11): - Releases API for discovery only; the Generic Package Registry is the canonical distribution and integrity surface. Release Link URLs are never integrity roots — the anchor is the package file API's `file_sha256` and `size` (absent -> RELEASE_INTEGRITY_METADATA_MISSING, malformed -> RELEASE_INTEGRITY_METADATA_INVALID), and the manifest file lookup requires exactly one `ainative-release-v3.json` (0 -> RELEASE_MANIFEST_MISSING, >1 -> RELEASE_MANIFEST_AMBIGUOUS). - One project identity (`release_project_ref`) is used for Releases, packages and package files — no second project configuration. `release_project_ref` is a numeric id or namespace path, never a URL; it is machine-scope config (`providers.gitlab`), while `github`/`local` remain non-redefinable. - Exact package match (`type == generic`, canonical package name, selected version) with exactly one record, and bounded pagination (a full page refuses as incomplete, never a partial best-of). - Authentication shapes differ per provider: `ReleaseProviderEndpointConfig` gained `auth_header`/`auth_prefix`. GitLab sends `PRIVATE-TOKEN: ` from `GITLAB_TOKEN`, only at its configured origin; GitHub keeps `Authorization: Bearer`, unchanged. - GitLab is V3-only: `supports_v2_fallback = False`, and a complete channel without a V3 candidate refuses as RELEASE_NO_CANDIDATE instead of falling back to a generation that does not exist there. Tests: 10 GitLab provider tests on a scripted GitLab (exact-identity match, private-token confinement, duplicate packages, every manifest/integrity refusal, pagination bounds, full chain, anonymous endpoints) plus 3 config tests; and the three purity suites required by §74 (tests/purity/test_dependency_purity.py, test_contract_purity.py, test_distributed_policy_purity.py) — neutral modules do not import provider implementations, the neutral helpers behave identically for both forges, and the distributed policy carries no GitHub-only authority. Verified: 314 lifecycle tests OK, release suites OK, purity 7/7, complexity 0 findings, LOC 0 warnings. Refs #166 * docs(multiforge): support matrix, qualification report and READMEs (#165, #166) * fix(e2e): follow the distributed AGENTS.md template in the upgrade E2E (#165) --- .github/workflows/ci.yml | 22 + CHANGELOG.md | 30 + README.fr.md | 22 + README.md | 20 + SUPPORT.md | 3 + ainative/claim_cli.py | 76 ++ ainative/claims.py | 321 ++++++++ ainative/cli.py | 100 ++- ainative/cli_support.py | 74 ++ ainative/forge.py | 169 +++++ ainative/forge_cli.py | 85 +++ ainative/lifecycle/data/components.json | 19 +- ainative/lifecycle/data/features.json | 41 ++ ainative/lifecycle/data/profiles.json | 3 +- ainative/lifecycle/errors.py | 28 + ainative/lifecycle/feature_cli.py | 96 +++ ainative/lifecycle/features.py | 169 +++++ ainative/lifecycle/installer.py | 140 +++- ainative/lifecycle/manifest.py | 112 ++- ainative/lifecycle/planner.py | 64 +- ainative/lifecycle/provider.py | 77 +- ainative/lifecycle/release_providers.py | 521 +++++++++++++ ainative/lifecycle/release_source.py | 306 ++++++++ ainative/lifecycle/release_v3.py | 413 +++++++++++ ainative/lifecycle/state.py | 17 +- ainative/lifecycle/status.py | 26 + ainative/lifecycle/transport.py | 9 +- ainative/lifecycle/updater.py | 147 +++- ainative/observation.py | 131 ++++ docs/DISTRIBUTION-LIFECYCLE.md | 33 + docs/FORGE-WORKFLOW.md | 262 +++++++ docs/GITHUB-WORKFLOW.md | 246 ++----- docs/GITLAB-WORKFLOW.md | 55 ++ docs/MULTIFORGE-QUALIFICATION.md | 100 +++ scripts/lifecycle_upgrade_e2e.py | 19 +- templates/AGENTS.md | 686 ++++++++++++++++++ templates/gitlab/issue_templates/bug.md | 21 + templates/gitlab/issue_templates/feature.md | 17 + .../gitlab/merge_request_templates/default.md | 13 + tests/lifecycle_support.py | 3 + tests/purity/test_contract_purity.py | 68 ++ tests/purity/test_dependency_purity.py | 61 ++ .../purity/test_distributed_policy_purity.py | 49 ++ tests/test_forge_claims.py | 347 +++++++++ tests/test_github_work_skills.py | 6 +- tests/test_lifecycle_features.py | 452 ++++++++++++ tests/test_lifecycle_security.py | 7 + tests/test_lifecycle_update_integrity.py | 6 +- tests/test_lifecycle_update_transport.py | 6 +- tests/test_lifecycle_update_v3.py | 122 ++++ tests/test_release_providers.py | 484 ++++++++++++ tests/test_release_source.py | 225 ++++++ tests/test_release_v3.py | 319 ++++++++ 53 files changed, 6501 insertions(+), 347 deletions(-) create mode 100644 ainative/claim_cli.py create mode 100644 ainative/claims.py create mode 100644 ainative/cli_support.py create mode 100644 ainative/forge.py create mode 100644 ainative/forge_cli.py create mode 100644 ainative/lifecycle/data/features.json create mode 100644 ainative/lifecycle/feature_cli.py create mode 100644 ainative/lifecycle/features.py create mode 100644 ainative/lifecycle/release_providers.py create mode 100644 ainative/lifecycle/release_source.py create mode 100644 ainative/lifecycle/release_v3.py create mode 100644 ainative/observation.py create mode 100644 docs/FORGE-WORKFLOW.md create mode 100644 docs/GITLAB-WORKFLOW.md create mode 100644 docs/MULTIFORGE-QUALIFICATION.md create mode 100644 templates/AGENTS.md create mode 100644 templates/gitlab/issue_templates/bug.md create mode 100644 templates/gitlab/issue_templates/feature.md create mode 100644 templates/gitlab/merge_request_templates/default.md create mode 100644 tests/purity/test_contract_purity.py create mode 100644 tests/purity/test_dependency_purity.py create mode 100644 tests/purity/test_distributed_policy_purity.py create mode 100644 tests/test_forge_claims.py create mode 100644 tests/test_lifecycle_features.py create mode 100644 tests/test_lifecycle_update_v3.py create mode 100644 tests/test_release_providers.py create mode 100644 tests/test_release_source.py create mode 100644 tests/test_release_v3.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea47d9f..31bebc4a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -201,6 +201,28 @@ jobs: - name: Ownership, user modifications and external config run: python -m unittest tests.test_lifecycle_ownership -v + - name: Features, State V2 and the shared projection + run: python -m unittest tests.test_lifecycle_features -v + + - name: Work authority, claim grammar and the claim journal + run: python -m unittest tests.test_forge_claims -v + + - name: Release source resolution and diagnostics + run: python -m unittest tests.test_release_source -v + + - name: Release V3 contract, manifest and version chain + run: python -m unittest tests.test_release_v3 -v + + - name: Release V3 providers (GitHub, anonymous, local mirror) + run: python -m unittest tests.test_release_providers -v + + - name: Release V3 update path end to end + run: python -m unittest tests.test_lifecycle_update_v3 -v + + - name: Purity - neutral layer, contracts, distributed policy + run: | + python -m unittest tests.purity.test_dependency_purity tests.purity.test_contract_purity tests.purity.test_distributed_policy_purity -v + - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 187d3f26..ffe6c2fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,36 @@ project adheres to [Semantic Versioning](https://semver.org/). workflow verifies a declared `V3_BRIDGE_RELEASE` through the V2 selection rules before publishing a V3 release; a missing, unpublished or bundle-less bridge blocks the release. +- **Features: profiles and capabilities are independent** (#162, ADR-0017): + `ainative feature enable | disable | switch | status` manages optional + project-scope capabilities in one lifecycle transaction. `forge-github` is + the default; it conflicts with `forge-gitlab`; a project has at most one + work forge, and `feature switch none` is the Generic Git shape. State schema + V2 records `active_features`; a V1 state projects to the compatibility + default and migrates inside the next mutation without resurrecting an + absent managed file. +- **Multi-Forge release sources and Release V3** (#165, ADR-0019): one + `resolve_release_source()` with fail-closed selector conflicts and a + machine-scope `~/.ai-native/release-providers.json`; a V3 release manifest + whose SHA-256 and size are verified (from provider metadata) *before* the + document is parsed; an exact version chain across candidate, manifest, + runtime, artifact, filename and the lifecycle protocol document; bounded + enumeration; SemVer without build metadata; duplicates refused. GitHub.com, + anonymous and local providers are wired into `update`/`update check` + (V2-era sources keep working), and `status`/`doctor` show the effective + source, its reason and whether it is authenticated. +- **GitLab provider (Release V3)** (#166): Releases for discovery, the + Generic Package Registry as the canonical integrity surface (`file_sha256` + + `size`), exact package lookup, bounded pagination, and `PRIVATE-TOKEN` + confined to the configured origin. GitLab.com live qualification is + UNTESTED and therefore not declared supported — + `docs/MULTIFORGE-QUALIFICATION.md` states exactly what was verified. +- **Work Authority observation and claims** (#163/#164, ADR-0018): + `ainative forge detect | status` (zero network, credentials, writes or + persistent trust), the doctor extension, the pure local resolver, the + canonical claim grammar and the durable + `ainative claim-attempt list | inspect | abandon` journal — written before + the remote signal, never retried blindly. ### Security diff --git a/README.fr.md b/README.fr.md index f69bd472..87b78450 100644 --- a/README.fr.md +++ b/README.fr.md @@ -577,6 +577,28 @@ ainative doctor # inclut la section Knowledge - Guides : docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md et docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git est le produit de base ; GitHub et GitLab sont des capacités +optionnelles, et le profil de gouvernance est indépendant d'elles +(ADR-0017/0018/0019). + +```bash +ainative feature status # l'ensemble effectif de features +ainative feature switch forge-gitlab # une transaction : GitHub -> GitLab +ainative feature switch none # Generic Git : aucune forge de travail +ainative forge status # remotes observés + résolution Work Authority +ainative claim-attempt list # tentatives de claim journalisées (recovery) +``` + +Les sources de release sont résolues par une seule fonction aux sélecteurs +fail-closed (GitHub.com par défaut ; un miroir local ; une +`AINATIVE_UPDATE_URL` anonyme ; GitLab.com via un `release-providers.json` de +portée machine). La gestion du travail est provider-neutre +(`docs/FORGE-WORKFLOW.md`, avec les mappings GitHub et GitLab) ; la chaîne V3 +vérifie un manifeste ancré de l'extérieur avant tout parsing, et la matrice de +support est dans `SUPPORT.md`. + ## Démarrage rapide ### Installer sur un projet existant diff --git a/README.md b/README.md index ccd72655..87215409 100644 --- a/README.md +++ b/README.md @@ -578,6 +578,26 @@ ainative doctor # includes the Knowledge section - Guides: docs/knowledge/KNOWLEDGE-CONVERGENCE-MATRIX.md and docs/knowledge/K1-K4-MEASUREMENT-GATE.md +## Multi-Forge + +Generic Git is the base product; GitHub and GitLab are optional capabilities, +and the governance profile is independent of them (ADR-0017/0018/0019). + +```bash +ainative feature status # the effective feature set +ainative feature switch forge-gitlab # one transaction: GitHub -> GitLab +ainative feature switch none # Generic Git: no work forge +ainative forge status # observed remotes + Work Authority resolution +ainative claim-attempt list # journaled claim attempts (recovery) +``` + +Release sources are resolved by one function with fail-closed selectors +(GitHub.com by default; a local mirror; an anonymous `AINATIVE_UPDATE_URL`; +GitLab.com through a machine-scoped `release-providers.json`). Work +management is provider-neutral (`docs/FORGE-WORKFLOW.md`, with the GitHub and +GitLab mappings); the V3 release chain verifies an externally anchored +manifest before parsing anything, and the support matrix is in `SUPPORT.md`. + ## Quick Start ### For an existing project diff --git a/SUPPORT.md b/SUPPORT.md index 02f97326..b5da57b4 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -9,6 +9,9 @@ | Operating systems | Linux, macOS and Windows (CI exercises all three) | | Profiles | Standard; Verified (adds governed Work Contracts and deterministic verification) | | Harnesses | Claude Code, Codex, OpenCode, Cursor, Gemini CLI, MiniMax/Mavis — for the shared method, skills and hooks | +| Work management | Generic Git; GitHub (mapping); GitLab (mapping). The Work Authority is resolved locally, purely and fail-closed (ADR-0018) | +| Features | Project-scope features independent of the profile (`forge-github` default, `forge-gitlab`), State schema V2 (ADR-0017) | +| Release sources | GitHub.com (built-in, qualified); a local mirror; an anonymous HTTPS release API (`AINATIVE_UPDATE_URL`, never credentialed); GitLab.com provider implemented and contract-tested, live-service qualification UNTESTED — **not declared supported**; GitLab Self-Managed and GitHub Enterprise Server UNTESTED — **not supported** | Only the latest release is supported; fixes ship as a new patch version. diff --git a/ainative/claim_cli.py b/ainative/claim_cli.py new file mode 100644 index 00000000..8f270550 --- /dev/null +++ b/ainative/claim_cli.py @@ -0,0 +1,76 @@ +"""The `ainative claim-attempt` recovery surface: list, inspect, abandon. + +Recovery is an operator act, so the surface is read-mostly: listing and +inspecting never change anything, and abandoning is one explicit transition +that requires `--confirm`. There is deliberately no "retry" here — an +uncertain POST is resolved by searching the remote for the attempt marker, +never by posting again (ADR-0018 section 6). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import claims +from .cli_support import project_from, report as _report + + +def add_claim_parser(commands) -> None: + claim = commands.add_parser( + "claim-attempt", help="Inspect or abandon journaled claim attempts.") + subcommands = claim.add_subparsers(dest="claim_command", required=True) + listing = subcommands.add_parser("list", help="Every journaled claim attempt.") + _common(listing) + inspect = subcommands.add_parser("inspect", help="One attempt, in full.") + inspect.add_argument("attempt_id") + _common(inspect) + abandon = subcommands.add_parser( + "abandon", help="Give up an unresolved attempt (local transition only).") + abandon.add_argument("attempt_id") + abandon.add_argument("--confirm", action="store_true", + help="required: this is an operator decision") + _common(abandon) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _attempt_text(attempt: claims.ClaimAttempt) -> str: + lines = [f"{attempt.attempt_id}: {attempt.state}", + f" authority: {attempt.authority.get('provider')}:" + f"{attempt.authority.get('project')}", + f" item: {attempt.item}", + f" principal: {attempt.principal}", + f" marker: {attempt.marker}", + f" created: {attempt.created_at}"] + if attempt.outcome_at: + lines.append(f" outcome: {attempt.outcome_at}") + if attempt.note: + lines.append(f" note: {attempt.note}") + return "\n".join(lines) + + +def run_claim_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.claim_command == "list": + attempts = claims.list_attempts(project) + record = {"attempts": [attempt.to_record() for attempt in attempts], + "unresolved": [attempt.attempt_id for attempt in attempts + if attempt.state in claims.UNRESOLVED]} + lines = [f"{len(attempts)} attempt(s), {len(record['unresolved'])} unresolved"] + for attempt in attempts: + lines.append(f" {attempt.state:<10} {attempt.attempt_id} " + f"item {attempt.item}") + return _report(args, record, "\n".join(lines)) + if args.claim_command == "inspect": + attempt = claims.load_attempt(project, args.attempt_id) + return _report(args, attempt.to_record(), _attempt_text(attempt)) + attempt = claims.abandon(project, args.attempt_id, confirm=args.confirm) + return _report(args, attempt.to_record(), _attempt_text(attempt)) + + +__all__ = ["add_claim_parser", "run_claim_command"] diff --git a/ainative/claims.py b/ainative/claims.py new file mode 100644 index 00000000..6bed3813 --- /dev/null +++ b/ainative/claims.py @@ -0,0 +1,321 @@ +"""Canonical claim identities, and the durable journal that survives a crash. + +A claim is a remote mutation: an agent asks a work authority to recognize it +as the owner of a WorkItem. What makes a crash recoverable is that the local +record of the attempt exists *before* the remote POST — journal first, then +exactly one POST, then a re-read that searches for the attempt's own marker +(ADR-0018 sections 3–6). + +This module owns the local half: canonical identities, UTC-normalized +ordering, the PENDING → outcome journal under `.ai-native/state/claim-attempts/`, +and the explicit operator transitions. It never calls a provider API, never +holds a credential, and never infers an outcome: the harness observes, this +module records and arbitrates deterministically. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +KIND_COMMENT = "comment" +KIND_NOTE = "note" +KIND_ASSIGNMENT = "assignment" +EVENT_KINDS = (KIND_COMMENT, KIND_NOTE, KIND_ASSIGNMENT) + +PENDING = "PENDING" +CONFIRMED = "CONFIRMED" +LOST = "LOST" +CONFLICT = "CONFLICT" +UNCERTAIN = "UNCERTAIN" +ABANDONED = "ABANDONED" +OUTCOMES = (CONFIRMED, LOST, CONFLICT, UNCERTAIN, ABANDONED) +UNRESOLVED = (PENDING, UNCERTAIN) + +ATTEMPTS_RELATIVE = Path(".ai-native") / "state" / "claim-attempts" +MARKER_PREFIX = "ainative-claim-attempt:" + +_ATTEMPT_ID = re.compile(r"^claim_[0-9a-f]{32}$") +_TOKEN = re.compile(r"^[^\s:]+$") + + +def _token(value: str, what: str) -> str: + if not isinstance(value, str) or not _TOKEN.match(value): + raise LifecycleError("CLAIM_INVALID", + f"a claim {what} must be a non-empty token without " + f"whitespace or ':', got {value!r}") + return value + + +def principal(provider: str, stable_id: str) -> str: + """`:principal:` — who is claiming.""" + + return f"{_token(provider, 'provider')}:principal:{_token(stable_id, 'principal id')}" + + +def event_identifier(provider: str, kind: str, stable_id: str) -> str: + """`::` — which claim event it is.""" + + if kind not in EVENT_KINDS: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim event kind {kind!r} " + f"(known: {', '.join(EVENT_KINDS)})") + return f"{_token(provider, 'provider')}:{kind}:{_token(stable_id, 'event id')}" + + +def to_utc(value: str) -> str: + """An ISO-8601 timestamp normalized to UTC. A naive one is refused. + + Ordering claims by timestamp is only deterministic if every timestamp + carries its zone; "2026-09-16T10:00:00" cannot be compared with + "2026-09-16T09:00:00+02:00" without guessing. + """ + + try: + moment = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError as error: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} is not ISO-8601") from error + if moment.tzinfo is None: + raise LifecycleError("CLAIM_INVALID", + f"claim timestamp {value!r} carries no timezone") + return moment.astimezone(timezone.utc).isoformat() + + +@dataclass(frozen=True) +class ClaimEvent: + """One observed claim signal, in canonical form.""" + + identifier: str # canonical event identifier + created_at: str # UTC ISO-8601 + actor: str # canonical principal + + def sort_key(self) -> tuple[str, str]: + return (self.created_at, self.identifier) + + def to_record(self) -> dict: + return {"identifier": self.identifier, "created_at": self.created_at, + "actor": self.actor} + + +def winner(events) -> ClaimEvent | None: + """The winning claim by `(created_at_utc, canonical_event_identifier)`. + + Exact duplicates are one event. Two distinct events with the same key — a + duplicate identifier carrying different actors — are unorderable and refuse + as `CLAIM_CONFLICT`: every claimant stops (ADR-0018 section 3). + """ + + unique: dict[str, ClaimEvent] = {} + for event in events: + existing = unique.get(event.identifier) + if existing is None: + unique[event.identifier] = event + elif existing != event: + raise LifecycleError( + "CLAIM_CONFLICT", + f"two different claim events share the identifier " + f"{event.identifier!r}; they cannot be ordered", + identifier=event.identifier) + if not unique: + return None + ordered = sorted(unique.values(), key=ClaimEvent.sort_key) + if len(ordered) > 1 and ordered[0].sort_key() == ordered[1].sort_key(): + raise LifecycleError("CLAIM_CONFLICT", + "two claim events are unorderable: identical " + "timestamp and identifier") + return ordered[0] + + +@dataclass +class ClaimAttempt: + """One journaled attempt to claim a WorkItem.""" + + attempt_id: str + authority: dict # WorkAuthorityRef.to_record() + item: str # the WorkItem identifier on that authority + principal: str # canonical principal + marker: str # exact marker the remote signal must carry + state: str = PENDING + created_at: str = "" + outcome_at: str | None = None + note: str = "" + history: list = field(default_factory=list) + + def to_record(self) -> dict: + return {"attempt_id": self.attempt_id, "authority": dict(self.authority), + "item": self.item, "principal": self.principal, "marker": self.marker, + "state": self.state, "created_at": self.created_at, + "outcome_at": self.outcome_at, "note": self.note, + "history": list(self.history)} + + @classmethod + def from_record(cls, raw: object) -> "ClaimAttempt": + if not isinstance(raw, dict): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record is not an object") + attempt_id = raw.get("attempt_id") + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + "a claim attempt record carries an invalid id") + if not isinstance(raw.get("authority"), dict) or not isinstance(raw.get("item"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + if not isinstance(raw.get("principal"), str) or not isinstance(raw.get("marker"), str): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} is malformed") + state = raw.get("state") + if state not in (PENDING, *OUTCOMES): + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id} carries an unknown state " + f"{state!r}") + return cls(attempt_id=attempt_id, authority=dict(raw["authority"]), + item=raw["item"], principal=raw["principal"], marker=raw["marker"], + state=state, created_at=str(raw.get("created_at") or ""), + outcome_at=raw.get("outcome_at"), note=str(raw.get("note") or ""), + history=list(raw.get("history") or [])) + + +def attempts_root(project: Path) -> Path: + return Path(project) / ATTEMPTS_RELATIVE + + +def new_attempt(*, authority, item: str, principal: str, note: str = "") -> ClaimAttempt: + """A fresh attempt with its deterministic remote marker. Not yet journaled.""" + + attempt_id = statelib.new_identifier("claim") + return ClaimAttempt(attempt_id=attempt_id, authority=authority.to_record(), + item=_token(item, "item"), principal=principal, + marker=f"{MARKER_PREFIX}{attempt_id}", note=note) + + +def _path(project: Path, attempt_id: str) -> Path: + if not isinstance(attempt_id, str) or not _ATTEMPT_ID.match(attempt_id): + raise LifecycleError("CLAIM_INVALID", + f"invalid claim attempt id {attempt_id!r}") + return attempts_root(project) / f"{attempt_id}.json" + + +def _write(project: Path, attempt: ClaimAttempt) -> None: + """One durable write of an attempt record, or a fail-closed refusal.""" + + path = _path(project, attempt.attempt_id) + try: + path.parent.mkdir(parents=True, exist_ok=True) + statelib.write_atomic(path, json.dumps(attempt.to_record(), indent=2, + sort_keys=True) + "\n") + except OSError as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"cannot write the claim journal at {path}: {error}") from error + + +def begin(project: Path, attempt: ClaimAttempt) -> Path: + """Write the PENDING attempt durably. This happens BEFORE the remote POST. + + A local write that fails is `CLAIM_JOURNAL_UNAVAILABLE` and the caller + must not POST at all: an attempt nobody recorded cannot be recovered. + """ + + attempt.state = PENDING + attempt.created_at = attempt.created_at or statelib.now() + attempt.history.append({"state": PENDING, "at": attempt.created_at}) + _write(project, attempt) + return _path(project, attempt.attempt_id) + + +def load_attempt(project: Path, attempt_id: str) -> ClaimAttempt: + path = _path(project, attempt_id) + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as error: + raise LifecycleError("CLAIM_JOURNAL_UNAVAILABLE", + f"no claim attempt {attempt_id!r} is journaled") from error + except (OSError, ValueError) as error: + raise LifecycleError( + "CLAIM_JOURNAL_UNAVAILABLE", + f"claim attempt {attempt_id!r} is unreadable; nothing may infer " + f"its outcome: {error}") from error + return ClaimAttempt.from_record(payload) + + +def list_attempts(project: Path) -> list[ClaimAttempt]: + """Every journaled attempt, or a refusal — a corrupt journal is never skipped. + + An unreadable entry might be the one that says a POST is in flight; listing + the others and pretending it is not there is how a second POST happens. + """ + + root = attempts_root(project) + if not root.is_dir(): + return [] + attempts: list[ClaimAttempt] = [] + for path in sorted(root.glob("*.json")): + attempts.append(load_attempt(project, path.stem)) + return attempts + + +def unresolved(project: Path) -> list[ClaimAttempt]: + return [attempt for attempt in list_attempts(project) + if attempt.state in UNRESOLVED] + + +def record_outcome(project: Path, attempt_id: str, outcome: str, *, note: str = "") -> ClaimAttempt: + """Persist what the re-read proved. Never invents an outcome.""" + + if outcome not in OUTCOMES: + raise LifecycleError("CLAIM_INVALID", + f"unknown claim outcome {outcome!r} " + f"(known: {', '.join(OUTCOMES)})") + attempt = load_attempt(project, attempt_id) + if attempt.state == ABANDONED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} was abandoned; its record is final " + "(abandonment is an operator decision, not a retry slot)") + attempt.state = outcome + attempt.outcome_at = statelib.now() + if note: + attempt.note = f"{attempt.note}\n{note}".strip() + attempt.history.append({"state": outcome, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +def abandon(project: Path, attempt_id: str, *, confirm: bool) -> ClaimAttempt: + """The explicit operator transition. Local only; the record is retained. + + Only an unresolved attempt (PENDING or UNCERTAIN) can be abandoned: a + CONFIRMED claim is real on the remote, and hiding it locally would not + release it. After abandonment a new attempt is allowed. + """ + + if not confirm: + raise LifecycleError("CONFIRMATION_REQUIRED", + "abandoning a claim attempt is an explicit operator " + "decision; pass --confirm after inspecting the remote state") + attempt = load_attempt(project, attempt_id) + if attempt.state not in UNRESOLVED: + raise LifecycleError("CLAIM_INVALID", + f"attempt {attempt_id} is {attempt.state}; only an " + "unresolved attempt can be abandoned") + attempt.state = ABANDONED + attempt.outcome_at = statelib.now() + attempt.history.append({"state": ABANDONED, "at": attempt.outcome_at}) + _write(project, attempt) + return attempt + + +__all__ = [ + "KIND_COMMENT", "KIND_NOTE", "KIND_ASSIGNMENT", "EVENT_KINDS", + "PENDING", "CONFIRMED", "LOST", "CONFLICT", "UNCERTAIN", "ABANDONED", + "OUTCOMES", "UNRESOLVED", "ATTEMPTS_RELATIVE", "MARKER_PREFIX", + "principal", "event_identifier", "to_utc", "ClaimEvent", "winner", + "ClaimAttempt", "attempts_root", "new_attempt", "begin", "load_attempt", + "list_attempts", "unresolved", "record_outcome", "abandon", +] diff --git a/ainative/cli.py b/ainative/cli.py index 8e77bd1a..bad5bb94 100644 --- a/ainative/cli.py +++ b/ainative/cli.py @@ -14,12 +14,14 @@ from __future__ import annotations import argparse -import json import os import sys from pathlib import Path from .lifecycle.errors import EXIT_FAILED, EXIT_INVALID_REQUEST, EXIT_OK, LifecycleError +from .cli_support import (ACTION_LABELS, action_label as _action_label, # noqa: F401 + emit as _emit, plan_text as _plan_text, + project_from as _project, report as _report) # Routed to the Verified Work Plane, unchanged. Their exit codes and output are # the Work Plane's contract and are not reinterpreted here. @@ -37,14 +39,6 @@ """ -def _emit(payload: object) -> None: - print(json.dumps(payload, indent=2, sort_keys=True, default=str)) - - -def _project(args: argparse.Namespace) -> Path: - return Path(getattr(args, "project", None) or Path.cwd()) - - def _add_common(parser: argparse.ArgumentParser, *, dry_run: bool = True, yes: bool = False) -> None: parser.add_argument("--project", type=Path, default=None, @@ -176,8 +170,14 @@ def build_parser() -> argparse.ArgumentParser: help="never prompt; choices come from flags only") from ainative.knowledge.cli import add_context_parser, add_knowledge_parser + from ainative.lifecycle.feature_cli import add_feature_parser + from ainative.claim_cli import add_claim_parser + from ainative.forge_cli import add_forge_parser add_knowledge_parser(commands) add_context_parser(commands) + add_feature_parser(commands) + add_claim_parser(commands) + add_forge_parser(commands) for name in VERIFIED_COMMANDS: commands.add_parser(name, add_help=False, @@ -224,55 +224,6 @@ def _choose_profile(args: argparse.Namespace) -> str: "Use `ainative init --profile standard` or `--profile verified`.") -def _report(args: argparse.Namespace, record: dict, text: str) -> int: - if getattr(args, "json", False): - _emit(record) - else: - print(text) - return EXIT_OK - - -# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` -# read to a user as "blocked" while the operation actually proceeded; the -# internal names stay stable for journals, the labels must not lie. -ACTION_LABELS = { - "CREATE": "create", - "REPLACE": "replace", - "REMOVE": "remove", - "SKIP": "skip", - "PRESERVE": "preserve", - "CONFLICT": "conflict", - "REGION_WRITE": "configure-region", - "REGION_REMOVE": "remove-region", - "HOOK_WRITE": "configure-hook", - "HOOK_REMOVE": "remove-hook", - "BLOCK_WRITE": "configure-region", - "BLOCK_REMOVE": "remove-region", -} - - -def _action_label(action: str) -> str: - return ACTION_LABELS.get(action, action.lower()) - - -def _plan_text(result) -> str: - plan = result.plan - header = "(dry-run — nothing was written)\n" if result.dry_run else "" - counts = ", ".join(f"{_action_label(action)} {count}" - for action, count in sorted(plan.counts().items())) or "no changes" - lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " - f"{plan.to_profile or 'none'}", f" {counts}"] - for change in plan.changes: - if change.action in ("SKIP",) and not result.dry_run: - continue - lines.append(f" {_action_label(change.action):<18} {change.path}" - + (f" ({change.reason})" if change.reason else "")) - for notice in result.notices: - lines.append("") - lines.append(notice) - return "\n".join(lines) - - def _cmd_init(args: argparse.Namespace) -> int: from .lifecycle import installer @@ -306,6 +257,24 @@ def _cmd_profile(args: argparse.Namespace) -> int: return _report(args, result.to_record(), _uninstall_text(result)) +def _cmd_feature(args: argparse.Namespace) -> int: + from .lifecycle.feature_cli import run_feature_command + + return run_feature_command(args) + + +def _cmd_claim_attempt(args: argparse.Namespace) -> int: + from .claim_cli import run_claim_command + + return run_claim_command(args) + + +def _cmd_forge(args: argparse.Namespace) -> int: + from .forge_cli import run_forge_command + + return run_forge_command(args) + + def _confirm_purge(args: argparse.Namespace, project: Path) -> bool: """Interactive confirmation. Without a TTY this is always False, so `--yes` is the only way through — no CI ever blocks on a prompt.""" @@ -370,16 +339,22 @@ def _doctor_collect(project: Path, check_updates: bool): def _cmd_doctor(args: argparse.Namespace) -> int: - from .lifecycle import environment, recovery, updater + from . import observation + from .lifecycle import environment, recovery, release_source, updater from .knowledge import doctor as knowledgedoctor project = _project(args) diagnosis, knowledge, checks, healthy = _doctor_collect( project, args.check_updates) + view = observation.project_view(project) if args.json: record = diagnosis.to_record() record["knowledge"] = knowledge record["environment"] = checks + record["features"] = view["features"] + record["forge"] = view["forge"] + record["claim_attempts"] = view["claim_attempts"] + record["release_source"] = release_source.describe() _emit(record) else: print(f"Project: {diagnosis.project}") @@ -398,6 +373,10 @@ def _cmd_doctor(args: argparse.Namespace) -> int: # (#131 / AUD-202). print("Updates") print(f" {updater.notice_line(diagnosis.update)}") + for line in release_source.describe_lines(): + print(line) + for line in observation.doctor_lines(view): + print(line) print("Knowledge:") print(f" status: {knowledge['status']} store: {knowledge['store']}") if knowledge["status"] != knowledgedoctor.STATUS_FAIL: @@ -700,6 +679,9 @@ def _cmd_setup(args: argparse.Namespace) -> int: "knowledge": _cmd_knowledge, "context": _cmd_context, "profile": _cmd_profile, + "feature": _cmd_feature, + "claim-attempt": _cmd_claim_attempt, + "forge": _cmd_forge, "status": _cmd_status, "doctor": _cmd_doctor, "repair": _cmd_repair, diff --git a/ainative/cli_support.py b/ainative/cli_support.py new file mode 100644 index 00000000..7dafe412 --- /dev/null +++ b/ainative/cli_support.py @@ -0,0 +1,74 @@ +"""Small shared plumbing for the `ainative` command modules. + +Split out so `cli.py` stays the dispatcher and the command modules +(`feature_cli`, `claim_cli`, `forge_cli`) render the same way without +importing `cli` back — which would be a cycle. Nothing here imports the +lifecycle or the Work Plane: it is argparse, JSON and a path. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from .lifecycle.errors import EXIT_OK + +# What the plan says, as opposed to what the journal records. `BLOCK_WRITE` +# read to a user as "blocked" while the operation actually proceeded; the +# internal names stay stable for journals, the labels must not lie. +ACTION_LABELS = { + "CREATE": "create", + "REPLACE": "replace", + "REMOVE": "remove", + "SKIP": "skip", + "PRESERVE": "preserve", + "CONFLICT": "conflict", + "REGION_WRITE": "configure-region", + "REGION_REMOVE": "remove-region", + "HOOK_WRITE": "configure-hook", + "HOOK_REMOVE": "remove-hook", + "BLOCK_WRITE": "configure-region", + "BLOCK_REMOVE": "remove-region", +} + + +def emit(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True, default=str)) + + +def project_from(args: argparse.Namespace) -> Path: + return Path(getattr(args, "project", None) or Path.cwd()) + + +def action_label(action: str) -> str: + return ACTION_LABELS.get(action, action.lower()) + + +def report(args: argparse.Namespace, record: dict, text: str) -> int: + if getattr(args, "json", False): + emit(record) + else: + print(text) + return EXIT_OK + + +def plan_text(result) -> str: + plan = result.plan + header = "(dry-run — nothing was written)\n" if result.dry_run else "" + counts = ", ".join(f"{action_label(action)} {count}" + for action, count in sorted(plan.counts().items())) or "no changes" + lines = [f"{header}{plan.operation}: {plan.from_profile or 'none'} -> " + f"{plan.to_profile or 'none'}", f" {counts}"] + for change in plan.changes: + if change.action in ("SKIP",) and not result.dry_run: + continue + lines.append(f" {action_label(change.action):<18} {change.path}" + + (f" ({change.reason})" if change.reason else "")) + for notice in result.notices: + lines.append("") + lines.append(notice) + return "\n".join(lines) + + +__all__ = ["ACTION_LABELS", "emit", "project_from", "action_label", "report", "plan_text"] diff --git a/ainative/forge.py b/ainative/forge.py new file mode 100644 index 00000000..8074e7cd --- /dev/null +++ b/ainative/forge.py @@ -0,0 +1,169 @@ +"""Resolve the Work Authority from locally observed facts — purely. + +Multi-Forge's Work Authority is the remote whose work records are +authoritative for a project: one provider plus a project identity on it. +Deciding *which* remote that is, is the only thing this module does, and it +decides it from facts observed elsewhere — an explicit WorkItem/ChangeRequest +reference, a harness declaration, or the Git remotes someone already read. It +never reaches the network, never looks up a credential, never writes, and never +authorizes a push (ADR-0018). + +The rules are ordered and fail closed (ADR-0018 section 2): + +1. an explicit WorkItem/ChangeRequest repository reference; +2. an explicit harness-declared authority; +3. exactly one compatible observed candidate; +4. otherwise a refusal — never a guess. + +A fork (origin and upstream disagreeing) is `AMBIGUOUS`, never "prefer +origin". A self-hosted host is `unknown`, never inferred from its name: +GitHub.com and GitLab.com are the only hosted forges V1 knows, and inferring +"gitlab" from a hostname is how a generic Git project gets claimed on a forge +it never chose. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable +from urllib.parse import urlsplit + +from .lifecycle.errors import LifecycleError + +PROVIDER_GITHUB = "github" +PROVIDER_GITLAB = "gitlab" +PROVIDER_UNKNOWN = "unknown" + +# The only hosts whose provider is a fact rather than a guess. +HOST_HINTS = {"github.com": PROVIDER_GITHUB, "gitlab.com": PROVIDER_GITLAB} + + +@dataclass(frozen=True) +class WorkAuthorityRef: + """One provider plus a stable project identity. Not a URL, not a hostname.""" + + provider: str + project: str + + def to_record(self) -> dict: + return {"provider": self.provider, "project": self.project} + + def __str__(self) -> str: + return f"{self.provider}:{self.project}" + + +@dataclass(frozen=True) +class ObservedRemote: + """A Git remote someone read locally. Evidence, never authority.""" + + name: str + url: str + + @property + def provider(self) -> str: + return provider_hint(self.url) + + @property + def identity(self) -> str | None: + return project_identity(self.url) + + def to_record(self) -> dict: + return {"name": self.name, "url": self.url, "provider": self.provider, + "project": self.identity} + + +def provider_hint(url: str) -> str: + """The provider a remote URL *names*: a hosted forge, or `unknown`.""" + + host, _ = _host_and_path(url) + return HOST_HINTS.get(host, PROVIDER_UNKNOWN) + + +def project_identity(url: str) -> str | None: + """`owner/name` (or a GitLab subgroup path) for a hosted URL, else None.""" + + host, path = _host_and_path(url) + if host not in HOST_HINTS: + return None + cleaned = path.strip("/") + if cleaned.endswith(".git"): + cleaned = cleaned[: -len(".git")] + segments = [segment for segment in cleaned.split("/") if segment] + if len(segments) < 2: + return None + return "/".join(segments) + + +def _host_and_path(url: str) -> tuple[str, str]: + """Host and path for both URL shapes Git uses, without guessing a provider.""" + + if "://" in url: + parts = urlsplit(url) + return (parts.hostname or "").lower(), parts.path + # scp-like: [user@]host:path + head, _, path = url.partition(":") + host = head.rpartition("@")[2] + return host.lower(), path + + +def observe_remotes(remotes: Iterable[tuple[str, str]]) -> tuple[ObservedRemote, ...]: + """(name, url) pairs as observed remotes; a thin adapter for callers.""" + + return tuple(ObservedRemote(name=name, url=url) for name, url in remotes) + + +def resolve_observed_work_authority(*, + explicit: WorkAuthorityRef | None = None, + declared: WorkAuthorityRef | None = None, + remotes: Iterable[ObservedRemote] = (), + ) -> WorkAuthorityRef: + """The authority the ordered rules accept, or a refusal that says why. + + `explicit` is a WorkItem/ChangeRequest repository reference (priority 1), + `declared` a harness statement (priority 2), `remotes` locally observed + evidence (priority 3). Two statements that disagree refuse as + `WORK_AUTHORITY_MISMATCH`; observation that is not unambiguous refuses as + `WORK_AUTHORITY_AMBIGUOUS` (a fork) or `WORK_AUTHORITY_UNAVAILABLE` + (nothing usable was observed). + """ + + if explicit is not None and declared is not None and explicit != declared: + raise LifecycleError( + "WORK_AUTHORITY_MISMATCH", + f"the explicit reference names {explicit} but the harness declares " + f"{declared}; reconcile them before mutating remote state", + explicit=explicit.to_record(), declared=declared.to_record()) + if explicit is not None: + return explicit + if declared is not None: + return declared + + candidates: list[WorkAuthorityRef] = [] + for remote in remotes: + if remote.provider == PROVIDER_UNKNOWN or remote.identity is None: + continue + candidate = WorkAuthorityRef(provider=remote.provider, project=remote.identity) + if candidate not in candidates: + candidates.append(candidate) + + if len(candidates) == 1: + return candidates[0] + observed = [remote.to_record() for remote in remotes] + if not candidates: + raise LifecycleError( + "WORK_AUTHORITY_UNAVAILABLE", + "no work authority could be resolved locally: no explicit reference, " + "no harness declaration, and no observed remote names a known forge", + remotes=observed) + raise LifecycleError( + "WORK_AUTHORITY_AMBIGUOUS", + "several observed remotes name different work authorities: " + + ", ".join(str(candidate) for candidate in candidates) + + "; declare the authority explicitly before mutating remote state", + candidates=[candidate.to_record() for candidate in candidates], + remotes=observed) + + +__all__ = ["WorkAuthorityRef", "ObservedRemote", "provider_hint", "project_identity", + "observe_remotes", "resolve_observed_work_authority", + "PROVIDER_GITHUB", "PROVIDER_GITLAB", "PROVIDER_UNKNOWN", "HOST_HINTS"] diff --git a/ainative/forge_cli.py b/ainative/forge_cli.py new file mode 100644 index 00000000..df503b75 --- /dev/null +++ b/ainative/forge_cli.py @@ -0,0 +1,85 @@ +"""`ainative forge detect|status` — observation only. + +Zero network, zero credentials, zero writes, zero persistent trust (PR-3, +#164). `detect` answers "what can be seen locally"; `status` adds the effective +feature set and the unresolved claim attempts. A fork renders as AMBIGUOUS and +nothing here prefers origin — the rendering is the diagnostic, the refusal +belongs to the mutation path (ADR-0018). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from . import observation +from .cli_support import project_from, report as _report + + +def add_forge_parser(commands) -> None: + forge = commands.add_parser( + "forge", help="Observe the project's remotes and work authority.") + subcommands = forge.add_subparsers(dest="forge_command", required=True) + detect = subcommands.add_parser( + "detect", help="Observed remotes and the Work Authority resolution.") + _common(detect) + status = subcommands.add_parser( + "status", help="The forge picture: remotes, resolution, features, claims.") + _common(status) + + +def _common(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + + +def _resolution_line(resolution: dict) -> str: + authority = resolution.get("authority") or {} + if authority: + return (f"Resolution: {resolution['state']} " + f"{authority.get('provider')}:{authority.get('project')}") + return f"Resolution: {resolution['state']}" + + +def detect_text(picture: dict) -> str: + lines = [f"Git repository: {'yes' if picture['git'] else 'no'}"] + if picture["remotes"]: + lines.append("Work authority (observed candidates):") + for remote in picture["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-':<24} {remote['url']}") + else: + lines.append("Work authority (observed candidates): none") + lines.append(_resolution_line(picture["resolution"])) + if picture["resolution"].get("detail"): + lines.append(f" {picture['resolution']['detail']}") + return "\n".join(lines) + + +def status_text(view: dict) -> str: + features = view["features"] + summary = view["claim_attempts"] + lines = ["Active features: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] + else "")] + if summary["journal"] == "ok": + lines.append(f"Unresolved claim attempts: {len(summary['unresolved'])}") + else: + lines.append(f"Unresolved claim attempts: journal unavailable " + f"({summary['detail']})") + lines.append("") + lines.append(detect_text(view["forge"])) + return "\n".join(lines) + + +def run_forge_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.forge_command == "detect": + picture = observation.forge_picture(project) + return _report(args, picture, detect_text(picture)) + view = observation.project_view(project) + return _report(args, view, status_text(view)) + + +__all__ = ["add_forge_parser", "run_forge_command", "detect_text", "status_text"] diff --git a/ainative/lifecycle/data/components.json b/ainative/lifecycle/data/components.json index 5b6485a9..cee436fb 100644 --- a/ainative/lifecycle/data/components.json +++ b/ainative/lifecycle/data/components.json @@ -43,12 +43,12 @@ "engineering-method": { "version": 1, "kind": "file", - "source": "AGENTS.md", + "source": "templates/AGENTS.md", "destination": "AGENTS.md", "ownership": "MANAGED_MUTABLE", "required": true, "title": "Engineering method", - "description": "The shared cross-tool engineering rules every agent reads." + "description": "The shared cross-tool engineering rules every agent reads. The distributed copy (templates/AGENTS.md) speaks provider-neutral Work Authority; this repository's own AGENTS.md stays GitHub-specific (ADR-0018)." }, "conventions": { "version": 1, @@ -147,6 +147,21 @@ "title": "GitHub contribution templates", "description": "Generic bug/feature issue templates and PR template, installed as individually-owned managed files." }, + "gitlab-templates": { + "version": 1, + "kind": "tree", + "source": "templates/gitlab", + "destination": ".gitlab", + "include": [ + "issue_templates/bug.md", + "issue_templates/feature.md", + "merge_request_templates/default.md" + ], + "ownership": "MANAGED_MUTABLE", + "required": false, + "title": "GitLab contribution templates", + "description": "GitLab issue and merge-request templates, installed as individually-owned managed files. Owned by the forge-gitlab feature (ADR-0017)." + }, "verified-workplane": { "version": 1, "kind": "marker", diff --git a/ainative/lifecycle/data/features.json b/ainative/lifecycle/data/features.json new file mode 100644 index 00000000..3ebb9688 --- /dev/null +++ b/ainative/lifecycle/data/features.json @@ -0,0 +1,41 @@ +{ + "_comment": [ + "Declarative features. A feature is an optional project-scope capability", + "that can be enabled or disabled independently of the governance profile", + "(ADR-0017). `components` are component IDs from components.json; a component", + "belongs to at most one feature. `conflicts` are feature IDs that may not be", + "active at the same time, and the relation must be declared symmetrically.", + "`legacy_default` is the feature every V1 project projects to: the", + "compatibility default, never a write (ADR-0017 section 4)." + ], + "schema_version": 1, + "legacy_default": "forge-github", + "features": { + "forge-github": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitHub work forge", + "summary": "GitHub issue and pull-request templates for the project.", + "components": [ + "github-templates" + ], + "conflicts": [ + "forge-gitlab" + ] + }, + "forge-gitlab": { + "version": 1, + "scope": "project", + "work_forge": true, + "title": "GitLab work forge", + "summary": "GitLab issue and merge-request templates for the project.", + "components": [ + "gitlab-templates" + ], + "conflicts": [ + "forge-github" + ] + } + } +} diff --git a/ainative/lifecycle/data/profiles.json b/ainative/lifecycle/data/profiles.json index a62225e3..c48c6f51 100644 --- a/ainative/lifecycle/data/profiles.json +++ b/ainative/lifecycle/data/profiles.json @@ -21,8 +21,7 @@ "skills-agents", "machine-config", "gitignore-entry", - "claude-hook", - "github-templates" + "claude-hook" ] }, "verified": { diff --git a/ainative/lifecycle/errors.py b/ainative/lifecycle/errors.py index 10809b85..8498fe9b 100644 --- a/ainative/lifecycle/errors.py +++ b/ainative/lifecycle/errors.py @@ -34,12 +34,40 @@ "LOCK_HELD": EXIT_FAILED, "UPDATE_UNAVAILABLE": EXIT_FAILED, "UPDATE_CHECK_FAILED": EXIT_FAILED, + "UPDATE_SOURCE_CONFLICT": EXIT_INVALID_REQUEST, + "RELEASE_CONFIG_INVALID": EXIT_INVALID_REQUEST, + # Release V3 (ADR-0019). Enumeration, SemVer and integrity refusals. + "RELEASE_ENUMERATION_INCOMPLETE": EXIT_FAILED, + "RELEASE_NO_CANDIDATE": EXIT_FAILED, + "RELEASE_BUILD_METADATA_UNSUPPORTED": EXIT_FAILED, + "RELEASE_DUPLICATE_VERSION": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, + "RELEASE_INTEGRITY_METADATA_INVALID": EXIT_FAILED, + "RELEASE_MANIFEST_MISSING": EXIT_FAILED, + "RELEASE_MANIFEST_AMBIGUOUS": EXIT_FAILED, "UPDATE_INTEGRITY_FAILED": EXIT_FAILED, "UPDATE_INTEGRITY_METADATA_MISSING": EXIT_FAILED, "UPDATE_VERSION_MISMATCH": EXIT_FAILED, "CLI_UPDATE_REQUIRED": EXIT_FAILED, "ROLLBACK_UNAVAILABLE": EXIT_FAILED, "APPLY_FAILED": EXIT_FAILED, + # Features (ADR-0017). A project has at most one work forge; two is a + # refusal, never a silent pick. + "STATE_CONFLICTING_WORK_FORGE_FEATURES": EXIT_FAILED, + "FEATURE_UNKNOWN": EXIT_INVALID_REQUEST, + # Enabling a feature that conflicts with an active one: the remedy is an + # explicit switch, not an implicit disable. + "FEATURE_CONFLICT": EXIT_INVALID_REQUEST, + # Work Authority resolution (ADR-0018). Ambiguity stops remote mutation. + "WORK_AUTHORITY_UNAVAILABLE": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_AMBIGUOUS": EXIT_INVALID_REQUEST, + "WORK_AUTHORITY_MISMATCH": EXIT_INVALID_REQUEST, + # Claims (ADR-0018). The journal is written before the remote POST, and + # an unknown outcome is never retried automatically. + "CLAIM_INVALID": EXIT_INVALID_REQUEST, + "CLAIM_CONFLICT": EXIT_FAILED, + "CLAIM_UNCERTAIN": EXIT_FAILED, + "CLAIM_JOURNAL_UNAVAILABLE": EXIT_FAILED, # Machine-wide integration (`ainative machine`, `ainative setup`). "MACHINE_MANIFEST_UNREADABLE": EXIT_INVALID_REQUEST, "MACHINE_VAULT_PAIR_REQUIRED": EXIT_INVALID_REQUEST, diff --git a/ainative/lifecycle/feature_cli.py b/ainative/lifecycle/feature_cli.py new file mode 100644 index 00000000..5c36b1fd --- /dev/null +++ b/ainative/lifecycle/feature_cli.py @@ -0,0 +1,96 @@ +"""The `ainative feature` command surface: parser, rendering, transitions. + +Split out of `cli.py` for the size budget. The command is self-contained — its +grammar, its status rendering and its transition handler — and the dispatcher +stays the dispatcher. The two rendering callables are injected so this module +never imports `cli` back (which would be a cycle). +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from ..cli_support import plan_text as _plan_text, project_from, report as _report +from . import features as featureslib +from . import installer +from . import manifest as manifestlib +from . import state as statelib + + +def add_feature_parser(commands) -> None: + feature = commands.add_parser("feature", + help="Inspect or change optional project features.") + feature_commands = feature.add_subparsers(dest="feature_command", required=True) + status = feature_commands.add_parser("status", + help="Report the effective feature set.") + _common(status, dry_run=False) + enable = feature_commands.add_parser("enable", help="Activate a feature.") + enable.add_argument("target") + _common(enable) + disable = feature_commands.add_parser("disable", help="Deactivate a feature.") + disable.add_argument("target") + _common(disable) + switch = feature_commands.add_parser( + "switch", help="Replace the active work forge ('none' for Generic Git).") + switch.add_argument("target", help="a feature name, or 'none'") + _common(switch) + + +def _common(parser: argparse.ArgumentParser, *, dry_run: bool = True) -> None: + parser.add_argument("--project", type=Path, default=None, + help="project root (default: the current directory)") + parser.add_argument("--json", action="store_true", help="machine-readable output") + if dry_run: + parser.add_argument("--dry-run", action="store_true", + help="print the change plan; touch nothing") + parser.add_argument("--force-unlock", action="store_true", + help="take the lifecycle lock even if another one is recorded") + + +def status_record(project: Path) -> tuple[dict, str]: + """The status payload and its text rendering, from the shared projection.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + active = list(effective.active_features) if effective else [] + record = { + "installed": effective is not None, + "active_profile": effective.active_profile if effective else None, + "active_features": active, + "projected_from_legacy": bool(effective and effective.projected_from_legacy), + "declared": {name: {"title": value.title, "work_forge": value.work_forge, + "components": list(value.components), + "conflicts": list(value.conflicts)} + for name, value in sorted(distribution.features.items())}, + } + lines = [f"Active features: {', '.join(active) or 'none'}"] + for name in sorted(record["declared"]): + mark = "*" if name in active else " " + lines.append(f" [{mark}] {name} — {record['declared'][name]['title']}") + if record["projected_from_legacy"]: + lines.append(" (projected from the V1 state: " + f"{featureslib.legacy_default(distribution)} is the " + "compatibility default)") + return record, "\n".join(lines) + + +def run_feature_command(args: argparse.Namespace) -> int: + project = project_from(args) + if args.feature_command == "status": + record, text = status_record(project) + return _report(args, record, text) + + transition = {"enable": None, "disable": None, "switch_to": None} + if args.feature_command == "enable": + transition["enable"] = args.target + elif args.feature_command == "disable": + transition["disable"] = args.target + else: + transition["switch_to"] = args.target + result = installer.set_features(project, dry_run=args.dry_run, + force_unlock=args.force_unlock, **transition) + return _report(args, result.to_record(), _plan_text(result)) + + +__all__ = ["add_feature_parser", "run_feature_command", "status_record"] diff --git a/ainative/lifecycle/features.py b/ainative/lifecycle/features.py new file mode 100644 index 00000000..724b05dd --- /dev/null +++ b/ainative/lifecycle/features.py @@ -0,0 +1,169 @@ +"""The feature view of a project: one projection, shared by every read path. + +ADR-0017 makes profiles and features orthogonal. A profile is a governance +level; a feature is an optional project-scope capability. State V2 records the +active features, older states do not — and "which features does this project +have?" must never have two answers. Every reader therefore goes through +`project_install_state()`, which is also the single place that knows the V1 +compatibility default (a legacy project is a GitHub project until it +explicitly switches). + +The projection never writes. `migrate_state()` is its write-side counterpart: +it stamps the current schema and seeds the legacy default exactly once, inside +an existing mutation transaction (state-last, ADR-0009 section 4). It plans no +file changes of its own, so a managed file that is already absent stays +absent through the migration; only an explicit feature transition seeds files. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from . import manifest as manifestlib +from . import state as statelib +from .errors import LifecycleError + + +@dataclass(frozen=True) +class EffectiveState: + """The feature view of one project, projected from whatever is stored.""" + + schema_version: int + active_profile: str + active_features: tuple[str, ...] + projected_from_legacy: bool + + def has_feature(self, name: str) -> bool: + return name in self.active_features + + def feature_objects(self, distribution: manifestlib.Distribution) -> tuple: + return tuple(distribution.feature(name) for name in self.active_features) + + +def project_install_state(state: statelib.InstallState | None, + distribution: manifestlib.Distribution) -> EffectiveState | None: + """The features a project effectively has; None when it has no state. + + A V1 state has no `active_features` and projects to the declared legacy + default (ADR-0017 section 4). A V2 state is validated: an undeclared + feature refuses as `INSTALL_STATE_CORRUPTED`, two work forges refuse as + `STATE_CONFLICTING_WORK_FORGE_FEATURES` — fail-closed, never a silent pick. + """ + + if state is None: + return None + if state.schema_version < statelib.SCHEMA_VERSION: + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=(legacy_default(distribution),), + projected_from_legacy=True) + return EffectiveState(schema_version=state.schema_version, + active_profile=state.active_profile, + active_features=_validated(state.active_features, distribution), + projected_from_legacy=False) + + +def legacy_default(distribution: manifestlib.Distribution) -> str: + """The feature a V1 project projects to; declared, never hard-coded.""" + + default = distribution.legacy_default_feature + if not default: + raise LifecycleError("MANIFEST_INVALID", + "the catalogue declares no legacy default feature") + return default + + +def _validated(names: list[str], distribution: manifestlib.Distribution) -> tuple[str, ...]: + ordered = tuple(dict.fromkeys(names)) + for name in ordered: + try: + distribution.feature(name) + except LifecycleError as error: + if error.code != "FEATURE_UNKNOWN": + raise + # A state naming a feature this release does not know is a state + # problem, not a request problem: the same stance as a newer schema + # — upgrade the CLI rather than downgrading state. + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates feature {name!r}, which this release " + "does not know; upgrade the CLI rather than downgrading state") from error + work_forges = [name for name in ordered if distribution.feature(name).work_forge] + if len(work_forges) > 1: + raise LifecycleError( + "STATE_CONFLICTING_WORK_FORGE_FEATURES", + "the saved state activates two work forges (" + + ", ".join(sorted(work_forges)) + + "); a project has at most one — use `ainative feature switch `", + features=sorted(work_forges)) + for name in ordered: + for other in distribution.feature(name).conflicts: + if other in ordered: + raise LifecycleError( + "INSTALL_STATE_CORRUPTED", + f"the saved state activates conflicting features " + f"{name!r} and {other!r}") + return ordered + + +def migrate_state(state: statelib.InstallState, + distribution: manifestlib.Distribution) -> statelib.InstallState: + """Stamp the current schema, seeding the legacy default exactly once. + + Returns the state object it was given (every caller works on the loaded + object); a state already at the current schema is returned unchanged, so + running this twice writes the same bytes. + """ + + if state.schema_version >= statelib.SCHEMA_VERSION: + return state + if not state.active_features: + state.active_features = [legacy_default(distribution)] + state.schema_version = statelib.SCHEMA_VERSION + return state + + +def transition(distribution: manifestlib.Distribution, current: tuple[str, ...], *, + enable: str | None = None, disable: str | None = None, + switch_to: str | None = None) -> tuple[str, ...]: + """The feature set a requested transition produces, or a refusal. + + Exactly one of `enable` / `disable` / `switch_to` is given. A transition is + idempotent: asking for the set the project already has returns it + unchanged. Enabling a feature that conflicts with an active one refuses + with the remedy stated — the conflict is never resolved by an implicit + disable. `switch_to="none"` is the Generic Git shape: it removes every + work forge and nothing else. + """ + + requested = [value for value in (enable, disable) if value is not None] + if len(requested) + (1 if switch_to is not None else 0) != 1: + raise ValueError( + "a feature transition takes exactly one of enable/disable/switch_to") + + if enable is not None: + candidate = distribution.feature(enable) + for name in current: + if name in candidate.conflicts: + raise LifecycleError( + "FEATURE_CONFLICT", + f"feature {enable!r} conflicts with the active feature {name!r}; " + f"use `ainative feature switch {enable}`", + requested=enable, active=name) + return current if enable in current else current + (enable,) + + if disable is not None: + distribution.feature(disable) + return tuple(name for name in current if name != disable) + + if switch_to == "none": + return tuple(name for name in current if not distribution.feature(name).work_forge) + + distribution.feature(switch_to) + conflicts = set(distribution.feature(switch_to).conflicts) + kept = tuple(name for name in current if name not in conflicts and name != switch_to) + return kept + (switch_to,) + + +__all__ = ["EffectiveState", "project_install_state", "legacy_default", + "migrate_state", "transition"] diff --git a/ainative/lifecycle/installer.py b/ainative/lifecycle/installer.py index 9589d0ae..4c4882af 100644 --- a/ainative/lifecycle/installer.py +++ b/ainative/lifecycle/installer.py @@ -13,6 +13,7 @@ from pathlib import Path from . import environment +from . import features as featureslib from . import legacy as legacylib from . import manifest as manifestlib from . import planner as plannerlib @@ -64,6 +65,7 @@ def to_record(self) -> dict: "transaction": self.transaction, "notices": list(self.notices), "active_profile": self.state.active_profile if self.state else None, + "active_features": sorted(self.state.active_features) if self.state else None, } if self.legacy is not None and self.legacy.detected: record["legacy_adoption"] = self.legacy.to_record() @@ -93,19 +95,32 @@ def _blocking_transaction(project: Path) -> None: transactions=txnlib.summarise(pending)) -def _fresh_state(source: DistributionSource, profile: str) -> InstallState: +def _fresh_state(source: DistributionSource, profile: str, + distribution: Distribution) -> InstallState: + """A new project starts on the catalogue's legacy default feature. + + That default is the compatibility rule ADR-0017 section 4 fixes for V1 + projects; without it a fresh Standard install would stop shipping the + GitHub templates every install has shipped so far — a behavior change + nobody asked for. `feature switch none` opts out explicitly. + """ + return InstallState(stack_version=source.version, source_version=source.version, - active_profile=profile) + active_profile=profile, + active_features=[featureslib.legacy_default(distribution)]) -def _commit_state(project: Path, state: InstallState, plan: Plan, - distribution: Distribution, source: DistributionSource, - journal_id: str) -> None: - """Fold a successfully applied plan into the install state.""" +def _fold_plan(state: InstallState, plan: Plan, distribution: Distribution, + target_features: tuple[str, ...] | None) -> None: + """Record a plan's outcome in the install state (no write, no metadata).""" for identifier in plan.components_removed: state.drop_component(identifier) - for identifier in distribution.effective_component_ids(plan.to_profile or state.active_profile): + if target_features is not None: + state.active_features = sorted(target_features) + features = tuple(state.active_features) + for identifier in plannerlib.wanted_component_ids( + distribution, plan.to_profile or state.active_profile, features): component = distribution.component(identifier) entries = plannerlib.managed_entries(component, plan.changes) # A CONFLICT leaves the file alone; keep whatever we already knew about @@ -123,6 +138,22 @@ def _commit_state(project: Path, state: InstallState, plan: Plan, if identifier not in state.installed_components: state.installed_components.append(identifier) + +def _commit_state(project: Path, state: InstallState, plan: Plan, + distribution: Distribution, source: DistributionSource, + journal_id: str, *, + target_features: tuple[str, ...] | None = None) -> None: + """Fold a successfully applied plan into the install state.""" + + _fold_plan(state, plan, distribution, target_features) + if plan.to_profile and plan.to_profile != state.active_profile: + state.previous_profile = state.active_profile + state.active_profile = plan.to_profile + state.stack_version = source.version + state.source_version = source.version + state.last_transaction = journal_id + statelib.save(project, state) + if plan.to_profile and plan.to_profile != state.active_profile: state.previous_profile = state.active_profile state.active_profile = plan.to_profile @@ -140,12 +171,19 @@ def plan_profile(project: Path, distribution: Distribution, source: Distribution distribution.profile(target_profile) # refuse an unknown profile up front current = state if state is not None else statelib.load(project) adoption = legacylib.Adoption(False, (), (), (), ()) - if current is None: - current = _fresh_state(source, target_profile) + fresh = current is None + if fresh: + current = _fresh_state(source, target_profile, distribution) adoption = legacylib.adopt(project, distribution, source, target_profile) current = legacylib.apply_to_state(current, adoption, stack_version=source.version) + else: + # A V1 state migrates inside this operation (state-last, ADR-0017 + # section 5). The migration itself plans no file change: a managed file + # that is already absent stays absent. + current = featureslib.migrate_state(current, distribution) plan = plannerlib.build_install_plan(project, distribution, source, current, - target_profile, operation=operation) + target_profile, operation=operation, + seed_feature_files=fresh) return plan, current, adoption @@ -169,11 +207,14 @@ def install(project: Path, target_profile: str, *, dry_run: bool = False, if dry_run or plan.is_noop: if plan.is_noop and not dry_run: - # Nothing had to change on disk, but the profile may still need - # recording. That is a write to the install state, so it takes the - # lock like every other write — an unlocked commit here could - # interleave with another operation's own commit. - if statelib.load(project) is None or state.active_profile != target_profile: + # Nothing had to change on disk, but the profile — or the schema a + # V1 state is still on — may need recording. That is a write to the + # install state, so it takes the lock like every other write — an + # unlocked commit here could interleave with another operation's + # own commit. + loaded = statelib.load(project) + if (loaded is None or state.active_profile != target_profile + or loaded.schema_version < statelib.SCHEMA_VERSION): with locklib.acquire(project, operation, force=force_unlock): state.active_profile = target_profile for identifier in distribution.effective_component_ids(target_profile): @@ -247,5 +288,70 @@ def switch(project: Path, target_profile: str, **kwargs) -> OperationResult: return install(project, target_profile, operation="profile-switch", **kwargs) -__all__ = ["OperationResult", "install", "switch", "plan_profile", "require_project", - "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", "VERIFIED_BOOTSTRAP_NOTICE"] +def set_features(project: Path, *, enable: str | None = None, disable: str | None = None, + switch_to: str | None = None, dry_run: bool = False, + distribution: Distribution | None = None, + source: DistributionSource | None = None, + force_unlock: bool = False) -> OperationResult: + """Enable, disable or switch features in one lifecycle transaction. + + The sequence is the profile path's, unchanged (ADR-0017 section 6): take + the project lifecycle lock, reload the state, project the effective V2 set, + plan, apply, verify, write the state last, commit. A V1 state migrates + inside the same transaction and the migration plans no file change of its + own, so a managed file that is already absent stays absent. + """ + + from . import features as featureslib + from . import lock as locklib + + project = require_project(project) + distribution = distribution or manifestlib.load() + source = source or sourcelib.resolve() + _blocking_transaction(project) + + with locklib.acquire(project, "feature", force=force_unlock): + _blocking_transaction(project) + state = statelib.load(project) + if state is None: + raise LifecycleError("NOT_INSTALLED", + f"no AI Native installation recorded in {project}") + was_legacy = state.schema_version < statelib.SCHEMA_VERSION + state = featureslib.migrate_state(state, distribution) + effective = featureslib.project_install_state(state, distribution) + current = effective.active_features + target = featureslib.transition(distribution, current, enable=enable, + disable=disable, switch_to=switch_to) + enabling = [name for name in target if name not in current] + notices = [f"feature {name!r} enabled" for name in enabling] + notices += [f"feature {name!r} disabled" for name in current + if name not in target] + plan = plannerlib.build_install_plan(project, distribution, source, state, + state.active_profile, operation="feature", + target_features=target, + seed_feature_files=bool(enabling)) + if plan.is_noop or dry_run: + if not dry_run: + # A no-op transition changes nothing — including the state + # bytes — unless it still has something to record: the schema + # migration, a feature set that moved, or component records the + # plan proved are gone (a disable whose files were all + # preserved must still drop its records). + needs_recording = (was_legacy or tuple(state.active_features) != target + or bool(plan.components_removed)) + if needs_recording: + _fold_plan(state, plan, distribution, target) + statelib.save(project, state) + return OperationResult("feature", plan, applied=False, dry_run=dry_run, + state=state, notices=notices) + applier = txnlib.Applier(project, distribution, source, plan) + journal = applier.run(lambda: _commit_state(project, state, plan, distribution, + source, applier.journal.identifier, + target_features=target)) + return OperationResult("feature", plan, applied=True, dry_run=False, state=state, + transaction=journal.identifier, notices=notices) + + +__all__ = ["OperationResult", "install", "switch", "set_features", "plan_profile", + "require_project", "trust_anchor_present", "TRUST_ANCHOR_RELATIVE", + "VERIFIED_BOOTSTRAP_NOTICE"] diff --git a/ainative/lifecycle/manifest.py b/ainative/lifecycle/manifest.py index 5632d098..61ed7e55 100644 --- a/ainative/lifecycle/manifest.py +++ b/ainative/lifecycle/manifest.py @@ -82,13 +82,35 @@ class Profile: components: tuple[str, ...] +# The only feature scope V1 knows: a feature is an optional project-scope +# capability. Machine-scope integration stays with `ainative machine` and is not +# a feature (ADR-0017 section 9). +FEATURE_SCOPE_PROJECT = "project" +FEATURE_SCOPES = (FEATURE_SCOPE_PROJECT,) + + +@dataclass(frozen=True) +class Feature: + """An optional capability, declared independently of any profile.""" + + name: str + scope: str + work_forge: bool + title: str + summary: str + components: tuple[str, ...] + conflicts: tuple[str, ...] + + @dataclass(frozen=True) class Distribution: - """The component and profile catalogue, already validated.""" + """The component, profile and feature catalogue, already validated.""" components: Mapping[str, Component] profiles: Mapping[str, Profile] default_profile: str + features: Mapping[str, Feature] = field(default_factory=dict) + legacy_default_feature: str = "" schema_version: int = 1 _cache: dict = field(default_factory=dict, compare=False, repr=False) @@ -100,6 +122,14 @@ def profile(self, name: str) -> Profile: raise LifecycleError("PROFILE_INVALID", f"unknown profile {name!r} (known: {known})") from None + def feature(self, name: str) -> Feature: + try: + return self.features[name] + except KeyError: + known = ", ".join(sorted(self.features)) + raise LifecycleError("FEATURE_UNKNOWN", + f"unknown feature {name!r} (known: {known})") from None + def component(self, identifier: str) -> Component: try: return self.components[identifier] @@ -222,6 +252,67 @@ def _build_profile(name: str, raw: Any) -> Profile: ) +def _build_feature(name: str, raw: Any, + components: Mapping[str, Component]) -> Feature: + if not isinstance(raw, dict): + raise LifecycleError("MANIFEST_INVALID", f"feature {name!r} is not an object") + scope = raw.get("scope") + if scope not in FEATURE_SCOPES: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r}: scope {scope!r} is not a project feature; " + "machine-scope integration is not a feature (ADR-0017 section 9)") + identifiers = _strings(raw.get("components"), "components", name) + for identifier in identifiers: + if identifier not in components: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} references unknown component {identifier!r}") + return Feature( + name=name, + scope=scope, + work_forge=bool(raw.get("work_forge", False)), + title=str(raw.get("title", name)), + summary=str(raw.get("summary", "")), + components=identifiers, + conflicts=_strings(raw.get("conflicts"), "conflicts", name), + ) + + +def _link_features(features: Mapping[str, Feature]) -> None: + """Refuse a catalogue whose conflicts or component claims cannot be honored. + + Two features sharing a component would make disable/enable ambiguous (whose + file is it?); an asymmetric conflict would let one side activate what the + other refuses; both are catalogue defects, not states a later stage could + resolve. + """ + + claimed: dict[str, str] = {} + for name, feature in features.items(): + if name in feature.conflicts: + raise LifecycleError("MANIFEST_INVALID", + f"feature {name!r} conflicts with itself") + for other in feature.conflicts: + if other not in features: + raise LifecycleError( + "MANIFEST_INVALID", + f"feature {name!r} conflicts with unknown feature {other!r}") + if name not in features[other].conflicts: + raise LifecycleError( + "MANIFEST_INVALID", + f"the conflict between {name!r} and {other!r} must be " + "declared symmetrically") + for identifier in feature.components: + if identifier in claimed: + raise LifecycleError( + "MANIFEST_INVALID", + f"component {identifier!r} is claimed by features " + f"{claimed[identifier]!r} and {name!r}; a component belongs " + "to exactly one feature") + claimed[identifier] = name + + def _reject_collisions(components: Mapping[str, Component]) -> None: """Two destinations that differ only in case are one path on Windows. @@ -266,7 +357,21 @@ def load(data_dir: Path | None = None) -> Distribution: if default not in profiles: raise LifecycleError("MANIFEST_INVALID", f"default profile {default!r} is not declared") - distribution = Distribution(components=components, profiles=profiles, default_profile=default, + feature_payload = _read_json(directory / "features.json") + raw_features = feature_payload.get("features") + if not isinstance(raw_features, dict) or not raw_features: + raise LifecycleError("MANIFEST_INVALID", "features.json declares no features") + features = {name: _build_feature(name, raw, components) + for name, raw in sorted(raw_features.items())} + _link_features(features) + legacy = feature_payload.get("legacy_default") + if not isinstance(legacy, str) or legacy not in features: + raise LifecycleError("MANIFEST_INVALID", + f"legacy_default {legacy!r} is not a declared feature") + + distribution = Distribution(components=components, profiles=profiles, + default_profile=default, features=features, + legacy_default_feature=legacy, schema_version=int(profile_payload.get("schema_version", 1))) for name in profiles: distribution.effective_component_ids(name) # proves the graph resolves @@ -274,8 +379,9 @@ def load(data_dir: Path | None = None) -> Distribution: __all__ = [ - "Component", "Profile", "Distribution", "load", "DATA_DIR", + "Component", "Profile", "Feature", "Distribution", "load", "DATA_DIR", "KIND_TREE", "KIND_FILE", "KIND_TEMPLATE", "KIND_EXTERNAL_BLOCK", "KIND_JSON_HOOK", "KIND_MARKER", "KIND_DATA_ROOT", "KINDS", "MANAGED_IMMUTABLE", "MANAGED_MUTABLE", "USER_DATA", "EXTERNAL_CONFIG", "OWNERSHIPS", + "FEATURE_SCOPE_PROJECT", "FEATURE_SCOPES", ] diff --git a/ainative/lifecycle/planner.py b/ainative/lifecycle/planner.py index 63ebdcbb..fa637cea 100644 --- a/ainative/lifecycle/planner.py +++ b/ainative/lifecycle/planner.py @@ -14,6 +14,7 @@ from . import digest as digestlib from . import external_json +from . import features as featureslib from . import hooks as hookslib from . import manifest as manifestlib from .errors import LifecycleError @@ -180,8 +181,14 @@ def component_files(component: Component, def _install_change(project: Path, destination: str, source_relative: str | None, component: Component, source: DistributionSource, - state: InstallState, payload: bytes | None) -> Change: - """One file's decision, taken from ownership and the recorded digest.""" + state: InstallState, payload: bytes | None, *, + seed_missing: bool) -> Change: + """One file's decision, taken from ownership and the recorded digest. + + `seed_missing=False` is the feature-component policy: a feature seeds its + files when it is enabled, so a file the user removed stays removed through + ordinary maintenance (ADR-0017 section 5). Profile components always seed. + """ target = resolve_within(project, destination) known = state.file_for(destination) @@ -203,6 +210,10 @@ def _install_change(project: Path, destination: str, source_relative: str | None "seeded from the shipped template", source_relative, new_digest) if not target.exists(): + if not seed_missing: + return Change(SKIP, destination, component.identifier, component.ownership, + "absent — a feature seeds its files when it is enabled", + source_relative, None) return Change(CREATE, destination, component.identifier, component.ownership, "absent", source_relative, new_digest) @@ -259,7 +270,8 @@ def _prune_changes(project: Path, component: Component, wanted: Iterable[str], def plan_component_install(project: Path, component: Component, source: DistributionSource, - state: InstallState, profile: str) -> list[Change]: + state: InstallState, profile: str, *, + seed_missing: bool = True) -> list[Change]: if component.kind == manifestlib.KIND_DATA_ROOT: return [Change(SKIP, path, component.identifier, component.ownership, "user data root — declared, never written", kind="data_root") @@ -301,7 +313,7 @@ def plan_component_install(project: Path, component: Component, source: Distribu if component.kind == manifestlib.KIND_MARKER: payload = marker_payload(component, source, profile).encode("utf-8") changes.append(_install_change(project, destination, source_relative, component, - source, state, payload)) + source, state, payload, seed_missing=seed_missing)) wanted.append(destination) changes.extend(_prune_changes(project, component, wanted, state)) return changes @@ -389,12 +401,47 @@ def plan_component_removal(project: Path, component: Component, state: InstallSt for entry in state.files_for_component(component.identifier)] +def wanted_component_ids(distribution: Distribution, profile: str, + features: tuple[str, ...]) -> list[str]: + """Profile components first, then the active features' components. + + One owner for the wanted set: the planner builds the plan from it and the + installer records the outcome from it, so the two cannot drift into + planning one set and recording another. + """ + + wanted = list(distribution.effective_component_ids(profile)) + for name in features: + for identifier in distribution.feature(name).components: + if identifier not in wanted: + wanted.append(identifier) + return wanted + + def build_install_plan(project: Path, distribution: Distribution, source: DistributionSource, state: InstallState, target_profile: str, *, - operation: str) -> Plan: - """Install or switch to `target_profile`, changing only what differs.""" + operation: str, + target_features: tuple[str, ...] | None = None, + seed_feature_files: bool = False) -> Plan: + """Install or switch to `target_profile`, changing only what differs. + + `target_features` names the features the project must end up with; None + keeps the effective set the state projects to (which is how a V1 state's + legacy default keeps its files maintained instead of being removed as + "no longer wanted"). Feature components are planned like any other managed + file, with one difference: their absent files are seeded only when a + feature is being enabled (`seed_feature_files`), so a file the user removed + stays removed through ordinary maintenance. + """ + + if target_features is None: + effective = featureslib.project_install_state(state, distribution) + target_features = effective.active_features if effective else () + feature_components: set[str] = set() + for name in target_features: + feature_components.update(distribution.feature(name).components) - wanted = distribution.effective_component_ids(target_profile) + wanted = wanted_component_ids(distribution, target_profile, target_features) present = list(state.installed_components) plan = Plan(operation=operation, project=project, from_profile=state.active_profile if present else None, @@ -402,8 +449,9 @@ def build_install_plan(project: Path, distribution: Distribution, source: Distri for identifier in wanted: component = distribution.component(identifier) + seed = identifier not in feature_components or seed_feature_files plan.changes.extend(plan_component_install(project, component, source, state, - target_profile)) + target_profile, seed_missing=seed)) if identifier not in present: plan.components_added.append(identifier) diff --git a/ainative/lifecycle/provider.py b/ainative/lifecycle/provider.py index f77ee3dd..47fc943b 100644 --- a/ainative/lifecycle/provider.py +++ b/ainative/lifecycle/provider.py @@ -30,6 +30,10 @@ from . import version as versionlib from .digest import digest_bytes from .errors import LifecycleError +# The selector names live with their owner (release_source); re-exported here +# because they have always been importable through the provider module. +from .release_source import (GITLAB_TOKEN_ENV, LOCAL_SOURCE_ENV, PROVIDER_ENV, + RELEASE_URL_ENV) def _update_token() -> str: @@ -41,10 +45,23 @@ def _update_token() -> str: return value return "" + +def environment_token() -> str: + """The token this environment offers, if any. Never logged anywhere. + + Public for the V3 providers, which attach it only when the transport's + endpoint rule allows the origin (PR-0A). + """ + + return _update_token() + + +def environment_gitlab_token() -> str: + """The GitLab token this environment offers, if any. Never logged anywhere.""" + + return (os.environ.get(GITLAB_TOKEN_ENV) or "").strip() + DEFAULT_RELEASE_URL = "https://api.github.com/repos/Rwanbt/ai-native-dev-stack/releases/latest" -PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" # "github" (default) | "local" -LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" -RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" # Authenticated checks, when the environment provides a token. Never logged, # never persisted: NAT-shared users hit the anonymous rate limit otherwise. TOKEN_ENVS = ("GITHUB_TOKEN", "GH_TOKEN") @@ -419,18 +436,47 @@ def verify_archive(payload: bytes, expected: str | None) -> str: def build(channel: str = "stable") -> UpdateProvider: - """The provider this environment selects. One variable, no discovery.""" + """The provider this environment selects. One resolver, no discovery. - selected = (os.environ.get(PROVIDER_ENV) or "").strip().lower() - if selected == "local": - root = os.environ.get(LOCAL_SOURCE_ENV) - if not root: - raise LifecycleError("UPDATE_CHECK_FAILED", - f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") - return LocalDirectoryProvider(Path(root)) - if selected in ("", "github", "release-api"): - return ReleaseApiProvider() - raise LifecycleError("UPDATE_CHECK_FAILED", f"unknown update provider {selected!r}") + The decision lives in `release_source.resolve_release_source()` so + `update`, `update check`, `status` and `doctor` cannot disagree about it + (ADR-0019 section 1); this function only constructs the selected provider. + """ + + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_GITLAB: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the gitlab provider speaks Release V3 only") + if source.kind == release_sourcelib.KIND_LOCAL: + return LocalDirectoryProvider(source.directory) + return ReleaseApiProvider(url=source.metadata_url, endpoint=source.endpoint) + + +def build_v3(channel: str = "stable"): + """The V3 provider for the resolved source (ADR-0019 section 11). + + The second generation of the same seam: the V2 path speaks a `Release` + listing, the V3 path speaks bounded enumeration plus an anchored manifest. + The updater prefers V3 and falls back to V2 only when a complete + enumeration proves the channel is V2-era. + """ + + from . import release_providers as release_providerslib + from . import release_source as release_sourcelib + + source = release_sourcelib.resolve_release_source() + if source.kind == release_sourcelib.KIND_LOCAL: + return release_providerslib.LocalReleaseProvider(source.directory) + if source.kind == release_sourcelib.KIND_ANONYMOUS: + return release_providerslib.AnonymousReleaseApiProvider(source.metadata_url, + source.endpoint) + if source.kind == release_sourcelib.KIND_GITLAB: + return release_providerslib.GitLabReleaseProvider(source.endpoint, + source.project_ref) + # github and named providers are both GitHub-Release-API-compatible. + return release_providerslib.GitHubReleaseProvider(source.endpoint) def copy_tree(source: Path, destination: Path) -> None: @@ -447,5 +493,6 @@ def copy_tree(source: Path, destination: Path) -> None: "LIFECYCLE_BUNDLE_PREFIX", "LIFECYCLE_BUNDLE_SUFFIX", "NETWORK_TIMEOUT_SECONDS", "MAX_ARCHIVE_BYTES", "MAX_METADATA_BYTES", "ReleaseProviderEndpointConfig", - "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", + "upgrade_command", "UPGRADE_COMMAND_TEMPLATE", "environment_token", + "environment_gitlab_token", ] \ No newline at end of file diff --git a/ainative/lifecycle/release_providers.py b/ainative/lifecycle/release_providers.py new file mode 100644 index 00000000..e72d890d --- /dev/null +++ b/ainative/lifecycle/release_providers.py @@ -0,0 +1,521 @@ +"""The V3 providers: GitHub.com, the anonymous release API, and a local mirror. + +Each provider implements the contract in `release_v3` and nothing else: it +enumerates what its source publishes, fetches the manifest bytes, fetches an +artifact. Every trust decision stays in `release_v3` — the provider that +fetched a manifest never decides whether to believe it (ADR-0019 sections 9–11). + +GitHub and the anonymous release document share the asset-selection rules of +the V2 path: an `assets[].url` API locator is preferred, `browser_download_url` +is the fallback, and neither is ever authenticated off the endpoint's declared +origin (PR-0A). The local mirror executes the same logical chain as a network +provider — its index supplies the manifest locator, size and SHA-256, and +nothing is trusted because "it is on disk". + +Enumeration is bounded. A provider that could not exhaust its listing says so +(`complete=False`), and `release_v3.select_candidate` then refuses rather than +picking the best of what it happened to see. +""" + +from __future__ import annotations + +import json +import urllib.parse +from pathlib import Path + +from . import release_v3 as release_v3lib +from . import transport as transportlib +from . import version as versionlib +from .errors import LifecycleError +from .paths import validate_relative + +MANIFEST_ASSET_NAME = "ainative-release-v3.json" +MAX_MANIFEST_BYTES = 1 << 20 # 1 MiB of release manifest is already absurd +MAX_ARCHIVE_BYTES = 256 << 20 # 256 MiB — mirrors the V2 bound +MAX_ENUMERATION_ENTRIES = 30 # one API page; beyond it, incomplete +GITLAB_PACKAGE_NAME = "ai-native-dev-stack" + + +def _json_object(payload: bytes, source: str) -> dict: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release metadata from {source} is not an object") + return document + + +def _json_list(payload: bytes, source: str) -> list: + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not valid JSON: " + f"{error}") from error + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release listing from {source} is not a list") + return document + + +def _asset_url(asset: dict) -> str | None: + for key in ("url", "browser_download_url"): + value = asset.get(key) + if isinstance(value, str) and value.lower().startswith("https://"): + return value + return None + + +def _manifest_asset(document: dict) -> dict | None: + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == MANIFEST_ASSET_NAME: + return asset + return None + + +def _anchor(asset: dict) -> tuple[str | None, int | None]: + raw_digest = asset.get("digest") + if isinstance(raw_digest, str) and raw_digest.startswith("sha256:"): + digest: str | None = raw_digest.split(":", 1)[1].strip().lower() + elif isinstance(raw_digest, str) and raw_digest.strip(): + # Present but not sha256: — kept so verification refuses as + # malformed metadata instead of missing metadata. + digest = raw_digest.strip() + else: + digest = None + size = asset.get("size") + if isinstance(size, bool) or not isinstance(size, int): + size = None + return digest, size + + +def _document_candidate(document: object) -> release_v3lib.ReleaseCandidate | None: + """A V3 candidate from a GitHub-shaped release document, or None. + + Not a candidate: a draft, a tag that is not SemVer (nothing installable), + or a release that publishes no V3 manifest asset — that last one is a + V2-era release, not a broken V3 one. + """ + + from . import version as versionlib + + if not isinstance(document, dict) or document.get("draft"): + return None + tag = document.get("tag_name") + if not isinstance(tag, str): + return None + parsed = versionlib.parse(tag) + if parsed is None: + return None + asset = _manifest_asset(document) + if asset is None: + return None + digest, size = _anchor(asset) + return release_v3lib.ReleaseCandidate( + version=str(parsed), identity=tag, + channel="beta" if document.get("prerelease") else "stable", + manifest_sha256=digest, manifest_size=size, + manifest_locator=_asset_url(asset)) + + +class _AssetDocumentProvider(release_v3lib.ReleaseProvider): + """Shared fetching for the providers whose releases are GitHub-shaped.""" + + def __init__(self) -> None: + self._documents: dict[str, dict] = {} + + def _remember(self, candidate: release_v3lib.ReleaseCandidate, + document: dict) -> None: + self._documents[candidate.identity] = document + + def _document(self, candidate: release_v3lib.ReleaseCandidate) -> dict: + try: + return self._documents[candidate.identity] + except KeyError: + raise LifecycleError( + "UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") from None + + def _token(self) -> str: + return "" + + def _get(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + locator = candidate.manifest_locator or _asset_url( + _manifest_asset(self._document(candidate)) or {}) + if not locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no " + f"fetchable {MANIFEST_ASSET_NAME}") + return self._get(locator, MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + document = self._document(candidate) + for asset in document.get("assets") or []: + if isinstance(asset, dict) and asset.get("name") == artifact.name: + url = _asset_url(asset) + if url: + return self._get(url, MAX_ARCHIVE_BYTES) + raise LifecycleError( + "UPDATE_UNAVAILABLE", + f"release {candidate.identity} publishes no asset {artifact.name!r}") + + +class GitHubReleaseProvider(_AssetDocumentProvider): + """GitHub Releases as a V3 source. Auth only at the configured origin.""" + + name = "github" + + def __init__(self, endpoint, repository: str = "Rwanbt/ai-native-dev-stack", + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + super().__init__() + self.endpoint = endpoint + self.repository = repository + self.page_bound = page_bound + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_token() + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + url = (f"{self.endpoint.api_base_url}/repos/{self.repository}" + f"/releases?per_page={self.page_bound}") + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + documents = _json_list(payload, url) + candidates = [] + for document in documents: + candidate = _document_candidate(document) + if candidate is None: + continue + self._remember(candidate, document) + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), + complete=len(documents) < self.page_bound) + + +class AnonymousReleaseApiProvider(_AssetDocumentProvider): + """`AINATIVE_UPDATE_URL`: one release document, anonymous, GitHub-shaped.""" + + name = "anonymous" + + def __init__(self, url: str, + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None) -> None: + super().__init__() + self.url = url + self.endpoint = endpoint or transportlib.anonymous_endpoint(url) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + payload = transportlib.get(self.url, limit=MAX_MANIFEST_BYTES, + endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + document = _json_object(payload, self.url) + candidate = _document_candidate(document) + if candidate is not None: + self._remember(candidate, document) + return release_v3lib.EnumerationResult((candidate,) if candidate else (), + complete=True) + + +class LocalReleaseProvider(release_v3lib.ReleaseProvider): + """A local mirror that executes the same logical chain as a network source. + + `releases.json` names, per channel, the version and the manifest locator + with its size and SHA-256 (`{"manifest": {"file": ..., "size": ..., + "sha256": ...}}`); the manifest then names the artifacts. A channel entry + without a V3 manifest paragraph is a V2-era entry and is not a candidate. + """ + + name = "local" + + def __init__(self, root: Path) -> None: + self.root = Path(root) + + def _index(self) -> dict: + path = self.root / "releases.json" + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("UPDATE_CHECK_FAILED", f"{path} is not a JSON object") + return payload + + def _candidate(self, channel: str, entry: object) -> release_v3lib.ReleaseCandidate | None: + if not isinstance(entry, dict): + return None + version = entry.get("version") + manifest = entry.get("manifest") + if not isinstance(version, str) or not version.strip() or not isinstance(manifest, dict): + return None + file = manifest.get("file") + locator = f"{version}/{file}" if isinstance(file, str) and file else None + sha = manifest.get("sha256") + size = manifest.get("size") + return release_v3lib.ReleaseCandidate( + version=version.strip(), identity=f"local:{channel}", channel=channel, + manifest_sha256=sha if isinstance(sha, str) else None, + manifest_size=size if isinstance(size, int) and not isinstance(size, bool) else None, + manifest_locator=locator) + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + index = self._index() + channels = index.get("channels") + if not isinstance(channels, dict) or not channels: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{self.root / 'releases.json'} declares no channels") + candidates = [] + for channel, entry in sorted(channels.items()): + candidate = self._candidate(str(channel), entry) + if candidate is not None: + candidates.append(candidate) + return release_v3lib.EnumerationResult(tuple(candidates), complete=True) + + def _release_file(self, relative: str, *, limit: int, + description: str) -> bytes: + parts = validate_relative(relative) + path = self.root.joinpath(*parts.parts) + try: + size = path.stat().st_size + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{description} is missing: {error}") from error + if size > limit: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"{description} is {size} bytes, over the " + f"{limit} limit") + try: + return path.read_bytes() + except OSError as error: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"cannot read {description}: {error}") from error + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + if not candidate.manifest_locator: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"{candidate.identity} declares no manifest locator") + return self._release_file(candidate.manifest_locator, limit=MAX_MANIFEST_BYTES, + description=f"manifest for {candidate.identity}") + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._release_file(f"{candidate.version}/{artifact.name}", + limit=MAX_ARCHIVE_BYTES, + description=f"artifact {artifact.name!r}") + + +class GitLabReleaseProvider(release_v3lib.ReleaseProvider): + """GitLab.com as a V3 source: Releases for discovery, the Generic Package + Registry as the canonical distribution and integrity surface (ADR-0019 + section 11). + + Release Link URLs are presentation, never integrity roots: the anchor is + the package file API's `file_sha256` and `size`. One project identity + (`release_project_ref`) is used for Releases, packages and package files — + there is no second project configuration. GitLab is V3-only: it has no V2 + fallback, and a complete channel without a V3 candidate refuses. + """ + + name = "gitlab" + supports_v2_fallback = False + + def __init__(self, endpoint, project_ref: str, + page_bound: int = MAX_ENUMERATION_ENTRIES) -> None: + self.endpoint = endpoint + self.project_ref = project_ref + self.page_bound = page_bound + self._downloads: dict[str, dict[str, str]] = {} + + # --- requests ------------------------------------------------------- + + def _token(self) -> str: + if self.endpoint.auth_origin is None: + return "" + from . import provider as providerlib + + return providerlib.environment_gitlab_token() + + def _project_url(self) -> str: + encoded = urllib.parse.quote(self.project_ref, safe="") + return f"{self.endpoint.api_base_url}/projects/{encoded}" + + def _get_json(self, url: str) -> object: + payload = transportlib.get(url, limit=MAX_MANIFEST_BYTES, endpoint=self.endpoint, + kind=transportlib.METADATA, + accept=transportlib.ACCEPT_GITHUB_JSON, + token=self._token()) + try: + return json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab answered {url} with invalid JSON: " + f"{error}") from error + + def _get_bytes(self, url: str, limit: int) -> bytes: + return transportlib.get(url, limit=limit, endpoint=self.endpoint, + kind=transportlib.ARTIFACT, + accept=transportlib.ACCEPT_OCTET_STREAM, + token=self._token()) + + # --- enumeration ---------------------------------------------------- + + def enumerate(self, query: release_v3lib.ReleaseQuery) -> release_v3lib.EnumerationResult: + releases, releases_complete = self._releases() + packages, packages_complete = self._packages() + candidates = self._candidates(releases, packages) + return release_v3lib.EnumerationResult( + tuple(candidates), complete=releases_complete and packages_complete) + + def _releases(self) -> tuple[list, bool]: + url = f"{self._project_url()}/releases?per_page={self.page_bound}" + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab releases from {url} are not a list") + return document, len(document) < self.page_bound + + def _packages(self) -> tuple[dict, bool]: + url = (f"{self._project_url()}/packages?package_type=generic" + f"&package_name={GITLAB_PACKAGE_NAME}&per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab packages from {url} are not a list") + by_version: dict[str, list] = {} + for record in document: + if not isinstance(record, dict): + continue + if (record.get("package_type") != "generic" + or record.get("name") != GITLAB_PACKAGE_NAME): + continue + parsed = versionlib.parse(record.get("version")) \ + if isinstance(record.get("version"), str) else None + if parsed is None: + continue + by_version.setdefault(str(parsed), []).append(record) + return by_version, len(document) < self.page_bound + + def _candidates(self, releases: list, packages: dict) -> list: + candidates = [] + for release in releases: + parsed = versionlib.parse(release.get("tag_name")) \ + if isinstance(release, dict) else None + if parsed is None or release.get("upcoming_release"): + continue + records = packages.get(str(parsed)) + if not records: + continue + if len(records) > 1: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"project {self.project_ref} publishes {len(records)} generic " + f"packages for version {parsed}; exactly one is required") + identity = str(release.get("tag_name")) + package_id = records[0].get("id") + files = self._package_files(package_id) + digest, size = self._anchor(files, identity) + self._downloads[identity] = self._download_urls(package_id, files) + candidates.append(release_v3lib.ReleaseCandidate( + version=str(parsed), identity=identity, + channel="beta" if parsed.pre else "stable", + manifest_sha256=digest, manifest_size=size)) + return candidates + + def _package_files(self, package_id: object) -> list: + url = (f"{self._project_url()}/packages/{package_id}/package_files" + f"?per_page={self.page_bound}") + document = self._get_json(url) + if not isinstance(document, list): + raise LifecycleError("UPDATE_CHECK_FAILED", + f"GitLab package files from {url} are not a list") + if len(document) >= self.page_bound: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + f"package {package_id} answered a full page of files; the " + "listing bounds were reached before exhaustion") + return [entry for entry in document if isinstance(entry, dict)] + + def _download_urls(self, package_id: object, files: list) -> dict: + return {str(entry.get("file_name")): + f"{self._project_url()}/packages/{package_id}/package_files/" + f"{entry.get('id')}/download" + for entry in files if entry.get("id") is not None} + + def _anchor(self, files: list, identity: str) -> tuple[str, int]: + manifests = [entry for entry in files + if entry.get("file_name") == MANIFEST_ASSET_NAME] + if not manifests: + raise LifecycleError("RELEASE_MANIFEST_MISSING", + f"release {identity} carries no {MANIFEST_ASSET_NAME}") + if len(manifests) > 1: + raise LifecycleError("RELEASE_MANIFEST_AMBIGUOUS", + f"release {identity} carries {len(manifests)} " + f"{MANIFEST_ASSET_NAME} files") + entry = manifests[0] + digest = entry.get("file_sha256") + if not isinstance(digest, str) or not digest.strip(): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no file_sha256") + cleaned = digest.strip().lower() + if len(cleaned) != 64 or any(character not in "0123456789abcdef" + for character in cleaned): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed file_sha256") + size = entry.get("size") + if size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + f"package file of release {identity} publishes no size") + if isinstance(size, bool) or not isinstance(size, int) or size <= 0: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"package file of release {identity} carries a malformed size") + return cleaned, size + + # --- fetching ------------------------------------------------------- + + def _download(self, identity: str, name: str, limit: int) -> bytes: + downloads = self._downloads.get(identity) + if downloads is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + "the provider must enumerate before it fetches") + url = downloads.get(name) + if not url: + raise LifecycleError("UPDATE_UNAVAILABLE", + f"release {identity} publishes no package file " + f"{name!r}") + return self._get_bytes(url, limit) + + def fetch_manifest(self, candidate: release_v3lib.ReleaseCandidate) -> bytes: + return self._download(candidate.identity, MANIFEST_ASSET_NAME, + MAX_MANIFEST_BYTES) + + def fetch_artifact(self, candidate: release_v3lib.ReleaseCandidate, + artifact: release_v3lib.ManifestArtifact) -> bytes: + return self._download(candidate.identity, artifact.name, MAX_ARCHIVE_BYTES) + + +__all__ = ["GitHubReleaseProvider", "AnonymousReleaseApiProvider", + "LocalReleaseProvider", "GitLabReleaseProvider", "MANIFEST_ASSET_NAME", + "GITLAB_PACKAGE_NAME", "MAX_MANIFEST_BYTES", "MAX_ARCHIVE_BYTES", + "MAX_ENUMERATION_ENTRIES"] diff --git a/ainative/lifecycle/release_source.py b/ainative/lifecycle/release_source.py new file mode 100644 index 00000000..7b3a4ed3 --- /dev/null +++ b/ainative/lifecycle/release_source.py @@ -0,0 +1,306 @@ +"""Where a release comes from: one resolver, one conflict policy, no guessing. + +`resolve_release_source()` is the only function that decides. `update`, +`update check`, `status` and `doctor` all consume it, so the answer cannot +differ between commands (ADR-0019 sections 1–3). Validation happens before +precedence: contradictory selectors are refused, never ordered, and nothing +falls back silently. + +Selectors (environment): + +```text +AINATIVE_UPDATE_PROVIDER=local + AINATIVE_UPDATE_LOCAL_DIR -> local mirror +AINATIVE_UPDATE_LOCAL_DIR without provider=local -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL mixed with a provider/local selector -> UPDATE_SOURCE_CONFLICT +AINATIVE_UPDATE_URL alone -> anonymous release API +AINATIVE_UPDATE_PROVIDER= -> machine config or built-in +nothing selected -> machine default, else GitHub.com +``` + +Machine configuration lives at `~/.ai-native/release-providers.json` — machine +scope, deliberately: a cloned repository must never be able to redirect a +user's updates or name its own credential origin. The built-in names +(`github`, `gitlab`, `local`) cannot be redefined there, and V1 supports no +custom `auth_origin`: a named provider is anonymous. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path + +from . import transport as transportlib +from .errors import LifecycleError + +PROVIDER_ENV = "AINATIVE_UPDATE_PROVIDER" +LOCAL_SOURCE_ENV = "AINATIVE_UPDATE_LOCAL_DIR" +RELEASE_URL_ENV = "AINATIVE_UPDATE_URL" +CONFIG_RELATIVE = Path(".ai-native") / "release-providers.json" + +RESERVED_PROVIDERS = ("github", "gitlab", "local") +CONFIG_SCHEMA_VERSION = 1 + +GITLAB_API_BASE_URL = "https://gitlab.com/api/v4" +GITLAB_AUTH_ORIGIN = "https://gitlab.com" +GITLAB_TOKEN_ENV = "GITLAB_TOKEN" + +KIND_GITHUB = "github" +KIND_GITLAB = "gitlab" +KIND_LOCAL = "local" +KIND_ANONYMOUS = "anonymous" +KIND_NAMED = "named" + + +@dataclass(frozen=True) +class ReleaseSource: + """The resolved source, with the reason and the trust facts to display.""" + + kind: str + provider_name: str + reason: str + endpoint: transportlib.ReleaseProviderEndpointConfig | None = None + directory: Path | None = None + metadata_url: str | None = None + project_ref: str | None = None + + @property + def authenticated(self) -> bool: + return bool(self.endpoint and self.endpoint.auth_origin is not None) + + def to_record(self) -> dict: + return {"kind": self.kind, "provider": self.provider_name, + "reason": self.reason, + "authenticated": self.authenticated, + "auth_origin": self.endpoint.auth_origin if self.endpoint else None, + "api_base_url": self.endpoint.api_base_url if self.endpoint else None, + "release_project_ref": self.project_ref, + "directory": str(self.directory) if self.directory else None} + + +def config_path(home: Path | None = None) -> Path: + return (Path(home) if home else Path.home()) / CONFIG_RELATIVE + + +def load_machine_config(home: Path | None = None) -> dict: + """The machine-scope provider configuration, validated, or {} when absent.""" + + path = config_path(home) + if not path.is_file(): + return {} + try: + payload = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as error: + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"cannot read {path}: {error}") from error + if not isinstance(payload, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", f"{path} is not a JSON object") + version = payload.get("schema_version") + if version != CONFIG_SCHEMA_VERSION: + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: schema_version {version!r} is not {CONFIG_SCHEMA_VERSION}; " + "upgrade the CLI rather than guessing") + providers = payload.get("providers", {}) + if not isinstance(providers, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: providers must be an object") + for name in providers: + # `gitlab` is a built-in provider that *is* configured here (its + # project reference has to live somewhere machine-scoped); the other + # built-ins cannot be redefined. + if name in RESERVED_PROVIDERS and name != "gitlab": + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"{path}: provider {name!r} is built in and cannot be redefined") + default = payload.get("default_provider") + if default is not None and (not isinstance(default, str) or not default.strip()): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"{path}: default_provider must be a provider name") + return payload + + +def _named_source(name: str, entry: object) -> ReleaseSource: + if not isinstance(entry, dict): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r} must be an object") + if entry.get("auth_origin"): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + f"provider {name!r}: custom auth_origin is not supported in V1; " + "a named provider is anonymous") + base = entry.get("api_base_url") + if not isinstance(base, str) or not base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + f"provider {name!r}: api_base_url must be an HTTPS URL") + return ReleaseSource(kind=KIND_NAMED, provider_name=name, + endpoint=transportlib.anonymous_endpoint(base), + metadata_url=base.rstrip("/") + "/releases/latest", + reason=f"named provider {name!r} (machine configuration)") + + +def _gitlab_source(entry: object) -> ReleaseSource: + """The built-in GitLab provider, configured per machine (ADR-0019 §61). + + The project reference has to live somewhere machine-scoped; it is a + numeric project id or a namespace path, never a URL. + """ + + if not isinstance(entry, dict): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab' requires a machine configuration entry with " + "release_project_ref") + project_ref = entry.get("release_project_ref") + if not isinstance(project_ref, str) or not project_ref.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is required (a numeric " + "project id or namespace/project)") + if project_ref.strip().lower().startswith(("http://", "https://")): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "provider 'gitlab': release_project_ref is a project reference, " + "not a URL") + api_base = str(entry.get("api_base_url") or GITLAB_API_BASE_URL).strip() + if not api_base.lower().startswith("https://"): + raise LifecycleError("RELEASE_CONFIG_INVALID", + "provider 'gitlab': api_base_url must be an HTTPS URL") + origin = str(entry.get("auth_origin") or GITLAB_AUTH_ORIGIN).strip() + endpoint = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url=api_base.rstrip("/"), auth_origin=origin, + api_version=str(entry.get("api_version") or "v4"), + credential_source=str(entry.get("credential_source") + or f"environment:{GITLAB_TOKEN_ENV}"), + auth_header="PRIVATE-TOKEN", auth_prefix="") + return ReleaseSource(kind=KIND_GITLAB, provider_name="gitlab", + endpoint=endpoint, project_ref=project_ref.strip(), + reason="gitlab provider (machine configuration)") + + +def _builtin_github(reason: str) -> ReleaseSource: + # Deferred: provider owns the default URL and imports this module inside + # `build()`, so the import direction stays release_source -> provider. + from . import provider as providerlib + + return ReleaseSource(kind=KIND_GITHUB, provider_name="github", + endpoint=transportlib.GITHUB_ENDPOINT, + metadata_url=providerlib.DEFAULT_RELEASE_URL, + reason=reason) + + +def resolve_release_source(environ=None, home: Path | None = None) -> ReleaseSource: + """The one source decision. Conflicts refuse; nothing is ordered silently.""" + + environment = dict(os.environ if environ is None else environ) + config = load_machine_config(home) + + provider = (environment.get(PROVIDER_ENV) or "").strip().lower() + local_dir = (environment.get(LOCAL_SOURCE_ENV) or "").strip() + url = (environment.get(RELEASE_URL_ENV) or "").strip() + _reject_conflicts(provider=provider, local_dir=local_dir, url=url) + + if url: + return ReleaseSource(kind=KIND_ANONYMOUS, provider_name="anonymous", + endpoint=transportlib.anonymous_endpoint(url), + metadata_url=url, + reason=f"anonymous release API ({RELEASE_URL_ENV})") + if provider == "local": + return _local_mirror(local_dir) + if provider: + return _selected_provider(provider, config) + return _default_source(config) + + +def _reject_conflicts(*, provider: str, local_dir: str, url: str) -> None: + if url and (provider or local_dir): + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{RELEASE_URL_ENV} cannot be combined with {PROVIDER_ENV}/" + f"{LOCAL_SOURCE_ENV}; declare exactly one source", + selectors={RELEASE_URL_ENV: url, PROVIDER_ENV: provider, + LOCAL_SOURCE_ENV: local_dir}) + if local_dir and provider != "local": + raise LifecycleError( + "UPDATE_SOURCE_CONFLICT", + f"{LOCAL_SOURCE_ENV} requires an explicit {PROVIDER_ENV}=local; " + "selectors are never ordered", + selectors={LOCAL_SOURCE_ENV: local_dir, PROVIDER_ENV: provider}) + + +def _local_mirror(local_dir: str) -> ReleaseSource: + if not local_dir: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"{PROVIDER_ENV}=local requires {LOCAL_SOURCE_ENV}") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(local_dir).expanduser(), + reason=f"local mirror ({LOCAL_SOURCE_ENV})") + + +def _selected_provider(provider: str, config: dict) -> ReleaseSource: + if provider in ("github", "release-api"): + return _builtin_github(f"built-in GitHub.com ({PROVIDER_ENV})") + if provider == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) + entry = (config.get("providers") or {}).get(provider) + if entry is None: + # Preserves the long-standing refusal for a name nothing declares; a + # declared-but-malformed entry is a config problem instead. + raise LifecycleError("UPDATE_CHECK_FAILED", + f"unknown update provider {provider!r}") + return _named_source(provider, entry) + + +def _default_source(config: dict) -> ReleaseSource: + default = str(config.get("default_provider") or "").strip().lower() + if default == "local": + entry = (config.get("providers") or {}).get("local") or {} + directory = entry.get("directory") if isinstance(entry, dict) else None + if not isinstance(directory, str) or not directory.strip(): + raise LifecycleError( + "RELEASE_CONFIG_INVALID", + "default_provider 'local' requires providers.local.directory " + "in the machine configuration") + return ReleaseSource(kind=KIND_LOCAL, provider_name="local", + directory=Path(directory).expanduser(), + reason="machine default provider 'local'") + if default == "gitlab": + return _gitlab_source((config.get("providers") or {}).get("gitlab")) + if default == "github": + return _builtin_github("machine default provider 'github'") + if default: + return _named_source(default, (config.get("providers") or {}).get(default)) + return _builtin_github("built-in default (GitHub.com)") + + +def describe(environ=None, home: Path | None = None) -> dict: + """The source as diagnostics display it; a refusal is a state, not a crash.""" + + try: + return resolve_release_source(environ=environ, home=home).to_record() + except LifecycleError as error: + return {"kind": "refused", "state": error.code, "detail": error.message, + "authenticated": False, "auth_origin": None} + + +def describe_lines(record: dict | None = None) -> list[str]: + """`describe()`'s text rendering, shared by `status` and `doctor`.""" + + record = record if record is not None else describe() + if record.get("kind") == "refused": + return ["Release source", + f" refused: {record.get('state')} — {record.get('detail')}"] + lines = ["Release source", + f" {record.get('kind')} ({record.get('provider')}) — {record.get('reason')}"] + suffix = f" (auth origin {record.get('auth_origin')})" if record.get("auth_origin") else "" + lines.append(f" authenticated: {'yes' if record.get('authenticated') else 'no'}{suffix}") + return lines + + +__all__ = ["ReleaseSource", "resolve_release_source", "load_machine_config", + "config_path", "describe", "describe_lines", "PROVIDER_ENV", + "LOCAL_SOURCE_ENV", "RELEASE_URL_ENV", "CONFIG_RELATIVE", + "RESERVED_PROVIDERS", "KIND_GITHUB", "KIND_GITLAB", "KIND_LOCAL", + "KIND_ANONYMOUS", "KIND_NAMED", "GITLAB_API_BASE_URL", + "GITLAB_AUTH_ORIGIN", "GITLAB_TOKEN_ENV"] diff --git a/ainative/lifecycle/release_v3.py b/ainative/lifecycle/release_v3.py new file mode 100644 index 00000000..92d74b5e --- /dev/null +++ b/ainative/lifecycle/release_v3.py @@ -0,0 +1,413 @@ +"""Release V3: the provider contract, the manifest, and the chains that bind them. + +ADR-0019 sections 7–10. Three ideas, each fail-closed: + +* A candidate is installable only when its version is SemVer without build + metadata, and two candidates must not claim the same precedence with + different identities (`RELEASE_DUPLICATE_VERSION`). +* An enumeration that stopped at its bounds is incomplete + (`RELEASE_ENUMERATION_INCOMPLETE`); a complete channel with nothing is + `RELEASE_NO_CANDIDATE`. Partial results are never used silently. +* The manifest's SHA-256 and size come from provider metadata, never from the + manifest itself: they are verified **before** the document is parsed, so + nothing a hostile manifest says can influence whether its own bytes are + trusted. The version chain then requires one version across candidate, + manifest, compatibility.runtime_version, artifact, artifact filename and the + lifecycle protocol document. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Iterable + +from . import version as versionlib +from .digest import digest_bytes +from .errors import LifecycleError + +MANIFEST_SCHEMA = "ainative.release" +MANIFEST_PROTOCOL = "v3" +# The `lifecycle-protocol.json` a V3 bundle carries at its root; the V2 bundle +# keeps protocol 2 (provider.UPDATE_PROTOCOL_VERSION). +BUNDLE_PROTOCOL_VERSION = 3 +ARTIFACT_KIND_LIFECYCLE = "lifecycle" +ARTIFACT_KINDS = (ARTIFACT_KIND_LIFECYCLE,) + +_V3_BUNDLE_PREFIX = "ainative-lifecycle-v3-" +_V3_BUNDLE_SUFFIX = ".zip" +_SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +@dataclass(frozen=True) +class ReleaseQuery: + """What a caller asks a provider for: one compatible channel.""" + + channel: str = "stable" + + +@dataclass(frozen=True) +class ReleaseCandidate: + """One enumerated release, with the anchor of its manifest. + + `manifest_sha256` and `manifest_size` are the external integrity anchor: + they come from the provider's own metadata (a release asset listing, a + mirror index), never from the manifest bytes themselves. + `manifest_locator` is the provider-internal way to fetch the manifest + (an asset API URL, a mirror path); it is opaque to this module. + """ + + version: str + identity: str + channel: str = "stable" + manifest_sha256: str | None = None + manifest_size: int | None = None + manifest_locator: str | None = None + + def to_record(self) -> dict: + return {"version": self.version, "identity": self.identity, + "channel": self.channel, + "manifest_sha256": self.manifest_sha256, + "manifest_size": self.manifest_size} + + +@dataclass(frozen=True) +class EnumerationResult: + """Everything the provider could see, and whether that was everything.""" + + candidates: tuple[ReleaseCandidate, ...] + complete: bool + + def to_record(self) -> dict: + return {"complete": self.complete, + "candidates": [candidate.to_record() for candidate in self.candidates]} + + +@dataclass(frozen=True) +class ManifestArtifact: + """One artifact the manifest declares. Verified, never trusted.""" + + name: str + kind: str + version: str + sha256: str + size: int + + def to_record(self) -> dict: + return {"name": self.name, "kind": self.kind, "version": self.version, + "sha256": self.sha256, "size": self.size} + + +@dataclass(frozen=True) +class ReleaseManifest: + """The parsed ReleaseManifest V3, already validated.""" + + version: str + channel: str + runtime_version: str + artifacts: tuple[ManifestArtifact, ...] + provenance: dict = field(default_factory=dict) + schema: str = MANIFEST_SCHEMA + protocol: str = MANIFEST_PROTOCOL + + def lifecycle_artifact(self) -> ManifestArtifact: + matches = [artifact for artifact in self.artifacts + if artifact.kind == ARTIFACT_KIND_LIFECYCLE] + if len(matches) != 1: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest declares {len(matches)} lifecycle artifacts; " + "exactly one is required") + return matches[0] + + def to_record(self) -> dict: + return {"schema": self.schema, "protocol": self.protocol, + "version": self.version, "channel": self.channel, + "compatibility": {"runtime_version": self.runtime_version}, + "artifacts": [artifact.to_record() for artifact in self.artifacts], + "provenance": dict(self.provenance)} + + +class ReleaseProvider: + """The V3 provider contract. Implementations fetch; this module decides. + + `supports_v2_fallback` says whether a complete channel without a V3 + candidate is a V2-era source (GitHub.com and mirrors are; GitLab is + V3-only and refuses instead). + """ + + name = "abstract" + supports_v2_fallback = True + + def enumerate(self, query: ReleaseQuery) -> EnumerationResult: + raise NotImplementedError + + def fetch_manifest(self, candidate: ReleaseCandidate) -> bytes: + raise NotImplementedError + + def fetch_artifact(self, candidate: ReleaseCandidate, + artifact: ManifestArtifact) -> bytes: + raise NotImplementedError + + +def canonical_version(value: object) -> str: + """A SemVer version string without build metadata, or a refusal. + + `1.2.3` and `1.2.3-rc.1` are installable; `1.2.3+build1` is refused with + `RELEASE_BUILD_METADATA_UNSUPPORTED` because two artifacts must never share + an installable version identity. A value that is not SemVer at all keeps + the historical `UPDATE_CHECK_FAILED` refusal. + """ + + parsed = versionlib.parse(value) if isinstance(value, str) else None + if parsed is None: + raise LifecycleError("UPDATE_CHECK_FAILED", + f"release version {value!r} is not a SemVer version") + if "+" in parsed.raw: + raise LifecycleError( + "RELEASE_BUILD_METADATA_UNSUPPORTED", + f"release version {parsed.raw!r} carries build metadata; installable " + "versions are exactly major.minor.patch with an optional pre-release", + version=parsed.raw) + return str(parsed) + + +def select_candidate(result: EnumerationResult, query: ReleaseQuery) -> ReleaseCandidate: + """The newest installable candidate of the channel, or a refusal.""" + + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the provider stopped enumerating at its bounds; partial results " + "are never used to pick a release") + compatible = [candidate for candidate in result.candidates + if candidate.channel == query.channel] + if not compatible: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {query.channel!r} channel is complete and holds no release") + by_precedence: dict[str, str] = {} + for candidate in compatible: + canonical = canonical_version(candidate.version) + previous = by_precedence.get(canonical) + if previous is not None and previous != candidate.identity: + raise LifecycleError( + "RELEASE_DUPLICATE_VERSION", + f"two releases claim version {canonical}: {previous!r} and " + f"{candidate.identity!r}; duplicates are never resolved by order", + version=canonical, identities=sorted({previous, candidate.identity})) + by_precedence[canonical] = candidate.identity + ordered = sorted(compatible, key=lambda candidate: versionlib.parse(candidate.version)) + return ordered[-1] + + +def verify_external_anchor(payload: bytes, *, sha256: str | None, size: int | None) -> None: + """Verify bytes against the anchor that travelled with the candidate. + + Absent anchor: `RELEASE_INTEGRITY_METADATA_MISSING`; malformed: + `RELEASE_INTEGRITY_METADATA_INVALID`; anything that does not match: + `UPDATE_INTEGRITY_FAILED`. This runs BEFORE parsing, always. + """ + + if sha256 is None and size is None: + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_MISSING", + "the provider published no manifest digest and size; a manifest " + "nobody anchored is not one this stack parses") + if not (isinstance(sha256, str) and _SHA256.match(sha256.strip().lower()) + and isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError( + "RELEASE_INTEGRITY_METADATA_INVALID", + f"the manifest anchor is malformed (sha256={sha256!r}, size={size!r})") + if len(payload) != size: + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest is {len(payload)} bytes but the provider declared {size}") + actual = digest_bytes(payload) + if actual != sha256.strip().lower(): + raise LifecycleError( + "UPDATE_INTEGRITY_FAILED", + f"the manifest digest {actual} does not match the anchored " + f"{sha256.strip().lower()}", + expected=sha256.strip().lower(), actual=actual) + + +def parse_manifest(payload: bytes) -> ReleaseManifest: + """Parse and validate a manifest whose bytes were already verified.""" + + try: + document = json.loads(payload.decode("utf-8")) + except (ValueError, UnicodeDecodeError) as error: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"the manifest is not valid JSON: {error}") from error + if not isinstance(document, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest is not an object") + if document.get("schema") != MANIFEST_SCHEMA: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest schema {document.get('schema')!r}") + protocol = document.get("protocol") + if protocol != MANIFEST_PROTOCOL: + newer = _newer_protocol(protocol) + if newer is not None: + raise LifecycleError( + "CLI_UPDATE_REQUIRED", + f"the release manifest speaks protocol {protocol}; this runtime " + "speaks v3 and the release must be applied by a newer CLI runtime") + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"unknown manifest protocol {protocol!r}") + + raw_version = document.get("version") + if not isinstance(raw_version, str) or not raw_version.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no version") + version = canonical_version(raw_version) + channel = document.get("channel") + if not isinstance(channel, str) or not channel.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "the manifest declares no channel") + compatibility = document.get("compatibility") + if not isinstance(compatibility, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility object") + raw_runtime = compatibility.get("runtime_version") + if not isinstance(raw_runtime, str) or not raw_runtime.strip(): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no compatibility.runtime_version") + runtime_version = canonical_version(raw_runtime) + artifacts = _parse_artifacts(document.get("artifacts")) + provenance = document.get("provenance", {}) + if not isinstance(provenance, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "provenance must be an object") + return ReleaseManifest(version=version, channel=channel.strip(), + runtime_version=runtime_version, artifacts=artifacts, + provenance=dict(provenance)) + + +def _newer_protocol(protocol: object) -> int | None: + """The protocol number when it is a v newer than this runtime's, else None.""" + + match = re.match(r"^v(?P\d+)$", str(protocol)) + if match is None: + return None + number = int(match.group("number")) + return number if number > int(MANIFEST_PROTOCOL.lstrip("v")) else None + + +def _parse_artifacts(raw: object) -> tuple[ManifestArtifact, ...]: + if not isinstance(raw, list) or not raw: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + "the manifest declares no artifacts") + artifacts = [] + for entry in raw: + artifacts.append(_parse_artifact(entry)) + return tuple(artifacts) + + +def _parse_artifact(entry: object) -> ManifestArtifact: + if not isinstance(entry, dict): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", "an artifact entry is not an object") + name = entry.get("name") + if not isinstance(name, str) or not name or "/" in name or "\\" in name \ + or name in (".", ".."): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact name {name!r} is not a plain filename") + kind = entry.get("kind") + if kind not in ARTIFACT_KINDS: + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares unknown kind {kind!r}") + version = canonical_version(entry.get("version")) + sha = entry.get("sha256") + if not (isinstance(sha, str) and _SHA256.match(sha.strip().lower())): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid sha256") + size = entry.get("size") + if not (isinstance(size, int) and not isinstance(size, bool) and size > 0): + raise LifecycleError("UPDATE_INTEGRITY_FAILED", + f"artifact {name!r} declares no valid size") + return ManifestArtifact(name=name, kind=kind, version=version, + sha256=sha.strip().lower(), size=size) + + +def lifecycle_bundle_name(version: str) -> str: + """The one filename a V3 lifecycle bundle of `version` may wear.""" + + return f"{_V3_BUNDLE_PREFIX}{version}{_V3_BUNDLE_SUFFIX}" + + +def require_exact_version_chain(candidate: ReleaseCandidate, manifest: ReleaseManifest, + artifact: ManifestArtifact, *, + protocol_release_version: str | None = None) -> None: + """One version across every trust-bearing identity, or `UPDATE_VERSION_MISMATCH`. + + candidate.version == manifest.version == manifest.compatibility.runtime_version + == artifact.version == the artifact filename version == (after extraction) + lifecycle-protocol.json.release_version. + """ + + links = [ + ("candidate version", candidate.version), + ("manifest version", manifest.version), + ("compatibility.runtime_version", manifest.runtime_version), + ("artifact version", artifact.version), + ("artifact filename version", _filename_version(artifact)), + ] + if protocol_release_version is not None: + links.append(("lifecycle-protocol.json release_version", + protocol_release_version)) + distinct = {value for _, value in links} + if len(distinct) != 1: + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + "; ".join(f"{label}={value!r}" for label, value in links), + chain={label: value for label, value in links}) + if artifact.name != lifecycle_bundle_name(artifact.version): + raise LifecycleError( + "UPDATE_VERSION_MISMATCH", + f"the artifact is named {artifact.name!r} but its version " + f"{artifact.version!r} requires {lifecycle_bundle_name(artifact.version)!r}", + published=artifact.name, + expected=lifecycle_bundle_name(artifact.version)) + + +def _filename_version(artifact: ManifestArtifact) -> str: + name = artifact.name + inner = name[len(_V3_BUNDLE_PREFIX):-len(_V3_BUNDLE_SUFFIX)] \ + if name.startswith(_V3_BUNDLE_PREFIX) and name.endswith(_V3_BUNDLE_SUFFIX) \ + else name + return inner + + +def fetch_manifest_for(provider: ReleaseProvider, + candidate: ReleaseCandidate) -> ReleaseManifest: + """Fetch, anchor-verify, parse and chain-check one candidate's manifest.""" + + payload = provider.fetch_manifest(candidate) + verify_external_anchor(payload, sha256=candidate.manifest_sha256, + size=candidate.manifest_size) + manifest = parse_manifest(payload) + artifact = manifest.lifecycle_artifact() + require_exact_version_chain(candidate, manifest, artifact) + return manifest + + +def resolve_manifest(provider: ReleaseProvider, query: ReleaseQuery + ) -> tuple[ReleaseCandidate, ReleaseManifest]: + """enumerate -> select -> anchor-verified manifest -> exact chain. + + The order is the trust order (ADR-0019 section 9): the manifest's bytes are + verified against provider metadata before the document is parsed, and the + version chain is checked before anything is downloaded. + """ + + result = provider.enumerate(query) + candidate = select_candidate(result, query) + return candidate, fetch_manifest_for(provider, candidate) + + +__all__ = [ + "ReleaseQuery", "ReleaseCandidate", "EnumerationResult", "ManifestArtifact", + "ReleaseManifest", "ReleaseProvider", "canonical_version", "select_candidate", + "verify_external_anchor", "parse_manifest", "lifecycle_bundle_name", + "require_exact_version_chain", "resolve_manifest", "fetch_manifest_for", + "MANIFEST_SCHEMA", "MANIFEST_PROTOCOL", "BUNDLE_PROTOCOL_VERSION", + "ARTIFACT_KIND_LIFECYCLE", +] diff --git a/ainative/lifecycle/state.py b/ainative/lifecycle/state.py index c680e1c3..573ae310 100644 --- a/ainative/lifecycle/state.py +++ b/ainative/lifecycle/state.py @@ -25,7 +25,7 @@ from .errors import LifecycleError -SCHEMA_VERSION = 1 +SCHEMA_VERSION = 2 LIFECYCLE_DIRNAME = Path(".ai-native") / "lifecycle" STATE_RELATIVE = LIFECYCLE_DIRNAME / "state.json" @@ -152,6 +152,11 @@ class InstallState: source_version: str = "0.0.0" source_revision: str | None = None installed_components: list[str] = field(default_factory=list) + # V2 (ADR-0017): the optional project-scope features this project activates. + # Empty on a V1 state (the schema that predates the field) and migrated + # explicitly; never derived here, because the catalogue of features is not + # this module's to know. + active_features: list[str] = field(default_factory=list) managed_files: list[ManagedFile] = field(default_factory=list) last_transaction: str | None = None update_channel: str = "stable" @@ -189,6 +194,7 @@ def to_record(self) -> dict[str, Any]: sorted(self.managed_files, key=lambda item: (item.component, item.path))] record["installed_components"] = sorted(set(self.installed_components)) + record["active_features"] = sorted(set(self.active_features)) return record @classmethod @@ -210,6 +216,14 @@ def from_record(cls, raw: Any) -> "InstallState": if not isinstance(components, list) or not all(isinstance(i, str) for i in components): raise LifecycleError("INSTALL_STATE_CORRUPTED", "installed_components must be a list of strings") + # V1 (the schema before features) has no `active_features` and must keep + # loading; anything that is present but not a list of strings is corrupt, + # not "an old state": guessing is how a state naming nothing becomes a + # state pretending to name something. + features = raw.get("active_features", []) if version >= 2 else [] + if not isinstance(features, list) or not all(isinstance(item, str) for item in features): + raise LifecycleError("INSTALL_STATE_CORRUPTED", + "active_features must be a list of strings") files = raw.get("managed_files", []) if not isinstance(files, list): raise LifecycleError("INSTALL_STATE_CORRUPTED", "managed_files must be a list") @@ -227,6 +241,7 @@ def from_record(cls, raw: Any) -> "InstallState": source_version=str(raw.get("source_version", "0.0.0")), source_revision=raw.get("source_revision"), installed_components=list(components), + active_features=list(features), managed_files=[ManagedFile.from_record(item) for item in files], last_transaction=raw.get("last_transaction"), update_channel=str(raw.get("update_channel", "stable")), diff --git a/ainative/lifecycle/status.py b/ainative/lifecycle/status.py index 3442be31..55a054a6 100644 --- a/ainative/lifecycle/status.py +++ b/ainative/lifecycle/status.py @@ -12,6 +12,7 @@ from . import manifest as manifestlib from . import recovery as recoverylib +from . import release_source as release_sourcelib from . import state as statelib from . import updater as updaterlib from .manifest import Distribution @@ -28,6 +29,9 @@ class Status: active_profile: str | None previous_profile: str | None components: list[dict] = field(default_factory=list) + features: list[str] = field(default_factory=list) + features_projected: bool = False + release_source: dict = field(default_factory=dict) healthy: bool = True lifecycle_notes: list[str] = field(default_factory=list) verified: dict = field(default_factory=dict) @@ -42,6 +46,9 @@ def to_record(self) -> dict: "profile": self.active_profile, "previous_profile": self.previous_profile, "components": self.components, + "features": self.features, + "features_projected_from_legacy": self.features_projected, + "release_source": self.release_source, "lifecycle": {"healthy": self.healthy, "notes": list(self.lifecycle_notes), "findings": self.counts}, "verified": self.verified, @@ -55,6 +62,12 @@ def render(self) -> str: return "\n".join(lines) lines.append("Profile") lines.append(f" {self.active_profile}") + if self.installed: + lines.append("") + lines.append("Features") + lines.append(f" {', '.join(self.features) or 'none'}" + + (" (projected from the V1 state)" + if self.features_projected else "")) lines.append("") lines.append("Components") for item in self.components: @@ -75,6 +88,9 @@ def render(self) -> str: lines.append("") lines.append("Updates") lines.append(f" {updaterlib.notice_line(self.update)}") + if self.release_source: + lines.append("") + lines.extend(release_sourcelib.describe_lines(self.release_source)) return "\n".join(lines) @@ -158,10 +174,20 @@ def build(project: Path, *, distribution: Distribution | None = None, if diagnosis.lock and diagnosis.lock.get("stale_suspect"): notes.append("a lifecycle lock is present and may be stale") + from . import features as featureslib + from . import release_source as release_sourcelib + + # One projection for every reader (ADR-0017 section 4): `status` reports + # the effective feature set, exactly as `feature status` does. + effective = featureslib.project_install_state(state, distribution) + return Status( project=project, installed=True, stack_version=state.stack_version, active_profile=state.active_profile, previous_profile=state.previous_profile, components=_component_rows(distribution, state, diagnosis), + features=list(effective.active_features) if effective else [], + features_projected=bool(effective and effective.projected_from_legacy), + release_source=release_sourcelib.describe(), healthy=diagnosis.healthy, lifecycle_notes=notes, verified=_verified_section(project, distribution, state), update=updaterlib.cached_notice(project, allow_network=check_updates, diff --git a/ainative/lifecycle/transport.py b/ainative/lifecycle/transport.py index 193491a7..30272b73 100644 --- a/ainative/lifecycle/transport.py +++ b/ainative/lifecycle/transport.py @@ -66,6 +66,10 @@ class ReleaseProviderEndpointConfig: where the credential comes from and is resolved by the provider, not here. An endpoint with `auth_origin=None` is anonymous by construction: no credential can be attached whatever the environment holds. + + `auth_header` and `auth_prefix` exist because providers disagree on the + shape of authentication: GitHub wants `Authorization: Bearer `, + GitLab wants `PRIVATE-TOKEN: `. The default is GitHub's. """ provider: str @@ -73,6 +77,8 @@ class ReleaseProviderEndpointConfig: auth_origin: str | None = None api_version: str = "" credential_source: str = "" + auth_header: str = "Authorization" + auth_prefix: str = "Bearer" GITHUB_ENDPOINT = ReleaseProviderEndpointConfig( @@ -136,7 +142,8 @@ def _request_headers(endpoint: ReleaseProviderEndpointConfig, origin: str, *, if endpoint.api_version: headers["X-GitHub-Api-Version"] = endpoint.api_version if token and _may_receive(endpoint, origin): - headers["Authorization"] = f"Bearer {token}" + value = f"{endpoint.auth_prefix} {token}".strip() if endpoint.auth_prefix else token + headers[endpoint.auth_header] = value return headers diff --git a/ainative/lifecycle/updater.py b/ainative/lifecycle/updater.py index 5f61513c..6072e5a1 100644 --- a/ainative/lifecycle/updater.py +++ b/ainative/lifecycle/updater.py @@ -40,6 +40,7 @@ from . import manifest as manifestlib from . import planner as plannerlib from . import provider as providerlib +from . import release_v3 as release_v3lib from . import source as sourcelib from . import state as statelib from . import version as versionlib @@ -196,6 +197,55 @@ def checks_disabled(state: statelib.InstallState | None) -> bool: return not preferences.get("enabled", True) or not preferences.get("auto_check", True) +def _check_refusal(project: Path, current: str, error: LifecycleError, + record: bool) -> CheckResult: + """The check outcome for a refused source. Never fatal, never a traceback.""" + + if error.code == "CLI_UPDATE_REQUIRED": + # A future-protocol release IS available; this runtime cannot apply it. + target = error.detail.get("target_version") + result = _runtime_fields(CheckResult( + UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, + current, latest=target if isinstance(target, str) else None, + detail=error.message, checked_at=statelib.now())) + else: + status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED + result = _runtime_fields(CheckResult(status, current, detail=error.message, + checked_at=statelib.now())) + if record: + _write_cache(project, result) + return result + + +def resolve_v3_candidate(channel: str): + """(candidate, provider) for a V3 source, or None when the channel is V2-era. + + `None` means the source was exhausted and publishes no V3 candidate for the + channel — a V2-era mirror — and the caller speaks the V2 path to it. A + source that hit its enumeration bounds refuses instead: partial results are + never a reason to change generation (ADR-0019 section 7). + """ + + provider = providerlib.build_v3(channel) + if provider is None: + return None + result = provider.enumerate(release_v3lib.ReleaseQuery(channel)) + if not result.complete: + raise LifecycleError( + "RELEASE_ENUMERATION_INCOMPLETE", + "the release source answered a full page; its enumeration bounds " + "were reached before exhaustion") + if not any(candidate.channel == channel for candidate in result.candidates): + if not provider.supports_v2_fallback: + raise LifecycleError( + "RELEASE_NO_CANDIDATE", + f"the {channel!r} channel of this source is complete and holds " + "no V3 release") + return None + candidate = release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery(channel)) + return candidate, provider + + def check(project: Path, *, force: bool = False, allow_network: bool = True, record: bool = True, state: statelib.InstallState | None = None, source: DistributionSource | None = None, @@ -230,26 +280,19 @@ def check(project: Path, *, force: bool = False, allow_network: bool = True, if release is None: try: - release = providerlib.build(channel).latest(channel) + resolved = resolve_v3_candidate(channel) except LifecycleError as error: - if error.code == "CLI_UPDATE_REQUIRED": - # A future-protocol release IS available; this runtime cannot - # apply it. The old "no bundle to verify" answer sent users - # looking for a publishing defect instead of upgrading (#157). - target = error.detail.get("target_version") - result = _runtime_fields(CheckResult( - UPDATE_AVAILABLE if isinstance(target, str) else CHECK_FAILED, - current, latest=target if isinstance(target, str) else None, - detail=error.message, checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result - status = OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED - result = _runtime_fields(CheckResult(status, current, detail=error.message, - checked_at=statelib.now())) - if record: - _write_cache(project, result) - return result + return _check_refusal(project, current, error, record) + if resolved is not None: + # The version is all a check needs; the manifest is fetched only + # when an update is actually applied. + release = providerlib.Release(version=resolved[0].version, url=None, + digest=None) + else: + try: + release = providerlib.build(channel).latest(channel) + except LifecycleError as error: + return _check_refusal(project, current, error, record) newer = versionlib.is_newer(release.version, current) result = _runtime_fields(CheckResult(UPDATE_AVAILABLE if newer else UP_TO_DATE, current, @@ -379,7 +422,8 @@ def _safe_extract(payload: bytes, destination: Path) -> Path: return root -def _distribution_root(extracted: Path, release: providerlib.Release | None = None) -> Path: +def _distribution_root(extracted: Path, release: providerlib.Release | None = None, + *, expected_protocol: int | None = None) -> Path: """The payload root of an extracted bundle. A v2 bundle carries `lifecycle-protocol.json` at its root and the payload @@ -404,11 +448,13 @@ def _distribution_root(extracted: Path, release: providerlib.Release | None = No raise LifecycleError("UPDATE_INTEGRITY_FAILED", f"{providerlib.PROTOCOL_MANIFEST} is not an object") protocol_version = document.get("protocol_version") - if protocol_version != providerlib.UPDATE_PROTOCOL_VERSION: + expected = (expected_protocol if expected_protocol is not None + else providerlib.UPDATE_PROTOCOL_VERSION) + if protocol_version != expected: raise LifecycleError( "UPDATE_VERSION_MISMATCH", f"bundle declares lifecycle protocol {protocol_version!r}; this runtime " - f"speaks protocol {providerlib.UPDATE_PROTOCOL_VERSION}") + f"speaks protocol {expected}") declared = str(document.get("release_version", "")) if release is not None and declared != release.version: raise LifecycleError( @@ -491,6 +537,18 @@ def _require_matching_runtime(release: providerlib.Release) -> None: upgrade_command=upgrade_command(release.version)) +def _degraded_apply(project: Path, state: statelib.InstallState, + error: LifecycleError, dry_run: bool) -> UpdateResult: + """The update outcome when the source could not be consulted.""" + + outcome = _runtime_fields(CheckResult( + OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, + state.stack_version, detail=error.message, checked_at=statelib.now())) + if not dry_run: + _write_cache(project, outcome) + return UpdateResult(False, dry_run, state.stack_version, None, outcome) + + def apply(project: Path, *, dry_run: bool = False, force: bool = False, distribution: manifestlib.Distribution | None = None) -> UpdateResult: """resolve -> runtime gate -> check -> fetch -> verify -> apply -> commit. @@ -509,22 +567,26 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, f"no AI Native installation recorded in {project}") channel = state.update_preferences.get("channel", "stable") or "stable" - provider = providerlib.build(channel) + v3 = None try: - release = provider.latest(channel) + v3 = resolve_v3_candidate(channel) except LifecycleError as error: - # An unreachable or empty source degrades gracefully, exactly as - # `check` reports it. A source that answered with something this stack - # refuses (mismatched bundle version, missing digest) is not degraded - # connectivity - it is a refusal, and it propagates. + # An unreachable source degrades gracefully, exactly as `check` + # reports it; anything a source answered with something this stack + # refuses is a refusal, and it propagates. if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): raise - outcome = _runtime_fields(CheckResult( - OFFLINE if error.code == "UPDATE_CHECK_FAILED" else CHECK_FAILED, - state.stack_version, detail=error.message, checked_at=statelib.now())) - if not dry_run: - _write_cache(project, outcome) - return UpdateResult(False, dry_run, state.stack_version, None, outcome) + return _degraded_apply(project, state, error, dry_run) + if v3 is not None: + release = providerlib.Release(version=v3[0].version, url=None, digest=None) + else: + provider = providerlib.build(channel) + try: + release = provider.latest(channel) + except LifecycleError as error: + if force or error.code not in ("UPDATE_CHECK_FAILED", "UPDATE_UNAVAILABLE"): + raise + return _degraded_apply(project, state, error, dry_run) # Availability first, runtime contract second: a project already at the # newest release has nothing to apply, so a runtime that differs is not @@ -543,13 +605,24 @@ def apply(project: Path, *, dry_run: bool = False, force: bool = False, return UpdateResult(applied=False, dry_run=dry_run, from_version=state.stack_version, to_version=outcome.latest, check=outcome) - payload = provider.fetch(release) - providerlib.verify_archive(payload, release.digest) + protocol_version = providerlib.UPDATE_PROTOCOL_VERSION + if v3 is not None: + candidate, v3provider = v3 + manifest = release_v3lib.fetch_manifest_for(v3provider, candidate) + artifact = manifest.lifecycle_artifact() + payload = v3provider.fetch_artifact(candidate, artifact) + release_v3lib.verify_external_anchor(payload, sha256=artifact.sha256, + size=artifact.size) + protocol_version = release_v3lib.BUNDLE_PROTOCOL_VERSION + else: + payload = provider.fetch(release) + providerlib.verify_archive(payload, release.digest) staging = Path(tempfile.mkdtemp(prefix="ainative-update-", dir=str(_staging_root(project)))) try: - root = _distribution_root(_safe_extract(payload, staging), release) + root = _distribution_root(_safe_extract(payload, staging), release, + expected_protocol=protocol_version) staged_source = DistributionSource(root=root.resolve(), origin="update", version=sourcelib.read_version(root)) # The release named a version; the bytes name another one. SHA-256 diff --git a/ainative/observation.py b/ainative/observation.py new file mode 100644 index 00000000..2878a5e5 --- /dev/null +++ b/ainative/observation.py @@ -0,0 +1,131 @@ +"""Locally observed forge facts: remotes, and what they support. + +Read-only by construction: the only subprocess is `git remote`, the only inputs +are the pure resolver, the install state and the claim journal. No network, no +credentials, no writes, no persistent trust (PR-3, #164). Diagnostic only — an +ambiguity is rendered, never resolved by preference. +""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from . import claims +from . import forge as forgelib +from .lifecycle import environment +from .lifecycle import features as featureslib +from .lifecycle import manifest as manifestlib +from .lifecycle import state as statelib +from .lifecycle.errors import LifecycleError + +RESOLVED = "RESOLVED" +AMBIGUOUS = "AMBIGUOUS" +UNAVAILABLE = "UNAVAILABLE" +MISMATCH = "MISMATCH" + +_RESOLUTION_STATES = { + "WORK_AUTHORITY_AMBIGUOUS": AMBIGUOUS, + "WORK_AUTHORITY_UNAVAILABLE": UNAVAILABLE, + "WORK_AUTHORITY_MISMATCH": MISMATCH, +} + + +def read_remotes(project: Path) -> tuple[forgelib.ObservedRemote, ...]: + """The project's Git remotes, or () when there are none / it is not a repo.""" + + names = _git(project, "remote") + if not names: + return () + remotes = [] + for name in names.splitlines(): + name = name.strip() + if not name: + continue + url = _git(project, "remote", "get-url", name) + if url: + remotes.append(forgelib.ObservedRemote(name=name, url=url.strip())) + return tuple(remotes) + + +def _git(project: Path, *arguments: str) -> str | None: + try: + completed = subprocess.run(["git", "-C", str(project), *arguments], + capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + return None + if completed.returncode != 0: + return None + return completed.stdout.strip() + + +def forge_picture(project: Path, *, explicit=None, declared=None) -> dict: + """Observed remotes plus the resolver's answer, as a renderable state.""" + + observed = read_remotes(project) + record = {"git": environment.git_root(project) is not None, + "remotes": [remote.to_record() for remote in observed], + "resolution": {"state": UNAVAILABLE, "authority": None, "detail": ""}} + try: + authority = forgelib.resolve_observed_work_authority( + explicit=explicit, declared=declared, remotes=observed) + except LifecycleError as refusal: + record["resolution"]["state"] = _RESOLUTION_STATES.get(refusal.code, UNAVAILABLE) + record["resolution"]["detail"] = refusal.message + return record + record["resolution"] = {"state": RESOLVED, "authority": authority.to_record(), + "detail": ""} + return record + + +def project_view(project: Path) -> dict: + """What `forge status` and doctor display: features, forge, claim attempts.""" + + distribution = manifestlib.load() + effective = featureslib.project_install_state(statelib.load(project), distribution) + return { + "features": {"active": list(effective.active_features) if effective else [], + "projected_from_legacy": bool( + effective and effective.projected_from_legacy)}, + "forge": forge_picture(project), + "claim_attempts": _claim_summary(project), + } + + +def doctor_lines(view: dict) -> list[str]: + """The doctor extension as text lines (PR-3 / #164). Never a credential.""" + + features = view["features"] + resolution = view["forge"]["resolution"] + authority = resolution.get("authority") or {} + summary = view["claim_attempts"] + lines = [ + "Features", + " active: " + (", ".join(features["active"]) or "none") + + (" (projected from the V1 state)" if features["projected_from_legacy"] else ""), + "Work authority (observed)", + " state: " + resolution["state"] + + (f" {authority.get('provider')}:{authority.get('project')}" if authority else ""), + ] + for remote in view["forge"]["remotes"]: + lines.append(f" {remote['name']:<10} {remote['provider']:<8} " + f"{remote['project'] or '-'}") + if summary["journal"] == "ok": + lines.append(f"Claim attempts: {len(summary['unresolved'])} unresolved") + else: + lines.append(f"Claim attempts: journal unavailable ({summary['detail']})") + return lines + + +def _claim_summary(project: Path) -> dict: + try: + pending = [attempt.to_record() for attempt in claims.unresolved(project)] + except LifecycleError as error: + # An unreadable journal is a fact to display, not a crash: doctor must + # keep diagnosing the rest, and the refusal is visible in the output. + return {"journal": "unavailable", "unresolved": [], "detail": error.message} + return {"journal": "ok", "unresolved": pending, "detail": ""} + + +__all__ = ["RESOLVED", "AMBIGUOUS", "UNAVAILABLE", "MISMATCH", + "read_remotes", "forge_picture", "project_view", "doctor_lines"] diff --git a/docs/DISTRIBUTION-LIFECYCLE.md b/docs/DISTRIBUTION-LIFECYCLE.md index 0a01c9ab..72670b7a 100644 --- a/docs/DISTRIBUTION-LIFECYCLE.md +++ b/docs/DISTRIBUTION-LIFECYCLE.md @@ -731,3 +731,36 @@ report whether machine integration is present. The machine surface has no project ownership state and no transaction journal; re-running the installer *is* the update path. + + +## 18. Features (project-scope capabilities) + +A profile is a governance level; a feature is an optional project-scope +capability. They are independent (ADR-0017): a `verified` project can be +Generic Git, and a `standard` project can work with GitLab. + +```bash +ainative feature status +ainative feature enable forge-gitlab +ainative feature disable forge-gitlab +ainative feature switch forge-gitlab # one transaction: disable + enable +ainative feature switch none # Generic Git: no work forge +``` + +At most one **work forge** may be active. `forge-github` (the default) conflicts +with `forge-gitlab`; enabling a conflicting feature refuses with the remedy +(`feature switch`), and a state that names two work forges refuses as +`STATE_CONFLICTING_WORK_FORGE_FEATURES` rather than picking one. + +State schema V2 records `active_features`. A V1 state has no such field and +projects to `forge-github` — the compatibility default, read-only. The +migration happens inside the next mutation, state-last like every other state +write, and plans no file change of its own: a previously managed file that is +already absent stays absent. Only an explicit feature transition seeds files. + +Feature files follow the same ownership rules as everything else: enabling +seeds absent files, disabling removes only unchanged files and preserves files +the user modified, and re-enabling never overwrites a user file. One difference +from profile components is deliberate: ordinary `init`/`update` maintenance +never re-seeds an absent feature file — a file you removed stays removed until +a feature transition puts it back. diff --git a/docs/FORGE-WORKFLOW.md b/docs/FORGE-WORKFLOW.md new file mode 100644 index 00000000..b6bc9706 --- /dev/null +++ b/docs/FORGE-WORKFLOW.md @@ -0,0 +1,262 @@ +# Forge Workflow — provider-neutral work management + +How AI agents and humans turn ideas into merged, verified work when the work +authority may be GitHub, GitLab, or nothing at all (Generic Git). This file is +the canonical statement of the *generic* rules; each provider's mechanics live +in its mapping — [`GITHUB-WORKFLOW.md`](GITHUB-WORKFLOW.md), +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md) — and where a mapping is silent, this +file decides. The architecture review for this document is closed; change it +through an Issue, not by silently editing policy. + +## Vocabulary + +```text +Work Authority the remote whose work records are authoritative for a project: + one provider + one project identity. Not a URL, not a hostname. +WorkItem the unit of work on that authority (GitHub Issue, GitLab WorkItem) +ChangeRequest the reviewable change (GitHub PR, GitLab merge request) +WorkAuthorityRef provider + stable project identity (never guessed from a hostname) +Claim an agent's remote signal that it owns a WorkItem +``` + +One provider, one project, at most: a project has **one** work authority. Two +candidates that disagree (a fork's `origin` and `upstream`) are *ambiguity*, and +ambiguity stops remote mutation — see "Work Authority resolution" below. + +## Where state lives + +```text +Work authority records = canonical actionable backlog (WorkItems and their state) +Provider metadata = labels, milestones, review state (per mapping) +ADRs = accepted architecture (in the repository) +Work Contracts = deterministic verification when policy requires them +Vault / Obsidian = historical context only +Skills = procedures +``` + +AI Native never stores project-specific backlog state: no `issues.json`, no +current-milestone file, no vault board mirroring the authority. If the work +authority is unreachable, the backlog is unreachable with it — that is the +point: one authoritative source per fact. + +### WorkItem vs ADR + +An ADR records an architecture decision already made. A WorkItem records work +that should happen. "Refactor X per ADR-0007" may be a WorkItem; disagreeing +with ADR-0007 is not — it is a proposal to amend the ADR, handled like any +architectural change (WorkItem first, then a deliberate ADR update). + +### WorkItem vs Work Contract + +A Verified Work Contract is the deterministic proof side of one unit of work: +constrained verification, evidence provenance, convergence. It does not +replace the WorkItem; it verifies it. When repository policy requires Verified +work, the issue-to-implementation skill creates or updates the contract after +the claim and binds the WorkItem's acceptance criteria into it. + +### WorkItem vs Vault memory + +The Vault keeps session summaries, research, postmortems, decision context and +links (WorkItem, ChangeRequest, ADR). It must never hold canonical current +state: no current backlog, no status mirrors, no live Kanban, no assignee +snapshots. No bidirectional Vault ↔ authority synchronization exists or should +exist. + +## Lifecycle of one unit of work + +```text +finding --> triage --> WorkItem (or rejected / Discussion / ADR candidate) + | + v + issue-to-implementation + claim -> branch -> implement -> validate + | + v + ChangeRequest with "Refs #N" + | + v + MERGE_READY (all conditions) + | + v + Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS + | + v + merge (authorized process) + | + v + WorkItem closed as completed --> provider state = Done +``` + +### Triage + +Ideas, bugs, audit findings and research observations are candidates, not +backlog. Triage validates, deduplicates, classifies and decides: WorkItem, +Discussion, ADR candidate, or reject. Only useful, actionable work is +persisted. Triage never implements code. + +### Implementation + +One skill owns the path from a claimed WorkItem to a merged ChangeRequest. Its +invariants: + +1. **Claim before working.** See "Claims" below. +2. **Scope is the WorkItem.** Implement only its canonical scope. +3. **Acceptance Criteria are protected.** An implementation agent MUST NOT + weaken, remove, replace or materially reinterpret acceptance criteria to + make its implementation pass. It MAY detect ambiguity, identify + infeasibility, propose a change or request clarification — but a material + change becomes authoritative only after explicit approval from an + authorized maintainer **and** persistence in the WorkItem. Material = + required behavior, functional scope, security requirements, performance + thresholds, supported platforms, failure behavior, public API/contract, + acceptance thresholds. If uncertain, treat it as material and ask. There is + no separate AC database: the WorkItem body is the only home of the AC. +4. **Policy conflicts stop work.** Root `AGENTS.md` is the repository baseline; + local `AGENTS.md` files specialize or strengthen it and must never silently + weaken it. A real contradiction is surfaced as a visible conflict — never + resolved silently. +5. **Refs while working, Closes when ready.** Open and update ChangeRequests + with `Refs #N` during development. `Closes #N` appears only once + MERGE_READY is satisfied, and only immediately before the merge. + +After canonical scope and AC are bound, implementation choices use +`implementation-economy`; it cannot alter scope or acceptance criteria. + +### MERGE_READY + +`MERGE_READY` is a checklist, not a merge: + +```text +MERGE_READY = + current WorkItem acceptance criteria satisfied + + required tests/checks pass + + documentation updated where required + + review requirements satisfied + + no relevant blocker + + Verified CONVERGED when policy requires it + + WorkItem scope/AC freshness confirmed +``` + +### FINAL_MERGE_FRESHNESS + +Immediately before the actual merge, re-read the WorkItem and its current +acceptance criteria and compare against the latest validated snapshot (Verified +work: compare the canonical AC digest with the digest bound in the Work +Contract). A material difference invalidates MERGE_READY: `ISSUE_CHANGED` — do +not merge, do not close, reconcile scope and verify again. This is not an +atomic remote transaction; it is the freshest possible pre-merge check. It +exists because the WorkItem is shared state and another actor may have changed +it while the ChangeRequest was in review. + +### DONE + +```text +DONE = MERGE_READY was valid + + ChangeRequest merged + + WorkItem closed as completed + + provider state = Done (when the mapping defines one) +``` + +Closed is not Done: WorkItems are closed as duplicate, not-planned, invalid or +superseded too. Only "closed as completed" after a merged ChangeRequest counts. +If the provider exposes a completion state but the automation lacks permission +to update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done +silently. + +## Work Authority resolution + +Reading and mutating remote state is the harness's job (it holds the provider +tooling and the credentials). `ainative` adds one pure local resolver, +`resolve_observed_work_authority()` (`ainative/forge.py`): no network, no +credentials, no writes, no push authorization. Priority: + +```text +1. an explicit WorkItem/ChangeRequest repository reference +2. an explicit harness-declared authority +3. exactly one compatible observed candidate +4. otherwise: WORK_AUTHORITY_UNAVAILABLE | WORK_AUTHORITY_AMBIGUOUS | WORK_AUTHORITY_MISMATCH +``` + +Observed candidates are diagnostic only. `github.com` and `gitlab.com` are the +only hosts whose provider is a fact; a self-hosted host is `unknown`, never +inferred from its name. When the resolution is ambiguous, remote mutation stops +— diagnostics may continue. + +## Claims + +There is no distributed lock and no lock service. A claim is a remote signal +and a local record: + +- **Preferred signal:** assignment. **Fallback:** an explicit claim comment or + note. The mapping names the provider's exact signals. +- **Identity is canonical**, never local formatting: + `:principal:` for the claimant, + `::` for the event (`comment`, `note`, + `assignment`). Timestamps are normalized to UTC. Winner ordering is the total + order `(created_at_utc, canonical_event_identifier)`; an unorderable pair is + `CLAIM_CONFLICT` and every claimant stops. +- **Before claiming**, read the WorkItem and any linked active ChangeRequest: a + linked open implementation ChangeRequest is a claim-level conflict + (`ACTIVE_PR_CONFLICT`) — stop, unless a maintainer requested a competing + attempt or the ChangeRequest was explicitly abandoned. +- **Journal before POST.** `ainative claim-attempt` writes a PENDING record + under `.ai-native/state/claim-attempts/` *before* the remote signal, with a + deterministic marker the signal must carry. A journal that cannot be written + is `CLAIM_JOURNAL_UNAVAILABLE` and no signal is sent. +- **Outcomes:** `CONFIRMED`, `LOST`, `CONFLICT`, `UNCERTAIN`, `ABANDONED`. An + unknown POST result is `UNCERTAIN` and is **never** retried automatically: + the marker search on the remote is the only resolution. A corrupt journal + fails closed. +- **Abandonment is explicit:** `ainative claim-attempt abandon --confirm` + is a local transition that keeps the record; only then may a new attempt be + created. No leases, no heartbeats, no age-based expiration: time is not + authority. + +## Review scope + +A ChangeRequest review checks: the WorkItem's acceptance criteria, the relevant +`AGENTS.md` policy, the relevant ADRs, and regressions the change introduces or +worsens. Anything else found during review is handled by priority and never +expands the current change: + +```text +P0/P1 -> separate WorkItem / escalation; may block a release globally, + does not expand the change +P2/P3 -> backlog candidate; does not expand the change +``` + +Only a finding caused or worsened by the current change — or one that directly +makes its merge unsafe — may block that change. + +## Deferred work + +Significant deferred work must not live only in chat, a temporary plan, or +historical memory. If it is actionable and worth retaining, it becomes a +WorkItem; if it was explicitly rejected, no WorkItem is created. + +## Branch naming + +Recommended, where the provider allows free branch names: + +```text +feat/-description fix/-description +docs/-description refactor/-description +chore/description # truly trivial internal work, no WorkItem required +``` + +Naming is a convention, not an enforcement mechanism. + +## Mappings + +| Concept | GitHub | GitLab | +|---|---|---| +| WorkItem | Issue | WorkItem (issue) | +| ChangeRequest | Pull request | Merge request | +| Claim signal | assignment, issue comment | assignee, note | +| Claim event id | comment/assignment event id | note/assignment id | +| Review state | PR review | MR approval | +| Templates | `templates/github/` → `.github/` | `templates/gitlab/` → `.gitlab/` | + +Each mapping document records its provider's exact mechanics and points back +here for the rules. `github-templates` and `gitlab-templates` are the two +feature components that install them (ADR-0017). diff --git a/docs/GITHUB-WORKFLOW.md b/docs/GITHUB-WORKFLOW.md index 71663569..1fc3f617 100644 --- a/docs/GITHUB-WORKFLOW.md +++ b/docs/GITHUB-WORKFLOW.md @@ -1,26 +1,26 @@ -# GitHub Workflow — AI-centered work management +# GitHub Workflow — the GitHub.com mapping -How AI agents and humans turn ideas into merged, verified work using GitHub as -the canonical active state. The architecture review for this document is -closed; change it through an issue, not by silently editing policy. +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, +FINAL_MERGE_FRESHNESS, DONE, review scope, deferred work, branch naming — live +in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md), with the GitLab mapping in +[`GITLAB-WORKFLOW.md`](GITLAB-WORKFLOW.md). This file records only what is +GitHub-specific. The architecture review for this document is closed; change +it through an Issue, not by silently editing policy. -## Where state lives +## Where GitHub state lives ```text GitHub Issues = canonical actionable backlog GitHub Project = operational visualization when configured Milestones = delivery grouping -ADRs = accepted architecture -Work Contracts = deterministic verification when policy requires them -Vault / Obsidian = historical context only -Skills = procedures (github-triage, issue-to-implementation, - implementation-economy) ``` AI Native never stores project-specific backlog state. There is no -`issues.json`, no `current-milestone.json`, no vault board that mirrors GitHub. -If GitHub is unreachable, the backlog is unreachable with it — that is the -point: one authoritative source per fact. +`issues.json`, no `current-milestone.json`, no vault board that mirrors +GitHub. If GitHub is unreachable, the backlog is unreachable with it — that is +the point: one authoritative source per fact. (The generic statement, and the +WorkItem vs ADR / Work Contract / Vault memory rules, are in +`FORGE-WORKFLOW.md`.) ### Issue vs Project @@ -33,9 +33,9 @@ canonical operational fields: | Status | `Inbox`, `Backlog`, `Ready`, `In Progress`, `Done` | | Area | project-specific metadata (subsystem, domain) | -Do not add `Priority`, `Type`, `Effort`, or `Review` fields in v1. Type and -priority are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with -the Issue everywhere, Project fields do not. PR state stays GitHub's native PR +Do not add `Priority`, `Type`, `Effort`, or `Review` fields: Type and priority +are Issue labels (`type:bug`, `priority:P1`, ...) — labels travel with the +Issue everywhere, Project fields do not. PR state stays GitHub's native PR state; it is not a Project column. A repository without a Project loses nothing but the visualization. Every @@ -43,157 +43,26 @@ skill must degrade gracefully when no Project exists: Issues, labels and PRs are the whole workflow for small repositories. Never fail because a Project is absent. -### Issue vs ADR - -An ADR records an architecture decision already made. An Issue records work -that should happen. "Refactor X per ADR-0007" may be an Issue; disagreeing -with ADR-0007 is not — it is a proposal to amend the ADR, handled like any -architectural change (issue first, then a deliberate ADR update). - -### Issue vs Work Contract - -A Verified Work Contract is the deterministic proof side of one unit of work: -constrained verification, evidence provenance, convergence. It does not -replace the Issue; it verifies it. When repository policy requires Verified -work, the issue-to-implementation skill creates or updates the contract after -the claim and binds the Issue's acceptance criteria into it. - -### Issue vs Vault memory - -The Vault keeps session summaries, research, postmortems, decision context and -links (Issue, PR, ADR). It must never hold canonical current state: no current -backlog, no Issue status mirrors, no live Kanban, no assignee snapshots. No -bidirectional Vault <-> GitHub synchronization exists or should exist. - -## Lifecycle of one unit of work +## Claim signals ```text -finding --> github-triage --> Issue (or rejected / Discussion / ADR candidate) - | - v - issue-to-implementation - claim -> branch -> implement -> validate - | - v - PR with "Refs #N" - | - v - MERGE_READY (all conditions) - | - v - Refs #N -> Closes #N --> FINAL_MERGE_FRESHNESS - | - v - merge (authorized process) - | - v - Issue closed as completed --> Project Status = Done +preferred: assign the Issue to the claiming account +fallback: an explicit Issue comment: + "Claiming this Issue for implementation." ``` -### Triage (skills/github-triage) - -Ideas, bugs, audit findings and research observations are candidates, not -backlog. Triage validates, deduplicates, classifies (`type:*`, `priority:P*`) -and decides: Issue, Discussion, ADR candidate, or reject. Only useful, -actionable work is persisted. P0/P1 validated findings may be escalated into -Issues when authorized; P2 findings may become Issues when useful; P3 and -research notes are not automatically created as Issues. Triage never -implements code. - -### Implementation (skills/issue-to-implementation) - -One skill owns the path from a claimed Issue to a merged PR. Its invariants: - -1. **Claim before working.** See "Multi-agent claims" below. -2. **Scope is the Issue.** Implement only the Issue's canonical scope. -3. **Acceptance Criteria are protected.** An implementation agent MUST NOT - weaken, remove, replace or materially reinterpret an Issue's Acceptance - Criteria to make its implementation pass. It MAY detect ambiguity, - identify infeasibility, propose a change or request clarification — but a - material AC change becomes authoritative only after explicit approval from - an authorized maintainer/user **and** persistence in the GitHub Issue. - Material changes include: required behavior, functional scope, security - requirements, performance thresholds, supported platforms, failure - behavior, public API/contract, acceptance thresholds. If uncertain, treat - the change as material and ask. There is no separate AC database: the - Issue body is the only home of the AC. -4. **Policy conflicts stop work.** Root AGENTS.md is the repository baseline; - local AGENTS.md files specialize or strengthen it and must never silently - weaken it. A real contradiction is surfaced as a visible conflict (on the - Issue when working from one: conflicting files, conflicting rules, why - implementation cannot proceed safely) — never resolved silently. ADRs and - AGENTS.md govern different domains; a genuine contradiction between them - requires reconciliation, not arbitrary precedence. -5. **Refs while working, Closes when ready.** Open and update PRs with - `Refs #N` during development. `Closes #N` appears only once MERGE_READY is - satisfied, and only immediately before the merge. - -After canonical scope and AC are bound, implementation choices use -`implementation-economy`; it cannot alter Issue scope or Acceptance Criteria. - -### MERGE_READY - -`MERGE_READY` is a checklist, not a merge: - -```text -MERGE_READY = - current Issue acceptance criteria satisfied - + required tests/checks pass - + documentation updated where required - + review requirements satisfied - + no relevant blocker - + Verified CONVERGED when policy requires it - + Issue scope/AC freshness confirmed -``` - -### FINAL_MERGE_FRESHNESS - -Immediately before the actual merge, re-read the Issue and its current -acceptance criteria and compare against the latest validated snapshot -(Verified work: compare the canonical AC digest with the digest bound in the -Work Contract). A material difference invalidates MERGE_READY: -`ISSUE_CHANGED` — do not merge, do not close, reconcile scope and verify -again. - -This is not an atomic GitHub transaction; it is the freshest possible -pre-merge check. It exists because the Issue is shared state and another -actor may have changed it while the PR was in review. - -### DONE - -```text -DONE = MERGE_READY was valid - + PR merged - + Issue closed as completed - + Project Status = Done (when a Project is configured) -``` - -Closed is not Done. Issues are closed as `duplicate`, `not planned`, -`invalid` or `superseded` too — only "closed as completed" after a merged PR -counts as Done. If a Project exists but the automation lacks permission to -update it, report `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done -silently. - -## Multi-agent claims - -There is no distributed lock and no lock service. The claim is GitHub state: - -- **Preferred:** GitHub Issue assignment. -- **Fallback** (actor cannot assign): an explicit Issue claim comment, e.g. - "Claiming this Issue for implementation." - Valid claim signals normalize to: `actor`, `created_at`, `stable_github_identifier` (comment or assignment event ID), `claim_kind`. -Before claiming, also read the open PRs that reference or implement the -Issue (PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit +Before claiming, also read the open PRs that reference or implement the Issue +(PR body `Refs`/`Closes`/`Fixes`, issue timeline links, or an explicit "implements #N"): **an active linked implementation PR is a claim-level conflict** (`ACTIVE_PR_CONFLICT` -> STOP). It lifts only when explicit: a maintainer requested a competing implementation or collaboration, the PR was explicitly abandoned or superseded, or the same actor is continuing their own -implementation. Age alone never proves abandonment; an ambiguous -relationship is surfaced, not duplicated around. After claiming, re-read the -Issue — the claim is only as good as the freshest read, and a PR that -appeared during the race re-triggers the conflict. +implementation. Age alone never proves abandonment; an ambiguous relationship +is surfaced, not duplicated around. After claiming, re-read the Issue — the +claim is only as good as the freshest read, and a PR that appeared during the +race re-triggers the conflict. Resolution (implemented by `skills/issue-to-implementation/bin/claim_resolution.py`): @@ -211,53 +80,42 @@ unorderable -> CLAIM_CONFLICT -> every claimant STOPs ``` Assignment preference is an acquisition rule, not an arbitration override. -Stale-claim expiration, stale-PR expiration, heartbeats and leases are out -of scope in v1. - -## Review scope - -A PR review checks: the Issue's acceptance criteria, the relevant AGENTS.md -policy, the relevant ADRs, and regressions the PR introduces or worsens. -Anything else found during review is handled by priority and never expands -the current PR: +Stale-claim expiration, stale-PR expiration, heartbeats and leases are out of +scope in v1 (issue #33 stays independent). The journal-before-POST rule and +the recovery surface (`ainative claim-attempt`) are in `FORGE-WORKFLOW.md`. -```text -P0/P1 -> separate Issue / escalation; may block a release globally, - does not expand the PR -P2/P3 -> backlog candidate (github-triage); does not expand the PR -``` - -Only a finding caused or worsened by the current PR — or one that directly -makes its merge unsafe — may block that PR. - -## Deferred work - -Significant deferred work must not live only in chat, a temporary plan, or -historical memory. If it is actionable and worth retaining, it becomes a -GitHub Issue; if it was explicitly rejected, no Issue is created. +## Implementation, on GitHub -## Branch naming +The generic invariants — claim before working, scope is the Issue, protected +acceptance criteria, policy conflicts stop work, `Refs` while working and +`Closes` only when ready — are stated once in `FORGE-WORKFLOW.md`. After scope +and AC are bound, implementation choices use `implementation-economy`; its +limits are stated there too. -Recommended: +## MERGE_READY and DONE, on GitHub -```text -feat/123-description fix/123-description -docs/123-description refactor/123-description -chore/description # truly trivial internal work, no Issue required -``` +`MERGE_READY` and `FINAL_MERGE_FRESHNESS` are defined once in +`FORGE-WORKFLOW.md`; on GitHub the linkage mechanics are: -Naming is a convention, not an enforcement mechanism, in v1. +- PRs start with `Refs #N`; `Closes #N` appears only once MERGE_READY holds, + only immediately before the merge; +- DONE additionally requires the Project Status to be `Done` when a Project is + configured; +- if the automation lacks permission to update the Project, report + `PROJECT_STATUS_SYNC_REQUIRED` instead of claiming Done silently. ## Templates Generic templates ship with the stack (`templates/github/`) and are installed -as managed files (individual files, never the whole `.github/` directory): +as managed files (individual files, never the whole `.github/` directory) by +the `github-templates` component, owned by the `forge-github` feature +(ADR-0017): - `.github/ISSUE_TEMPLATE/bug.md` — Problem, Reproduction, Expected outcome, Acceptance criteria, Affected surface (triage hint only; Project Area is canonical when a Project is used). -- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, - Acceptance criteria, Out of scope. +- `.github/ISSUE_TEMPLATE/feature.md` — Problem, Expected outcome, Acceptance + criteria, Out of scope. - `.github/PULL_REQUEST_TEMPLATE.md` — What changed, Why, Refs #, Validation, Risk. It deliberately contains no `Closes`: development starts with `Refs`. @@ -266,3 +124,9 @@ is preserved; a stack-installed template the user modified is preserved and reported as a conflict; a stack-installed, unmodified template may be updated or removed by update/uninstall. See `tests/test_lifecycle_github_templates.py` for the executable contract. + +## Review scope + +The generic rule (only findings caused or worsened by the current change may +block it; P0/P1 escalate separately, P2/P3 become backlog candidates) is stated +once in `FORGE-WORKFLOW.md`. diff --git a/docs/GITLAB-WORKFLOW.md b/docs/GITLAB-WORKFLOW.md new file mode 100644 index 00000000..33dddbf0 --- /dev/null +++ b/docs/GITLAB-WORKFLOW.md @@ -0,0 +1,55 @@ +# GitLab Workflow — the GitLab.com mapping + +The generic rules — vocabulary, lifecycle, claims, MERGE_READY, DONE, review +scope, deferred work — live in [`FORGE-WORKFLOW.md`](FORGE-WORKFLOW.md). This +file records only what is GitLab-specific. GitLab Self-Managed is not a +declared V1 target: it stays `UNTESTED` until qualified (ADR-0019). + +## Records + +| Generic concept | GitLab record | +|---|---| +| WorkItem | issue (work item) | +| ChangeRequest | merge request | +| Backlog state | labels (e.g. `type:*`, `priority:*`), milestones, boards | +| Review state | MR approval; merge when pipeline succeeds, per project settings | +| Completion | issue closed; board/list membership | + +GitLab calls its unit of work a *work item* natively; the generic vocabulary +uses the same word deliberately — the mapping is nearly identity for records, +and the interesting differences are claim signals and templates. + +## Claim signals + +```text +preferred: assign the issue to the claiming account +fallback: a note on the issue: "Claiming this work item for implementation." +``` + +The canonical event kinds are `note` (GitLab notes are comments) and +`assignment`. A claim event's stable identifier is the note's id or the +assignee-change event id; the claimant principal is +`gitlab:principal:` (the numeric user id, not the username — +a username can be renamed). Timestamps arrive with a zone; normalize with +`to_utc`. + +The resolution rule itself (earliest valid claim, deterministic ordering) is in +`FORGE-WORKFLOW.md`; no GitLab-specific resolution exists. + +## Templates + +`gitlab-templates` (feature `forge-gitlab`, ADR-0017) installs individual +managed files from `templates/gitlab/`: + +```text +.gitlab/issue_templates/bug.md +.gitlab/issue_templates/feature.md +.gitlab/merge_request_templates/default.md +``` + +They are `MANAGED_MUTABLE`: a re-install updates a file still holding the +bytes the stack wrote, preserves a file the user edited (as a conflict), and +never adopts a file it did not write. The GitLab provider itself (release +distribution through the GitLab Releases API and the Generic Package Registry) +is defined by ADR-0019 and implemented by PR-5; this document covers work +management, not release distribution. diff --git a/docs/MULTIFORGE-QUALIFICATION.md b/docs/MULTIFORGE-QUALIFICATION.md new file mode 100644 index 00000000..4b97e19a --- /dev/null +++ b/docs/MULTIFORGE-QUALIFICATION.md @@ -0,0 +1,100 @@ +# Multi-Forge qualification report + +**Base commit:** `multiforge` head `6f4946b` plus the documentation commit that +carries this report. **Architecture:** frozen (Multi-Forge v1.3.2-final; +ADR-0017/0018/0019). **Execution:** local, issue-driven; the full CI matrix +runs when the branch is proposed for `dev`. + +This report states what was verified, with which evidence, and what was not. +Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). + +## What was delivered + +| Stream | Deliverable | Evidence | +|---|---|---| +| PR-0A | Release transport credential confinement (`transport.py`) | 23 tests; characterization probe recorded pre-fix | +| ADR-MF-01/02/03 | ADR-0017/0018/0019 accepted | merged documents | +| PR-0B | V2 forward bridge (`CLI_UPDATE_REQUIRED`) + publication gate | 5 tests; `scripts/check_bridge_release.py` + 9 gate tests | +| PR-1 | Feature model, State V2, atomic switching, GitLab templates | 34 tests + 309-suite regression | +| PR-2 | Work Authority resolver, claim grammar, journal, policy docs, distributed template | 27 tests; policy purity tests | +| PR-3 | `forge detect/status`, doctor extension, status parity | 7 observation tests | +| PR-4 | Source resolver, ReleaseManifest V3, providers, updater wiring | 19 + 32 + 16 + 5 tests | +| PR-5 | GitLab provider (V3 contract), purity tests | 10 provider tests + 7 purity tests | + +## Platform matrix + +| Platform | Status | +|---|---| +| Windows (local) | GREEN — the full local suite (314 lifecycle + CLI/knowledge/machine suites) | +| Linux, macOS | PENDING — CI runs on the `multiforge` → `dev` proposal; not yet executed | + +## Forge qualification + +| Tuple | Status | +|---|---| +| Generic Git | GREEN — `feature switch none`, zero-feature states valid, observation resolves `UNAVAILABLE` | +| GitHub.com | GREEN for the release source (existing qualified releases + provider contract tests); GREEN for work management (mapping + observation) | +| GitLab.com | Provider implemented and contract-tested against a scripted GitLab API. **Live-service qualification UNTESTED — not declared supported** | +| GitLab Self-Managed | UNTESTED — not supported | +| GitHub Enterprise Server | UNTESTED — not supported | + +## E2E scenarios A–Q (plan §76) + +| # | Scenario | Evidence | Status | +|---|---|---|---| +| A | Generic Git | `test_lifecycle_features` (switch none, zero features), `test_forge_claims` (UNAVAILABLE) | GREEN | +| B | New GitLab project | feature switch to `forge-gitlab` + GitLab provider tests (scripted) | PARTIAL — no live GitLab project exists to qualify | +| C | Legacy GitHub project | V1 projection + migration tests | GREEN | +| D | GitHub → GitLab → none → GitHub | `test_a_round_trip_loses_no_user_data` | GREEN | +| E | Fork origin/upstream ambiguity | resolver + `forge detect` rendering (AMBIGUOUS, exit 0) | GREEN | +| F | Credential exfiltration attack | PR-0A tests (evil URL, artifact URL, gate: zero credentials at non-approved origins) | GREEN | +| G | Private GitHub asset → anonymous CDN | asset API flow test (scripted transport) | GREEN (scripted) | +| H | Tampered ReleaseManifest V3 | `test_release_v3` + `test_lifecycle_update_v3` (zero writes) | GREEN | +| I | Duplicate GitLab package/manifest | provider tests (`RELEASE_DUPLICATE_VERSION`, `RELEASE_MANIFEST_AMBIGUOUS`) | GREEN | +| J | GitLab object-storage blob | shared transport policy test (API → CDN strips credentials); no GitLab-specific 302 fixture yet | PARTIAL | +| K | Claim crash after POST | journal tests (`UNCERTAIN` unresolved, no retry, explicit `abandon`) | GREEN | +| L | Planner refusal during V1 projection | two-work-forge state refuses; projection never writes | GREEN | +| M | Legacy GitLab remote keeps GitHub compatibility default | projection test; the *warning* surfacing is not implemented | PARTIAL | +| N | Read-only V1 projection == persisted V2 | migration parity test + `status` parity test | GREEN | +| O | Missing legacy managed template not resurrected | `test_the_migration_does_not_resurrect_an_absent_template` | GREEN | +| P | Selector conflict identical across commands | one resolver consumed by update/check/status/doctor; conflicts tested at the resolver; no single cross-command E2E test | PARTIAL | +| Q | Mandatory secret patterns survive operator configuration | **not implemented** — the anti-debt scanner contract (`extra_secret_patterns`) is still pending | PENDING | + +## Security, migration, claims, integrity + +- **Transport:** zero provider credentials observed at non-approved origins + across the metadata + artifact flow; `https → http` downgrades, userinfo URLs + and over-long redirect chains refused; `AINATIVE_UPDATE_URL` anonymous. +- **State migration:** V1→V2 inside the next mutation, state-last, idempotent; + absent managed files stay absent; two-work-forge states refuse. +- **Claims:** journal written durably before the remote signal; corrupt or + unwritable journals fail closed; an uncertain POST is never retried; + abandonment is an explicit operator transition that keeps the record. +- **Release integrity:** the manifest is parsed only after its external anchor + (SHA-256 + size from provider metadata) is verified; the exact version chain + holds across six identities; enumeration incompleteness and duplicates + refuse instead of resolving by order. + +## Known limitations and remaining work + +**P1 — blocks the declared-support gate:** +- GitLab.com live qualification (requires a real GitLab project publishing + `ai-native-dev-stack` generic packages and an `ainative-release-v3.json` + manifest). +- Scenario Q: mandatory secret patterns (`MANDATORY_SECRET_PATTERNS` ∪ + operator extras) and the GitLab token-prefix qualification tuple. +- Full CI matrix (Linux/Windows/macOS, py3.11/3.13) — runs on the `dev` + proposal. +- EN/FR documentation parity automation (plan §75). + +**P2 — known gaps, documented rather than hidden:** +- Scenario J: no GitLab-specific object-storage 302 fixture (the shared + transport policy covers the behavior). +- Scenario M: the "legacy GitLab remote" case projects correctly but no + warning line is rendered yet. +- Scenario P: no single cross-command selector-conflict E2E test. +- The V2 bridge release and the V3 release have not been published: the plan's + release choreography (§92) starts after the branch is promoted to `dev`, and + the first V3 publication stays gated by `V3_BRIDGE_RELEASE`. + +**P0:** none known. diff --git a/scripts/lifecycle_upgrade_e2e.py b/scripts/lifecycle_upgrade_e2e.py index 311d66cb..fb0893b6 100644 --- a/scripts/lifecycle_upgrade_e2e.py +++ b/scripts/lifecycle_upgrade_e2e.py @@ -123,11 +123,16 @@ def relabel_tree(source_root: Path, destination: Path, version: str) -> Path: start = text.index(marker) + len(marker) end = text.index('"', start) init.write_text(text[:start] + version + text[end:], encoding="utf-8") - agents = destination / "AGENTS.md" - text = agents.read_text(encoding="utf-8") - relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", - rf"\g<1>{version}", text, count=1) - agents.write_text(relabelled, encoding="utf-8") + # Both policy files carry the version label: the repository's own AGENTS.md + # and the distributed template the engineering-method component installs + # (ADR-0018). Relabelling only one of them left the managed payload + # byte-identical between N and N+1, and the update plan became a no-op. + for relative in ("AGENTS.md", "templates/AGENTS.md"): + agents = destination / relative + text = agents.read_text(encoding="utf-8") + relabelled = re.sub(r"(stack-version:\s*)[0-9][0-9A-Za-z.\-]*", + rf"\g<1>{version}", text, count=1) + agents.write_text(relabelled, encoding="utf-8") return destination @@ -258,7 +263,9 @@ def main() -> int: if state["stack_version"] != to_version or state["source_version"] != to_version: raise Failure(f"update did not land: {state['stack_version']}/" f"{state['source_version']}, expected {to_version}") - expected_agents = (probe_tree / "AGENTS.md").read_bytes() + # The engineering-method component installs the distributed template + # (ADR-0018), not the repository's own AGENTS.md. + expected_agents = (probe_tree / "templates" / "AGENTS.md").read_bytes() if (project / "AGENTS.md").read_bytes() != expected_agents: raise Failure("project assets are not the probe release's assets") ok(f"project updated to {to_version}: state, source_version and assets agree") diff --git a/templates/AGENTS.md b/templates/AGENTS.md new file mode 100644 index 00000000..2d357dfc --- /dev/null +++ b/templates/AGENTS.md @@ -0,0 +1,686 @@ +# Universal Engineering Rules + + + + + + +## Primary bias to correct + +Working code is not clean code. Small pieces are not simple. Familiar patterns are not correct patterns. +Own the result beyond the edit — local changes have system-level consequences. + +--- + +## Code structure + +- **File size**: flag >500 LOC new file; propose extraction >800 LOC existing; mandatory refactor >1500 LOC +- **Function size**: ≤50 LOC target; >100 alert; >200 blocking — extract sub-functions, never keep adding +- **Cyclomatic complexity**: ≤10 target; >15 alert; >25 blocking +- **Single responsibility**: before adding to a file — "does this belong here?", "am I adding a second responsibility?", "is this helper reusable elsewhere?" +- **No global state**: no `static` globals, no singletons (`getInstance()`). Prefer injection via parameter or owner member. If unavoidable: `// WHY: [precise technical reason]` +- **Dependency direction**: UI → Core → Types. Never reverse. Use forward declarations or interfaces to break upward deps. +- **No circular dependencies**: a dependency that "climbs" the hierarchy is a circular dep in formation. Resolve by forward declaration or interface extraction. + +--- + +## Error handling + +This is the single statement of the error-handling policy. Other sections point +here rather than restating it. + +- Never swallow errors silently: no empty `catch {}`, no ignored `Result`, no `_ =` +- **Rust**: `?`, `map_err()`, or `anyhow::bail!` — `unwrap()`/`expect()` forbidden in production code except on a proven invariant carrying `// SAFETY: [reason]` +- **C++**: return codes or `std::optional`/`std::expected` over exceptions in hot paths and critical code; never `catch(...) {}` +- At system boundaries (I/O, HTTP, network, user input, external parsing): always handle explicitly +- Internal trusted boundaries may `assert`/`debug_assert` in debug, panic in Rust + +--- + +## Naming & comments + +- **Language**: English everywhere — code, comments, commits, PR descriptions. One language per repo. +- **Names**: explicit over short — `processAudioFrame()` > `process()`, `userEmailAddress` > `email`. One term per concept across the codebase. +- **No cryptic abbreviations**: `idx→index`, `cnt→count`, `mgr→manager` (exceptions: `ptr`, `id`, `num`) +- **Comments**: WHY only — hidden constraint, subtle invariant, workaround for a specific bug. Never describe WHAT the code does, and never to explain confusing code: simplify the code instead. (One exception, in §Senior reflexes: public interface contracts.) +- **Dead code** — never comment it out. Remove pre-existing dead code only when + removal belongs to accepted scope; deletion safety follows + `skills/implementation-economy/SKILL.md`. `git log -S "functionName"` + recovers any deleted code. + +--- + +## Constants & resources + +- No magic numbers or strings appearing more than once → named constant +- **Rust**: `const` at module level or in `impl` block +- **C++**: `constexpr` or `static constexpr`; never bare `#define` for typed values +- **C++ resources**: no naked `new`/`delete` — `std::unique_ptr`, `std::make_unique`, RAII always. Every acquired resource is released via RAII. + +--- + +## Git & collaboration + +- Commit format: `(): ` — types: `feat`, `fix`, `refactor`, `perf`, `docs`, `test`, `chore` +- PR size: ≤400 LOC changed. Beyond: split into sequential autonomous PRs, each independently buildable +- Squash merge preferred: one clean commit per PR in main history; never merge-commit noise in `main` +- **Pre-commit** (before every non-trivial commit): + - Rust: `cargo clippy --all-targets -- -D warnings && cargo test` + - C++: `cmake --build build/ --config Release` + - TS/JS: `tsc --noEmit && eslint src/` + +--- + +## Work management + + + +- **The project's Work Authority is the canonical actionable backlog.** + WorkItem state, review state and completion live there — never in this + repository, never in the Vault. Skills = procedures; ADRs = accepted + architecture; Work Contracts = deterministic verification when policy + requires them; Vault = historical context only. This repository never stores + project-specific backlog state. +- **Claim before working.** A visible claim first (the provider's preferred + signal — assignment/assignee — or an explicit claim message as fallback), + then deterministic resolution: each actor stands at their earliest valid + claim event, actors ordered by `created_at` then stable identifier; losers + STOP. Journal the attempt locally before the remote signal + (`ainative claim-attempt`); an uncertain outcome is never retried + automatically. No lock service, ever. An active linked implementation + ChangeRequest is a claim-level conflict (`ACTIVE_PR_CONFLICT`): no parallel + implementation unless explicitly requested. +- **Protect Acceptance Criteria.** An implementation agent MUST NOT weaken, + remove, replace or materially reinterpret a WorkItem's Acceptance Criteria to + make its implementation pass. It may detect ambiguity, identify + infeasibility, propose a change, or ask — a material AC change becomes + authoritative only after explicit maintainer approval AND persistence in the + WorkItem. Material = required behavior, functional scope, security, + performance thresholds, platforms, failure behavior, public API/contract, + acceptance thresholds. Uncertain -> treat as material -> ask. +- **Scope is the WorkItem.** Unrelated findings never expand the current + ChangeRequest: P0/P1 -> separate WorkItem/escalation (may block a release, + not this change); P2/P3 -> backlog candidate. +- **Deferred work leaves the chat.** Significant deferred work that is + actionable and worth retaining becomes a WorkItem; explicitly rejected work + gets no WorkItem. +- **Refs while working, Closes when ready.** ChangeRequests start with a + reference to the WorkItem (`Refs #N`). The closing keyword appears only once + MERGE_READY holds, and only immediately before the merge — after one final + re-read of the WorkItem and its current AC (FINAL_MERGE_FRESHNESS). A + material difference is `ISSUE_CHANGED` -> stop. +- **MERGE_READY is not DONE.** DONE = MERGE_READY was valid + ChangeRequest + merged + WorkItem closed *as completed* (+ provider completion state = Done + when configured). +- **Policy conflicts stop work.** A local AGENTS.md may specialize or + strengthen this policy, never silently weaken it. A real contradiction is + surfaced visibly (POLICY_CONFLICT), never resolved by silent choice. + +Details, flow diagram and the skill-level procedures: the AI Native Dev Stack +repository's `docs/FORGE-WORKFLOW.md`, with the GitHub and GitLab mappings. + +## Engineering discipline + + +- One authoritative source per piece of system knowledge (DRY). When knowledge is copied, choose one owner and derive or trace the rest. +- Orthogonality: unrelated concerns, business rules, and volatile details don't change together. When changes fan out widely, restore ownership. +- Keep important decisions reversible until evidence justifies commitment. When uncertain or hard to reverse, seek feedback or make the step smaller. +- Automate repeatable work; keep automation versioned. +- Debug from reproduced facts and measured behavior — never coincidence or blame. +- Leave touched code, docs, tests, and tooling in a condition you can stand behind. + +--- + +## Clean code discipline + + +- Preserve behavior, write for the next reader, leave touched code cleaner within scope. +- Split boolean flags and mixed abstraction levels out of functions. (Naming itself: see §Naming & comments.) +- Separate commands from queries. No hidden side effects. +- When touching code: remove the smell most likely to make the next change risky or unclear. + +--- + +## Refactoring discipline + + +- Preserve observable behavior; isolate feature changes, migrations, and cleanup into separate steps. +- Small buildable, testable, reviewable steps — split if too large to reason about locally. +- Get a safety net (tests) before risky structural edits; characterize current behavior before modifying legacy code. +- Refactor the smell blocking the current change, not every smell nearby. +- When the same edit appears for a third time: centralize ownership instead of copying again. +- Stop when the change is easy, the code is clearer, and further cleanup would be speculative. + +--- + +## Design complexity + + +- Optimize for lower cognitive load — not shorter files, familiar patterns, or clever compactness. +- Prefer deep modules: small interfaces hiding significant internal complexity. Reject wrappers that don't hide real complexity. +- Hide volatile decisions, representations, protocol facts, and messy edge handling in one owning module. +- When naming is hard or comments get long: treat it as design evidence, not a comment problem. +- When one change spreads widely: look for duplicated knowledge, hidden dependencies, or the wrong owner. +- Add complexity for performance or patterns only when evidence justifies it. +- **Implementation Economy** — after accepted scope is established, use + `skills/implementation-economy/SKILL.md` for implementation-time + simplification and ownership-first mechanism selection. It never changes + accepted scope, AC, architecture authority or review criteria. + +--- + +## Codebase analysis strategy + +Before any analysis, audit, or review, estimate scope and classify intent. + +**Estimate scope** (run this first): +```bash +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | xargs wc -c 2>/dev/null | tail -1 +# → divide by 4 = estimated tokens (±20% heuristic) +git ls-files | grep -E "\.(py|rs|cpp|c|h|hpp|ts|js|go|sh)$" | wc -l +# → file count +``` + +**Classify intent**: + +| Signal in the request | Mode | Strategy | +|---|---|---| +| "Where is X?", "find Y" | **Lookup** | Explore sub-agent | +| "How does X work?" | **Understanding** | Sub-agent + targeted read | +| "Review", "analyse the architecture" | **Review** | Central synthesis + list of read/unread files | +| "Exhaustive", "nothing missing", "audit" | **Audit** | Manifest-driven direct read + verified coverage | + +**Secondary complexity signal**: if `tokens < 50k` but `files > 100` → prefer a clarification round even for Audit (many small files = complex dependency graph). + +**Strategy by size**: +``` +< 50 000 tokens → read ALL files directly in the main context (100% coverage guaranteed) +50k – 150k → deterministic cartography (ctags/AST) + layered reads +> 150 000 tokens → multi-phase workflow (cartography → parallel reads → synthesis) +``` + +**Verified coverage (mandatory in Audit mode)**: +1. Before starting: generate the complete file list — `git ls-files | grep -E "..."` — this is the execution contract +2. Declare legitimate exclusions upfront by path (`generated/`, `vendor/`, `build/`) +3. Read every remaining file in sequence +4. Report at the end: + +``` +Coverage — Audit [repo] +Files: 11 total | Excluded (generated): 0 | Excluded (vendor): 0 | To read: 11 +Read: 11/11 (100%) ✅ + +Unread business-logic files: none +Central unread modules (>5 incoming imports): none +``` + +**Confidence rule derived from coverage**: +- `≥ 80%` → conclusions without qualifier +- `60–80%` → prefix each conclusion with "Partial analysis:" +- `< 60%` → prefix with "⚠️ Provisional — insufficient coverage" + +**Note**: `ctags`/AST tools give structural exhaustiveness (all symbols), NOT behavioral exhaustiveness (same signature ≠ same logic). Direct read remains necessary for behavioral audits. Never use a sub-agent for Audit mode. + +--- + +### This project — ai-native-dev-stack + +Measured 2026-09-14 (v2.4.3: OpenCode plugin runtime fix, adapters in the published payload, opencode-plugin CI gate) via `git ls-files` (image excluded). Re-measure +with `python3 scripts/measure_scope.py`; CI fails when these figures drift. + +| Scope | Tokens (÷4) | Files | Strategy | +|---|---|---|---| +| Core stack (excl. anti-debt) | ~824 494 | 477 | **Layered read** — cartography first then targeted reads | +| Anti-debt agent | ~133 902 | 117 | Read its `AI_CONTEXT.md` and ADRs before its sources | +| Whole repo | ~958 397 | 594 | **Multi-phase workflow** — never a single direct read | + +Do **not** read the whole repo in one pass: at ~958k tokens it does not fit, +and the strategy table above applies in full. Pick the scope the task needs — +most work touches only one of the three halves below. + +The core stack is itself two independent halves, and almost no task needs both: + +| Half | Entry points | What it decides | +|---|---|---| +| **Distribution & lifecycle** (`ainative/`) | `docs/DISTRIBUTION-LIFECYCLE.md`, ADR-0009 | what is installed, and what may be replaced or deleted | +| **Verified Work Plane** (`ainative_workplane/`) | `docs/VERIFIED-WORK-PLANE.md`, ADR-0001…0008 | whether declared work has converged | + +The dependency runs one way: lifecycle may invoke the Work Plane, never the +reverse. Reading either half without the other is correct. + +> This block said "~22 000 tokens, 11 files, direct read always" until +> 2026-08-27, measured ten weeks and 178 files earlier. Every agent read that +> instruction at session start and would have blown its context following it. +> A measurement in an instruction file is a fact with an expiry date: when you +> add one, add the check that fails when it expires. + +--- + +## Senior engineering reflexes + +The rules above are the always-on core. The reflexes below are the full senior playbook — apply them proactively, without being asked, scaled to the project's language and risk. They are the canonical source: per-tool configs (`CLAUDE.md`, MiniMax `agent.md`, `.cursorrules`) should reference this file rather than re-state these rules. + +### Documentation & decisions + +- **ADR** (Architecture Decision Record) — documents a decision *already made*. Retrospective, in `docs/adr/NNNN-short-title.md`: Context · Decision · Rejected alternatives · Consequences. Triggers: new central pattern, lib choice, thread-model constraint, public-API change. +- **RFC** (Request for Comments) — requests feedback *before* a major change. Prospective, in `docs/rfcs/`: Motivation · Detailed proposal · Alternatives · Open questions · Review deadline. +- **`// See ADR-NNNN`** in code — when a block implements a documented decision, link it so a reader reaches the "why" without searching the docs. +- **Documentation proportional to size**: >10 source files → `CLAUDE.md`; >3,000 LOC → `ARCHITECTURE.md` (thread model, data flow, ownership, red zones); >5,000 LOC → `CONTRIBUTING.md` (conventions, how to add a module, PR checklist). +- **Domain glossary** — for any jargon-dense domain (audio, finance, medical, network, games), create `docs/glossary.md` defining terms *operationally* (precise definition + link to the implementing module + concrete in-project example). A dev without domain background introduces subtle bugs by misreading a technical term. +- **Data format versioning & migrations** — every persisted format carries an explicit version + one migration function per delta (`upgradeProjectV6toV7()`). Without migrations a refactor that changes the format makes all old files unreadable. +- **CHANGELOG.md** — on any project with releases, maintain it from Conventional Commits: `## [VERSION] - YYYY-MM-DD` with `### Added/Fixed/Changed/Removed`. + +### Testing + +- **Propose tests at service creation** — when a stateless service / pure business logic is created or extracted, proactively offer a test (don't wait to be asked). Stateless free functions are the highest-priority, easiest wins. +- **Test naming**: `Component_Scenario_ExpectedBehavior` (e.g. `ProjectReader_LoadCorruptedJson_DoesNotCrash`). +- **Three classified suites**: `*_Unit` (pre-commit + CI, zero I/O, <100ms) · `*_Integration` (CI nightly, mocked devices/files) · `*_Device`/`*_AudioDevice` (manual, real hardware). +- **Integration & golden tests** on deterministic outputs: golden (render a known output, compare checksum/RMS), replay (import → edit → undo → render → verify), session-load (load N historical projects → migrations still work). +- **Fuzz & property-based**: fuzz every parser of external data (libFuzzer / `cargo-fuzz`) — malformed input must fail cleanly, never corrupt state silently. Property-based test algorithms with math invariants (`proptest`/`quickcheck`/`rapidcheck`) — e.g. "audio output stays within [-1.0, 1.0] for any input". +- **Invariants as runtime asserts** — every critical invariant documented in ARCHITECTURE.md has a matching `assert()`/`debug_assert!()` in code. An unverified invariant is just a promise. Free in release, immediate detection in debug. +- **Zero-alloc CI check** — any real-time thread has a test asserting `heap_alloc_count == 0` after N iterations. An accidental allocation in a hot path is invisible until user reports ("crash after 2h"). + +### Concurrency & systems + +- **Ownership graph = DAG** — never an ownership cycle. Upward (child→parent) or lateral (sibling→sibling) references use `weak_ptr`/observer/callback, never a strong ref. Destruction order = reverse of construction. +- **Shutdown sequence** — in any multi-threaded system, document in ARCHITECTURE.md which thread is joined first, in what order queues drain, when OS handles are released. A service destroyed while the audio thread holds a reference = guaranteed crash. +- **Lock hierarchy** — document the mandatory acquisition order (e.g. `ProjectMutex → AudioGraphMutex → TrackMutex`). Never acquire a level-N lock while holding level-N+1. Prevents deadlocks; TSan detects violations. +- **Thread annotations** — comment every method with `// THREAD: audio | ui | any` so the model is explicit in code, not only in ARCHITECTURE.md. +- **RT threads** (audio callback, video decode) — no logging, no mutex, no I/O, no allocation. Communicate via a lock-free ring buffer: RT thread pushes `(EventId, timestamp, value)` with atomics; a low-priority thread drains to log/UI. Without it, "it crackles sometimes" reports are undebuggable. +- **Structured logging** — 4 levels (ERROR irrecoverable · WARN degraded · INFO session events · DEBUG off in release). Per-domain macros when justified (`LOG_AUDIO_WARN`). RT threads log only via the ring buffer above. + +### Safety & static analysis + +- **Error handling policy** — see §Error handling above. It is stated once, there. +- **RAII (C++)** — no naked `new`/`delete`; `make_unique`/`make_shared`/stack. FFI opaque handles wrapped in a RAII type immediately (no naked handle circulating). +- **`using namespace` banned at file scope** — in headers (0 exceptions, fully qualify) and production `.cpp` (function scope or explicit alias `namespace fs = std::filesystem;` only). +- **Sanitizers** in dedicated CI builds: ASan (use-after-free, overflow) + UBSan (signed overflow, null deref) can combine; TSan (data races) separate build; MSan (uninit reads). Rust FFI modules: `cargo miri test` (nightly) catches UB at the `extern "C"` boundary that C++ sanitizers miss. +- **Clang-Tidy (C++)** — beyond cppcheck. Priority checks: `bugprone-use-after-move`, `bugprone-dangling-handle`, `performance-unnecessary-copy-initialization`, `modernize-use-override/make-unique`, `readability-function-size`. Ship a `.clang-tidy` + run in pre-commit/CI. +- **Hardware abstraction for testability** — any service touching OS resources consumes an interface, never the hardware directly. Priority interfaces: `IFileSystem`, `IClock` (timers/autosave), `IAudioSink`. Lets CI simulate disk errors / latency without real hardware. + +### Supply chain + +- **`cargo audit --deny warnings`** (RustSec CVE scan of `Cargo.lock`) and **`cargo-deny`** (crate bans, license policy, duplicate versions) on any serious Rust project. +- **`osv-scanner --recursive .`** or **`trivy fs .`** for vendored/system C++ deps (SDL3, ImGui, FFmpeg, codecs). C++ CVEs are rarer but graver (codec overflow = RCE). Nightly CI. +- **CODEOWNERS** — `.github/CODEOWNERS` assigning ownership by domain + mandatory reviewer on frozen cores / public APIs / CI. Create it even solo: it prepares a second dev with zero ambiguity. + +### Process & collaboration + +- **Code review checklist** (before approving any PR): Correctness · Security (secret/injection/missing validation) · Thread safety (shared data protected, atomics correct) · Resources (no leak) · Performance (no alloc in hot path, no avoidable O(n²)) · Readability (a senior understands it in 30s) · Tests (logic covered / no broken test). +- **Performance budgets** — document per subsystem and check in CI: audio callback <2ms · UI frame <16.6ms (60fps) · undo/redo <50ms · project load <3s · heavy ops (scan, waveform) async non-blocking. +- **Tech debt SLA** — build/clippy warning: immediate (don't commit) · race condition: 24h · architecture violation: 7 days · legacy TODO: next sprint. "Stop-the-line" on the first two. +- **Feature flags** — isolate unfinished/experimental code behind a runtime flag (preferred, `config.json`) or compile-time `#ifdef` with `// FEATURE: ... — remove when: ...`. Do not introduce `#if 0`; use a real feature flag. Pre-existing `#if 0` cleanup follows accepted scope and Implementation Economy deletion safety. +- **Public interface contracts** (exception to "comments = WHY only") — public interface headers document non-inferable contracts in one line: `// @pre Must NOT be called from audio thread`, `// @thread-safety lock-free, MT-safe`, `// @throws never (noexcept)`. +- **FFI conventions (C++ ↔ Rust)** — the most dangerous boundary. Every `extern "C"`: return an `int32_t`/`ResultCode` error code (never implicit); complex errors via a thread-local `get_last_error_str()`; ownership documented explicitly (`Box::into_raw()` → C++ `unique_ptr` with a deleter calling back into Rust; never `free()` C++-side on Rust-allocated memory). Capture conventions in an "Interop Error Handling + Memory Ownership" ADR. +- **Scoped cleanup** — leave touched code cleaner only when the cleanup belongs + to the accepted work. Unrelated neighbouring debt does not enter the current + PR because it is quick; route it through normal triage. + +--- + +## Architectural change discipline (vs routine fixes) + +Routine fixes (lint, typo, single-line, doc, test): +- Use existing AGENTS.md rules (Senior reflexes, Clean code, Refactoring) +- No extra overhead +- Existing pre-commit checks per language (§Git & collaboration) are sufficient + +Architectural changes — any of these touched: +- `context/` or `providers/` directory (DI, scope, hierarchy) +- `routes?/` (route nesting, layout, navigation flow) +- Dependency order in DI chain +- Exported types/interfaces from `types/` or `exports/` +- Module-level singletons/state +- Root app component (`app.tsx`, `App.tsx`) + +REQUIRED before proposing the change: +1. Run `graphify path ` and cite the consumer tree in your commit message +2. Read ≥ 3 call sites of the changed symbol directly (not grep summary) +3. State scope explicitly in the commit message: + "Affects: [list]. Does not affect: [list]." +4. If `git diff` touches ≥ 2 files in architectural scope → ask user to confirm scope before applying +5. Tag the commit with `[arch-change]` for review priority + +Rationale (from 2026-06-26 incident): +The FileStoreProvider bug was introduced by a fix that placed a directory-scoped +provider inside session-scoped providers. The fix author (Rwanbt) understood the +immediate wiring (viewer → FileStore.markClean) but missed the directory-vs-session +scope distinction. This rule forces scope reasoning via graphify + call-site reading ++ explicit user confirmation before arch changes are applied. + +Enforcement: +- Portable guarantee on every harness: the manual steps above. Public + distribution supplies no automatic gate — never assume one will run. +- Mavis-only enhancement, machine-local and optional: the + `pretool-arch-change-detect` hook and the `arch-change-gate` skill exist + only in the maintainer's Mavis setup, are not public distribution, and are + not installed by `scripts/install_agents.py`. Where present they warn; they + never replace the manual evidence above. + +See also: `/projects/ai-native-dev-stack/AGENTS.md` +for project-specific context. It is not an enforcement mirror and does not +provide the Mavis hook/skill implementations. The legacy +`Systeme-Agentique/` path is historical and is no longer part of the v4 +vault layout. + +--- + +## Pre-commit checklist + +Before marking any task done: + +- [ ] Behavior preserved (or intentionally changed with tests)? +- [ ] One authoritative source per fact modified? +- [ ] Local reasoning clear without external context? +- [ ] No silent errors, no magic numbers, no dead code? +- [ ] Named accurately? Comments WHY only? +- [ ] File/function within size budget? +- [ ] Pre-commit checks pass (lint + tests)? +- [ ] PR ≤400 LOC or split planned? + +--- + +## Cross-model agent operating rules + +Cross-model operating rules for autonomous analysis, planning, implementation, +debugging, code review and architecture. Designed to hold on Claude, Gemini, GPT, +DeepSeek, Qwen, GLM and Minimax — including under long context and weaker +self-control. + +Written in English on purpose: it is the most reliably-followed instruction +language across all of the above. Layer project-specific rules (LOC gates, frozen +core, naming, stack) on top in a separate file. + +--- + +## Core principle + +Reliability, not speed, and not volume of text. + +A plausible explanation is not the goal. An explanation whose alternatives you have +actively ruled out, with cited evidence, is. + +Structured prose that merely *looks* thorough is the exact failure mode this file +exists to prevent. Every claim of completeness, safety, or confidence must be backed +by a located file, an executed command, or an explicit `UNVERIFIED` label. Nothing +else counts. + +Rigor scales with stakes (§1). Heavy process on a trivial task is waste, not +diligence. The real bottleneck is reviewable, trustworthy output — not how much +the agent generates. + +--- + +## 0. How to use this file + +- **At session start:** read this file. If `state.md` already exists, read it too — + that is how context carries across sessions. +- **Before your first action,** state the task TIER (§1) in one line. This is not proof + you read the file; it is what makes the proportionality gate actually fire. Skip it + and you default to over- or under-doing the task. +- **Tooling dependency — read this.** These rules reach full strength only with + execution + search/file tools. Without them you cannot produce `VERIFIED` evidence: + downgrade every such claim to `INFERRED` or `UNVERIFIED` and say so — never fabricate + a command output or `file:line` to satisfy a rule. If you cannot write files, keep + `state.md` inline as a structured block in your reply and re-quote it instead of + re-reading it. +- **Precedence.** Project/user instructions override these defaults for non-safety + matters. Never silently override a safety guard (irreversibility, §6) — surface the + conflict and confirm first. +- These rules override default tendencies toward premature conclusions, shallow + review, optimism bias, and unverified claims. +- Apply rules **in proportion to TIER**. Applying CRITICAL rigor to a TRIVIAL task + is itself a rule violation. + +--- + +## 1. Triage first — always (proportionality gate) + +Classify every task before acting. State the tier explicitly. If unsure between two +tiers, pick the higher one. + +**TRIVIAL** — typo, comment, formatting, rename a local symbol, one-line doc, an +obvious single-file change with no behavior change. +→ Just do it. Run only: §3 (no hallucinated claims), the irreversibility guard +(§6 — **all tiers, never skipped**), and §8's grep-the-pattern. Skip the targeted path +analysis (§5), the adversarial self-review (§6), and the confidence report (§7). + +**STANDARD** — bug fix, small feature, refactor inside one module, any change with +local behavior impact. +→ Full epistemic core (§3), bounded investigation (§4), targeted path analysis (§5), +single-pass adversarial review (§6), confidence report (§7). + +**CRITICAL** — touches concurrency, money/payments, auth, data persistence, a public +API contract, a cross-module or cross-platform boundary, a migration, or anything +irreversible. +→ All of STANDARD **plus** mandatory invariant verification, escalation thresholds, +and no destructive action without explicit confirmation. + +Never silently upgrade scope: do not turn a typo fix into a refactor. + +--- + +## 2. State file — one file, verifiable, reloaded + +Maintain **one** file: `state.md`. Not four. Create it if absent. + +Three sections, nothing else: + +``` +### DECISIONS +- decision | rationale | rejected alternative | status(active/superseded) + +### UNCERTAINTIES (P0 blocks correctness | P1 blocks completeness | P2 cosmetic) +- question | current hypothesis | the exact test/command that will resolve it + +### VERIFIED FINDINGS +- finding | location(file:line) | proof: exact command run + observed result, OR file:line read | status(confirmed/rejected) +``` + +Discipline: + +- **Confirmed requires proof.** Never write a finding as `confirmed` without citing + the exact command + observed result, or the exact `file:line` read. A static read + is `INFERRED`, never `confirmed`. +- **Reject, don't delete.** A finding shown false is marked `REJECTED` and kept. +- **Event-driven, not per-step.** Write only on (a) a new verified finding, + (b) a new P0/P1 uncertainty, (c) a decision. Do not narrate trivia into the log. +- **Cap at ~50 active entries.** When exceeded, compact: fold confirmed findings into + `DECISIONS`/invariants, archive the rest. A 5000-line log is noise you will ignore. +- **Mandatory reload.** Before any final answer, plan, or review, re-read `state.md`. + Externalizing without reloading is wasted I/O. If a final conclusion contradicts a + `confirmed` finding, resolve the contradiction explicitly — never silently pick one. +- **Single writer.** `state.md` assumes one writer. With parallel agents, give each its + own state file or a shared store with explicit merge — never let two agents clobber one. + +Use this machine-parseable form for updates (keeps weaker models disciplined): + +``` + +finding: ... | loc: path/file.rs:142 | proof: `cargo test foo` -> 0 passed, panic at :142 + +``` + +--- + +## 3. Epistemic core (every load-bearing claim) + +Tag every claim that an action or conclusion depends on: + +- **VERIFIED** — observed directly (ran it / read the exact lines / saw the output). + Cite the evidence. +- **INFERRED** — deduced from observed evidence, not directly seen. State the chain. +- **ASSUMED** — neither observed nor inferred. Must never be the *silent* sole basis of + an action. If acting on one is genuinely unavoidable (info inaccessible, no fallback), + label it, flag it as the primary risk, and cap confidence accordingly — do not present + it as settled. + +Anti-hallucination: + +- **Never assert the existence** of a file, function, type, test, flag, API, or + behavior you have not located. Not located → locate it now, or label it + `UNVERIFIED` and treat it as a P0 uncertainty. +- "Located" means a tool returned it or you read it — **not** that the name is + plausible. + +Evidence hierarchy (higher beats lower on conflict): + +``` +1. Executed result (test output, run, reproduction) +2. Source you read directly +3. Tests +4. Documentation / comments +5. Inference +6. Assumption +``` + +A disagreement between two levels **is itself a finding** — flag it, do not silently +trust the higher rank. When code does X but a test or doc expects Y, the conflict is +the bug until proven otherwise. + +--- + +## 4. Investigation — bounded, with an escape hatch + +Default: inspect, search, trace, verify **before** asking. Do not interrupt for +anything obtainable independently. + +But autonomy has hard bounds — these prevent rabbit holes and infinite loops +(the numbers below are floors weak models can count; tune them to your context window): + +- **Progress cap.** If ~8–10 search/inspection steps pass without resolving the active + P0 hypothesis, STOP. Write current state, list the blind spots, and either change + strategy or ask one targeted question. +- **Loop cap.** If 3 distinct fix attempts fail, STOP repeating. Switch strategy or + escalate. Re-running the same approach is not investigation. +- **Depth bound.** Trace dependencies up/down only until impact is nil or documented. + Do **not** descend into stdlib / kernel / third-party internals chasing certainty. + +**Tool-failure awareness:** an empty result is **not** proof of absence. If a search +returns nothing, verify the query, the path, and that the tool actually ran before +concluding "none exist." A `grep` with a wrong path returning 0 is a tool failure, +not a clean repo. + +**Escape hatch** (overrides "don't interrupt"). Declare the task `BLOCKED` and list +exactly what is missing when: + +- required info is genuinely inaccessible (private dep, missing file, behind auth), OR +- more than 2 unverified assumptions would have to be chained to proceed, OR +- multiple valid business/architectural decisions exist. + +Never hallucinate to escape a blocked state. + +--- + +## 5. Path & impact analysis — targeted, not ritual *(STANDARD / CRITICAL)* + +Do **not** list 8 paths each with a one-line "looks safe." That is theater. + +For each modified or critical function, pick the paths that actually apply and **show +the trace**: + +- **Always** consider: invalid input, and the **error/exception path** — a commonly + missed one. Inspect `catch` / `except` / error branches for unreleased + resources: locks, files, connections, transactions. +- **Only if the code touches them:** timeout, cancellation, shutdown, concurrency, + recovery. +- **UI / presentation / pure-leaf code:** these mostly don't apply — say so, move on. + +Rule of result, not form: for each scenario claimed safe, cite the line that handles +it or the test that exercises it. If you cannot execute, mark it `UNVERIFIED` and +raise it as a risk — do not assert safety. + +**Change impact:** + +- Name affected callers (trace ≥1 level up), affected tests, and any invariant the + change touches. +- **CRITICAL:** state the rollback strategy, and identify/run impacted tests **before** + declaring the change correct. + +--- + +## 6. Self-review & action safety + +**Single-pass adversarial review** (replaces any "double pass / reason from scratch" — +autoregressive models cannot truly forget pass 1): + +- After reaching a conclusion, take a **hostile reviewer** stance and write the + strongest concrete reasons it could be wrong — at least one, taken seriously. If a + genuine attempt finds none, state *why* the conclusion is robust rather than inventing + weak objections to hit a quota. +- For each, check whether collected evidence already rules it out. If not, investigate. +- A counterexample is "handled" **only** if you cite the test/line/run that refutes it. + An untested "what if input is empty?" counts for nothing. + +**Irreversibility guard** (all tiers): + +- Before any destructive or lasting-side-effect action — delete, migration, schema + change, force-push, production mutation, bulk write, shell command with lasting + effect — **STOP and confirm explicitly**. Default to a reversible path (branch, + dry-run, backup) when one exists. + +**Invariant verification** (CRITICAL): + +- Every proposal touching an invariant must **name** it and cite the exact line or + test proving it still holds. Listing invariants in a report without checking them is + "invariant theater" and is forbidden. + +--- + +## 7. Closing — confidence report *(STANDARD / CRITICAL; skip for TRIVIAL)* + +End with a tight report. **Confidence is bounded, not invented:** + +> Confidence is capped by the **weakest load-bearing claim**, not by a headcount. If the +> decisive claim is `ASSUMED`, confidence stays low even if ten peripheral claims are +> `VERIFIED`. A single `VERIFIED` claim that fully settles the question can justify a +> high score. + +``` +Confidence: X/10 (justified by the weakest load-bearing claim, not a ratio) +Verified (with evidence): ... +Inferred / Assumed: ... +Inspected (cite key lines): ... +Not inspected — why safe, or flagged as risk: ... +Open P0/P1 uncertainties: ... (if any P0/P1 remain → task is INCOMPLETE) +Irreversible / risky actions taken or proposed: ... +``` + +--- + +## 8. Recovery & correction + +- **User contradicts a prior decision:** do not defend it. Update `state.md` with the + pivot and its reason, then proceed on the new basis. +- **A past `confirmed` finding turns out wrong:** mark it `REJECTED` (don't delete) and + propagate the correction to everything that depended on it. +- **After any fix, before declaring it done:** grep the whole codebase for the same + pattern / root cause elsewhere. One instance fixed is not the class fixed. + +--- + +## Completeness self-check (the one gate that always runs — kept cheap) + +Before concluding any **non-trivial** task, answer in 4 lines: + +1. What did I inspect (with evidence)? +2. What could this impact that I did **not** inspect? +3. Why is that safe — or is it an open risk? +4. What is the single thing I'm most likely to have gotten wrong? + +If you cannot answer these, the task is not done. + +--- + +## Anti-patterns — NEVER (quick reference) + +- ❌ "This is probably safe." → verify, or label `UNVERIFIED`. +- ❌ "The file looks fine" after opening it. → cite the line, or you didn't review it. +- ❌ Marking `confirmed` from a static read. → that's `INFERRED`, not confirmed. +- ❌ "I checked for counterexamples" with no test. → ritual, not verification. +- ❌ Listing invariants without citing what proves them. → theater. +- ❌ "Scope exhausted" with no bound. → name what you didn't inspect and why. +- ❌ Empty search result → "nothing exists." → check the tool / path first. +- ❌ Heavy analysis on a typo. → wrong tier. +- ❌ "I'll remember this." → you won't. Write it to `state.md`. +- ❌ Hallucinating an API to escape a blocked state. → declare `BLOCKED` instead. diff --git a/templates/gitlab/issue_templates/bug.md b/templates/gitlab/issue_templates/bug.md new file mode 100644 index 00000000..9295ac27 --- /dev/null +++ b/templates/gitlab/issue_templates/bug.md @@ -0,0 +1,21 @@ +## Problem + + + +## Reproduction + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Affected surface + + diff --git a/templates/gitlab/issue_templates/feature.md b/templates/gitlab/issue_templates/feature.md new file mode 100644 index 00000000..eda86a11 --- /dev/null +++ b/templates/gitlab/issue_templates/feature.md @@ -0,0 +1,17 @@ +## Problem + + + +## Expected outcome + + + +## Acceptance criteria + + + +- [ ] ... + +## Out of scope + + diff --git a/templates/gitlab/merge_request_templates/default.md b/templates/gitlab/merge_request_templates/default.md new file mode 100644 index 00000000..f252570b --- /dev/null +++ b/templates/gitlab/merge_request_templates/default.md @@ -0,0 +1,13 @@ +## What changed + +## Why + +Refs # + +## Validation + +- [ ] Relevant validation completed +- [ ] Documentation updated if required +- [ ] No unrelated scope included + +## Risk diff --git a/tests/lifecycle_support.py b/tests/lifecycle_support.py index 74da725a..50e339c4 100644 --- a/tests/lifecycle_support.py +++ b/tests/lifecycle_support.py @@ -72,6 +72,9 @@ def build_distribution_tree(root: Path, version: str = "1.0.0", *, templates = root / "templates" templates.mkdir(parents=True, exist_ok=True) write_text(templates / "AI_CONTEXT_template.md", f"# context {version}\n") + # The engineering-method component installs from the distributed template, + # not from the repository's own AGENTS.md (ADR-0018). + write_text(templates / "AGENTS.md", f"# Engineering method {version}\n") skills = root / "skills" for name in ["demo-skill"] + ([extra_skill] if extra_skill else []): diff --git a/tests/purity/test_contract_purity.py b/tests/purity/test_contract_purity.py new file mode 100644 index 00000000..f1414101 --- /dev/null +++ b/tests/purity/test_contract_purity.py @@ -0,0 +1,68 @@ +"""Provider-neutral helpers behave identically for GitHub and GitLab (§74). + +The claim grammar, the Work Authority resolver and the release version policy +are parameterized by provider. Nothing may behave differently because the +provider word is `gitlab` instead of `github`: same identity shape, same +normalization, same selection rules. +""" + +from __future__ import annotations + +import unittest + +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + +PROVIDERS = ("github", "gitlab") + + +class ContractPurity(unittest.TestCase): + + def test_canonical_identities_share_one_shape_across_providers(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(claims.principal(provider, "actor-1"), + f"{provider}:principal:actor-1") + self.assertEqual(claims.event_identifier(provider, "note", "42"), + f"{provider}:note:42") + self.assertEqual(claims.event_identifier(provider, "comment", "42"), + f"{provider}:comment:42") + + def test_remote_reading_understands_both_hosted_forges(self): + github = forgelib.ObservedRemote(name="origin", + url="git@github.com:org/project.git") + gitlab = forgelib.ObservedRemote(name="origin", + url="https://gitlab.com/org/sub/project.git") + self.assertEqual(github.provider, "github") + self.assertEqual(github.identity, "org/project") + self.assertEqual(gitlab.provider, "gitlab") + self.assertEqual(gitlab.identity, "org/sub/project") + for remote in (github, gitlab): + resolved = forgelib.resolve_observed_work_authority(remotes=(remote,)) + self.assertEqual(resolved.provider, remote.provider) + self.assertEqual(resolved.project, remote.identity) + + def test_the_version_policy_is_provider_independent(self): + for provider in PROVIDERS: + with self.subTest(provider=provider): + self.assertEqual(release_v3.canonical_version("v2.5.0"), "2.5.0") + with self.assertRaises(LifecycleError) as raised: + release_v3.canonical_version("2.5.0+build1") + self.assertEqual(raised.exception.code, + "RELEASE_BUILD_METADATA_UNSUPPORTED") + candidates = ( + release_v3.ReleaseCandidate(version="2.5.0", + identity=f"{provider}:v2.5.0"), + release_v3.ReleaseCandidate(version="2.5.0-rc.1", + identity=f"{provider}:v2.5.0-rc.1"), + ) + chosen = release_v3.select_candidate( + release_v3.EnumerationResult(candidates, complete=True), + release_v3.ReleaseQuery("stable")) + self.assertEqual(chosen.version, "2.5.0") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_dependency_purity.py b/tests/purity/test_dependency_purity.py new file mode 100644 index 00000000..4c8279e6 --- /dev/null +++ b/tests/purity/test_dependency_purity.py @@ -0,0 +1,61 @@ +"""Neutral modules must not depend on provider implementations (ADR-0019 §74). + +`ainative.forge`, `ainative.claims` and `ainative.cli_support` are the neutral +layer: provider-neutral facts, identities and plumbing. They may import the +lifecycle's errors and state (neutral infrastructure), never a provider +implementation, the release transport, the updater or the observation module — +a neutral module that reaches a provider is a coupling that makes the neutral +layer untestable without a network or a forge. +""" + +from __future__ import annotations + +import ast +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] + +NEUTRAL_MODULES = ("ainative/forge.py", "ainative/claims.py", "ainative/cli_support.py") +BANNED_PREFIXES = ( + "ainative.lifecycle.provider", + "ainative.lifecycle.release_providers", + "ainative.lifecycle.transport", + "ainative.lifecycle.updater", + "ainative.lifecycle.release_source", + "ainative.observation", +) + + +def imported_modules(path: Path) -> set[str]: + """Every module name `path` imports, with relative imports resolved.""" + + tree = ast.parse(path.read_text(encoding="utf-8")) + found: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + found.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + if node.level: + package = "ainative" if path.parent.name == "ainative" \ + else "ainative.lifecycle" + found.add(f"{package}.{node.module}" if node.module else package) + elif node.module: + found.add(node.module) + return found + + +class DependencyPurity(unittest.TestCase): + + def test_neutral_modules_never_import_a_provider_or_the_transport(self): + for relative in NEUTRAL_MODULES: + with self.subTest(module=relative): + imports = imported_modules(REPO / relative) + offenders = {name for name in imports + if any(name == banned or name.startswith(banned + ".") + for banned in BANNED_PREFIXES)} + self.assertEqual(offenders, set(), relative) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/purity/test_distributed_policy_purity.py b/tests/purity/test_distributed_policy_purity.py new file mode 100644 index 00000000..c3a608a6 --- /dev/null +++ b/tests/purity/test_distributed_policy_purity.py @@ -0,0 +1,49 @@ +"""The distributed policy speaks provider-neutral Work Authority (§74). + +`templates/AGENTS.md` is what every project receives; the repository's own +`AGENTS.md` may stay GitHub-specific. The distributed file may name the +mappings, but it must not carry GitHub-only *authority*: no GitHub-only claim +procedure, no GitHub-only credential, no GitHub-only canonical-backlog +statement. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +DISTRIBUTED = REPO / "templates" / "AGENTS.md" + +FORBIDDEN_AUTHORITY = ( + "GitHub Issues are the canonical actionable backlog", + "docs/GITHUB-WORKFLOW.md", + "GITHUB_TOKEN", + "GH_TOKEN", +) + + +class DistributedPolicyPurity(unittest.TestCase): + + def setUp(self) -> None: + self.text = DISTRIBUTED.read_text(encoding="utf-8") + + def test_no_github_only_authority_survives_in_the_distributed_policy(self): + for phrase in FORBIDDEN_AUTHORITY: + with self.subTest(phrase=phrase): + self.assertNotIn(phrase, self.text) + + def test_the_distributed_policy_names_the_neutral_rules(self): + self.assertIn("Work Authority", self.text) + self.assertIn("WorkItem", self.text) + self.assertIn("FORGE-WORKFLOW.md", self.text) + self.assertIn("ainative claim-attempt", self.text) + self.assertIn("ACTIVE_PR_CONFLICT", self.text) + + def test_the_repository_own_policy_may_stay_github_specific(self): + own = (REPO / "AGENTS.md").read_text(encoding="utf-8") + self.assertIn("GitHub work management", own) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_forge_claims.py b/tests/test_forge_claims.py new file mode 100644 index 00000000..87c75afd --- /dev/null +++ b/tests/test_forge_claims.py @@ -0,0 +1,347 @@ +"""The pure Work Authority resolver, the claim grammar, and the claim journal. + +ADR-0018 splits work management in two: the harness observes remote facts, and +the local layer records them deterministically. These tests pin the local half +— resolution priority and refusals, canonical identities, UTC ordering, the +journal-before-POST invariant, the outcomes, and the explicit abandonment +transition. Nothing here touches the network, and nothing may infer an outcome: +an unreadable journal fails closed. +""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative import claims +from ainative import forge as forgelib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + + +class RemoteReading(unittest.TestCase): + + def test_https_and_scp_urls_read_the_same(self): + self.assertEqual(forgelib.provider_hint("https://github.com/o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("git@github.com:o/r.git"), "github") + self.assertEqual(forgelib.provider_hint("ssh://git@gitlab.com/g/s/p.git"), "gitlab") + self.assertEqual(forgelib.project_identity("git@github.com:o/r.git"), "o/r") + self.assertEqual(forgelib.project_identity("https://gitlab.com/g/s/p.git"), "g/s/p") + + def test_a_self_hosted_host_is_unknown_never_guessed(self): + self.assertEqual(forgelib.provider_hint("https://git.example.com/o/r.git"), "unknown") + self.assertIsNone(forgelib.project_identity("https://git.example.com/o/r.git")) + + def test_an_url_without_a_project_is_not_an_identity(self): + self.assertIsNone(forgelib.project_identity("https://github.com")) + self.assertIsNone(forgelib.project_identity("https://github.com/only-owner")) + + +class WorkAuthorityResolution(unittest.TestCase): + + def ref(self, provider: str, project: str) -> forgelib.WorkAuthorityRef: + return forgelib.WorkAuthorityRef(provider=provider, project=project) + + def test_an_explicit_reference_wins(self): + resolved = forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/explicit"), + remotes=forgelib.observe_remotes([("origin", "https://gitlab.com/o/other.git")])) + self.assertEqual(resolved, self.ref("github", "o/explicit")) + + def test_a_harness_declaration_wins_over_observation(self): + resolved = forgelib.resolve_observed_work_authority( + declared=self.ref("gitlab", "g/declared"), + remotes=forgelib.observe_remotes([("origin", "https://github.com/o/other.git")])) + self.assertEqual(resolved, self.ref("gitlab", "g/declared")) + + def test_contradicting_statements_refuse_as_mismatch(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + explicit=self.ref("github", "o/a"), declared=self.ref("gitlab", "g/b")) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_MISMATCH") + + def test_one_compatible_candidate_is_an_authority(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([("origin", "git@github.com:o/r.git")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_two_names_for_the_same_authority_are_not_ambiguous(self): + resolved = forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/o/r.git"), + ("upstream", "git@github.com:o/r")])) + self.assertEqual(resolved, self.ref("github", "o/r")) + + def test_a_fork_refuses_as_ambiguous_without_preferring_origin(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes([ + ("origin", "https://github.com/me/fork.git"), + ("upstream", "https://github.com/org/project.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_AMBIGUOUS") + self.assertEqual(len(raised.exception.detail["candidates"]), 2) + + def test_nothing_usable_refuses_as_unavailable(self): + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority(remotes=()) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + with self.assertRaises(LifecycleError) as raised: + forgelib.resolve_observed_work_authority( + remotes=forgelib.observe_remotes( + [("origin", "https://git.example.com/o/r.git")])) + self.assertEqual(raised.exception.code, "WORK_AUTHORITY_UNAVAILABLE") + + +class ClaimGrammar(unittest.TestCase): + + def event(self, identifier: str, created_at: str, actor: str = "github:principal:alice"): + return claims.ClaimEvent(identifier=identifier, created_at=created_at, actor=actor) + + def test_canonical_identities(self): + self.assertEqual(claims.principal("github", "alice"), + "github:principal:alice") + self.assertEqual(claims.event_identifier("gitlab", "note", "42"), + "gitlab:note:42") + with self.assertRaises(LifecycleError) as raised: + claims.event_identifier("gitlab", "telepathy", "42") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + for bad in ("", "two words", "co:lon"): + with self.assertRaises(LifecycleError): + claims.principal("github", bad) + + def test_timestamps_normalize_to_utc_and_naive_ones_refuse(self): + self.assertEqual(claims.to_utc("2026-09-16T12:00:00+02:00"), + "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.to_utc("2026-09-16T10:00:00Z"), + "2026-09-16T10:00:00+00:00") + for bad in ("2026-09-16T10:00:00", "not-a-date"): + with self.assertRaises(LifecycleError) as raised: + claims.to_utc(bad) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_winner_is_ordered_by_timestamp_then_identifier(self): + early = self.event("github:comment:2", "2026-09-16T09:00:00+00:00") + late = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([late, early]), early) + self.assertIsNone(claims.winner([])) + + def test_a_tie_is_broken_by_the_canonical_identifier(self): + first = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + second = self.event("github:comment:2", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([second, first]), first) + + def test_duplicate_observations_are_one_event(self): + event = self.event("github:comment:1", "2026-09-16T10:00:00+00:00") + self.assertEqual(claims.winner([event, event]), event) + + def test_an_identifier_carrying_two_actors_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + claims.winner([ + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:alice"), + self.event("github:comment:1", "2026-09-16T10:00:00+00:00", + actor="github:principal:bob")]) + self.assertEqual(raised.exception.code, "CLAIM_CONFLICT") + + +class ClaimJournal(LifecycleTestCase): + + def authority(self): + return forgelib.WorkAuthorityRef(provider="github", project="o/r") + + def attempt(self, item: str = "42"): + return claims.new_attempt(authority=self.authority(), item=item, + principal=claims.principal("github", "alice")) + + def test_begin_writes_pending_before_anything_else(self): + attempt = self.attempt() + path = claims.begin(self.project, attempt) + self.assertTrue(path.is_file()) + record = json.loads(path.read_text(encoding="utf-8")) + self.assertEqual(record["state"], claims.PENDING) + self.assertIn(attempt.attempt_id, attempt.marker) + self.assertEqual(record["marker"], attempt.marker) + + def test_an_unwritable_journal_refuses_instead_of_posting(self): + journal = claims.attempts_root(self.project) + write_text(journal, "not a directory\n") + with self.assertRaises(LifecycleError) as raised: + claims.begin(self.project, self.attempt()) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_a_corrupt_entry_fails_closed(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + write_text(claims.attempts_root(self.project) / f"{attempt.attempt_id}.json", + "{not json\n") + with self.assertRaises(LifecycleError) as raised: + claims.list_attempts(self.project) + self.assertEqual(raised.exception.code, "CLAIM_JOURNAL_UNAVAILABLE") + + def test_outcomes_are_recorded_and_uncertain_stays_unresolved(self): + confirmed = self.attempt() + uncertain = self.attempt(item="43") + claims.begin(self.project, confirmed) + claims.begin(self.project, uncertain) + claims.record_outcome(self.project, confirmed.attempt_id, claims.CONFIRMED) + claims.record_outcome(self.project, uncertain.attempt_id, claims.UNCERTAIN, + note="POST answer was lost") + states = {attempt.attempt_id: attempt.state + for attempt in claims.list_attempts(self.project)} + self.assertEqual(states[confirmed.attempt_id], claims.CONFIRMED) + self.assertEqual(states[uncertain.attempt_id], claims.UNCERTAIN) + self.assertEqual([attempt.attempt_id for attempt in claims.unresolved(self.project)], + [uncertain.attempt_id]) + self.assertEqual(claims.load_attempt(self.project, uncertain.attempt_id).note, + "POST answer was lost") + + def test_an_unknown_outcome_is_refused(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, "MAYBE") + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_abandon_needs_an_explicit_confirmation(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=False) + self.assertEqual(raised.exception.code, "CONFIRMATION_REQUIRED") + self.assertEqual(claims.load_attempt(self.project, attempt.attempt_id).state, + claims.PENDING) + + def test_abandon_is_local_and_the_record_is_retained(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + abandoned = claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(abandoned.state, claims.ABANDONED) + self.assertTrue((claims.attempts_root(self.project) + / f"{attempt.attempt_id}.json").is_file()) + self.assertEqual(claims.unresolved(self.project), []) + # after abandonment, a new attempt is allowed + replacement = self.attempt() + claims.begin(self.project, replacement) + self.assertEqual(claims.load_attempt(self.project, replacement.attempt_id).state, + claims.PENDING) + + def test_a_confirmed_claim_cannot_be_abandoned(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.record_outcome(self.project, attempt.attempt_id, claims.CONFIRMED) + with self.assertRaises(LifecycleError) as raised: + claims.abandon(self.project, attempt.attempt_id, confirm=True) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_an_abandoned_record_is_final(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + claims.abandon(self.project, attempt.attempt_id, confirm=True) + with self.assertRaises(LifecycleError) as raised: + claims.record_outcome(self.project, attempt.attempt_id, claims.LOST) + self.assertEqual(raised.exception.code, "CLAIM_INVALID") + + def test_a_pending_attempt_survives_a_lifecycle_update_byte_identically(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + attempt = self.attempt() + path = claims.begin(self.project, attempt) + before = digest_file(path) + from ainative.lifecycle import installer + + installer.install(self.project, "standard", operation="update", + distribution=self.distribution, source=self.source) + self.assertEqual(digest_file(path), before, + "a lifecycle update touched a pending claim attempt") + self.assertEqual(statelib.SCHEMA_VERSION, + statelib.load(self.project).schema_version) + + def test_the_cli_lists_inspects_and_abandons(self): + attempt = self.attempt() + claims.begin(self.project, attempt) + listed = json.loads(self.cli("claim-attempt", "list", "--json").stdout) + self.assertEqual(listed["unresolved"], [attempt.attempt_id]) + inspected = json.loads(self.cli("claim-attempt", "inspect", + attempt.attempt_id, "--json").stdout) + self.assertEqual(inspected["marker"], attempt.marker) + refused = self.cli("claim-attempt", "abandon", attempt.attempt_id) + self.assertEqual(refused.returncode, 2, "abandon without --confirm must refuse") + abandoned = json.loads(self.cli("claim-attempt", "abandon", attempt.attempt_id, + "--confirm", "--json").stdout) + self.assertEqual(abandoned["state"], claims.ABANDONED) + self.assertEqual(json.loads(self.cli("claim-attempt", "list", "--json").stdout) + ["unresolved"], []) + + +class ForgeObservation(LifecycleTestCase): + """`forge detect|status` and the doctor extension: observe, never mutate.""" + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def remote(self, name: str, url: str) -> None: + subprocess.run(["git", "-C", str(self.project), "remote", "add", name, url], + check=True, capture_output=True) + + def test_detect_resolves_a_single_hosted_remote(self): + self.remote("origin", "git@github.com:o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["resolution"]["state"], "RESOLVED") + self.assertEqual(record["resolution"]["authority"], + {"provider": "github", "project": "o/r"}) + + def test_a_fork_renders_ambiguous_without_preferring_origin(self): + self.remote("origin", "https://github.com/me/fork.git") + self.remote("upstream", "https://github.com/org/project.git") + completed = self.cli("forge", "detect", "--json") + self.assertEqual(completed.returncode, 0, "detection is diagnostic, not a failure") + record = json.loads(completed.stdout) + self.assertEqual(record["resolution"]["state"], "AMBIGUOUS") + self.assertEqual([item["name"] for item in record["remotes"]], + ["origin", "upstream"]) + self.assertIn("Resolution: AMBIGUOUS", self.cli("forge", "detect").stdout) + + def test_an_unknown_host_is_unavailable_not_guessed(self): + self.remote("origin", "https://git.example.com/o/r.git") + record = json.loads(self.cli("forge", "detect", "--json").stdout) + self.assertEqual(record["remotes"][0]["provider"], "unknown") + self.assertEqual(record["resolution"]["state"], "UNAVAILABLE") + + def test_forge_status_reports_features_and_claim_attempts(self): + self.install("standard") + claims.begin(self.project, claims.new_attempt( + authority=forgelib.WorkAuthorityRef(provider="github", project="o/r"), + item="42", principal=claims.principal("github", "alice"))) + record = json.loads(self.cli("forge", "status", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(len(record["claim_attempts"]["unresolved"]), 1) + + def test_observation_commands_never_write(self): + self.install("standard") + self.remote("origin", "git@github.com:o/r.git") + before = self.snapshot() + self.cli("forge", "detect") + self.cli("forge", "status") + self.cli("doctor") + self.assertEqual(self.snapshot(), before, + "an observation command touched the project") + + def test_doctor_reports_features_forge_and_claims(self): + self.install("standard") + self.remote("origin", "git@gitlab.com:g/p.git") + record = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(record["features"]["active"], ["forge-github"]) + self.assertEqual(record["forge"]["resolution"]["authority"]["provider"], "gitlab") + self.assertEqual(record["claim_attempts"]["journal"], "ok") + self.assertEqual(record["claim_attempts"]["unresolved"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_github_work_skills.py b/tests/test_github_work_skills.py index 3a8b0bda..0a073de0 100644 --- a/tests/test_github_work_skills.py +++ b/tests/test_github_work_skills.py @@ -458,8 +458,12 @@ def test_boy_scout_under_15_min_rule_is_gone(self) -> None: self.assertIn('Scoped cleanup', self.AGENTS) def test_github_workflow_lists_implementation_economy(self) -> None: + # The generic rule moved to the provider-neutral policy (ADR-0018); + # the GitHub mapping must still name the skill and point at it. self.assertIn('implementation-economy', self.WORKFLOW) - self.assertIn('it cannot alter Issue scope or Acceptance Criteria', self.WORKFLOW) + neutral = (REPO / 'docs' / 'FORGE-WORKFLOW.md').read_text(encoding='utf-8') + self.assertIn('implementation-economy', neutral) + self.assertIn('it cannot alter scope or acceptance criteria', neutral) def test_preexisting_deletion_imperatives_are_scoped(self) -> None: diff --git a/tests/test_lifecycle_features.py b/tests/test_lifecycle_features.py new file mode 100644 index 00000000..742f9957 --- /dev/null +++ b/tests/test_lifecycle_features.py @@ -0,0 +1,452 @@ +"""The feature model, State V2, and the one projection every reader shares. + +ADR-0017 makes profiles (governance) and features (optional project-scope +capabilities) orthogonal. A V1 state predates `active_features`; it projects to +the declared legacy default — a GitHub project until it explicitly switches — +and migrates inside a normal mutation, state-last. These tests pin the model, +the projection and the migration, including every refusal that must never be a +silent pick: two work forges, an undeclared feature, a malformed catalogue. +""" + +from __future__ import annotations + +import json +import subprocess +import unittest +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import features as featureslib +from ainative.lifecycle import manifest as manifestlib +from ainative.lifecycle import state as statelib +from ainative.lifecycle.errors import LifecycleError + +LEGACY = "forge-github" +GITLAB = "forge-gitlab" + + +def write_catalogue(directory: Path, *, features: dict, legacy: str = "forge") -> Path: + """A minimal catalogue: one component, one profile, the given features.""" + + directory.mkdir(parents=True, exist_ok=True) + write_text(directory / "components.json", json.dumps({ + "schema_version": 1, + "components": {"tpl": {"kind": "file", "ownership": "MANAGED_MUTABLE", + "source": "AGENTS.md", "destination": "TEMPLATE.md"}}})) + write_text(directory / "profiles.json", json.dumps({ + "schema_version": 1, "default": "standard", + "profiles": {"standard": {"extends": None, "components": []}}})) + write_text(directory / "features.json", json.dumps({ + "schema_version": 1, "legacy_default": legacy, "features": features})) + return directory + + +class FeatureCatalogue(unittest.TestCase): + + def setUp(self) -> None: + import shutil + import tempfile + + self.root = Path(tempfile.mkdtemp(prefix="ainative-features-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.distribution = manifestlib.load() + + def feature(self, name: str, **fields) -> dict: + base = {"scope": "project", "work_forge": False, "components": [], "conflicts": []} + base.update(fields) + return base + + def test_the_real_catalogue_declares_both_work_forges_and_a_default(self): + github = self.distribution.feature(LEGACY) + gitlab = self.distribution.feature(GITLAB) + self.assertEqual(self.distribution.legacy_default_feature, LEGACY) + for feature in (github, gitlab): + self.assertEqual(feature.scope, manifestlib.FEATURE_SCOPE_PROJECT) + self.assertTrue(feature.work_forge) + self.assertIn(GITLAB, github.conflicts) + self.assertIn(LEGACY, gitlab.conflicts) + self.assertEqual(github.components, ("github-templates",)) + self.assertEqual(gitlab.components, ("gitlab-templates",)) + + def test_an_undeclared_feature_id_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.distribution.feature("forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_machine_scope_entry_is_not_a_feature(self): + directory = write_catalogue( + self.root / "machine", + features={"hal": self.feature("hal", scope="machine")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_asymmetric_conflicts_are_refused(self): + directory = write_catalogue( + self.root / "asymmetric", + features={"a": self.feature("a", components=["tpl"], conflicts=["b"]), + "b": self.feature("b", components=[])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_component_claimed_by_two_features_is_refused(self): + directory = write_catalogue( + self.root / "claimed", + features={"a": self.feature("a", components=["tpl"]), + "b": self.feature("b", components=["tpl"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_a_feature_referencing_an_unknown_component_is_refused(self): + directory = write_catalogue( + self.root / "unknown-component", + features={"a": self.feature("a", components=["ghost"])}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + def test_the_legacy_default_must_be_declared(self): + directory = write_catalogue( + self.root / "legacy", legacy="ghost", + features={"a": self.feature("a")}) + with self.assertRaises(LifecycleError) as raised: + manifestlib.load(directory) + self.assertEqual(raised.exception.code, "MANIFEST_INVALID") + + +class StateProjection(LifecycleTestCase): + """One projection, one answer — whatever schema is on disk.""" + + def setUp(self) -> None: + super().setUp() + self.install("standard") + + def v1_record(self) -> dict: + """Rewrite the state as the release before V2 wrote it.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def effective(self): + return featureslib.project_install_state( + statelib.load(self.project), manifestlib.load()) + + def test_a_v1_project_projects_to_the_legacy_default(self): + self.v1_record() + effective = self.effective() + self.assertEqual(effective.schema_version, 1) + self.assertEqual(effective.active_features, (LEGACY,)) + self.assertTrue(effective.projected_from_legacy) + self.assertTrue(effective.has_feature(LEGACY)) + + def test_a_v1_verified_project_projects_to_the_legacy_default(self): + self.switch("verified") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_a_gitlab_remote_still_projects_to_the_legacy_default(self): + subprocess.run(["git", "-C", str(self.project), "remote", "add", "origin", + "https://gitlab.com/org/project.git"], check=True, + capture_output=True) + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_a_v1_project_with_no_remote_still_projects_to_the_legacy_default(self): + remotes = subprocess.run(["git", "-C", str(self.project), "remote"], + check=True, capture_output=True, text=True) + self.assertEqual(remotes.stdout.strip(), "") + self.v1_record() + self.assertEqual(self.effective().active_features, (LEGACY,)) + + def test_the_projection_is_read_only(self): + from ainative.lifecycle.digest import digest_file + + self.v1_record() + path = statelib.state_path(self.project) + before = digest_file(path) + self.effective() + self.effective() + self.assertEqual(digest_file(path), before) + + def test_a_v2_state_with_one_forge_projects_it(self): + state = statelib.load(self.project) + state.active_features = [GITLAB] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, (GITLAB,)) + self.assertFalse(self.effective().projected_from_legacy) + + def test_a_v2_state_with_no_forge_is_valid(self): + state = statelib.load(self.project) + state.active_features = [] + statelib.save(self.project, state) + self.assertEqual(self.effective().active_features, ()) + + def test_a_v2_state_with_two_work_forges_is_refused(self): + state = statelib.load(self.project) + state.active_features = [LEGACY, GITLAB] + statelib.save(self.project, state) + from ainative.lifecycle.digest import digest_file + + before = digest_file(statelib.state_path(self.project)) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "STATE_CONFLICTING_WORK_FORGE_FEATURES") + self.assertEqual(digest_file(statelib.state_path(self.project)), before, + "a refused projection rewrote the state") + + def test_a_v2_state_naming_an_undeclared_feature_is_refused(self): + state = statelib.load(self.project) + state.active_features = ["forge-sourceforge"] + statelib.save(self.project, state) + with self.assertRaises(LifecycleError) as raised: + self.effective() + self.assertEqual(raised.exception.code, "INSTALL_STATE_CORRUPTED") + + def test_the_projection_is_idempotent(self): + self.v1_record() + first = self.effective() + second = self.effective() + self.assertEqual(first, second) + state = statelib.load(self.project) + statelib.save(self.project, state) + self.assertEqual(self.effective(), second) + + +class StateMigration(LifecycleTestCase): + + def snapshot(self, *, skip_state: bool = True) -> dict: + from ainative.lifecycle.digest import digest_file + + state_path = statelib.state_path(self.project) + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") + if path.is_file() and not (skip_state and path == state_path)} + + def v1_record(self) -> dict: + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + return record + + def test_a_no_op_install_migrates_a_v1_state(self): + self.install("standard") + self.v1_record() + before = self.snapshot() + self.install("standard") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + self.assertEqual(self.snapshot(), before, + "the migration changed managed files") + + def test_migration_is_idempotent(self): + from ainative.lifecycle.digest import digest_file + + self.install("standard") + self.v1_record() + self.install("standard") + path = statelib.state_path(self.project) + migrated = digest_file(path) + self.install("standard") + self.assertEqual(digest_file(path), migrated, + "a second install rewrote the migrated state") + self.assertEqual(statelib.load(self.project).active_features, [LEGACY]) + + def test_migrate_state_leaves_a_current_state_untouched(self): + state = statelib.InstallState(active_features=[GITLAB]) + migrated = featureslib.migrate_state(state, manifestlib.load()) + self.assertIs(migrated, state) + self.assertEqual(migrated.active_features, [GITLAB]) + + def test_the_effective_feature_set_is_identical_across_the_migration(self): + """Read-only parity: projection before == projection after (ADR-0017 §4).""" + + self.install("standard") + self.v1_record() + before = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.install("standard") + after = featureslib.project_install_state(statelib.load(self.project), + manifestlib.load()) + self.assertEqual(before.active_features, after.active_features) + self.assertEqual(before.active_profile, after.active_profile) + + +class FeatureSwitching(LifecycleTestCase): + """One transaction per transition; ownership decides every file.""" + + TEMPLATES = { + "github": {"ISSUE_TEMPLATE/bug.md": "# bug\n", + "ISSUE_TEMPLATE/feature.md": "# feature\n", + "PULL_REQUEST_TEMPLATE.md": "# pr\n"}, + "gitlab": {"issue_templates/bug.md": "# bug\n", + "issue_templates/feature.md": "# feature\n", + "merge_request_templates/default.md": "# mr\n"}, + } + + def setUp(self) -> None: + super().setUp() + for forge, files in self.TEMPLATES.items(): + for relative, content in files.items(): + write_text(self.distribution_root / "templates" / forge / relative, content) + self.install("standard") + + def switch_features(self, **kwargs): + from ainative.lifecycle import installer + + return installer.set_features(self.project, distribution=self.distribution, + source=self.source, **kwargs) + + def features_now(self) -> list[str]: + return statelib.load(self.project).active_features + + def state_digest(self) -> str: + from ainative.lifecycle.digest import digest_file + + return digest_file(statelib.state_path(self.project)) + + def test_switch_replaces_the_work_forge_in_one_transaction(self): + result = self.switch_features(switch_to=GITLAB) + self.assertTrue(result.applied) + self.assertEqual(self.features_now(), [GITLAB]) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the previous forge's template survived the switch") + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md")) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# bug\n") + state = statelib.load(self.project) + self.assertEqual([entry.path for entry + in state.files_for_component("gitlab-templates")], + [".gitlab/issue_templates/bug.md", + ".gitlab/issue_templates/feature.md", + ".gitlab/merge_request_templates/default.md"]) + + def test_switch_none_leaves_generic_git(self): + self.switch_features(switch_to="none") + self.assertEqual(self.features_now(), []) + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md")) + + def test_disable_preserves_a_modified_file(self): + self.write(".github/PULL_REQUEST_TEMPLATE.md", "# mine\n") + self.switch_features(disable=LEGACY) + self.assertEqual(self.features_now(), []) + self.assertEqual(self.read(".github/PULL_REQUEST_TEMPLATE.md"), "# mine\n") + self.assertFalse(self.exists(".github/ISSUE_TEMPLATE/bug.md"), + "an unchanged template was not removed by disable") + + def test_enable_seeds_the_absent_files(self): + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/merge_request_templates/default.md")) + + def test_enable_of_an_active_feature_is_a_no_op(self): + before = self.state_digest() + result = self.switch_features(enable=LEGACY) + self.assertFalse(result.applied) + self.assertTrue(result.plan.is_noop) + self.assertEqual(self.state_digest(), before) + + def test_enable_of_a_conflicting_feature_names_the_remedy(self): + before = self.state_digest() + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable=GITLAB) + self.assertEqual(raised.exception.code, "FEATURE_CONFLICT") + self.assertIn(f"feature switch {GITLAB}", raised.exception.message) + self.assertEqual(self.features_now(), [LEGACY]) + self.assertEqual(self.state_digest(), before) + + def test_an_unknown_feature_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.switch_features(enable="forge-sourceforge") + self.assertEqual(raised.exception.code, "FEATURE_UNKNOWN") + + def test_a_feature_command_needs_an_installed_project(self): + empty = self.root / "empty" + empty.mkdir() + from ainative.lifecycle import installer + + with self.assertRaises(LifecycleError) as raised: + installer.set_features(empty, disable=LEGACY, distribution=self.distribution, + source=self.source) + self.assertEqual(raised.exception.code, "NOT_INSTALLED") + + def test_an_absent_feature_file_stays_absent_through_an_install(self): + self.switch_features(switch_to=GITLAB) + (self.project / ".gitlab" / "issue_templates" / "bug.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".gitlab/issue_templates/bug.md"), + "an install re-seeded a feature file the user removed") + self.switch_features(switch_to="none") + self.switch_features(enable=GITLAB) + self.assertTrue(self.exists(".gitlab/issue_templates/bug.md"), + "an explicit enable did not seed the feature's files") + + def test_the_migration_does_not_resurrect_an_absent_template(self): + """ADR-0017 section 5: the fixture pinned by PR-1B.""" + + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + (self.project / ".github" / "PULL_REQUEST_TEMPLATE.md").unlink() + self.install("standard") + self.assertFalse(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the migration resurrected an absent managed file") + state = statelib.load(self.project) + self.assertEqual(state.schema_version, statelib.SCHEMA_VERSION) + self.assertEqual(state.active_features, [LEGACY]) + + def test_a_round_trip_loses_no_user_data(self): + self.write(".github/ISSUE_TEMPLATE/bug.md", "# mine\n") + self.switch_features(switch_to=GITLAB) + self.switch_features(switch_to="none") + self.switch_features(enable=LEGACY) + self.assertEqual(self.read(".github/ISSUE_TEMPLATE/bug.md"), "# mine\n") + self.assertEqual(self.features_now(), [LEGACY]) + self.assertTrue(self.exists(".github/PULL_REQUEST_TEMPLATE.md"), + "the unmodified templates were not restored by the enable") + + def test_a_user_file_where_a_feature_file_belongs_is_never_adopted(self): + self.write(".gitlab/issue_templates/bug.md", "# mine\n") + result = self.switch_features(switch_to=GITLAB) + self.assertEqual(self.read(".gitlab/issue_templates/bug.md"), "# mine\n") + conflicts = [change.path for change in result.plan.changes + if change.action == "CONFLICT"] + self.assertEqual(conflicts, [".gitlab/issue_templates/bug.md"]) + self.assertTrue(self.exists(".gitlab/issue_templates/feature.md"), + "the other files of the enabled feature were not seeded") + state = statelib.load(self.project) + self.assertIsNone(state.file_for(".gitlab/issue_templates/bug.md"), + "the user's file was adopted into the state") + + def test_the_cli_switches_and_reports(self): + status = json.loads(self.cli("feature", "status", "--json").stdout) + self.assertEqual(status["active_features"], [LEGACY]) + self.assertTrue(status["installed"]) + switched = json.loads(self.cli("feature", "switch", GITLAB, "--json").stdout) + self.assertTrue(switched["applied"]) + self.assertEqual(switched["active_features"], [GITLAB]) + self.assertEqual(self.features_now(), [GITLAB]) + + + def test_status_reports_the_same_effective_features_as_the_projection(self): + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["features"], [LEGACY]) + self.assertTrue(report["features_projected_from_legacy"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_lifecycle_security.py b/tests/test_lifecycle_security.py index ad1424c4..5dd32b5e 100644 --- a/tests/test_lifecycle_security.py +++ b/tests/test_lifecycle_security.py @@ -212,6 +212,13 @@ def _write(self, directory: Path, components: dict, profiles: dict) -> Path: (directory / "profiles.json").write_text( json.dumps({"schema_version": 1, "default": "standard", "profiles": profiles}), encoding="utf-8") + # The feature catalogue is a third required manifest (ADR-0017); these + # fixtures are about components and profiles, so it is minimal. + (directory / "features.json").write_text( + json.dumps({"schema_version": 1, "legacy_default": "forge", + "features": {"forge": {"scope": "project", "work_forge": True, + "components": [], "conflicts": []}}}), + encoding="utf-8") return directory def setUp(self) -> None: diff --git a/tests/test_lifecycle_update_integrity.py b/tests/test_lifecycle_update_integrity.py index 564dc613..b5a7d255 100644 --- a/tests/test_lifecycle_update_integrity.py +++ b/tests/test_lifecycle_update_integrity.py @@ -133,7 +133,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): mutated[-1] ^= 0xFF provider = self.provider(self.document(), bytes(mutated)) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") @@ -142,7 +143,8 @@ def test_one_flipped_byte_refuses_before_any_write(self): def test_the_official_path_updates_end_to_end(self): provider = self.provider(self.document(), self.archive_bytes) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.apply(self.project, distribution=self.distribution) self.assertTrue(result.applied) self.assertEqual(result.to_version, "2.0.0") diff --git a/tests/test_lifecycle_update_transport.py b/tests/test_lifecycle_update_transport.py index 6ccaef3c..a597b062 100644 --- a/tests/test_lifecycle_update_transport.py +++ b/tests/test_lifecycle_update_transport.py @@ -368,7 +368,8 @@ def test_a_bridge_style_release_stays_selectable_by_the_v2_runtime(self): def test_check_reports_a_future_release_as_available_with_a_cli_upgrade(self): self.install("standard") provider = self.provider(self.v3_document()) - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): result = updaterlib.check(self.project, force=True, record=False) self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) self.assertEqual(result.latest, "3.0.0") @@ -378,7 +379,8 @@ def test_apply_refuses_a_future_release_before_any_write(self): self.install("standard") provider = self.provider(self.v3_document()) before = self.snapshot() - with mock.patch.object(providerlib, "build", lambda channel="stable": provider): + with mock.patch.object(providerlib, "build", lambda channel="stable": provider), \ + mock.patch.object(providerlib, "build_v3", lambda channel="stable": None): with self.assertRaises(LifecycleError) as raised: updaterlib.apply(self.project, distribution=self.distribution) self.assertEqual(raised.exception.code, "CLI_UPDATE_REQUIRED") diff --git a/tests/test_lifecycle_update_v3.py b/tests/test_lifecycle_update_v3.py new file mode 100644 index 00000000..e73e0e82 --- /dev/null +++ b/tests/test_lifecycle_update_v3.py @@ -0,0 +1,122 @@ +"""The V3 update path, end to end on a local mirror. + +The updater prefers V3 when the source publishes an anchored manifest, and +falls back to the V2 path only when a complete enumeration proves the channel +is V2-era. Everything that can refuse happens before the first project write. +""" + +from __future__ import annotations + +import json +import unittest +import zipfile +from hashlib import sha256 +from pathlib import Path + +from tests.lifecycle_support import LifecycleTestCase, build_distribution_tree, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import state as statelib +from ainative.lifecycle import updater as updaterlib +from ainative.lifecycle.errors import LifecycleError + +TARGET = "2.0.0" + + +def make_v3_bundle(directory: Path, version: str, *, protocol: int = 3) -> Path: + tree = build_distribution_tree(directory / f"dist-{version}", version) + path = directory / release_v3lib.lifecycle_bundle_name(version) + protocol_document = {"schema_name": "lifecycle_protocol", + "protocol_version": protocol, "release_version": version, + "payload_root": "stack"} + with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as archive: + archive.writestr("lifecycle-protocol.json", json.dumps(protocol_document)) + for item in sorted(tree.rglob("*")): + if item.is_file(): + archive.write(item, f"stack/{item.relative_to(tree).as_posix()}") + return path + + +class V3MirrorUpdate(LifecycleTestCase): + + def setUp(self) -> None: + super().setUp() + self.releases = self.root / "releases" + (self.releases / TARGET).mkdir(parents=True) + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET) + self.manifest_document = { + "schema": "ainative.release", "protocol": "v3", "version": TARGET, + "channel": "stable", + "compatibility": {"runtime_version": TARGET}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(TARGET), + "kind": "lifecycle", "version": TARGET, + "sha256": "", "size": 0}], + "provenance": {"source": "test"}, + } + self.record_bundle() + self.publish() + self.set_env(providerlib.PROVIDER_ENV, "local") + self.set_env(providerlib.LOCAL_SOURCE_ENV, str(self.releases)) + self.install("standard") + self.assume_runtime(TARGET) + + def record_bundle(self) -> None: + payload = self.bundle.read_bytes() + self.manifest_document["artifacts"][0]["sha256"] = sha256(payload).hexdigest() + self.manifest_document["artifacts"][0]["size"] = len(payload) + + def publish(self, *, sha: str | None = None, size: int | None = None) -> None: + manifest = json.dumps(self.manifest_document).encode("utf-8") + write_text(self.releases / TARGET / "ainative-release-v3.json", + manifest.decode()) + index = {"channels": {"stable": {"version": TARGET, "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(manifest), + "sha256": sha if sha is not None else sha256(manifest).hexdigest()}}}} + write_text(self.releases / "releases.json", json.dumps(index)) + + def snapshot(self) -> dict: + from ainative.lifecycle.digest import digest_file + + return {path.relative_to(self.project).as_posix(): digest_file(path) or "" + for path in self.project.rglob("*") if path.is_file()} + + def test_check_sees_the_v3_release(self): + result = updaterlib.check(self.project, force=True, record=False) + self.assertEqual(result.status, updaterlib.UPDATE_AVAILABLE) + self.assertEqual(result.latest, TARGET) + + def test_a_v3_update_applies_end_to_end(self): + result = updaterlib.apply(self.project, distribution=self.distribution) + self.assertTrue(result.applied) + self.assertEqual(result.to_version, TARGET) + self.assertEqual(self.read("AGENTS.md"), f"# Engineering method {TARGET}\n") + self.assertEqual(statelib.load(self.project).stack_version, TARGET) + self.assertTrue(result.rollback_available) + + def test_a_tampered_v3_manifest_is_refused_before_any_write(self): + self.publish(sha="a" * 64) + before = self.snapshot() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + self.assertEqual(self.snapshot(), before, + "a refused V3 update touched the project") + + def test_a_v3_bundle_carrying_the_v2_protocol_is_refused(self): + self.bundle.unlink() + self.bundle = make_v3_bundle(self.releases / TARGET, TARGET, protocol=2) + self.record_bundle() + self.publish() + with self.assertRaises(LifecycleError) as raised: + updaterlib.apply(self.project, distribution=self.distribution) + self.assertEqual(raised.exception.code, "UPDATE_VERSION_MISMATCH") + + def test_the_cli_reports_the_v3_check(self): + record = json.loads(self.cli("update", "check", "--force", "--json").stdout) + self.assertEqual(record["status"], updaterlib.UPDATE_AVAILABLE) + self.assertEqual(record["latest"], TARGET) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_providers.py b/tests/test_release_providers.py new file mode 100644 index 00000000..7851604b --- /dev/null +++ b/tests/test_release_providers.py @@ -0,0 +1,484 @@ +"""The V3 providers: GitHub, the anonymous document, and the local mirror. + +Providers fetch; release_v3 decides. These tests pin the fetching contract: +what is a candidate (drafts, non-SemVer tags and V2-era releases are not), how +the anchor travels from provider metadata, that a bounded listing says so, that +credentials follow the endpoint rule, and that the local mirror runs the same +logical verification chain as a network source. +""" + +from __future__ import annotations + +import json +import unittest +import urllib.error +import urllib.request +from hashlib import sha256 +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest import mock + +from tests.lifecycle_support import write_text +from ainative.lifecycle import release_providers as providerslib +from ainative.lifecycle import release_v3 as release_v3lib +from ainative.lifecycle import transport as transportlib +from ainative.lifecycle.errors import LifecycleError + + +class FakeResponse: + def __init__(self, payload: bytes) -> None: + self.payload = payload + + def read(self, limit: int) -> bytes: + return self.payload[:limit] + + def __enter__(self) -> "FakeResponse": + return self + + def __exit__(self, *args: object) -> bool: + return False + + +class ScriptedServer: + def __init__(self, hops: dict) -> None: + self.hops = hops + self.observed: list[tuple[str, str | None, str | None]] = [] + + def send(self, request: urllib.request.Request): + url = request.full_url + headers = {key.lower(): value for key, value in request.headers.items()} + self.observed.append((url, + headers.get("authorization") + or headers.get("private-token"), + headers.get("accept"))) + if url not in self.hops: + raise AssertionError(f"unexpected request: {url}") + answer = self.hops[url] + if isinstance(answer, tuple) and answer[0] == "redirect": + raise urllib.error.HTTPError(url, 302, "Found", + {"Location": answer[1]}, None) + return FakeResponse(answer) + + +LIST_URL = "https://api.github.com/repos/o/r/releases?per_page=30" +MANIFEST_URL = "https://api.github.com/repos/o/r/releases/assets/1" + + +def release_document(version: str = "2.5.0", *, tag: str | None = None, + prerelease: bool = False, draft: bool = False, + manifest: bool = True, digest: str | None = "sha256:" + "a" * 64, + size: int | None = 10, artifact: bool = True) -> dict: + tag = tag or f"v{version}" + assets = [] + if manifest: + asset = {"name": "ainative-release-v3.json", "url": MANIFEST_URL, + "browser_download_url": f"https://github.com/o/r/releases/download/{tag}/" + "ainative-release-v3.json"} + if digest is not None: + asset["digest"] = digest + if size is not None: + asset["size"] = size + assets.append(asset) + if artifact: + assets.append({"name": release_v3lib.lifecycle_bundle_name(version), + "url": "https://api.github.com/repos/o/r/releases/assets/2", + "digest": "sha256:" + "b" * 64, "size": 20}) + return {"tag_name": tag, "prerelease": prerelease, "draft": draft, "assets": assets} + + +def anchored_manifest(version: str = "2.5.0") -> bytes: + document = { + "schema": "ainative.release", "protocol": "v3", "version": version, + "channel": "stable", + "compatibility": {"runtime_version": version}, + "artifacts": [{"name": release_v3lib.lifecycle_bundle_name(version), + "kind": "lifecycle", "version": version, + "sha256": "b" * 64, "size": 20}], + "provenance": {"source": "test"}, + } + return json.dumps(document).encode("utf-8") + + +def github_provider(server: ScriptedServer, **kwargs): + provider = providerslib.GitHubReleaseProvider( + transportlib.GITHUB_ENDPOINT, repository="o/r", **kwargs) + return provider, mock.patch.object(transportlib, "_send", server.send) + + +class GitHubEnumerate(unittest.TestCase): + + def enumerate(self, documents: list, **kwargs): + bound = kwargs.get("page_bound", 30) + server = ScriptedServer({ + f"https://api.github.com/repos/o/r/releases?per_page={bound}": + json.dumps(documents).encode("utf-8")}) + provider, patcher = github_provider(server, **kwargs) + with patcher: + return provider.enumerate(release_v3lib.ReleaseQuery("stable")) + + def test_a_v3_release_becomes_a_candidate_with_its_anchor(self): + result = self.enumerate([release_document()]) + self.assertTrue(result.complete) + candidate = result.candidates[0] + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_sha256, "a" * 64) + self.assertEqual(candidate.manifest_size, 10) + self.assertEqual(candidate.manifest_locator, MANIFEST_URL) + + def test_drafts_prereleases_and_non_v3_releases_are_not_candidates(self): + result = self.enumerate([ + release_document(draft=True), + release_document(version="2.4.0", prerelease=True), + release_document(version="2.3.0", manifest=False), + release_document(version="2.2.0", tag="nightly"), + ]) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.4.0"]) + self.assertEqual(result.candidates[0].channel, "beta") + + def test_a_full_page_is_an_incomplete_enumeration(self): + result = self.enumerate([release_document(), release_document(version="2.4.0")], + page_bound=2) + self.assertFalse(result.complete) + + def test_a_missing_digest_is_carried_as_a_missing_anchor(self): + result = self.enumerate([release_document(digest=None, size=None)]) + self.assertIsNone(result.candidates[0].manifest_sha256) + self.assertIsNone(result.candidates[0].manifest_size) + + +class GitHubFetching(unittest.TestCase): + + def provider(self, server: ScriptedServer): + return github_provider(server) + + def test_fetch_manifest_uses_the_locator_with_the_artifact_policy(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_manifest(candidate) + self.assertEqual(payload, manifest) + self.assertEqual(server.observed[1][0], MANIFEST_URL) + self.assertEqual(server.observed[1][1], "Bearer ghp_" + "x" * 36) + self.assertEqual(server.observed[1][2], transportlib.ACCEPT_OCTET_STREAM) + + def test_fetch_artifact_picks_the_named_asset(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + "https://api.github.com/repos/o/r/releases/assets/2": b"bundle-bytes"}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name=release_v3lib.lifecycle_bundle_name("2.5.0"), kind="lifecycle", + version="2.5.0", sha256="b" * 64, size=20)) + self.assertEqual(payload, b"bundle-bytes") + + def test_an_unknown_asset_is_refused(self): + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8")}) + provider, patcher = self.provider(server) + with patcher: + candidate = provider.enumerate(release_v3lib.ReleaseQuery("stable")).candidates[0] + with self.assertRaises(LifecycleError) as raised: + provider.fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="other.zip", kind="lifecycle", version="2.5.0", + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitHubReleaseProvider(transportlib.GITHUB_ENDPOINT, + repository="o/r") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_resolve_manifest_runs_the_whole_chain(self): + manifest = anchored_manifest() + server = ScriptedServer({ + LIST_URL: json.dumps([release_document()]).encode("utf-8"), + MANIFEST_URL: manifest}) + provider, patcher = self.provider(server) + # The candidate's anchor must match the served manifest for the chain + # to pass: rebuild the listing with the real digest and size. + document = release_document(digest="sha256:" + sha256(manifest).hexdigest(), + size=len(manifest)) + server.hops[LIST_URL] = json.dumps([document]).encode("utf-8") + with patcher: + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(parsed.version, "2.5.0") + + +class AnonymousDocument(unittest.TestCase): + + URL = "https://mirror.example/releases/latest" + + def provider(self, document: object): + server = ScriptedServer({self.URL: json.dumps(document).encode("utf-8")}) + provider = providerslib.AnonymousReleaseApiProvider(self.URL) + return provider, mock.patch.object(transportlib, "_send", server.send), server + + def test_a_v3_document_yields_one_candidate_and_no_credential(self): + provider, patcher, server = self.provider(release_document()) + with patcher, mock.patch.dict("os.environ", {"GITHUB_TOKEN": "ghp_" + "x" * 36}): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual(len(result.candidates), 1) + self.assertEqual(server.observed[0][1], None, + "the anonymous source received a credential") + + def test_a_v2_document_yields_no_candidate(self): + provider, patcher, _server = self.provider(release_document(manifest=False)) + with patcher: + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + +class LocalMirror(unittest.TestCase): + + VERSION = "2.5.0" + + def setUp(self) -> None: + self.directory = TemporaryDirectory(prefix="ainative-mirror-") + self.addCleanup(self.directory.cleanup) + self.root = Path(self.directory.name) + self.manifest = anchored_manifest(self.VERSION) + write_text(self.root / self.VERSION / "ainative-release-v3.json", self.manifest.decode()) + write_text(self.root / self.VERSION / release_v3lib.lifecycle_bundle_name(self.VERSION), + "bundle-bytes\n") + self.publish() + + def publish(self, *, sha: str | None = None, size: int | None = None, + version: str = "2.5.0") -> None: + payload = {"channels": {"stable": { + "version": version, + "manifest": { + "file": "ainative-release-v3.json", + "size": size if size is not None else len(self.manifest), + "sha256": sha if sha is not None else sha256(self.manifest).hexdigest(), + }}}} + write_text(self.root / "releases.json", json.dumps(payload)) + + def provider(self) -> providerslib.LocalReleaseProvider: + return providerslib.LocalReleaseProvider(self.root) + + def test_the_whole_chain_runs_on_a_local_mirror(self): + candidate, manifest = release_v3lib.resolve_manifest( + self.provider(), release_v3lib.ReleaseQuery("stable")) + self.assertEqual(candidate.version, self.VERSION) + self.assertEqual(manifest.version, self.VERSION) + payload = self.provider().fetch_artifact(candidate, manifest.lifecycle_artifact()) + self.assertEqual(payload, b"bundle-bytes\n") + + def test_a_tampered_manifest_is_refused_by_the_anchor(self): + write_text(self.root / self.VERSION / "ainative-release-v3.json", + self.manifest.decode().replace("2.5.0", "2.5.1")) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.resolve_manifest(self.provider(), + release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "UPDATE_INTEGRITY_FAILED") + + def test_a_v2_era_index_entry_is_not_a_candidate(self): + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(len(result.candidates), 1) + write_text(self.root / "releases.json", + json.dumps({"channels": {"stable": {"version": "2.5.0", + "archive": "stack.zip", + "sha256": "0" * 64}}})) + result = self.provider().enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(result.candidates, ()) + with self.assertRaises(LifecycleError) as raised: + release_v3lib.select_candidate(result, release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_NO_CANDIDATE") + + def test_a_traversing_locator_cannot_leave_the_mirror(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable", + manifest_locator="../outside/ainative-release-v3.json") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_manifest(candidate) + self.assertEqual(raised.exception.code, "PATH_ESCAPE") + + def test_a_missing_artifact_is_refused(self): + candidate = release_v3lib.ReleaseCandidate( + version=self.VERSION, identity="local:stable") + with self.assertRaises(LifecycleError) as raised: + self.provider().fetch_artifact( + candidate, release_v3lib.ManifestArtifact( + name="never-shipped.zip", kind="lifecycle", version=self.VERSION, + sha256="b" * 64, size=1)) + self.assertEqual(raised.exception.code, "UPDATE_UNAVAILABLE") + + +GITLAB_ENDPOINT = transportlib.ReleaseProviderEndpointConfig( + provider="gitlab", api_base_url="https://gitlab.com/api/v4", + auth_origin="https://gitlab.com", auth_header="PRIVATE-TOKEN", auth_prefix="") + +RELEASES_URL = "https://gitlab.com/api/v4/projects/group%2Fproject/releases?per_page=30" +PACKAGES_URL = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + "?package_type=generic&package_name=ai-native-dev-stack&per_page=30") +MANIFEST_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/101/download") +BUNDLE_DOWNLOAD = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + "/package_files/102/download") + + +def package_files(manifest_bytes: bytes, *, duplicate_manifest: bool = False, + digest: bool = True, digest_value: str | None = None, + size: bool = True) -> list: + manifest_entry = {"id": 101, "file_name": "ainative-release-v3.json"} + if digest: + manifest_entry["file_sha256"] = digest_value or sha256(manifest_bytes).hexdigest() + if size: + manifest_entry["size"] = len(manifest_bytes) + files = [manifest_entry, {"id": 102, "file_name": release_v3lib.lifecycle_bundle_name("2.5.0"), + "file_sha256": "b" * 64, "size": 20}] + if duplicate_manifest: + files.append({"id": 103, "file_name": "ainative-release-v3.json", + "file_sha256": "c" * 64, "size": 10}) + return files + + +class GitLabProvider(unittest.TestCase): + + def server(self, *, files: list | None = None, releases: list | None = None, + packages: list | None = None, manifest: bytes | None = None, + page_bound: int = 30) -> ScriptedServer: + manifest = manifest if manifest is not None else anchored_manifest() + releases_url = (f"https://gitlab.com/api/v4/projects/group%2Fproject/releases" + f"?per_page={page_bound}") + packages_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages" + f"?package_type=generic&package_name=ai-native-dev-stack" + f"&per_page={page_bound}") + files_url = ("https://gitlab.com/api/v4/projects/group%2Fproject/packages/77" + f"/package_files?per_page={page_bound}") + hops = { + releases_url: json.dumps(releases if releases is not None else [ + {"tag_name": "v2.6.0", "upcoming_release": True}, + {"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "nightly", "upcoming_release": False}, + ]).encode("utf-8"), + packages_url: json.dumps(packages if packages is not None else [ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "other", "version": "9.9.9"}, + ]).encode("utf-8"), + files_url: json.dumps(files if files is not None else package_files(manifest) + ).encode("utf-8"), + MANIFEST_DOWNLOAD: manifest, + BUNDLE_DOWNLOAD: b"bundle-bytes", + } + return ScriptedServer(hops) + + def provider(self, server: ScriptedServer, **kwargs): + return providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project", **kwargs) + + def test_enumeration_matches_release_and_package_identities(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertTrue(result.complete) + self.assertEqual([candidate.version for candidate in result.candidates], ["2.5.0"]) + candidate = result.candidates[0] + self.assertEqual(candidate.identity, "v2.5.0") + self.assertEqual(candidate.channel, "stable") + self.assertEqual(candidate.manifest_size, len(anchored_manifest())) + + def test_the_token_travels_as_a_private_token_only_at_the_origin(self): + server = self.server() + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + headers = {url: token for url, token, _accept in server.observed} + self.assertEqual(headers[RELEASES_URL], "glpat-" + "x" * 20) + + def test_an_anonymous_endpoint_wears_no_gitlab_token(self): + endpoint = transportlib.anonymous_endpoint("https://mirror.example/api/v4") + provider = providerslib.GitLabReleaseProvider(endpoint, "group/project") + server = ScriptedServer({}) + server.hops = {RELEASES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]", + PACKAGES_URL.replace("https://gitlab.com/api/v4", "https://mirror.example/api/v4"): b"[]"} + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual([token for _url, token, _a in server.observed], [None, None]) + + def test_a_duplicate_package_for_one_version_is_refused(self): + server = self.server(packages=[ + {"id": 77, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}, + {"id": 78, "package_type": "generic", "name": "ai-native-dev-stack", + "version": "2.5.0"}]) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, "RELEASE_DUPLICATE_VERSION") + + def test_manifest_lookup_refusals(self): + cases = ( + (package_files(anchored_manifest())[1:], "RELEASE_MANIFEST_MISSING"), + (package_files(anchored_manifest(), duplicate_manifest=True), + "RELEASE_MANIFEST_AMBIGUOUS"), + (package_files(anchored_manifest(), digest=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + (package_files(anchored_manifest(), digest_value="nope"), + "RELEASE_INTEGRITY_METADATA_INVALID"), + (package_files(anchored_manifest(), size=False), + "RELEASE_INTEGRITY_METADATA_MISSING"), + ) + for files, code in cases: + with self.subTest(code=code): + server = self.server(files=files) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + with self.assertRaises(LifecycleError) as raised: + provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertEqual(raised.exception.code, code) + + def test_a_full_release_page_is_an_incomplete_enumeration(self): + server = self.server(releases=[{"tag_name": "v2.5.0", "upcoming_release": False}, + {"tag_name": "v2.4.0", "upcoming_release": False}, + {"tag_name": "v2.3.0", "upcoming_release": False}], + page_bound=3) + provider = self.provider(server, page_bound=3) + with mock.patch.object(transportlib, "_send", server.send): + result = provider.enumerate(release_v3lib.ReleaseQuery("stable")) + self.assertFalse(result.complete) + + def test_the_whole_chain_runs_against_a_scripted_gitlab(self): + manifest = anchored_manifest() + server = self.server(manifest=manifest) + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send): + candidate, parsed = release_v3lib.resolve_manifest( + provider, release_v3lib.ReleaseQuery("stable")) + payload = provider.fetch_artifact(candidate, parsed.lifecycle_artifact()) + self.assertEqual(candidate.version, "2.5.0") + self.assertEqual(payload, b"bundle-bytes") + + def test_fetching_before_enumerating_is_a_programming_refusal(self): + provider = providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project") + with self.assertRaises(LifecycleError) as raised: + provider.fetch_manifest(release_v3lib.ReleaseCandidate( + version="2.5.0", identity="v2.5.0")) + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_source.py b/tests/test_release_source.py new file mode 100644 index 00000000..a7f9f48c --- /dev/null +++ b/tests/test_release_source.py @@ -0,0 +1,225 @@ +"""One source resolver: selector conflicts refuse, and every command agrees. + +ADR-0019 sections 1–3: `resolve_release_source()` is the only decision, and it +validates before precedence — contradictory selectors are refused, never +ordered. The machine configuration is machine scope (a repository cannot +redirect a user's updates), reserved names cannot be redefined, and a named +provider is anonymous in V1. +""" + +from __future__ import annotations + +import json +import os +import shutil +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from tests.lifecycle_support import LifecycleTestCase, write_text +from ainative.lifecycle import provider as providerlib +from ainative.lifecycle import release_source as sourcelib +from ainative.lifecycle.errors import LifecycleError + + +class TempHome(unittest.TestCase): + + def setUp(self) -> None: + self.root = Path(tempfile.mkdtemp(prefix="ainative-source-")) + self.addCleanup(shutil.rmtree, self.root, ignore_errors=True) + self.home = self.root / "home" + self.home.mkdir() + self.mirror = self.root / "mirror" + self.mirror.mkdir() + + def write_config(self, payload: dict) -> Path: + return write_text(sourcelib.config_path(self.home), json.dumps(payload) + "\n") + + +class SelectorMatrix(TempHome): + + def resolve(self, **environment): + return sourcelib.resolve_release_source(environ=environment, home=self.home) + + def test_nothing_selected_is_the_builtin_github(self): + source = self.resolve() + self.assertEqual(source.kind, sourcelib.KIND_GITHUB) + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(source.metadata_url, providerlib.DEFAULT_RELEASE_URL) + + def test_the_local_pair_resolves_to_the_mirror(self): + source = self.resolve(AINATIVE_UPDATE_PROVIDER="local", + AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(source.kind, sourcelib.KIND_LOCAL) + self.assertEqual(source.directory, self.mirror) + self.assertFalse(source.authenticated) + + def test_a_local_dir_without_the_explicit_provider_is_a_conflict(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_LOCAL_DIR=str(self.mirror)) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_mixed_with_a_selector_is_a_conflict(self): + cases = ({"AINATIVE_UPDATE_PROVIDER": "github"}, + {"AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, + {"AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}) + for extra in cases: + with self.subTest(extra=extra): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest", + **extra) + self.assertEqual(raised.exception.code, "UPDATE_SOURCE_CONFLICT") + + def test_the_url_alone_is_anonymous(self): + source = self.resolve(AINATIVE_UPDATE_URL="https://mirror.example/releases/latest") + self.assertEqual(source.kind, sourcelib.KIND_ANONYMOUS) + self.assertFalse(source.authenticated) + self.assertIsNone(source.endpoint.auth_origin) + + def test_the_builtin_name_and_its_alias_resolve_to_github(self): + for name in ("github", "release-api"): + self.assertEqual(self.resolve(AINATIVE_UPDATE_PROVIDER=name).kind, + sourcelib.KIND_GITHUB) + + def test_an_unknown_provider_name_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="carrier-pigeon") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + def test_local_without_a_directory_is_refused(self): + with self.assertRaises(LifecycleError) as raised: + self.resolve(AINATIVE_UPDATE_PROVIDER="local") + self.assertEqual(raised.exception.code, "UPDATE_CHECK_FAILED") + + +class MachineConfig(TempHome): + + def test_a_named_provider_resolves_anonymously(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_NAMED) + self.assertFalse(source.authenticated) + self.assertEqual(source.metadata_url, + "https://releases.internal.example/api/releases/latest") + + def test_the_default_provider_applies_when_nothing_is_selected(self): + self.write_config({"schema_version": 1, "default_provider": "internal", + "providers": {"internal": { + "api_base_url": "https://releases.internal.example/api"}}}) + source = sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(source.provider_name, "internal") + + def test_the_default_local_requires_a_directory(self): + self.write_config({"schema_version": 1, "default_provider": "local"}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_reserved_name_cannot_be_redefined(self): + self.write_config({"schema_version": 1, "providers": { + "github": {"api_base_url": "https://evil.example/api"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_the_gitlab_provider_requires_a_project_reference(self): + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_the_gitlab_provider_resolves_with_its_configured_project(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "group/sub/project"}}}) + source = sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(source.kind, sourcelib.KIND_GITLAB) + self.assertEqual(source.project_ref, "group/sub/project") + self.assertTrue(source.authenticated) + self.assertEqual(source.endpoint.auth_header, "PRIVATE-TOKEN") + self.assertEqual(source.endpoint.auth_origin, "https://gitlab.com") + self.assertEqual(source.endpoint.api_base_url, "https://gitlab.com/api/v4") + + def test_a_gitlab_project_reference_is_not_a_url(self): + self.write_config({"schema_version": 1, "providers": { + "gitlab": {"release_project_ref": "https://gitlab.com/group/project"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "gitlab"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_custom_auth_origin_is_refused_in_v1(self): + self.write_config({"schema_version": 1, "providers": { + "internal": {"api_base_url": "https://x.example/api", + "auth_origin": "https://x.example"}}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source( + environ={"AINATIVE_UPDATE_PROVIDER": "internal"}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_a_future_config_schema_is_refused_not_guessed(self): + self.write_config({"schema_version": 2, "providers": {}}) + with self.assertRaises(LifecycleError) as raised: + sourcelib.resolve_release_source(environ={}, home=self.home) + self.assertEqual(raised.exception.code, "RELEASE_CONFIG_INVALID") + + def test_describe_turns_a_conflict_into_a_state(self): + record = sourcelib.describe(environ={"AINATIVE_UPDATE_LOCAL_DIR": "x"}, + home=self.home) + self.assertEqual(record["kind"], "refused") + self.assertEqual(record["state"], "UPDATE_SOURCE_CONFLICT") + self.assertIn("Release source", sourcelib.describe_lines(record)[0]) + + +class ProviderBuild(TempHome): + + def test_build_delegates_to_the_resolver(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_PROVIDER": "local", + "AINATIVE_UPDATE_LOCAL_DIR": str(self.mirror)}, clear=False): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.LocalDirectoryProvider) + self.assertEqual(built.root, self.mirror) + + def test_build_of_the_default_is_the_github_endpoint_provider(self): + for name in (providerlib.PROVIDER_ENV, providerlib.LOCAL_SOURCE_ENV, + providerlib.RELEASE_URL_ENV): + os.environ.pop(name, None) + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}): + built = providerlib.build("stable") + self.assertIsInstance(built, providerlib.ReleaseApiProvider) + self.assertEqual(built.endpoint.auth_origin, "https://api.github.com") + self.assertEqual(built.url, providerlib.DEFAULT_RELEASE_URL) + + def test_build_of_an_anonymous_url_wears_no_credential(self): + with mock.patch.object(sourcelib, "load_machine_config", lambda home=None: {}), \ + mock.patch.dict(os.environ, { + "AINATIVE_UPDATE_URL": "https://mirror.example/releases/latest"}): + built = providerlib.build("stable") + self.assertIsNone(built.endpoint.auth_origin) + self.assertEqual(built._token(), "") + + +class Diagnostics(LifecycleTestCase): + + def test_status_and_doctor_display_the_source_without_secrets(self): + self.install("standard") + report = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(report["release_source"]["kind"], "github") + self.assertTrue(report["release_source"]["authenticated"]) + text = self.cli("status").stdout + self.assertIn("Release source", text) + self.assertIn("authenticated: yes", text) + doctor = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(doctor["release_source"]["reason"], + report["release_source"]["reason"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_v3.py b/tests/test_release_v3.py new file mode 100644 index 00000000..a0366476 --- /dev/null +++ b/tests/test_release_v3.py @@ -0,0 +1,319 @@ +"""Release V3: candidate policy, the anchored manifest, and the exact chain. + +ADR-0019 sections 7–10, pinned: build metadata is not an installable version, +duplicates are never resolved by order, an incomplete enumeration refuses +instead of picking the best of what it saw, the manifest is verified against +provider metadata BEFORE it is parsed, and one version must hold across every +trust-bearing identity. +""" + +from __future__ import annotations + +import json +import unittest +from hashlib import sha256 + +from ainative.lifecycle import release_v3 +from ainative.lifecycle.errors import LifecycleError + + +def manifest_payload(version: str = "2.5.0", *, runtime: str | None = None, + artifact_version: str | None = None, + artifact_name: str | None = None, + protocol: str = "v3", channel: str = "stable", + extra_artifacts: list | None = None, + **document_overrides) -> bytes: + artifact_version = artifact_version or version + artifacts = [{ + "name": artifact_name or release_v3.lifecycle_bundle_name(artifact_version), + "kind": "lifecycle", "version": artifact_version, + "sha256": "a" * 64, "size": 10, + }] + artifacts.extend(extra_artifacts or []) + document = { + "schema": "ainative.release", + "protocol": protocol, + "version": version, + "channel": channel, + "compatibility": {"runtime_version": runtime or version}, + "artifacts": artifacts, + "provenance": {"source": "test"}, + } + document.update(document_overrides) + return json.dumps(document).encode("utf-8") + + +def anchor(payload: bytes) -> tuple[str, int]: + return sha256(payload).hexdigest(), len(payload) + + +def candidate(version: str, *, identity: str | None = None, channel: str = "stable", + payload: bytes | None = None, anchored: bool = True): + digest, size = anchor(payload) if payload is not None else ("a" * 64, 10) + return release_v3.ReleaseCandidate( + version=version, identity=identity or f"release-{version}", channel=channel, + manifest_sha256=digest if anchored else None, + manifest_size=size if anchored else None) + + +def expect(code: str, action) -> LifecycleError: + try: + action() + except LifecycleError as error: + if error.code != code: + raise AssertionError(f"expected {code}, got {error.code}: " + f"{error.message}") from error + return error + raise AssertionError(f"expected a {code} refusal; nothing was raised") + + +class SemVerPolicy(unittest.TestCase): + + def test_canonical_versions_are_semver_without_build_metadata(self): + self.assertEqual(release_v3.canonical_version("1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("v1.2.3"), "1.2.3") + self.assertEqual(release_v3.canonical_version("1.2.3-rc.1"), "1.2.3-rc.1") + + def test_build_metadata_is_not_an_installable_version(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: release_v3.canonical_version("1.2.3+build1")) + + def test_a_non_semver_candidate_keeps_the_historical_refusal(self): + expect("UPDATE_CHECK_FAILED", lambda: release_v3.canonical_version("nightly")) + + +class CandidateSelection(unittest.TestCase): + + def select(self, candidates, *, complete=True, channel="stable"): + result = release_v3.EnumerationResult(tuple(candidates), complete) + return release_v3.select_candidate(result, release_v3.ReleaseQuery(channel)) + + def test_order_is_version_order_not_string_order(self): + chosen = self.select([candidate("1.9.0"), candidate("1.10.0")]) + self.assertEqual(chosen.version, "1.10.0") + + def test_a_release_outranks_its_pre_releases(self): + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3")]) + self.assertEqual(chosen.version, "1.2.3") + chosen = self.select([candidate("1.2.3-rc.1"), candidate("1.2.3-rc.2")]) + self.assertEqual(chosen.version, "1.2.3-rc.2") + + def test_another_channel_is_not_a_candidate(self): + expect("RELEASE_NO_CANDIDATE", + lambda: self.select([candidate("1.2.3", channel="beta")])) + + def test_an_incomplete_enumeration_refuses_even_with_candidates(self): + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: self.select([candidate("1.2.3")], complete=False)) + + def test_a_complete_channel_with_nothing_refuses(self): + expect("RELEASE_NO_CANDIDATE", lambda: self.select([])) + + def test_two_identities_on_one_version_refuse_as_duplicates(self): + error = expect("RELEASE_DUPLICATE_VERSION", + lambda: self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-b")])) + self.assertEqual(error.detail["version"], "1.2.3") + + def test_the_same_identity_listed_twice_is_one_candidate(self): + chosen = self.select([candidate("1.2.3", identity="tag-a"), + candidate("1.2.3", identity="tag-a")]) + self.assertEqual(chosen.identity, "tag-a") + + +class TrustAnchor(unittest.TestCase): + + def verify(self, payload, *, sha=None, size=None): + release_v3.verify_external_anchor(payload, sha256=sha, size=size) + + def test_an_absent_anchor_is_missing_metadata(self): + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: self.verify(b"{}", sha=None, size=None)) + + def test_a_malformed_anchor_is_invalid_metadata(self): + for sha, size in (("zz", 2), ("a" * 64, 0), ("a" * 64, "2")): + with self.subTest(sha=sha, size=size): + expect("RELEASE_INTEGRITY_METADATA_INVALID", + lambda sha=sha, size=size: self.verify(b"{}", sha=sha, size=size)) + + def test_a_size_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=99)) + + def test_a_digest_mismatch_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.verify(b"{}", sha="a" * 64, size=2)) + + def test_a_matching_anchor_passes(self): + digest, size = anchor(b'{"ok": true}') + self.verify(b'{"ok": true}', sha=digest, size=size) + + def test_verification_happens_before_parsing(self): + # Invalid JSON with a WRONG digest must fail on the digest, proving the + # bytes were verified before being parsed; with a correct anchor the + # same bytes reach the parser and fail there. + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: self._verify_then_parse(b"{not json")) + self.assertIn("does not match", error.message) + error = expect("UPDATE_INTEGRITY_FAILED", + lambda: release_v3.parse_manifest(b"{not json")) + self.assertIn("not valid JSON", error.message) + + def _verify_then_parse(self, payload: bytes) -> None: + self.verify(payload, sha="a" * 64, size=len(payload)) + release_v3.parse_manifest(payload) + + +class ManifestValidation(unittest.TestCase): + + def parse(self, payload: bytes): + return release_v3.parse_manifest(payload) + + def test_a_valid_manifest_round_trips(self): + manifest = self.parse(manifest_payload()) + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(manifest.runtime_version, "2.5.0") + self.assertEqual(manifest.channel, "stable") + self.assertEqual(manifest.lifecycle_artifact().name, + "ainative-lifecycle-v3-2.5.0.zip") + self.assertEqual(manifest.to_record()["provenance"], {"source": "test"}) + + def test_a_newer_protocol_asks_for_a_newer_cli(self): + expect("CLI_UPDATE_REQUIRED", + lambda: self.parse(manifest_payload(protocol="v4"))) + + def test_an_unknown_protocol_is_an_integrity_refusal(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(protocol="v2"))) + + def test_an_unknown_schema_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(schema="something.else"))) + + def test_a_build_metadata_manifest_version_is_refused(self): + expect("RELEASE_BUILD_METADATA_UNSUPPORTED", + lambda: self.parse(manifest_payload(version="2.5.0+build1"))) + + def test_missing_pieces_are_refused(self): + for overrides in ({"channel": ""}, {"compatibility": None}, + {"artifacts": []}, {"provenance": []}, + {"version": None}): + with self.subTest(overrides=overrides): + expect("UPDATE_INTEGRITY_FAILED", + lambda overrides=overrides: self.parse( + manifest_payload(**overrides))) + + def test_a_traversing_artifact_name_is_refused(self): + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="../../evil.zip"))) + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(artifact_name="sub/evil.zip"))) + + def test_an_unknown_artifact_kind_is_refused(self): + digest, size = anchor(b"x") + extra = [{"name": "wheel.whl", "kind": "wheel", "version": "2.5.0", + "sha256": "a" * 64, "size": 10}] + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(manifest_payload(extra_artifacts=extra))) + + def test_a_malformed_artifact_digest_is_refused(self): + payload = manifest_payload() + document = json.loads(payload) + document["artifacts"][0]["sha256"] = "nope" + expect("UPDATE_INTEGRITY_FAILED", + lambda: self.parse(json.dumps(document).encode())) + + def test_two_lifecycle_artifacts_are_ambiguous(self): + second = {"name": "ainative-lifecycle-v3-2.5.0.zip", "kind": "lifecycle", + "version": "2.5.0", "sha256": "b" * 64, "size": 11} + manifest = self.parse(manifest_payload(extra_artifacts=[second])) + expect("UPDATE_INTEGRITY_FAILED", manifest.lifecycle_artifact) + + +class VersionChain(unittest.TestCase): + + def chain(self, *, candidate_version="2.5.0", manifest_payload_args=None, + protocol_release_version=None): + payload = manifest_payload(**(manifest_payload_args or {})) + manifest = release_v3.parse_manifest(payload) + release_v3.require_exact_version_chain( + candidate(candidate_version), manifest, manifest.lifecycle_artifact(), + protocol_release_version=protocol_release_version) + + def test_the_exact_chain_passes(self): + self.chain(protocol_release_version="2.5.0") + + def test_every_broken_link_is_refused(self): + cases = ( + {"candidate_version": "2.5.1"}, + {"manifest_payload_args": {"version": "2.5.1"}}, + {"manifest_payload_args": {"runtime": "2.4.9"}}, + {"manifest_payload_args": {"artifact_version": "2.4.9"}}, + {"manifest_payload_args": {"artifact_name": "ainative-lifecycle-v3-2.4.9.zip"}}, + {"protocol_release_version": "2.4.9"}, + ) + for case in cases: + with self.subTest(case=case): + error = expect("UPDATE_VERSION_MISMATCH", lambda case=case: self.chain(**case)) + self.assertIn("chain", error.detail) + + def test_the_chain_detail_names_every_link(self): + error = expect("UPDATE_VERSION_MISMATCH", + lambda: self.chain(candidate_version="2.5.1", + protocol_release_version="2.5.0")) + self.assertEqual(set(error.detail["chain"]), + {"candidate version", "manifest version", + "compatibility.runtime_version", "artifact version", + "artifact filename version", + "lifecycle-protocol.json release_version"}) + + +class ResolutionOrder(unittest.TestCase): + + class FakeProvider(release_v3.ReleaseProvider): + name = "fake" + + def __init__(self, candidates, payload, *, complete=True): + self._result = release_v3.EnumerationResult(tuple(candidates), complete) + self._payload = payload + self.calls: list[str] = [] + + def enumerate(self, query): + self.calls.append("enumerate") + return self._result + + def fetch_manifest(self, candidate): + self.calls.append(f"fetch_manifest:{candidate.version}") + return self._payload + + def fetch_artifact(self, candidate, artifact): + raise AssertionError("resolve_manifest must not download artifacts") + + def test_a_complete_chain_resolves(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload) + resolved, manifest = release_v3.resolve_manifest( + provider, release_v3.ReleaseQuery("stable")) + self.assertEqual(resolved.version, "2.5.0") + self.assertEqual(manifest.version, "2.5.0") + self.assertEqual(provider.calls, ["enumerate", "fetch_manifest:2.5.0"]) + + def test_an_incomplete_enumeration_never_fetches_a_manifest(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", payload=payload)], payload, + complete=False) + expect("RELEASE_ENUMERATION_INCOMPLETE", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + self.assertEqual(provider.calls, ["enumerate"]) + + def test_an_unanchored_candidate_never_reaches_the_parser(self): + payload = manifest_payload() + provider = self.FakeProvider([candidate("2.5.0", anchored=False)], payload) + expect("RELEASE_INTEGRITY_METADATA_MISSING", + lambda: release_v3.resolve_manifest(provider, + release_v3.ReleaseQuery("stable"))) + + +if __name__ == "__main__": + unittest.main() From a8e1fb634ef4e61aa772d5aed3f61628cec770fb Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Wed, 16 Sep 2026 23:00:39 +0200 Subject: [PATCH 26/27] feat(security): mandatory secret patterns as an unremovable floor, scenario closures (#166) (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the remaining traceable items from the Multi-Forge qualification report. - Candidate scanner (ainative/multivault/git_scanner.py): the constructor now takes `extra_secret_patterns` and no longer accepts a replacement set; the effective patterns are `MANDATORY_SECRET_PATTERNS + extras`, so no caller, repository or operator configuration can remove, replace, disable or shadow the floor (ADR-0019 section 12). The floor gains the documented GitLab token prefixes (`glpat-`, `gldt-`, `glrt-`, `glsoat-`), alongside private-key markers, AWS, GitHub and Slack patterns. The anti-debt owner (`finding_common.SECRET_PATTERNS`) and the vault-sync fallback carry the same GitLab prefixes. - Scenario J: the GitLab provider test proves an object-storage redirect on a package-file download is followed anonymously — the token stays at the API origin. - Scenario M: `doctor` warns when a GitLab remote is observed while the legacy `forge-github` default is only projected, naming the explicit `feature switch forge-gitlab` transition. - Scenario P: one end-to-end test asserts `update check`, `update`, `status` and `doctor` all surface the same `UPDATE_SOURCE_CONFLICT` for the same environment — one resolver, one refusal. - Documentation parity (plan section 75): `tests/purity/test_docs_parity.py` enforces the EN/FR README heading hierarchy, the operational surface (commands, env vars, config files) and the critical security statements — structurally, never by raw line counts. CI runs it with the purity suite. - Qualification report and CHANGELOG updated: scenarios J/M/P/Q and parity are GREEN; only the GitLab.com LIVE qualification and the release choreography remain, both declared. Verified: 314 lifecycle tests OK, 245 other suites OK (18 scanner, 110 release/claims/purity), gates green. Refs #166 --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 8 ++ ainative/multivault/git_scanner.py | 15 +++- ainative/observation.py | 12 ++- docs/MULTIFORGE-QUALIFICATION.md | 32 ++++---- scripts/vault_sync.py | 2 + .../agents/anti-debt/tools/finding_common.py | 4 + tests/purity/test_docs_parity.py | 78 +++++++++++++++++++ tests/test_forge_claims.py | 18 ++++- tests/test_multivault_git_scanner.py | 24 ++++++ tests/test_release_providers.py | 20 +++++ tests/test_release_source.py | 19 +++++ 12 files changed, 212 insertions(+), 24 deletions(-) create mode 100644 tests/purity/test_docs_parity.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31bebc4a..392fa0cf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -219,9 +219,9 @@ jobs: - name: Release V3 update path end to end run: python -m unittest tests.test_lifecycle_update_v3 -v - - name: Purity - neutral layer, contracts, distributed policy + - name: Purity - neutral layer, contracts, distributed policy, docs parity run: | - python -m unittest tests.purity.test_dependency_purity tests.purity.test_contract_purity tests.purity.test_distributed_policy_purity -v + python -m unittest tests.purity.test_dependency_purity tests.purity.test_contract_purity tests.purity.test_distributed_policy_purity tests.purity.test_docs_parity -v - name: Path traversal, link escape, tampered state, archive safety run: python -m unittest tests.test_lifecycle_security -v diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe6c2fa..562c4caa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,14 @@ project adheres to [Semantic Versioning](https://semver.org/). canonical claim grammar and the durable `ainative claim-attempt list | inspect | abandon` journal — written before the remote signal, never retried blindly. +- **Mandatory secret patterns can only be extended** (#166, ADR-0019 §12): + the candidate scanner takes `extra_secret_patterns` (a union with + `MANDATORY_SECRET_PATTERNS`) and no longer offers any replacement + parameter; the mandatory floor now includes the documented GitLab token + prefixes (`glpat-`, `gldt-`, `glrt-`, `glsoat-`) in the scanner, the + anti-debt owner and the vault-sync fallback. EN/FR documentation parity is + enforced structurally (heading hierarchy, operational surface, security + statements). ### Security diff --git a/ainative/multivault/git_scanner.py b/ainative/multivault/git_scanner.py index 5bb2fd82..77dc69cb 100644 --- a/ainative/multivault/git_scanner.py +++ b/ainative/multivault/git_scanner.py @@ -20,7 +20,11 @@ from .schema import digest as canonical_digest -DEFAULT_SECRET_PATTERNS: tuple[bytes, ...] = ( +# The mandatory floor (ADR-0019 section 12): provider token prefixes and +# private-key markers every scanner in the stack keeps. Operators may ADD +# patterns (`extra_secret_patterns`), never remove, replace, disable or shadow +# these — which is why no constructor parameter accepts a replacement set. +MANDATORY_SECRET_PATTERNS: tuple[bytes, ...] = ( b"-----BEGIN RSA PRIVATE KEY", b"-----BEGIN OPENSSH PRIVATE KEY", b"-----BEGIN EC PRIVATE KEY", @@ -29,6 +33,10 @@ b"github_pat_", b"ghp_", b"xoxb-", + b"glpat-", + b"gldt-", + b"glrt-", + b"glsoat-", ) LARGE_BINARY_THRESHOLD_BYTES = 10 * 1024 * 1024 @@ -113,7 +121,7 @@ def __init__( repository: Path, *, forbidden_paths: tuple[str, ...] = (), - secret_patterns: tuple[bytes, ...] = DEFAULT_SECRET_PATTERNS, + extra_secret_patterns: tuple[bytes, ...] = (), max_binary_bytes: int = LARGE_BINARY_THRESHOLD_BYTES, git_executable: str | None = None, environment: Mapping[str, str] | None = None, @@ -127,7 +135,8 @@ def __init__( self._git = executable self._repository = Path(repository) self._forbidden_paths = tuple(forbidden_paths) - self._secret_patterns = tuple(secret_patterns) + # Extra patterns extend the mandatory floor; nothing replaces it. + self._secret_patterns = MANDATORY_SECRET_PATTERNS + tuple(extra_secret_patterns) self._max_binary_bytes = max_binary_bytes if environment is not None: validate_git_environment(environment) diff --git a/ainative/observation.py b/ainative/observation.py index 2878a5e5..d29d3fe8 100644 --- a/ainative/observation.py +++ b/ainative/observation.py @@ -86,7 +86,8 @@ def project_view(project: Path) -> dict: return { "features": {"active": list(effective.active_features) if effective else [], "projected_from_legacy": bool( - effective and effective.projected_from_legacy)}, + effective and effective.projected_from_legacy), + "legacy_default": featureslib.legacy_default(distribution)}, "forge": forge_picture(project), "claim_attempts": _claim_summary(project), } @@ -110,6 +111,15 @@ def doctor_lines(view: dict) -> list[str]: for remote in view["forge"]["remotes"]: lines.append(f" {remote['name']:<10} {remote['provider']:<8} " f"{remote['project'] or '-'}") + if features["projected_from_legacy"] and any( + remote["provider"] == "gitlab" for remote in view["forge"]["remotes"]): + # A legacy project with a GitLab remote keeps the compatibility default + # until it says otherwise; the switch is explicit, never inferred from + # the remote (ADR-0017 section 4 / scenario M). + lines.append(" note: a GitLab remote is observed while the legacy " + f"{features.get('legacy_default', 'forge-github')} default is " + "projected; run `ainative feature switch forge-gitlab` to " + "switch explicitly") if summary["journal"] == "ok": lines.append(f"Claim attempts: {len(summary['unresolved'])} unresolved") else: diff --git a/docs/MULTIFORGE-QUALIFICATION.md b/docs/MULTIFORGE-QUALIFICATION.md index 4b97e19a..3ee6e10a 100644 --- a/docs/MULTIFORGE-QUALIFICATION.md +++ b/docs/MULTIFORGE-QUALIFICATION.md @@ -51,14 +51,14 @@ Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). | G | Private GitHub asset → anonymous CDN | asset API flow test (scripted transport) | GREEN (scripted) | | H | Tampered ReleaseManifest V3 | `test_release_v3` + `test_lifecycle_update_v3` (zero writes) | GREEN | | I | Duplicate GitLab package/manifest | provider tests (`RELEASE_DUPLICATE_VERSION`, `RELEASE_MANIFEST_AMBIGUOUS`) | GREEN | -| J | GitLab object-storage blob | shared transport policy test (API → CDN strips credentials); no GitLab-specific 302 fixture yet | PARTIAL | +| J | GitLab object-storage blob | provider test: the manifest download 302s to an object store; the token is stripped on the blob hop and the bytes arrive | GREEN | | K | Claim crash after POST | journal tests (`UNCERTAIN` unresolved, no retry, explicit `abandon`) | GREEN | | L | Planner refusal during V1 projection | two-work-forge state refuses; projection never writes | GREEN | -| M | Legacy GitLab remote keeps GitHub compatibility default | projection test; the *warning* surfacing is not implemented | PARTIAL | +| M | Legacy GitLab remote keeps GitHub compatibility default | projection test + the doctor warning naming `feature switch forge-gitlab` | GREEN | | N | Read-only V1 projection == persisted V2 | migration parity test + `status` parity test | GREEN | | O | Missing legacy managed template not resurrected | `test_the_migration_does_not_resurrect_an_absent_template` | GREEN | -| P | Selector conflict identical across commands | one resolver consumed by update/check/status/doctor; conflicts tested at the resolver; no single cross-command E2E test | PARTIAL | -| Q | Mandatory secret patterns survive operator configuration | **not implemented** — the anti-debt scanner contract (`extra_secret_patterns`) is still pending | PENDING | +| P | Selector conflict identical across commands | one resolver; a single E2E asserting update check / update / status / doctor all surface `UPDATE_SOURCE_CONFLICT` | GREEN | +| Q | Mandatory secret patterns survive operator configuration | `MANDATORY_SECRET_PATTERNS` (private keys, AWS, GitHub, Slack, GitLab `glpat-`/`gldt-`/`glrt-`/`glsoat-`) with `extra_secret_patterns` as a union; the constructor accepts no replacement parameter; the anti-debt owner and the vault-sync fallback carry the same GitLab prefixes | GREEN | ## Security, migration, claims, integrity @@ -77,24 +77,22 @@ Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). ## Known limitations and remaining work -**P1 — blocks the declared-support gate:** +**P1 — the declared-support gate:** - GitLab.com live qualification (requires a real GitLab project publishing `ai-native-dev-stack` generic packages and an `ainative-release-v3.json` - manifest). -- Scenario Q: mandatory secret patterns (`MANDATORY_SECRET_PATTERNS` ∪ - operator extras) and the GitLab token-prefix qualification tuple. + manifest). Until then GitLab.com stays UNTESTED and is not declared + supported. - Full CI matrix (Linux/Windows/macOS, py3.11/3.13) — runs on the `dev` - proposal. -- EN/FR documentation parity automation (plan §75). - -**P2 — known gaps, documented rather than hidden:** -- Scenario J: no GitLab-specific object-storage 302 fixture (the shared - transport policy covers the behavior). -- Scenario M: the "legacy GitLab remote" case projects correctly but no - warning line is rendered yet. -- Scenario P: no single cross-command selector-conflict E2E test. + proposals; the first run of the complete implementation was green 52/52. - The V2 bridge release and the V3 release have not been published: the plan's release choreography (§92) starts after the branch is promoted to `dev`, and the first V3 publication stays gated by `V3_BRIDGE_RELEASE`. +**Resolved since the first draft of this report:** scenario Q (mandatory +secret patterns with operator extras), scenario J (GitLab object-storage +blob), scenario M (legacy GitLab remote warning), scenario P (one +cross-command selector-conflict E2E) and the EN/FR documentation parity +automation (`tests/purity/test_docs_parity.py`: heading hierarchy, operational +surface, critical security statements — no raw line-count equality). + **P0:** none known. diff --git a/scripts/vault_sync.py b/scripts/vault_sync.py index 8aaca2cc..db591ff8 100644 --- a/scripts/vault_sync.py +++ b/scripts/vault_sync.py @@ -105,6 +105,8 @@ def _load_secret_patterns() -> list: return [ (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS access key ID"), (re.compile(r"gh[pousr]_[A-Za-z0-9]{36,}"), "GitHub token"), + (re.compile(r"glpat-[A-Za-z0-9_\-]{20,}"), "GitLab personal access token"), + (re.compile(r"gldt-[A-Za-z0-9_\-]{20,}"), "GitLab deploy token"), (re.compile(r"sk_live_[A-Za-z0-9]{20,}"), "Stripe live secret key"), (re.compile(r"-----BEGIN [A-Z ]+PRIVATE KEY-----"), "Private key"), ] diff --git a/stack/agents/anti-debt/tools/finding_common.py b/stack/agents/anti-debt/tools/finding_common.py index f462e9b2..fb607c27 100644 --- a/stack/agents/anti-debt/tools/finding_common.py +++ b/stack/agents/anti-debt/tools/finding_common.py @@ -31,6 +31,10 @@ (re.compile(r'AIza[0-9A-Za-z\-_]{35}'), "Google API key"), (re.compile(r'gh[pousr]_[A-Za-z0-9]{36,}'), "GitHub token"), (re.compile(r'xox[baprs]-[A-Za-z0-9-]{10,}'), "Slack token"), + (re.compile(r'glpat-[A-Za-z0-9_\-]{20,}'), "GitLab personal access token"), + (re.compile(r'gldt-[A-Za-z0-9_\-]{20,}'), "GitLab deploy token"), + (re.compile(r'glrt-[A-Za-z0-9_\-]{20,}'), "GitLab runner token"), + (re.compile(r'glsoat-[A-Za-z0-9_\-]{20,}'), "GitLab service account token"), (re.compile(r'-----BEGIN [A-Z ]+PRIVATE KEY-----'), "Private key"), (re.compile(r'(?i)(?:api[_-]?key|secret|token|passwd|password|access[_-]?key|client[_-]?secret)\s*[:=]\s*["\'][A-Za-z0-9+/_\-]{16,}["\']'), "generic credential"), ] diff --git a/tests/purity/test_docs_parity.py b/tests/purity/test_docs_parity.py new file mode 100644 index 00000000..d7abba1d --- /dev/null +++ b/tests/purity/test_docs_parity.py @@ -0,0 +1,78 @@ +"""EN/FR documentation parity, stated structurally (plan section 75). + +Raw line counts are not parity: a translation legitimately differs in length. +What must agree is structure — the heading hierarchy of the README pair — and +what must appear in both is the *operational surface* a user types: command +names, environment variables and configuration file names. A translated page +that silently drops a command or a security section fails here. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +ENGLISH = REPO / "README.md" +FRENCH = REPO / "README.fr.md" + +FENCE = "```" + +# The operational surface introduced by Multi-Forge: present in both languages +# or the translation has fallen behind the tool. +OPERATIONAL_KEYS = ( + "ainative feature status", + "ainative feature switch forge-gitlab", + "ainative feature switch none", + "ainative forge status", + "ainative claim-attempt list", + "AINATIVE_UPDATE_URL", + "release-providers.json", + "FORGE-WORKFLOW.md", + "SUPPORT.md", +) + + +def heading_levels(text: str) -> list[int]: + """Heading levels outside fenced code blocks, in document order.""" + + levels: list[int] = [] + fence = False + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith(FENCE): + fence = not fence + continue + if fence: + continue + if stripped.startswith("#"): + levels.append(len(stripped) - len(stripped.lstrip("#"))) + return levels + + +class DocumentationParity(unittest.TestCase): + + def setUp(self) -> None: + self.english = ENGLISH.read_text(encoding="utf-8") + self.french = FRENCH.read_text(encoding="utf-8") + + def test_the_heading_hierarchy_is_identical_between_languages(self): + self.assertEqual(heading_levels(self.english), heading_levels(self.french), + "the EN/FR README structures drifted apart") + + def test_both_languages_speak_the_operational_surface(self): + for key in OPERATIONAL_KEYS: + with self.subTest(key=key): + self.assertIn(key, self.english) + self.assertIn(key, self.french) + + def test_the_critical_security_statements_appear_in_both(self): + # The statements, not the words: the French page says "ancré". + self.assertIn("fail-closed", self.english) + self.assertIn("anchored", self.english) + self.assertIn("fail-closed", self.french) + self.assertIn("ancré", self.french) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_forge_claims.py b/tests/test_forge_claims.py index 87c75afd..0e72dc44 100644 --- a/tests/test_forge_claims.py +++ b/tests/test_forge_claims.py @@ -335,13 +335,29 @@ def test_observation_commands_never_write(self): def test_doctor_reports_features_forge_and_claims(self): self.install("standard") - self.remote("origin", "git@gitlab.com:g/p.git") + self.remote("origin", "https://gitlab.com/g/p.git") record = json.loads(self.cli("doctor", "--json").stdout) self.assertEqual(record["features"]["active"], ["forge-github"]) self.assertEqual(record["forge"]["resolution"]["authority"]["provider"], "gitlab") self.assertEqual(record["claim_attempts"]["journal"], "ok") self.assertEqual(record["claim_attempts"]["unresolved"], []) + def test_a_legacy_gitlab_remote_keeps_the_default_with_a_warning(self): + """Scenario M: the choice stays explicit; the doctor says so.""" + + self.install("standard") + self.remote("origin", "https://gitlab.com/g/p.git") + path = statelib.state_path(self.project) + record = json.loads(path.read_text(encoding="utf-8")) + record["schema_version"] = 1 + record.pop("active_features", None) + statelib.write_atomic(path, json.dumps(record, indent=2, sort_keys=True) + "\n") + text = self.cli("doctor").stdout + self.assertIn("feature switch forge-gitlab", text) + reported = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(reported["features"]["active"], ["forge-github"]) + self.assertTrue(reported["features"]["projected_from_legacy"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_multivault_git_scanner.py b/tests/test_multivault_git_scanner.py index 364437a0..33f3a4de 100644 --- a/tests/test_multivault_git_scanner.py +++ b/tests/test_multivault_git_scanner.py @@ -1,3 +1,4 @@ +import inspect import shutil import subprocess import tempfile @@ -71,6 +72,29 @@ def test_lfs_pointer_is_a_leak(self): self.assertEqual(ScanVerdict.LEAK, result.verdict) self.assertIn("lfs_pointer", [finding.category for finding in result.findings]) + def test_a_gitlab_token_is_a_leak(self): + source = commit_file(self.repo, "gitlab.txt", + b"token glpat-abcdefghijklmnopqrst\n") + result = self.scan(source) + self.assertEqual(ScanVerdict.LEAK, result.verdict) + self.assertIn("secret", [finding.category for finding in result.findings]) + + def test_extra_patterns_cannot_replace_the_mandatory_floor(self): + # The GitHub floor is still detected when an operator adds their own + # pattern: extras are a union, never a replacement (ADR-0019 §12). + source = commit_file(self.repo, "both.txt", + b"ghp_abcdefghijklmnop\nACME-INTERNAL-KEY-1234567890\n") + result = self.scan(source, extra_secret_patterns=(b"ACME-INTERNAL-KEY-",)) + self.assertEqual(ScanVerdict.LEAK, result.verdict) + details = " ".join(finding.detail for finding in result.findings) + self.assertIn("ghp_", details) + self.assertIn("ACME-INTERNAL-KEY-", details) + + def test_the_constructor_accepts_no_replacement_parameter(self): + parameters = inspect.signature(CandidateObjectScanner.__init__).parameters + self.assertIn("extra_secret_patterns", parameters) + self.assertNotIn("secret_patterns", parameters) + def test_missing_source_object_is_incomplete(self): unknown = "f" * 40 scanner = CandidateObjectScanner(self.repo) diff --git a/tests/test_release_providers.py b/tests/test_release_providers.py index 7851604b..27fae13b 100644 --- a/tests/test_release_providers.py +++ b/tests/test_release_providers.py @@ -472,6 +472,26 @@ def test_the_whole_chain_runs_against_a_scripted_gitlab(self): self.assertEqual(candidate.version, "2.5.0") self.assertEqual(payload, b"bundle-bytes") + def test_an_object_storage_redirect_strips_the_gitlab_token(self): + """Scenario J: the blob hop is anonymous, the API hop is not.""" + + manifest = anchored_manifest() + object_url = "https://objectstore.example/packages/77/blob" + server = self.server(manifest=manifest) + server.hops[MANIFEST_DOWNLOAD] = ("redirect", object_url) + server.hops[object_url] = manifest + provider = self.provider(server) + with mock.patch.object(transportlib, "_send", server.send), \ + mock.patch.dict("os.environ", {"GITLAB_TOKEN": "glpat-" + "x" * 20}): + candidate = provider.enumerate( + release_v3lib.ReleaseQuery("stable")).candidates[0] + payload = provider.fetch_manifest(candidate) + self.assertEqual(payload, manifest) + hops = {url: token for url, token, _accept in server.observed} + self.assertEqual(hops[MANIFEST_DOWNLOAD], "glpat-" + "x" * 20) + self.assertIsNone(hops[object_url], + "the object store received the GitLab token") + def test_fetching_before_enumerating_is_a_programming_refusal(self): provider = providerslib.GitLabReleaseProvider(GITLAB_ENDPOINT, "group/project") with self.assertRaises(LifecycleError) as raised: diff --git a/tests/test_release_source.py b/tests/test_release_source.py index a7f9f48c..e91ff8ca 100644 --- a/tests/test_release_source.py +++ b/tests/test_release_source.py @@ -220,6 +220,25 @@ def test_status_and_doctor_display_the_source_without_secrets(self): self.assertEqual(doctor["release_source"]["reason"], report["release_source"]["reason"]) + def test_a_selector_conflict_is_identical_across_commands(self): + """Scenario P: one resolver, one refusal, everywhere.""" + + self.install("standard") + environment = {"AINATIVE_UPDATE_LOCAL_DIR": str(self.root / "mirror")} + with mock.patch.dict(os.environ, environment): + check = self.cli("update", "check", "--strict", "--json") + self.assertEqual(check.returncode, 1) + checked = json.loads(check.stdout) + self.assertEqual(checked["status"], "CHECK_FAILED") + self.assertIn("never ordered", checked["detail"]) + update = self.cli("update", "--json") + self.assertEqual(update.returncode, 2) + self.assertEqual(json.loads(update.stdout)["error"], "UPDATE_SOURCE_CONFLICT") + status = json.loads(self.cli("status", "--json").stdout) + self.assertEqual(status["release_source"]["state"], "UPDATE_SOURCE_CONFLICT") + doctor = json.loads(self.cli("doctor", "--json").stdout) + self.assertEqual(doctor["release_source"]["state"], "UPDATE_SOURCE_CONFLICT") + if __name__ == "__main__": unittest.main() From b4d5a375438833381897fa1075f7ae2f430a8ca7 Mon Sep 17 00:00:00 2001 From: Erwan Barat Date: Thu, 17 Sep 2026 02:04:26 +0200 Subject: [PATCH 27/27] docs(qualification): GitLab.com qualified live (V3 chain, exact-runtime gate) (#166) (#175) --- CHANGELOG.md | 5 +++-- SUPPORT.md | 2 +- docs/MULTIFORGE-QUALIFICATION.md | 23 ++++++++++------------- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 562c4caa..a2b16fc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,8 +40,9 @@ project adheres to [Semantic Versioning](https://semver.org/). - **GitLab provider (Release V3)** (#166): Releases for discovery, the Generic Package Registry as the canonical integrity surface (`file_sha256` + `size`), exact package lookup, bounded pagination, and `PRIVATE-TOKEN` - confined to the configured origin. GitLab.com live qualification is - UNTESTED and therefore not declared supported — + confined to the configured origin. **GitLab.com is qualified live** + (2026-09-16): a published package and release were enumerated, anchored, + downloaded and verified end to end against the real API — `docs/MULTIFORGE-QUALIFICATION.md` states exactly what was verified. - **Work Authority observation and claims** (#163/#164, ADR-0018): `ainative forge detect | status` (zero network, credentials, writes or diff --git a/SUPPORT.md b/SUPPORT.md index b5da57b4..f4764468 100644 --- a/SUPPORT.md +++ b/SUPPORT.md @@ -11,7 +11,7 @@ | Harnesses | Claude Code, Codex, OpenCode, Cursor, Gemini CLI, MiniMax/Mavis — for the shared method, skills and hooks | | Work management | Generic Git; GitHub (mapping); GitLab (mapping). The Work Authority is resolved locally, purely and fail-closed (ADR-0018) | | Features | Project-scope features independent of the profile (`forge-github` default, `forge-gitlab`), State schema V2 (ADR-0017) | -| Release sources | GitHub.com (built-in, qualified); a local mirror; an anonymous HTTPS release API (`AINATIVE_UPDATE_URL`, never credentialed); GitLab.com provider implemented and contract-tested, live-service qualification UNTESTED — **not declared supported**; GitLab Self-Managed and GitHub Enterprise Server UNTESTED — **not supported** | +| Release sources | GitHub.com (built-in, qualified); a local mirror; an anonymous HTTPS release API (`AINATIVE_UPDATE_URL`, never credentialed); GitLab.com (qualified live: Generic Package Registry anchor, managed releases, exact version chain — 2026-09-16); GitLab Self-Managed and GitHub Enterprise Server UNTESTED — **not supported** | Only the latest release is supported; fixes ship as a new patch version. diff --git a/docs/MULTIFORGE-QUALIFICATION.md b/docs/MULTIFORGE-QUALIFICATION.md index 3ee6e10a..90328f49 100644 --- a/docs/MULTIFORGE-QUALIFICATION.md +++ b/docs/MULTIFORGE-QUALIFICATION.md @@ -34,7 +34,7 @@ Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). |---|---| | Generic Git | GREEN — `feature switch none`, zero-feature states valid, observation resolves `UNAVAILABLE` | | GitHub.com | GREEN for the release source (existing qualified releases + provider contract tests); GREEN for work management (mapping + observation) | -| GitLab.com | Provider implemented and contract-tested against a scripted GitLab API. **Live-service qualification UNTESTED — not declared supported** | +| GitLab.com | **GREEN — qualified live** (2026-09-16) against a public test project (`barat.erwan/ai-native-dev-stack-probe`): Generic Package Registry published `ai-native-dev-stack` 2.4.4 (manifest 514 B `sha256:84a921eb…`, bundle 119 895 B `sha256:67670398…`) plus release `v2.4.4`. Authenticated enumeration (`PRIVATE-TOKEN`), anchor read from the package-file API's `file_sha256` + `size`, anchored manifest parsed, exact version chain held, artifact downloaded with size + SHA-256 verified, lifecycle protocol 3 accepted, and `update check` reported `UPDATE_AVAILABLE 2.4.4` with the exact-runtime gate (`runtime_ready=false` for the 2.4.3 runtime) | | GitLab Self-Managed | UNTESTED — not supported | | GitHub Enterprise Server | UNTESTED — not supported | @@ -43,7 +43,7 @@ Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). | # | Scenario | Evidence | Status | |---|---|---|---| | A | Generic Git | `test_lifecycle_features` (switch none, zero features), `test_forge_claims` (UNAVAILABLE) | GREEN | -| B | New GitLab project | feature switch to `forge-gitlab` + GitLab provider tests (scripted) | PARTIAL — no live GitLab project exists to qualify | +| B | New GitLab project | live qualification probe (above) + feature switch to `forge-gitlab` | GREEN | | C | Legacy GitHub project | V1 projection + migration tests | GREEN | | D | GitHub → GitLab → none → GitHub | `test_a_round_trip_loses_no_user_data` | GREEN | | E | Fork origin/upstream ambiguity | resolver + `forge detect` rendering (AMBIGUOUS, exit 0) | GREEN | @@ -78,21 +78,18 @@ Declared support never exceeds qualified support (`SUPPORT.md`, ADR-0019 §13). ## Known limitations and remaining work **P1 — the declared-support gate:** -- GitLab.com live qualification (requires a real GitLab project publishing - `ai-native-dev-stack` generic packages and an `ainative-release-v3.json` - manifest). Until then GitLab.com stays UNTESTED and is not declared - supported. - Full CI matrix (Linux/Windows/macOS, py3.11/3.13) — runs on the `dev` - proposals; the first run of the complete implementation was green 52/52. + proposals; every run so far has been green (52/52, then 76/76). - The V2 bridge release and the V3 release have not been published: the plan's release choreography (§92) starts after the branch is promoted to `dev`, and the first V3 publication stays gated by `V3_BRIDGE_RELEASE`. -**Resolved since the first draft of this report:** scenario Q (mandatory -secret patterns with operator extras), scenario J (GitLab object-storage -blob), scenario M (legacy GitLab remote warning), scenario P (one -cross-command selector-conflict E2E) and the EN/FR documentation parity -automation (`tests/purity/test_docs_parity.py`: heading hierarchy, operational -surface, critical security statements — no raw line-count equality). +**Resolved since the first draft of this report:** the GitLab.com **live** +qualification (scenario B), scenario Q (mandatory secret patterns with +operator extras), scenario J (GitLab object-storage blob), scenario M (legacy +GitLab remote warning), scenario P (one cross-command selector-conflict E2E) +and the EN/FR documentation parity automation +(`tests/purity/test_docs_parity.py`: heading hierarchy, operational surface, +critical security statements — no raw line-count equality). **P0:** none known.