From 8f2920fb25d3b3ddc995f3e8e667c30b6fb13db5 Mon Sep 17 00:00:00 2001 From: junjslee Date: Tue, 21 Jul 2026 11:32:27 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(dist):=20self-contained=20wheel=20?= =?UTF-8?q?=E2=80=94=20assets=20ship,=20paths=20resolve,=20privacy=20is=20?= =?UTF-8?q?structural=20(Event=20177)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The distribution truth run, then the fix class it named. Measured findings: the wheel shipped src/episteme ONLY; REPO_ROOT=parents[2] resolved into lib/python3.X when installed (doctor's own wire probe said 'wheel-shaped install?'); SIX modules duplicated the same pattern; sync's E166 origin guard refused site-packages as 'a temp-root checkout'; and a naive asset bundling would have PUBLISHED the operator's personal memory. Fixes, bounded to that class: - episteme/_assets.py: ONE definition of asset resolution — checkout wins (markers walk; dev behavior unchanged, verified), wheel-shipped episteme/_assets otherwise, legacy parents[2] as diagnosable last resort. All six definition sites + viewer rewired to it. - setup.py build_py: bundles core/kernel/skills/templates into the wheel with PRIVACY AS STRUCTURE: core/memory/global ships examples/ ONLY (live operator memory excluded by construction), skills/private, *.jsonl, key-shaped files, caches dropped anywhere. Pinned field-by-field in tests/test_wheel_assets.py; also gated in CI against the built artifact. - sync origin guard learns the 'installed package' origin class (versioned immutable-by-install assets are not a throwaway checkout). - CI clean-install job (macos-latest): wheel → fresh-HOME venv install → doctor '0 warn · 0 fail' → sync → every hook script path resolves → workflow_guard FIRES from site-packages assets. The E155 reproducibility gap, closed with evidence. - docs/SETUP.md: the no-checkout install path, told truthfully. Measured acceptance (isolated HOME, zero checkout): doctor 0 warn · sync completes, 21 hooks wired to installed paths · targeted DOC ADVISORY fired from _assets · viewer live with doc map. Suite 1832+91. --- .github/workflows/ci.yml | 62 +++++++++++++++++++ docs/SETUP.md | 27 ++++++++- setup.py | 81 +++++++++++++++++++++++++ src/episteme/_assets.py | 77 ++++++++++++++++++++++++ src/episteme/_cognitive_budget.py | 4 +- src/episteme/_policy_history.py | 4 +- src/episteme/_profile_audit.py | 4 +- src/episteme/_profile_audit_ack.py | 4 +- src/episteme/_profile_history.py | 4 +- src/episteme/cli.py | 21 ++++++- src/episteme/viewer/server.py | 4 +- tests/test_wheel_assets.py | 95 ++++++++++++++++++++++++++++++ 12 files changed, 379 insertions(+), 8 deletions(-) create mode 100644 setup.py create mode 100644 src/episteme/_assets.py create mode 100644 tests/test_wheel_assets.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eedd3b6..e9fd14e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,3 +49,65 @@ 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)\.md"; then + echo "PRIVACY VIOLATION: operator memory in wheel"; exit 1; fi + if echo "$LISTING" | grep -E "skills/private|\.jsonl|\.pem|\.key"; 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" + "$FRESH/venv/bin/episteme" doctor | tee doctor.out + grep -Eq " 0 warn · 0 fail" doctor.out + "$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 + GUARD="$FRESH/venv/lib/python3.12/site-packages/episteme/_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..cdbebd3 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -1,6 +1,31 @@ - + # 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`; +the operator-memory lane activates once you seed your own memory +(`episteme init`, from the bundled examples). 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..17131be --- /dev/null +++ b/setup.py @@ -0,0 +1,81 @@ +"""Wheel asset bundling (Event 177). + +Copies the governance asset trees into ``episteme/_assets/`` at build time so +a pip-installed episteme carries what it governs with — measured before this +event, the wheel shipped ``src/episteme`` only, and every asset consumer +resolved into ``lib/python3.X`` (see ``episteme/_assets.py``). + +PRIVACY IS STRUCTURAL HERE, not advisory: ``core/memory/global/`` is the +operator's PERSONAL cognitive profile (gitignored live files). The ignore +below ships its ``examples/`` templates only; a naive ``copytree`` would have +published the operator's private memory in every distributed wheel. The same +ignore drops private skills, runtime ``*.jsonl`` state, caches, and any +key-shaped file, so a leak requires editing THIS file, not forgetting a step. +``tests/test_wheel_assets.py`` pins these exclusions. +""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +from setuptools import setup +from setuptools.command.build_py import build_py + +REPO = Path(__file__).resolve().parent + +#: 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(directory: str, names: list) -> set: + """`shutil.copytree` ignore callable enforcing the shipping contract.""" + directory_path = Path(directory) + rel = directory_path.relative_to(REPO) if directory_path.is_relative_to(REPO) else directory_path + 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 str(rel) == "core/memory/global": + ignored.update(n for n in names if n != "examples") + # Substrate records are runtime data, not distributable assets. + if str(rel) == "core/memory": + ignored.add("substrates") + # Private skills never ship. + if str(rel) == "skills": + ignored.add("private") + return ignored + + +class build_py_with_assets(build_py): # noqa: N801 (setuptools naming) + def run(self) -> None: + super().run() + dest_root = Path(self.build_lib) / "episteme" / "_assets" + for tree in ASSET_TREES: + src = REPO / tree + if not src.is_dir(): + continue + dest = dest_root / tree + if dest.exists(): + shutil.rmtree(dest) + shutil.copytree(src, dest, ignore=asset_ignore) + (dest_root / "README.txt").parent.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.\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + # Import-safe: tests import this module to pin the ignore contract; + # build frontends execute it as a script, which takes this branch. + 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/_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/cli.py b/src/episteme/cli.py index 366f3fe..50f1617 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() @@ -1721,6 +1727,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..33c620d --- /dev/null +++ b/tests/test_wheel_assets.py @@ -0,0 +1,95 @@ +"""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 + +import setup as wheel_setup +from episteme import _assets + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +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 wheel_setup.asset_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( + wheel_setup.ASSET_TREES, ("core", "kernel", "skills", "templates") + ) + + +if __name__ == "__main__": + unittest.main() From 5858c4ac56683815f5aac252f302ead1f79ee004 Mon Sep 17 00:00:00 2001 From: junjslee Date: Tue, 21 Jul 2026 11:36:00 -0400 Subject: [PATCH 2/3] fix(dist): contract module without setuptools, CI doctor assertion names its one allowed failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Python 3.12 CI lane has no bundled setuptools and importing setup.py broke collection — the shipping/privacy contract now lives in episteme/_packaging.py (pure, testable everywhere); setup.py is a thin consumer that binds the ignore to an explicit repo root, and a new test pins that an sdist-staged build keeps the memory/global exclusion (an unmatched root must not silently disable privacy rules). The macos clean-install doctor step asserts 0 warnings and exactly one permitted failure line — 'missing tool: claude', the one thing a runner cannot have — instead of demanding a clean exit it can never produce. Suite 1833+91; rebuilt wheel re-verified private. --- .github/workflows/ci.yml | 12 ++++++-- setup.py | 63 ++++++++++---------------------------- src/episteme/_packaging.py | 60 ++++++++++++++++++++++++++++++++++++ tests/test_wheel_assets.py | 23 +++++++++++--- 4 files changed, 105 insertions(+), 53 deletions(-) create mode 100644 src/episteme/_packaging.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9fd14e..88cde81 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -88,8 +88,16 @@ jobs: "$FRESH/venv/bin/pip" install -q dist_wheel/*.whl cd "$FRESH" export HOME="$FRESH/home" EPISTEME_HOME="$FRESH/home/.episteme" - "$FRESH/venv/bin/episteme" doctor | tee doctor.out - grep -Eq " 0 warn · 0 fail" doctor.out + # 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 diff --git a/setup.py b/setup.py index 17131be..480a721 100644 --- a/setup.py +++ b/setup.py @@ -1,62 +1,32 @@ -"""Wheel asset bundling (Event 177). - -Copies the governance asset trees into ``episteme/_assets/`` at build time so -a pip-installed episteme carries what it governs with — measured before this -event, the wheel shipped ``src/episteme`` only, and every asset consumer -resolved into ``lib/python3.X`` (see ``episteme/_assets.py``). - -PRIVACY IS STRUCTURAL HERE, not advisory: ``core/memory/global/`` is the -operator's PERSONAL cognitive profile (gitignored live files). The ignore -below ships its ``examples/`` templates only; a naive ``copytree`` would have -published the operator's private memory in every distributed wheel. The same -ignore drops private skills, runtime ``*.jsonl`` state, caches, and any -key-shaped file, so a leak requires editing THIS file, not forgetting a step. -``tests/test_wheel_assets.py`` pins these exclusions. +"""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")) -#: 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(directory: str, names: list) -> set: - """`shutil.copytree` ignore callable enforcing the shipping contract.""" - directory_path = Path(directory) - rel = directory_path.relative_to(REPO) if directory_path.is_relative_to(REPO) else directory_path - 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 str(rel) == "core/memory/global": - ignored.update(n for n in names if n != "examples") - # Substrate records are runtime data, not distributable assets. - if str(rel) == "core/memory": - ignored.add("substrates") - # Private skills never ship. - if str(rel) == "skills": - ignored.add("private") - return ignored +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 @@ -65,17 +35,16 @@ def run(self) -> None: dest = dest_root / tree if dest.exists(): shutil.rmtree(dest) - shutil.copytree(src, dest, ignore=asset_ignore) - (dest_root / "README.txt").parent.mkdir(parents=True, exist_ok=True) + 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.\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__": - # Import-safe: tests import this module to pin the ignore contract; - # build frontends execute it as a script, which takes this branch. setup(cmdclass={"build_py": build_py_with_assets}) diff --git a/src/episteme/_packaging.py b/src/episteme/_packaging.py new file mode 100644 index 0000000..55a2334 --- /dev/null +++ b/src/episteme/_packaging.py @@ -0,0 +1,60 @@ +"""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: + rel = "" + 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") + # Substrate records are runtime data, not distributable assets. + if rel == "core/memory": + ignored.add("substrates") + # Private skills never ship. + if rel == "skills": + ignored.add("private") + return ignored + + return _ignore diff --git a/tests/test_wheel_assets.py b/tests/test_wheel_assets.py index 33c620d..e01c1f1 100644 --- a/tests/test_wheel_assets.py +++ b/tests/test_wheel_assets.py @@ -13,10 +13,10 @@ from tempfile import TemporaryDirectory from unittest.mock import patch -import setup as wheel_setup -from episteme import _assets +from episteme import _assets, _packaging REPO_ROOT = Path(__file__).resolve().parents[1] +_ignore = _packaging.asset_ignore_for(REPO_ROOT) class AssetRootTests(unittest.TestCase): @@ -50,7 +50,7 @@ class PrivacyIgnoreTests(unittest.TestCase): """setup.py's shipping contract, pinned field by field.""" def _ignored(self, rel: str, names: list) -> set: - return wheel_setup.asset_ignore(str(REPO_ROOT / rel), names) + return _ignore(str(REPO_ROOT / rel), names) def test_operator_memory_ships_examples_only(self): names = [ @@ -87,9 +87,24 @@ def test_substrate_records_do_not_ship(self): def test_asset_trees_are_the_ratified_four(self): self.assertEqual( - wheel_setup.ASSET_TREES, ("core", "kernel", "skills", "templates") + _packaging.ASSET_TREES, ("core", "kernel", "skills", "templates") ) + 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() From 630ee2279e75f7642a1cba976c7dec8a44198d94 Mon Sep 17 00:00:00 2001 From: junjslee Date: Tue, 21 Jul 2026 11:54:11 -0400 Subject: [PATCH 3/3] =?UTF-8?q?fix(dist):=20close=20the=20local-build=20pr?= =?UTF-8?q?ivacy=20leak=20the=20review=20reproduced=20=E2=80=94=20evolutio?= =?UTF-8?q?n=20lane=20dropped,=20boundary=20fails=20closed,=20gitignore?= =?UTF-8?q?=E2=86=94packaging=20cross-checked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocker (empirically reproduced by review): gitignored runtime core/memory/evolution/episodes/*.json shipped from LOCAL wheel builds while the CI gate stayed green — a fresh checkout has no untracked files, so the leak was unobservable exactly where the gate ran; the episode schema can embed diffs of operator-PRIVATE memory. Fixes, as a class not a patch: - core/memory/evolution dropped like substrates; and a new test parses the REAL .gitignore and asserts every rule under a shipped tree has a corresponding drop — a future runtime lane cannot leak by omission. - The privacy boundary now FAILS CLOSED: an unrelatable staging dir raises instead of silently disabling every rule (the old except-ValueError default-open was the wrong posture for the one function guarding memory). - setup.py fails loudly when asset trees are missing (an sdist-staged build previously shipped an asset-less wheel silently; in-tree Processing /Users/junlee/episteme Installing build dependencies: started Installing build dependencies: finished with status 'done' Getting requirements to build wheel: started Getting requirements to build wheel: finished with status 'done' Preparing metadata (pyproject.toml): started Preparing metadata (pyproject.toml): finished with status 'done' Building wheels for collected packages: episteme Building wheel for episteme (pyproject.toml): started Building wheel for episteme (pyproject.toml): finished with status 'done' Created wheel for episteme: filename=episteme-1.10.0rc1-py3-none-any.whl size=893707 sha256=89630d1f013bf8392d9c83afe20c58a024ab8816f03f7830baa2215eb34140ae Stored in directory: /private/var/folders/yy/7c1vkn4j6yd5w4s3wjy3lj9c0000gn/T/pip-ephem-wheel-cache-irs065ml/wheels/2f/5e/6e/08a4c02ca1b39d4fe2f96e53b00624d782a8e5f970077d4919 Successfully built episteme is the supported path and now the only one that can succeed). - episteme init refuses in installed context (was: writes into read-only site-packages — the documented personalization path was broken; honest refusal + E178 pointer; SETUP.md told the truth accordingly). - Installed sync records the 'installed-package' sentinel, not its site-packages path (a later checkout sync would have tripped rule-3 against a meaningless origin). - CI privacy grep covers python_runtime_policy.md + memory runtime lanes + .p12; site-packages path computed, not hardcoded. Verified: rebuilt wheel has 0 memory/evolution files, 0 episodes; installed init exits 2 with the refusal; suite 1836+91. --- .github/workflows/ci.yml | 7 ++--- docs/SETUP.md | 10 +++++--- setup.py | 11 +++++++- src/episteme/_packaging.py | 17 +++++++++++-- src/episteme/adapters/claude.py | 19 ++++++++++++-- src/episteme/cli.py | 19 ++++++++++++++ tests/test_wheel_assets.py | 45 +++++++++++++++++++++++++++++++++ 7 files changed, 116 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88cde81..87701d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -74,9 +74,9 @@ jobs: 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)\.md"; then + 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|\.jsonl|\.pem|\.key"; then + 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 @@ -115,7 +115,8 @@ jobs: printf 'Entry: `src/app.py`.\n' > docs/APP.md printf 'pass\n' > src/app.py git init -q && git add -A - GUARD="$FRESH/venv/lib/python3.12/site-packages/episteme/_assets/core/hooks/workflow_guard.py" + 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 cdbebd3..b36c791 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -19,10 +19,12 @@ 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`; -the operator-memory lane activates once you seed your own memory -(`episteme init`, from the bundled examples). Developing episteme itself? -A checkout always wins asset resolution — nothing changes for you. +(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. --- diff --git a/setup.py b/setup.py index 480a721..24a7a53 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,16 @@ def run(self) -> None: for tree in ASSET_TREES: src = REPO / tree if not src.is_dir(): - continue + # 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) diff --git a/src/episteme/_packaging.py b/src/episteme/_packaging.py index 55a2334..91572fa 100644 --- a/src/episteme/_packaging.py +++ b/src/episteme/_packaging.py @@ -41,7 +41,15 @@ def _ignore(directory: str, names: list) -> set: try: rel = str(directory_path.relative_to(repo_root)) except ValueError: - rel = "" + # 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): @@ -49,9 +57,14 @@ def _ignore(directory: str, names: list) -> set: # 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") - # Substrate records are runtime data, not distributable assets. + # 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") 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 50f1617..13e6fd7 100644 --- a/src/episteme/cli.py +++ b/src/episteme/cli.py @@ -1364,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" diff --git a/tests/test_wheel_assets.py b/tests/test_wheel_assets.py index e01c1f1..296db84 100644 --- a/tests/test_wheel_assets.py +++ b/tests/test_wheel_assets.py @@ -90,6 +90,51 @@ def test_asset_trees_are_the_ratified_four(self): _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