diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eedd3b6..87701d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,74 @@ jobs: - name: Run tests run: python3 -m pytest -v + + # Event 177 — the distribution acceptance bar as CI: a clean macOS runner + # with nothing but Python must go wheel → install → doctor clean → sync → + # resolvable hooks → a hook actually firing, with ZERO repo checkout in + # reach of the installed package. This is the E155 reproducibility gap + # closed with evidence instead of reasoning. Also gates the privacy + # contract: a wheel that ships operator memory fails here. + clean-install: + runs-on: macos-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Build wheel + run: python3 -m pip wheel . --no-deps -w dist_wheel + + - name: Privacy gate — no operator memory / private skills / state in wheel + run: | + LISTING="$(python3 -m zipfile -l dist_wheel/*.whl)" + echo "$LISTING" | grep -q "_assets/core/hooks/workflow_guard.py" || { echo "assets missing from wheel"; exit 1; } + if echo "$LISTING" | grep -E "memory/global/(runtime_digest|operator_profile|agent_feedback|workflow_policy|cognitive_profile|overview|python_runtime_policy)\.md"; then + echo "PRIVACY VIOLATION: operator memory in wheel"; exit 1; fi + if echo "$LISTING" | grep -E "skills/private|memory/(evolution|substrates)|\.jsonl|\.pem|\.key|\.p12"; then + echo "PRIVACY VIOLATION: private/state/key material in wheel"; exit 1; fi + + - name: Clean-home install and acceptance chain + env: + FRESH: ${{ runner.temp }}/fresh + run: | + mkdir -p "$FRESH/home" + python3 -m venv "$FRESH/venv" + "$FRESH/venv/bin/pip" install -q dist_wheel/*.whl + cd "$FRESH" + export HOME="$FRESH/home" EPISTEME_HOME="$FRESH/home/.episteme" + # doctor exits non-zero here for exactly ONE legitimate reason: the + # runner cannot have the Claude Code binary. Assert 0 warnings and + # that "missing tool: claude" is the ONLY failure — any other + # failure (or a second one) breaks the job. + "$FRESH/venv/bin/episteme" doctor | tee doctor.out || true + grep -Eq " 0 warn " doctor.out + FAILS="$(grep -E '^\s+×' doctor.out || true)" + echo "failure lines: ${FAILS:-none}" + [ "$(printf '%s' "$FAILS" | grep -c .)" -le 1 ] + if [ -n "$FAILS" ]; then echo "$FAILS" | grep -q "missing tool: claude"; fi + "$FRESH/venv/bin/episteme" sync + "$FRESH/venv/bin/python" - << 'PY' + import json, os + from pathlib import Path + cfg = json.loads((Path(os.environ["HOME"]) / ".claude/settings.json").read_text()) + cmds = [h["command"] for grp in cfg.get("hooks", {}).values() for e in grp for h in e.get("hooks", [])] + script_cmds = [c for c in cmds if " " in c and c.split(" ", 1)[1].endswith(".py")] + assert script_cmds, "no hook scripts written" + missing = [c for c in script_cmds if not Path(c.split(" ", 1)[1]).exists()] + assert not missing, f"dangling hook paths: {missing}" + print(f"{len(script_cmds)} hook scripts resolve on disk") + PY + mkdir -p proj/src proj/docs && cd proj + printf '# agents\n' > AGENTS.md + printf 'Entry: `src/app.py`.\n' > docs/APP.md + printf 'pass\n' > src/app.py + git init -q && git add -A + SITE="$("$FRESH/venv/bin/python" -c 'import episteme, os; print(os.path.dirname(episteme.__file__))')" + GUARD="$SITE/_assets/core/hooks/workflow_guard.py" + OUT="$(printf '{"tool_name":"Edit","tool_input":{"file_path":"%s/proj/src/app.py"},"session_type":"main","cwd":"%s/proj"}' "$FRESH" "$FRESH" | "$FRESH/venv/bin/python" "$GUARD")" + echo "$OUT" + echo "$OUT" | grep -q "ADVISORY" || { echo "hook did not fire"; exit 1; } diff --git a/docs/SETUP.md b/docs/SETUP.md index 31789d9..b36c791 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,6 +1,33 @@ - + # Setup — Profile, Cognition, One-Command +## Install without a checkout (E177 — the distribution path) + +A pip-installed wheel is self-contained: it bundles the governance assets +(`core/hooks`, `kernel/`, `skills/`, `templates/` → `episteme/_assets/`, +built by `setup.py`; the maintainer's personal memory is excluded by +construction) and every asset consumer resolves them via +`src/episteme/_assets.py` — repo checkout when present, packaged assets +otherwise. The clean-machine chain is CI-enforced on a fresh macOS runner +(`.github/workflows/ci.yml`, `clean-install` job): + +```bash +pip install # from a GitHub release artifact +episteme doctor # expect 0 warn · 0 fail +episteme sync # wires hooks from the installed assets +episteme viewer # live governance dashboard +``` + +`episteme sync` from an installed package deploys the governance layer +(hooks, skills, settings) and reports its origin as `installed package`. +Personalized memory for installed users is not wired yet — `episteme init` +refuses in installed context rather than writing into read-only wheel +assets (sync deploys the generic examples layer meanwhile; to personalize +today, clone the repo and run init from the checkout). Developing episteme +itself? A checkout always wins asset resolution — nothing changes for you. + +--- + Deterministic onboarding for episteme. Two axes: - **profile** — *how work runs* (planning, testing, docs, automation). diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..24a7a53 --- /dev/null +++ b/setup.py @@ -0,0 +1,59 @@ +"""Wheel asset bundling (Event 177) — thin setuptools consumer. + +The shipping/privacy contract lives in ``src/episteme/_packaging.py`` (pure, +importable without setuptools — the 3.12 CI lane has no bundled setuptools +and must still run the contract tests). This file only wires that contract +into ``build_py``: copy the governance trees into ``episteme/_assets/`` with +the privacy ignore bound to THIS repo root, so an sdist-staged build applies +identical exclusions. +""" + +from __future__ import annotations + +import shutil +import sys +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + +REPO = Path(__file__).resolve().parent +sys.path.insert(0, str(REPO / "src")) + +from episteme._packaging import ASSET_TREES, asset_ignore_for # noqa: E402 + + +class build_py_with_assets(build_py): # noqa: N801 (setuptools naming) + def run(self) -> None: + super().run() + ignore = asset_ignore_for(REPO) + dest_root = Path(self.build_lib) / "episteme" / "_assets" + for tree in ASSET_TREES: + src = REPO / tree + if not src.is_dir(): + # FAIL LOUDLY (review): an sdist-staged build has none of the + # asset trees, and skipping silently ships an asset-less + # wheel that breaks at runtime. Wheels are built in-tree + # (`pip wheel .` — the CI clean-install path); any other + # staging must die here, visibly. + raise RuntimeError( + f"asset tree '{tree}' missing at {src} — building outside " + f"the repo checkout (sdist staging?) is not a supported " + f"wheel path; build with `pip wheel .` from the checkout" + ) + dest = dest_root / tree + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest, ignore=ignore) + dest_root.mkdir(parents=True, exist_ok=True) + (dest_root / "README.txt").write_text( + "episteme governance assets, bundled at build time by setup.py.\n" + "Operator personal memory (core/memory/global live files), private\n" + "skills, and runtime state are excluded by construction — see\n" + "episteme/_packaging.py for the contract and its tests.\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + setup(cmdclass={"build_py": build_py_with_assets}) diff --git a/src/episteme/_assets.py b/src/episteme/_assets.py new file mode 100644 index 0000000..9e53611 --- /dev/null +++ b/src/episteme/_assets.py @@ -0,0 +1,77 @@ +"""Asset-root resolution — repo checkout vs installed package (Event 177). + +Every governance asset (core/hooks, kernel/, skills/, templates/) historically +resolved via ``Path(__file__).parents[2]`` — six independent copies of that +pattern, all of which point into ``lib/python3.X`` when the package is +pip-installed (measured: doctor's own wire probe flagged +``venv/lib/python3.11/core/hooks/... missing (wheel-shaped install?)``). +This module is the ONE definition of where assets live: + +1. **Checkout wins.** Walk upward from this file looking for the checkout + markers; a dev tree (or a venv nested inside one) resolves to the repo + root exactly as before — zero behavior change for the operator. +2. **Packaged assets** otherwise: the ``episteme/_assets/`` tree that + ``setup.py`` copies into the wheel (core/kernel/skills/templates, with the + operator's personal memory excluded by construction — see setup.py's + privacy ignore). +3. Legacy ``parents[2]`` as the last resort, so a broken install degrades to + the old (diagnosable) behavior rather than a new one. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from typing import Optional + +#: A directory is the episteme checkout iff ALL markers exist. Two markers so +#: a stray ``kernel/`` dir in some unrelated ancestor cannot hijack resolution. +_CHECKOUT_MARKERS = ("kernel/CONSTITUTION.md", "core/hooks") + + +def checkout_root() -> Optional[Path]: + """The enclosing repo checkout, or ``None`` in an installed context. + + ``EPISTEME_CHECKOUT`` overrides the walk (useful for tests and unusual + layouts); it must itself carry the markers to be honored — an override + pointing at a non-checkout is ignored, not trusted. + """ + override = os.environ.get("EPISTEME_CHECKOUT") + if override: + cand = Path(override) + if all((cand / m).exists() for m in _CHECKOUT_MARKERS): + return cand.resolve() + here = Path(__file__).resolve() + for parent in here.parents: + if all((parent / m).exists() for m in _CHECKOUT_MARKERS): + return parent + return None + + +def packaged_root() -> Optional[Path]: + """The wheel-shipped ``_assets`` tree, or ``None`` when absent (dev tree).""" + cand = Path(__file__).resolve().parent / "_assets" + if (cand / "core" / "hooks").is_dir(): + return cand + return None + + +def asset_root() -> Path: + """Where ``core/``, ``kernel/``, ``skills/``, ``templates/`` live.""" + root = checkout_root() + if root is not None: + return root + packaged = packaged_root() + if packaged is not None: + return packaged + return Path(__file__).resolve().parents[2] + + +def is_installed_context() -> bool: + """True when running from an installed package with no checkout in reach. + + The sync origin guard branches on this: an installed wheel's assets are + versioned, immutable-by-install content — a legitimate origin class, + unlike the throwaway-checkout class the guard exists to refuse. + """ + return checkout_root() is None diff --git a/src/episteme/_cognitive_budget.py b/src/episteme/_cognitive_budget.py index e244f31..4a2813c 100644 --- a/src/episteme/_cognitive_budget.py +++ b/src/episteme/_cognitive_budget.py @@ -70,7 +70,9 @@ # Locate core/hooks/_chain.py — same lazy-import pattern other src/episteme/ # library modules use for hook-tier modules. -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +from episteme._assets import asset_root as _asset_root + +_REPO_ROOT = _asset_root() _CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks" if str(_CORE_HOOKS_DIR) not in sys.path: sys.path.insert(0, str(_CORE_HOOKS_DIR)) diff --git a/src/episteme/_packaging.py b/src/episteme/_packaging.py new file mode 100644 index 0000000..91572fa --- /dev/null +++ b/src/episteme/_packaging.py @@ -0,0 +1,73 @@ +"""The wheel's asset-shipping contract — pure logic, no setuptools (E177). + +Split from setup.py because Python 3.12 venvs no longer bundle setuptools: +importing the contract for TESTING must not require the build backend (the +3.12 CI lane failed collection on exactly that). setup.py consumes these; +tests/test_wheel_assets.py pins them; neither needs the other's runtime. + +PRIVACY IS STRUCTURAL HERE: ``core/memory/global`` is the operator's +personal cognitive profile — only its ``examples/`` templates ship. Private +skills, runtime ``*.jsonl`` state, caches, and key-shaped files are dropped +wherever they appear. A leak requires editing THIS file, not forgetting a +step. +""" + +from __future__ import annotations + +from pathlib import Path + +#: Trees shipped into episteme/_assets/ (relative to the repo root). +ASSET_TREES = ("core", "kernel", "skills", "templates") + +#: Directory names dropped anywhere in the copy. +DROP_DIR_NAMES = {"__pycache__", ".git", ".DS_Store", "node_modules"} + +#: File suffixes dropped anywhere (runtime state, caches, key material). +DROP_SUFFIXES = (".pyc", ".jsonl", ".pem", ".key", ".p12") + + +def asset_ignore_for(repo_root: Path): + """`shutil.copytree` ignore callable bound to ``repo_root``. + + Binding the root explicitly (instead of assuming the copy source lives + under this file's repo) keeps the privacy rules correct no matter where + the build frontend stages the tree — an sdist temp dir must get the same + exclusions as an in-repo build. + """ + repo_root = Path(repo_root).resolve() + + def _ignore(directory: str, names: list) -> set: + directory_path = Path(directory).resolve() + try: + rel = str(directory_path.relative_to(repo_root)) + except ValueError: + # FAIL CLOSED (review blocker-class): an unrelatable staging dir + # would silently disable every privacy rule and ship the + # operator's memory. A build that cannot place itself must die + # loudly, never ship openly. + raise RuntimeError( + f"asset staging dir {directory_path} is not under the bound " + f"repo root {repo_root}; refusing to apply privacy rules " + f"blind" + ) + ignored = set() + for name in names: + if name in DROP_DIR_NAMES or name.endswith(DROP_SUFFIXES): + ignored.add(name) + # The operator's personal memory: ship the fork-install templates ONLY. + if rel == "core/memory/global": + ignored.update(n for n in names if n != "examples") + # Runtime data lanes, mirrored from .gitignore's asset-tree rules — + # tests/test_wheel_assets.py cross-checks that every gitignored + # pattern under the shipped trees has a drop rule here, so a new + # runtime lane cannot leak by omission (review: evolution episodes + # were exactly such an omission, and only LOCAL builds could see it). + if rel == "core/memory": + ignored.add("substrates") + ignored.add("evolution") + # Private skills never ship. + if rel == "skills": + ignored.add("private") + return ignored + + return _ignore diff --git a/src/episteme/_policy_history.py b/src/episteme/_policy_history.py index 81ec844..497d841 100644 --- a/src/episteme/_policy_history.py +++ b/src/episteme/_policy_history.py @@ -59,7 +59,9 @@ from pathlib import Path from typing import Iterable -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +from episteme._assets import asset_root as _asset_root + +_REPO_ROOT = _asset_root() _CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks" if str(_CORE_HOOKS_DIR) not in sys.path: sys.path.insert(0, str(_CORE_HOOKS_DIR)) diff --git a/src/episteme/_profile_audit.py b/src/episteme/_profile_audit.py index 64da7f5..e346ded 100644 --- a/src/episteme/_profile_audit.py +++ b/src/episteme/_profile_audit.py @@ -53,7 +53,9 @@ from typing import Any, Literal, TypedDict -REPO_ROOT = Path(__file__).resolve().parents[2] +from episteme._assets import asset_root as _asset_root + +REPO_ROOT = _asset_root() # v1.0 RC CP1 — `_classify_disconfirmation` and its three pattern tuples moved # to `core/hooks/_specificity.py` so the hot-path validator diff --git a/src/episteme/_profile_audit_ack.py b/src/episteme/_profile_audit_ack.py index d0eee39..b4cb221 100644 --- a/src/episteme/_profile_audit_ack.py +++ b/src/episteme/_profile_audit_ack.py @@ -55,7 +55,9 @@ # Locate core/hooks/_chain.py — same lazy-import pattern the rest of # the CLI uses for hook-tier modules. -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +from episteme._assets import asset_root as _asset_root + +_REPO_ROOT = _asset_root() _CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks" if str(_CORE_HOOKS_DIR) not in sys.path: sys.path.insert(0, str(_CORE_HOOKS_DIR)) diff --git a/src/episteme/_profile_history.py b/src/episteme/_profile_history.py index 50ac498..98b2ad5 100644 --- a/src/episteme/_profile_history.py +++ b/src/episteme/_profile_history.py @@ -72,7 +72,9 @@ # Locate core/hooks/_chain.py — same lazy-import pattern other src/episteme/ # library modules use for hook-tier modules. -_REPO_ROOT = Path(__file__).resolve().parent.parent.parent +from episteme._assets import asset_root as _asset_root + +_REPO_ROOT = _asset_root() _CORE_HOOKS_DIR = _REPO_ROOT / "core" / "hooks" if str(_CORE_HOOKS_DIR) not in sys.path: sys.path.insert(0, str(_CORE_HOOKS_DIR)) diff --git a/src/episteme/adapters/claude.py b/src/episteme/adapters/claude.py index 448db83..8082e21 100644 --- a/src/episteme/adapters/claude.py +++ b/src/episteme/adapters/claude.py @@ -12,6 +12,18 @@ from pathlib import Path from .. import cli as _cli +from .._assets import is_installed_context as _is_installed_context + + +def _sync_origin_record() -> str: + """What the sync ledger records as the deploying origin (E166/E177). + + A checkout records its path (rule-3 clobber protection compares against + it). An installed package records a sentinel — its site-packages + ``_assets`` path is meaningless as an origin and would make a later + checkout sync trip the refusal against it. + """ + return "installed-package" if _is_installed_context() else str(_cli.REPO_ROOT) # --------------------------------------------------------------------------- @@ -643,8 +655,11 @@ def sync(governance_mode: str = "balanced") -> None: # compares against this to refuse a clobber from a # second clone/worktree (a scratchpad clone once # rewrote the operator's global memory to point at its - # own example files). - "repo_root": str(_cli.REPO_ROOT), + # own example files). E177: an installed package records + # a sentinel instead of its site-packages _assets path — + # recording that path would make a LATER checkout sync + # trip the rule-3 refusal against a meaningless origin. + "repo_root": _sync_origin_record(), "deployed_skills": deployed_skills, "deployed_agents": deployed_agents, }, diff --git a/src/episteme/cli.py b/src/episteme/cli.py index 366f3fe..13e6fd7 100644 --- a/src/episteme/cli.py +++ b/src/episteme/cli.py @@ -15,7 +15,13 @@ from typing import Iterable -REPO_ROOT = Path(__file__).resolve().parents[2] +# E177: one definition of where governance assets live — repo checkout when +# present (operator/dev, unchanged), wheel-shipped episteme/_assets otherwise +# (installed distribution). parents[2] resolved into lib/python3.X for +# wheel installs and every asset consumer inherited the breakage. +from episteme._assets import asset_root as _asset_root + +REPO_ROOT = _asset_root() HOME = Path.home() @@ -1358,6 +1364,25 @@ def _init_memory() -> int: If the personal files already exist (e.g. the repo ships with the author's real profiles), init skips them so forks start from those real profiles. """ + from episteme import _assets as _assets_mod + + if _assets_mod.is_installed_context(): + # E177 review finding: in installed context REPO_ROOT is the wheel's + # read-only/ephemeral _assets tree — seeding memory there either + # PermissionErrors (system installs) or evaporates on upgrade (venv). + # Honest refusal beats a broken write; the home-based memory lane for + # installed users is the E178 install-story work. + print( + "[episteme init] refused — installed-package context has no " + "writable memory root yet (the wheel's assets are read-only).\n" + " Installed sync deploys the generic governance layer from the " + "bundled examples; PERSONALIZED memory for installed users lands " + "in a coming release. To personalize today, clone the repo and " + "run init from the checkout.", + file=sys.stderr, + ) + return 2 + cwd = Path.cwd().resolve() memory_dir = REPO_ROOT / "core" / "memory" / "global" examples_dir = memory_dir / "examples" @@ -1721,6 +1746,19 @@ def _enforce_sync_origin(claude_root: Path, force: bool) -> int | None: """Print + refuse when the origin is wrong. Returns an exit code to propagate, or None to proceed. ``--force`` downgrades to a warning so a deliberate primary-checkout move stays possible.""" + from episteme import _assets as _assets_mod + + if _assets_mod.is_installed_context(): + # E177: an installed wheel is a LEGITIMATE origin class the E166 + # guard's vocabulary lacked — its assets are versioned, immutable-by- + # install content, not a throwaway checkout that could hijack the + # operator's memory. (Measured pre-fix: the temp-root rule refused + # site-packages as "a checkout under a temp root".) The operator- + # memory lane still self-guards: live memory files simply do not + # exist in the wheel (privacy exclusion in setup.py), so sync + # deploys the governance layer and reports the memory lane skipped. + print("[episteme sync] origin: installed package (no repo checkout)") + return None problem = sync_origin_problem(REPO_ROOT, claude_root) if problem is None: return None diff --git a/src/episteme/viewer/server.py b/src/episteme/viewer/server.py index a38c5a2..3dc10c2 100644 --- a/src/episteme/viewer/server.py +++ b/src/episteme/viewer/server.py @@ -39,7 +39,9 @@ from episteme.viewer import live as _live -REPO_ROOT = Path(__file__).resolve().parents[3] +from episteme._assets import asset_root as _asset_root + +REPO_ROOT = _asset_root() VIEWER_DIR = Path(__file__).resolve().parent # Per-server-start session token (E175). Injected into index.html at serve diff --git a/tests/test_wheel_assets.py b/tests/test_wheel_assets.py new file mode 100644 index 0000000..296db84 --- /dev/null +++ b/tests/test_wheel_assets.py @@ -0,0 +1,155 @@ +"""Event 177 — asset-root resolution + the wheel's privacy shipping contract. + +The privacy tests pin setup.py's ignore callable directly: a regression that +would ship the operator's personal memory, private skills, runtime state, or +key material must fail HERE, in CI, before any wheel is built — not be +discovered in a published artifact. +""" + +from __future__ import annotations + +import unittest +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from episteme import _assets, _packaging + +REPO_ROOT = Path(__file__).resolve().parents[1] +_ignore = _packaging.asset_ignore_for(REPO_ROOT) + + +class AssetRootTests(unittest.TestCase): + def test_checkout_context_resolves_repo_root(self): + self.assertEqual(_assets.asset_root(), REPO_ROOT) + self.assertFalse(_assets.is_installed_context()) + + def test_checkout_override_requires_markers(self): + with TemporaryDirectory() as tmp: + with patch.dict("os.environ", {"EPISTEME_CHECKOUT": tmp}): + # A non-checkout override is ignored, not trusted. + self.assertEqual(_assets.asset_root(), REPO_ROOT) + + def test_installed_context_uses_packaged_assets(self): + with TemporaryDirectory() as tmp: + fake_pkg = Path(tmp) / "site-packages" / "episteme" + (fake_pkg / "_assets" / "core" / "hooks").mkdir(parents=True) + fake_module_file = fake_pkg / "_assets.py" + fake_module_file.write_text("# fake\n", encoding="utf-8") + with patch.object(_assets, "__file__", str(fake_module_file)): + self.assertIsNone(_assets.checkout_root()) + self.assertTrue(_assets.is_installed_context()) + self.assertEqual( + # resolve(): macOS tempdirs live under the /var → + # /private/var symlink and asset_root returns resolved. + _assets.asset_root(), (fake_pkg / "_assets").resolve() + ) + + +class PrivacyIgnoreTests(unittest.TestCase): + """setup.py's shipping contract, pinned field by field.""" + + def _ignored(self, rel: str, names: list) -> set: + return _ignore(str(REPO_ROOT / rel), names) + + def test_operator_memory_ships_examples_only(self): + names = [ + "examples", "runtime_digest.md", "operator_profile.md", + "agent_feedback.md", "workflow_policy.md", "cognitive_profile.md", + "overview.md", ".generated", + ] + ignored = self._ignored("core/memory/global", names) + self.assertNotIn("examples", ignored) + for personal in names: + if personal != "examples": + self.assertIn(personal, ignored, personal) + + def test_private_skills_never_ship(self): + ignored = self._ignored("skills", ["custom", "vendor", "private"]) + self.assertEqual(ignored, {"private"}) + + def test_runtime_state_and_key_material_dropped_anywhere(self): + ignored = self._ignored( + "core/hooks", + ["workflow_guard.py", "state.jsonl", "signing.pem", "id.key", + "bundle.p12", "cache.pyc", "__pycache__"], + ) + self.assertEqual( + ignored, + {"state.jsonl", "signing.pem", "id.key", "bundle.p12", + "cache.pyc", "__pycache__"}, + ) + + def test_substrate_records_do_not_ship(self): + ignored = self._ignored("core/memory", ["global", "substrates"]) + self.assertIn("substrates", ignored) + self.assertNotIn("global", ignored) + + def test_asset_trees_are_the_ratified_four(self): + self.assertEqual( + _packaging.ASSET_TREES, ("core", "kernel", "skills", "templates") + ) + + def test_evolution_runtime_state_never_ships(self): + # Review blocker, reproduced empirically: gitignored + # core/memory/evolution/episodes/*.json shipped from LOCAL builds + # while CI stayed green (a fresh checkout has no untracked files — + # the leak was unobservable exactly where the gate ran). Episode + # schemas can embed diffs of operator-private memory files. + ignored = self._ignored("core/memory", ["global", "substrates", "evolution"]) + self.assertIn("evolution", ignored) + + def test_gitignored_asset_tree_patterns_all_have_drop_rules(self): + # Structural guard: every .gitignore rule under a shipped tree must + # correspond to a drop in the packaging contract, so a NEW runtime + # lane cannot leak by omission. Parses the real .gitignore. + patterns = [ + line.strip() + for line in (REPO_ROOT / ".gitignore").read_text().splitlines() + if line.strip().startswith(("core/", "kernel/", "skills/", "templates/")) + ] + self.assertTrue(patterns, ".gitignore asset-tree rules disappeared?") + for pattern in patterns: + parts = pattern.rstrip("/").split("/") + covered = False + # Walk each prefix: covered if some ancestor dir's listing drops + # the next component (dir rule), or the leaf is suffix-dropped, + # or it falls under the memory/global examples-only rule. + for i in range(1, len(parts)): + parent_rel = "/".join(parts[:i]) + child = parts[i] + probe = child.replace("*", "leak") # e.g. *.json → leak.json + ignored = self._ignored(parent_rel, [probe, "examples"]) + if probe in ignored: + covered = True + break + if parent_rel == "core/memory/global" and probe != "examples": + covered = True + break + self.assertTrue(covered, f"gitignored but shippable: {pattern}") + + def test_unrelatable_staging_dir_fails_closed(self): + # The privacy boundary must die loudly, never ship openly (review: + # the previous except-ValueError silently disabled every rule). + with TemporaryDirectory() as tmp: + with self.assertRaises(RuntimeError): + _ignore(str(Path(tmp)), ["anything"]) + + def test_sdist_staged_build_keeps_privacy_rules(self): + # The ignore is bound to an explicit root: when a build frontend + # stages the tree elsewhere (sdist temp dir), the SAME relative + # rules must hold — an unmatched root must not silently disable + # the memory/global exclusion. + with TemporaryDirectory() as tmp: + staged = Path(tmp) / "staging" + (staged / "core" / "memory" / "global").mkdir(parents=True) + ignore = _packaging.asset_ignore_for(staged) + ignored = ignore( + str(staged / "core" / "memory" / "global"), + ["examples", "runtime_digest.md", "operator_profile.md"], + ) + self.assertEqual(ignored, {"runtime_digest.md", "operator_profile.md"}) + + +if __name__ == "__main__": + unittest.main()