diff --git a/README.md b/README.md index d619656..f7b4289 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Skill CI Docs CI Docs - License: MIT + License: LicenseRef-Zero-State-Proprietary-1.0 Version 0.4.1 Python Hermes skill @@ -121,7 +121,7 @@ Phases **0–4** are production skill surface. **5.0–5.3** adds research simul | | | |--|--| | **Version** | **0.4.1** | -| **Posture** | Hermes skill · Python package (≥3.10) · MIT | +| **Posture** | Hermes skill · Python package (≥3.10) · LicenseRef-Zero-State-Proprietary-1.0 | | **Primary store** | `~/.hyperlex/` | | **Public PyPI** | Not planned | | **Abraxas** | Wire shapes only — Hyperlex never imports Abraxas | @@ -367,7 +367,7 @@ bash install.sh --dry-run ## License -MIT — [LICENSE](./LICENSE). +LicenseRef-Zero-State-Proprietary-1.0 — [LICENSE](./LICENSE). Built for operators who want memetic signal **with a receipt trail**, not a mood board. @@ -377,3 +377,5 @@ Built for operators who want memetic signal **with a receipt trail**, not a mood Copyright © 2026 Zero State LLC. All Rights Reserved. Licensed under the Zero State Proprietary License v1.0. See [`LICENSE`](LICENSE). Third-party components remain subject to their respective licenses. + +Source, profile, and migration rules: `references/source-and-upgrades.md`. diff --git a/SKILL.md b/SKILL.md index e260163..ce47b6f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -3,7 +3,7 @@ name: hyperlex description: Catch slang while it is still becoming culture. version: 0.4.1 author: Daniel Meyer (scrimshawlife-ctrl), Hermes Agent -license: MIT +license: LicenseRef-Zero-State-Proprietary-1.0 platforms: [linux, macos] metadata: hermes: @@ -155,3 +155,5 @@ Successful packaging: Design references: `DESIGN.md`, `docs/brier-calibration.md`, `docs/slang-lineages.md`, `docs/phase5.md`, `docs/modules/simulation.md`, `schemas/`, `examples/slang-families/`, `data/backfill/2026/`, `references/hermes-runtime-contract.md`. Local stdlib-first CLI. Baseline (`mock`) needs no network. Real ingest may call public web APIs. Score log and receipts are local files under `~/.hyperlex/` or skill `out/`. + +Source, profile, and migration rules: `references/source-and-upgrades.md`. diff --git a/hyperlex.manifest.yaml b/hyperlex.manifest.yaml index 1c7ee19..48cb5c6 100644 --- a/hyperlex.manifest.yaml +++ b/hyperlex.manifest.yaml @@ -5,7 +5,7 @@ kind: hermes-openclaw-skill description: > Standalone memetic emergence engine for slang detection, hyperstition tracking, virality analysis, lineage matching, and settled Brier calibration. -license: MIT +license: LicenseRef-Zero-State-Proprietary-1.0 homepage: https://github.com/scrimshawlife-ctrl/Hyperlex repository: https://github.com/scrimshawlife-ctrl/Hyperlex diff --git a/install.sh b/install.sh index cd0879e..22e89e5 100755 --- a/install.sh +++ b/install.sh @@ -78,10 +78,18 @@ while [[ $# -gt 0 ]]; do done validate_target() { + python3 - "$TARGET" <<'PY' +import sys +from pathlib import Path +p = Path(sys.argv[1]).expanduser().absolute() +if not sys.argv[1].strip() or any(q.is_symlink() for q in (p, *p.parents)): + raise SystemExit("refusing empty or symlinked target path") +PY + local parent base resolved home_resolved parent="$(dirname "$TARGET")" base="$(basename "$TARGET")" - mkdir -p "$parent" 2>/dev/null || true + # Path resolution and dry-run must not create target parents. resolved="$(resolve_path "$parent")/${base}" TARGET="$resolved" @@ -206,18 +214,13 @@ post_check() { do_rollback() { validate_target - local latest - latest="$(ls -1dt "${BACKUP_ROOT}"/*/ 2>/dev/null | head -1 || true)" - [[ -n "$latest" ]] || die "no backups under ${BACKUP_ROOT}" - log "Rolling back from ${latest}" if [[ $DRY_RUN -eq 1 ]]; then - printf '[dry-run] restore %q → %q\n' "$latest" "$TARGET" - else - rm -rf "$TARGET" - mkdir -p "$(dirname "$TARGET")" - cp -a "$latest" "$TARGET" + log "DRY RUN: would validate and restore a backup bound to ${TARGET}" + return 0 fi - log "Rollback complete" + local check_args=() + [[ $SKIP_SMOKE -eq 1 ]] && check_args+=(--skip-checks) + python3 "${ROOT}/scripts/install_transaction.py" "$ROOT" "$TARGET" hyperlex --rollback "${check_args[@]}" } if [[ $ROLLBACK -eq 1 ]]; then @@ -235,9 +238,9 @@ if [[ $DRY_RUN -eq 1 ]]; then exit 0 fi -backup_existing -sync_tree "$TARGET" -post_check "$TARGET" || true +CHECK_ARGS=() +[[ $SKIP_SMOKE -eq 1 ]] && CHECK_ARGS+=(--skip-checks) +python3 "${ROOT}/scripts/install_transaction.py" "$ROOT" "$TARGET" hyperlex "${CHECK_ARGS[@]}" if [[ $INSTALL_OPENCLAW -eq 1 ]]; then mkdir -p "$(dirname "$OPENCLAW_TARGET")" @@ -245,13 +248,16 @@ if [[ $INSTALL_OPENCLAW -eq 1 ]]; then _saved="$TARGET" TARGET="$OPENCLAW_TARGET" validate_target - sync_tree "$TARGET" - post_check "$TARGET" || true + python3 "${ROOT}/scripts/install_transaction.py" "$ROOT" "$TARGET" hyperlex "${CHECK_ARGS[@]}" TARGET="$_saved" fi echo "" -log "Hyperlex v${VERSION} installed" +if [[ $SKIP_SMOKE -eq 1 ]]; then + log "Hyperlex v${VERSION} installed — UNVERIFIED (--skip-smoke)" +else + log "Hyperlex v${VERSION} installed" +fi echo " Hermes: ${TARGET}" [[ $INSTALL_OPENCLAW -eq 1 ]] && echo " OpenClaw: ${OPENCLAW_TARGET}" echo "" diff --git a/pyproject.toml b/pyproject.toml index 26dc5dc..40567a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,13 +10,13 @@ authors = [ {name = "Applied Alchemy Labs", email = "scrimshawlife@gmail.com"} ] readme = "README.md" -license = {text = "MIT"} +license = {file = "LICENSE"} requires-python = ">=3.10" keywords = ["memetics", "slang", "hyperstition", "virality", "brier", "hermes", "forecasting"] classifiers = [ "Development Status :: 4 - Beta", "Intended Audience :: Developers", - "License :: OSI Approved :: MIT License", + "License :: Other/Proprietary License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", diff --git a/references/source-and-upgrades.md b/references/source-and-upgrades.md new file mode 100644 index 0000000..cb8dc00 --- /dev/null +++ b/references/source-and-upgrades.md @@ -0,0 +1,58 @@ +# Source identity and upgrades + +This distribution is `Zero-State-LLC/Hyperlex`, version `0.4.1`, based on commit +`5eaae2dee88d80b27415bf362a87924fb9c41c2e` before the local audit-fix commit. Record the actual installed commit +with `git rev-parse HEAD` before installation; do not use a version string alone +as a source identity. Root and hub packaging are distribution surfaces, not a +claim that organizational, personal, or legacy embedded variants are identical. +No personal version is deprecated by this change. + +Before switching sources, stop runtime writers, record current source/commit, +review the destination under `${HERMES_HOME:-$HOME/.hermes}`, compare contracts, +and retain a separate backup of outputs, sessions, and local customization. +Do not install two different contracts with the same skill name into one profile. +A successful compatibility check does not grant deployment/publication authority. + +The bundled LICENSE controls this distribution. No license grant is changed by +these fixes; earlier grants and third-party notices remain intact. + +The installer validates a fresh stage on the target filesystem before replacement, +then reads back the activated contract/version/provenance receipt. Checks use an +isolated temporary home. Legacy `out` contents (including an out symlink) survive. +Backups are unique, keyed by canonical destination under the active Hermes home's +`backups///`; `.install-provenance.json` records source, base +commit, dirty status, version, destination, and skipped checks. Stop writers during +upgrades. Two renames have an absent-target window: this is NOT crash-atomic. +On a reported recovery failure, retain the printed recovery directory and backup; +do not delete it or retry blindly. Inspect the exact target and restore from the +named backup only after validating it. No automatic source migration is implied. + +## Interrupted installs and stale locks + +SIGKILL or power loss can leave `..install-lock` beside the target. +Locks are never automatically reclaimed: age, an empty directory, or a reused PID +cannot prove that no installer is active. To recover: + +1. Stop install launchers and runtime writers. Confirm no installer is active on + this target (including other sessions/hosts sharing the filesystem). +2. Inspect the exact target, sibling `.-stage-*` recovery directories + (especially `previous` and `failed-package`), and target-keyed backups. Retain + all recovery data until the original installation and outputs are accounted + for. Restore/validate a complete package first if activation was interrupted. +3. Only then set `lock` to the exact path printed in the error and run + `rmdir -- "$lock"`. This removes only an empty lock; never use recursive + deletion or remove a lock whose owner/activity is uncertain. Keep launchers + stopped through inspection and removal to avoid races, then retry installation. + +Backup copies are staged in `.backup-incomplete-*` outside the target's selectable +backup directory and published by rename after copying and writing the destination +record. Hard termination can leave these incomplete staging trees; never select +one for rollback. Inspect them manually after quiescing writers. Rollback skips +legacy partial entries without a valid matching destination record. + +Git is optional. Archive sources, failed Git lookups, and unrelated enclosing +worktrees record unknown Git fields as JSON `null`, never a false clean claim. +A source-root worktree or the tracked `skills/neon-genie` hub with matching root +contract/version supplies Git provenance. Receipts distinguish +`source_repository_root` from `source_subdirectory` (`.` or `skills/neon-genie`). +A dirty status lookup failure remains `null` even in a recognized worktree. diff --git a/scripts/install_transaction.py b/scripts/install_transaction.py new file mode 100644 index 0000000..c11124e --- /dev/null +++ b/scripts/install_transaction.py @@ -0,0 +1,295 @@ +"""Staged, checked upgrades with target-keyed backups and recovery. + +The two renames have an absent-target window; this is not crash-atomic. Stop +runtime writers during upgrades. A retained recovery directory requires operator +inspection. No live profile is used by validation subprocesses. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import shutil +import subprocess +import sys +import tempfile +import uuid +from pathlib import Path + +CHECKS = { + "neon-genie": [("validate_hermes_skill.py",), ("neon_genie.py", "do", "check")], + "sigil-forge": [("validate_hermes_skill.py",), ("sigil_forge.py", "check")], + "hyperlex": [("hyperlex.py", "check"), ("hyperlex.py", "smoke")], +} +IGNORE = shutil.ignore_patterns( + ".git", + "skills", + "out", + ".venv", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "*.pyc", + ".worktrees", + "graft", + ".env", + ".env.*", + ".hermes", + "*.pem", + "*.key", + ".superpowers", + "superpowers", +) + + +def install(source: Path, target: Path, kind: str, skip_checks: bool = False) -> None: + # Check the lexical final component before resolve follows a dangling link. + if any(p.is_symlink() for p in (target.absolute(), *target.absolute().parents)): + raise ValueError("refusing symlink target") + source, target = source.resolve(), target.resolve() + if source == target or source in target.parents or target in source.parents: + raise ValueError("source and target must not overlap") + home = Path(os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")).resolve() + if target in (Path("/"), Path.home().resolve(), home, home / "skills"): + raise ValueError("refusing installation root") + if target.exists() and not target.is_dir(): + raise ValueError("target must be a directory") + key = hashlib.sha256(str(target).encode()).hexdigest()[:20] + backups = home / "backups" / kind / key + if target == backups or target in backups.parents or backups in target.parents: + raise ValueError("backup and target must not overlap") + # Reject payload links rather than shipping aliases into private source state. + for directory, dirs, files in os.walk(source, followlinks=False): + ignored = IGNORE(directory, dirs + files) + dirs[:] = [d for d in dirs if d not in ignored] + for name in dirs + [f for f in files if f not in ignored]: + if (Path(directory) / name).is_symlink(): + raise ValueError(f"source payload symlink is not portable: {name}") + target.parent.mkdir(parents=True, exist_ok=True) + lock = target.parent / ("." + target.name + ".install-lock") + try: + lock.mkdir() # exclusive; never remove another installer's lock + except FileExistsError as exc: + raise FileExistsError( + f"Install lock exists: {lock}. Do not reclaim automatically. " + "Stop launchers and confirm no installer or runtime writer is active; " + "inspect the target, sibling stage/recovery directories and backups. " + "Only after resolving recovery, use rmdir on this exact empty lock " + "(never recursive removal), then retry. See references/source-and-upgrades.md." + ) from exc + workspace = None + retain_recovery = False + try: + workspace = Path( + tempfile.mkdtemp(prefix="." + kind + "-stage-", dir=target.parent) + ) + stage = workspace / "package" + shutil.copytree(source, stage, ignore=IGNORE) + check_home = workspace / "check-home" + check_home.mkdir() + env = { + k: v + for k, v in os.environ.items() + if not k.startswith(("HYPERLEX_", "SIGIL_FORGE_")) + } + env.update( + HOME=str(check_home), + HERMES_HOME=str(check_home / ".hermes"), + HERMES_SKILL_DIR=str(stage), + SIGIL_FORGE_STATE_DIR=str(check_home / "state"), + PYTHONDONTWRITEBYTECODE="1", + ) + if not skip_checks: + for command in CHECKS[kind]: + subprocess.run( + [sys.executable, str(stage / "scripts" / command[0]), *command[1:]], + cwd=check_home, + env=env, + check=True, + ) + if (stage / "out").exists(): + shutil.rmtree(stage / "out") # NEW staging data only + old_out = target / "out" + if old_out.is_symlink(): + (stage / "out").symlink_to(os.readlink(old_out), target_is_directory=True) + elif old_out.exists(): + shutil.copytree(old_out, stage / "out", symlinks=True) + + def git(*args: str) -> str | None: + try: + r = subprocess.run( + check=False, + args=["git", "-C", str(source), *args], + capture_output=True, + text=True, + env={ + k: v for k, v in os.environ.items() if not k.startswith("GIT_") + }, + ) + except OSError: + return None # Git is optional for archive/Python-only installs. + return r.stdout.strip() if r.returncode == 0 else None + + # Git searches parents: accept the source root or the tracked Neon hub, + # not arbitrary archives nested in an unrelated worktree. + top = git("rev-parse", "--show-toplevel") + repo_root = Path(top).resolve() if top is not None else None + own_checkout = repo_root == source + subtree = "." if own_checkout else None + if ( + repo_root is not None + and kind == "neon-genie" + and source == repo_root / "skills/neon-genie" + ): + tracked = git( + "ls-files", + "--error-unmatch", + "--", + "SKILL.md", + "VERSION", + str(repo_root / "SKILL.md"), + str(repo_root / "VERSION"), + ) + try: + # Root/hub grants can legitimately differ; compare the contract + # apart from its license metadata, without changing either grant. + root_contract = (repo_root / "SKILL.md").read_text().splitlines() + hub_contract = (source / "SKILL.md").read_text().splitlines() + matching_root = [ + line for line in root_contract if not line.startswith("license:") + ] == [ + line for line in hub_contract if not line.startswith("license:") + ] and (repo_root / "VERSION").read_bytes() == ( + source / "VERSION" + ).read_bytes() + except (OSError, UnicodeError): + matching_root = False + if tracked is not None and matching_root: + own_checkout = True + subtree = "skills/neon-genie" + dirty = git("status", "--porcelain") if own_checkout else None + receipt = { + "source": str(source), + "source_repository_root": str(repo_root) if own_checkout else None, + "source_subdirectory": subtree, + "repository": git("remote", "get-url", "origin") if own_checkout else None, + "source_commit": git("rev-parse", "HEAD") if own_checkout else None, + "source_dirty": bool(dirty) if dirty is not None else None, + "version": (source / "VERSION").read_text().strip(), + "destination": str(target), + "status": "UNVERIFIED" if skip_checks else "VALIDATED", + "checks_skipped": list(CHECKS[kind]) if skip_checks else [], + "validation": "staged runtime; activated contract/version read-back", + } + receipt_text = json.dumps(receipt, indent=2) + "\n" + (stage / ".install-provenance.json").write_text(receipt_text) + expected = { + p: (stage / p).read_bytes() + for p in ("SKILL.md", "VERSION", ".install-provenance.json") + } + backup = None + if target.exists(): + backups.mkdir(parents=True, exist_ok=True) + backup = backups / uuid.uuid4().hex + # Incomplete copies live outside the selectable backup namespace. + # Publish only after the payload and destination record are complete. + with tempfile.TemporaryDirectory( + prefix=".backup-incomplete-", dir=backups.parent + ) as tmp: + pending = Path(tmp) / "backup-payload" + shutil.copytree(target, pending, symlinks=True) + marker = pending / ".backup-target.json" + marker.unlink(missing_ok=True) + marker.write_text(json.dumps({"destination": str(target)}) + "\n") + os.replace(pending, backup) + displaced = workspace / "previous" + # Detect a concurrent change of target identity since staging began. + if any(p.is_symlink() for p in (target.absolute(), *target.absolute().parents)): + raise ValueError("target became a symlink during staging") + try: + if target.exists(): + os.replace(target, displaced) + os.replace(stage, target) + for relative, content in expected.items(): + if (target / relative).read_bytes() != content: + raise OSError(f"activation read-back mismatch: {relative}") + except BaseException as original: + try: + # Infer completed renames from disk even if replace raised after + # its side effect. A still-staged package means target is not new. + if not stage.exists() and target.exists(): + os.replace(target, workspace / "failed-package") + if displaced.exists(): + os.replace(displaced, target) + except BaseException as recovery_error: + retain_recovery = True + raise RuntimeError( + f"recovery required at {workspace}; backup {backup}; " + f"activation: {original}; restoration: {recovery_error}" + ) from recovery_error + raise + print(f"Installed {kind}: {target} ({receipt['status']})") + if backup: + print(f"Backup: {backup}") + finally: + if workspace is not None and not retain_recovery: + shutil.rmtree(workspace) + lock.rmdir() + + +def rollback(target: Path, kind: str, skip_checks: bool = False) -> None: + if any(p.is_symlink() for p in (target.absolute(), *target.absolute().parents)): + raise ValueError("refusing symlink target") + target = target.resolve() + home = Path(os.environ.get("HERMES_HOME") or str(Path.home() / ".hermes")).resolve() + key = hashlib.sha256(str(target).encode()).hexdigest()[:20] + backups = home / "backups" / kind / key + candidates = sorted( + backups.glob("*"), key=lambda p: p.lstat().st_mtime_ns, reverse=True + ) + backup = None + for candidate in candidates: + marker = candidate / ".backup-target.json" + if candidate.is_symlink() or not candidate.is_dir() or marker.is_symlink(): + continue + try: + record = json.loads(marker.read_text()) + except (OSError, ValueError): + continue + if isinstance(record, dict) and record.get("destination") == str(target): + backup = candidate + break + if backup is None: + raise ValueError( + f"no target-bound backup for {target}; legacy backups require manual review" + ) + # Reuse staged runtime checks, backup, activated read-back and failed-rename recovery. + install(backup, target, kind, skip_checks) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source", type=Path) + parser.add_argument("target") + parser.add_argument("kind", choices=CHECKS) + parser.add_argument("--skip-checks", action="store_true") + parser.add_argument("--rollback", action="store_true") + args = parser.parse_args() + try: + if not args.target.strip(): + raise ValueError("empty target") + if args.rollback: + rollback(Path(args.target), args.kind, args.skip_checks) + else: + install(args.source, Path(args.target), args.kind, args.skip_checks) + except (OSError, ValueError, RuntimeError, subprocess.CalledProcessError) as exc: + print(f"Installation failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests_audit/PLAN.md b/tests_audit/PLAN.md new file mode 100644 index 0000000..b2442b9 --- /dev/null +++ b/tests_audit/PLAN.md @@ -0,0 +1,4 @@ +# Audit remediation + +User explicitly authorized C1–C6 and relevant A1/A4 fixes, local commits only. +Plan: reproduce each defect before its fix; stage installs and validate before activation; preserve legacy output; isolate new state; repair session recipe and repository-only CI validation; align only current organizational license metadata; preserve personal variants and advisory boundaries; run sandbox regressions and existing offline checks; parent independently reviews before remote action. diff --git a/tests_audit/test_hyperlex_install.py b/tests_audit/test_hyperlex_install.py new file mode 100644 index 0000000..2fbb21d --- /dev/null +++ b/tests_audit/test_hyperlex_install.py @@ -0,0 +1,81 @@ +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class HyperlexInstallAudit(unittest.TestCase): + def test_real_install_reinstall_and_explicit_skip(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + home.mkdir() + profile = home / "profile" + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(profile)) + dest = profile / "skills/hyperlex" + for args in ([], ["--skip-smoke"]): + result = subprocess.run( + ["bash", str(ROOT / "install.sh"), *args], + cwd=tmp, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + receipt = json.loads((dest / ".install-provenance.json").read_text()) + self.assertEqual( + receipt["status"], "UNVERIFIED" if args else "VALIDATED" + ) + if args: + self.assertIn("UNVERIFIED", result.stdout) + self.assertEqual((dest / "out/keep").read_text(), "operator output") + else: + (dest / "out").mkdir(exist_ok=True) + (dest / "out/keep").write_text("operator output") + self.assertFalse((home / ".hermes").exists()) + + def test_smoke_failure_not_just_check_failure(self): + with tempfile.TemporaryDirectory() as tmp: + source = Path(tmp) / "source" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns(".git", "out", "__pycache__", ".venv"), + ) + cli = source / "scripts/hyperlex.py" + text = cli.read_text() + signature = "def cmd_smoke(_args: argparse.Namespace) -> int:\n" + self.assertIn(signature, text) + cli.write_text( + text.replace( + signature, + signature + " return 42 # injected smoke-only failure\n", + 1, + ) + ) + home = Path(tmp) / "home" + home.mkdir() + dest = home / "profile/skills/hyperlex" + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("previous install") + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(home / "profile")) + result = subprocess.run( + ["bash", str(source / "install.sh")], + cwd=tmp, + env=env, + text=True, + capture_output=True, + check=False, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("42", result.stderr) + self.assertEqual((dest / "SKILL.md").read_text(), "previous install") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_audit/test_install.py b/tests_audit/test_install.py new file mode 100644 index 0000000..f67f90b --- /dev/null +++ b/tests_audit/test_install.py @@ -0,0 +1,76 @@ +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SKILL = "hyperlex" + + +class InstallAudit(unittest.TestCase): + def test_failed_check_preserves_previous_install(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + home.mkdir() + profile = home / "profile" + dest = profile / "skills" / SKILL + dest.mkdir(parents=True) + (dest / "SKILL.md").write_text("previous package") + (dest / "out/wizard-sessions").mkdir(parents=True) + sentinel = dest / "out/wizard-sessions/keep.json" + sentinel.write_text("previous session") + import shutil + + source = Path(tmp) / "source" + shutil.copytree( + ROOT, + source, + ignore=shutil.ignore_patterns( + ".git", "skills", "out", "__pycache__", ".venv" + ), + ) + script = { + "neon-genie": "validate_hermes_skill.py", + "sigil-forge": "sigil_forge.py", + "hyperlex": "hyperlex.py", + }[SKILL] + (source / "scripts" / script).write_text("import sys\nsys.exit(42)\n") + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(profile)) + r = subprocess.run( + check=False, + args=["bash", str(source / "install.sh")], + cwd=tmp, + env=env, + capture_output=True, + text=True, + ) + self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertEqual((dest / "SKILL.md").read_text(), "previous package") + self.assertEqual(sentinel.read_text(), "previous session") + self.assertFalse((home / ".hermes").exists()) + + def test_profile_skills_symlink_cannot_redirect_install(self): + with tempfile.TemporaryDirectory() as tmp: + home = Path(tmp) / "home" + profile = home / "profile-a" + foreign = home / "profile-b/skills" + profile.mkdir(parents=True) + foreign.mkdir(parents=True) + (profile / "skills").symlink_to(foreign, target_is_directory=True) + env = dict(os.environ, HOME=str(home), HERMES_HOME=str(profile)) + r = subprocess.run( + ["bash", str(ROOT / "install.sh")], + cwd=tmp, + env=env, + capture_output=True, + text=True, + check=False, + ) + self.assertNotEqual(r.returncode, 0, r.stdout + r.stderr) + self.assertEqual(list(foreign.iterdir()), []) + self.assertTrue((profile / "skills").is_symlink()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_audit/test_install_followup.py b/tests_audit/test_install_followup.py new file mode 100644 index 0000000..5660e21 --- /dev/null +++ b/tests_audit/test_install_followup.py @@ -0,0 +1,191 @@ +"""Regression coverage for installer PR follow-up; shared across distributions.""" + +import hashlib +import importlib.util +import json +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "transaction_followup", ROOT / "scripts/install_transaction.py" +) +assert spec is not None and spec.loader is not None +transaction = importlib.util.module_from_spec(spec) +spec.loader.exec_module(transaction) + + +class FollowupTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.base = Path(self.tmp.name) + self.source = self.base / "source" + self.source.mkdir() + (self.source / "SKILL.md").write_text("new") + (self.source / "VERSION").write_text("1") + self.target = self.base / "target" + self.home = self.base / "profile" + env = patch.dict(os.environ, HERMES_HOME=str(self.home)) + env.start() + self.addCleanup(env.stop) + key = hashlib.sha256(str(self.target).encode()).hexdigest()[:20] + self.backups = self.home / "backups/hyperlex" / key + + def install(self): + transaction.install(self.source, self.target, "hyperlex", True) + + def receipt(self): + return json.loads((self.target / ".install-provenance.json").read_text()) + + def test_archive_dirty_state_is_unknown(self): + self.install() + self.assertIsNone(self.receipt()["source_dirty"]) + + def test_enclosing_repository_is_not_source_provenance(self): + subprocess.run(["git", "init", str(self.base)], check=True, capture_output=True) + subprocess.run( + [ + "git", + "-C", + str(self.base), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "--allow-empty", + "-m", + "unrelated", + ], + check=True, + capture_output=True, + ) + subprocess.run( + [ + "git", + "-C", + str(self.base), + "remote", + "add", + "origin", + "https://example.invalid/unrelated", + ], + check=True, + ) + self.install() + for field in ("repository", "source_commit", "source_dirty"): + self.assertIsNone(self.receipt()[field], field) + + def test_missing_git_does_not_block_install(self): + with patch.dict(os.environ, PATH=str(self.base / "no-executables")): + self.install() + for field in ("repository", "source_commit", "source_dirty"): + self.assertIsNone(self.receipt()[field], field) + + def test_stale_lock_error_and_docs_are_actionable(self): + lock = self.target.parent / ("." + self.target.name + ".install-lock") + lock.mkdir() + with self.assertRaisesRegex( + FileExistsError, "Do not reclaim automatically" + ) as error: + self.install() + self.assertIn(str(lock), str(error.exception)) + self.assertIn("rmdir", str(error.exception)) + self.assertTrue(lock.is_dir()) + self.assertFalse(self.target.exists()) + docs = (ROOT / "references/source-and-upgrades.md").read_text() + for phrase in ( + "rmdir", + "SIGKILL", + "no installer", + ".install-lock", + ".backup-incomplete-", + ): + self.assertIn(phrase, docs) + + def test_tracked_neon_hub_retains_repository_and_subtree(self): + repo = self.base / "neon" + hub = repo / "skills/neon-genie" + hub.mkdir(parents=True) + for directory in (repo, hub): + license_id = "MIT" if directory == repo else "Apache-2.0" + (directory / "SKILL.md").write_text( + f"---\nname: neon-genie\nlicense: {license_id}\n---\n" + ) + (directory / "VERSION").write_text("1") + subprocess.run(["git", "init", str(repo)], check=True, capture_output=True) + subprocess.run(["git", "-C", str(repo), "add", "."], check=True) + subprocess.run( + [ + "git", + "-C", + str(repo), + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "commit", + "-m", + "hub", + ], + check=True, + capture_output=True, + ) + transaction.install(hub, self.target, "neon-genie", True) + receipt = self.receipt() + self.assertIsNotNone(receipt["source_commit"]) + self.assertIs(receipt["source_dirty"], False) + self.assertEqual(receipt["source_subdirectory"], "skills/neon-genie") + self.assertEqual(receipt["source_repository_root"], str(repo)) + + def test_invalid_utf8_enclosing_root_drops_optional_provenance(self): + self.test_tracked_neon_hub_retains_repository_and_subtree() + repo = self.base / "neon" + hub = repo / "skills/neon-genie" + (repo / "SKILL.md").write_bytes(b"\xff") + transaction.install(hub, self.target, "neon-genie", True) + receipt = self.receipt() + for field in ( + "repository", "source_repository_root", "source_subdirectory", + "source_commit", "source_dirty", + ): + self.assertIsNone(receipt[field], field) + for name in ("SKILL.md", "VERSION"): + self.assertEqual((self.target / name).read_bytes(), (hub / name).read_bytes()) + + def test_partial_backup_never_published(self): + self.install() + self.install() + previous = set(self.backups.iterdir()) + copytree = shutil.copytree + + def interrupt(src, dst, *args, **kwargs): + if Path(src) == self.target: + Path(dst).mkdir() + (Path(dst) / "truncated").write_text("partial") + raise KeyboardInterrupt("backup copy interrupted") + return copytree(src, dst, *args, **kwargs) + + with ( + patch.object(transaction.shutil, "copytree", side_effect=interrupt), + self.assertRaises(KeyboardInterrupt), + ): + self.install() + self.assertEqual(set(self.backups.iterdir()), previous) + self.assertEqual((self.target / "SKILL.md").read_text(), "new") + transaction.rollback(self.target, "hyperlex", True) + + def test_rollback_skips_legacy_partial_backup(self): + self.install() + self.install() + partial = self.backups / "partial" + partial.mkdir() + os.utime(partial, (2000000000, 2000000000)) + transaction.rollback(self.target, "hyperlex", True) + self.assertEqual((self.target / "SKILL.md").read_text(), "new") diff --git a/tests_audit/test_license_metadata.py b/tests_audit/test_license_metadata.py new file mode 100644 index 0000000..2972989 --- /dev/null +++ b/tests_audit/test_license_metadata.py @@ -0,0 +1,35 @@ +"""Current distribution metadata must match the unchanged root license.""" + +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class LicenseMetadata(unittest.TestCase): + def test_current_distribution_metadata(self): + self.assertIn("Zero State Proprietary License", (ROOT / "LICENSE").read_text()) + files = [ROOT / "SKILL.md", ROOT / "README.md"] + files += list((ROOT / "skills").glob("*/SKILL.md")) + files += list((ROOT / "skills").glob("*/README.md")) + files += [ + p + for p in [ + ROOT / "manifest.json", + ROOT / "references/manifest.json", + ROOT / "hyperlex.manifest.yaml", + ROOT / "pyproject.toml", + ] + if p.exists() + ] + for p in files: + with self.subTest(path=str(p)): + self.assertNotRegex(p.read_text(), r"\bMIT\b") + self.assertIn( + "license: LicenseRef-Zero-State-Proprietary-1.0", + (ROOT / "SKILL.md").read_text(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests_audit/test_transaction.py b/tests_audit/test_transaction.py new file mode 100644 index 0000000..4458239 --- /dev/null +++ b/tests_audit/test_transaction.py @@ -0,0 +1,214 @@ +"""Adversarial transactions: sandbox only, no mocked runtime successes.""" + +import importlib.util +import io +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +spec = importlib.util.spec_from_file_location( + "install_transaction", ROOT / "scripts/install_transaction.py" +) +assert spec is not None and spec.loader is not None +m = importlib.util.module_from_spec(spec) +spec.loader.exec_module(m) + + +class TransactionAudit(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.source = self.root / "source" + self.source.mkdir() + (self.source / "VERSION").write_text("1") + (self.source / "SKILL.md").write_text("new") + self.target = self.root / "profile/skills/test" + self.env = patch.dict( + os.environ, + HOME=str(self.root / "home"), + HERMES_HOME=str(self.root / "profile"), + ) + self.env.start() + self.addCleanup(self.env.stop) + + def install(self): + m.install(self.source, self.target, "hyperlex", skip_checks=True) + + def previous(self): + self.target.mkdir(parents=True) + (self.target / "SKILL.md").write_text("old") + (self.target / "VERSION").write_text("1") + + def test_final_and_dangling_target_symlinks_rejected(self): + self.target.parent.mkdir(parents=True) + for exists in (True, False): + referent = self.root / str(exists) + if exists: + referent.mkdir() + self.target.symlink_to(referent, target_is_directory=True) + with self.assertRaises(ValueError): + self.install() + self.assertTrue(self.target.is_symlink()) + self.assertFalse((referent / "SKILL.md").exists()) + self.target.unlink() + + def test_source_ancestor_rejected(self): + self.target = self.root + with self.assertRaises(ValueError): + self.install() + + def test_secrets_not_packaged(self): + (self.source / ".env").write_text("FAKE_SECRET=do-not-copy") + (self.source / ".env.local").write_text("do-not-copy") + self.install() + self.assertFalse((self.target / ".env").exists()) + self.assertFalse((self.target / ".env.local").exists()) + + def test_source_symlink_rejected(self): + (self.source / "linked-secret").symlink_to(self.root / "private") + with self.assertRaises(ValueError): + self.install() + + def test_output_symlink_preserved_without_crawling(self): + self.previous() + private = self.root / "private" + private.mkdir() + (private / "keep").write_text("keep") + (self.target / "out").symlink_to(private, target_is_directory=True) + self.install() + self.assertTrue((self.target / "out").is_symlink()) + self.assertEqual((private / "keep").read_text(), "keep") + + def test_activation_failure_restores_previous(self): + self.previous() + replace = os.replace + + def fail(src, dst): + if Path(src).name == "package": + raise OSError("injected activation failure") + return replace(src, dst) + + with ( + patch.object(m.os, "replace", side_effect=fail), + self.assertRaises(OSError), + ): + self.install() + self.assertEqual((self.target / "SKILL.md").read_text(), "old") + + def test_restoration_failure_retains_recovery_tree(self): + self.previous() + replace = os.replace + + def fail(src, dst): + if Path(src).name in ("package", "previous"): + raise OSError("injected rename failure") + return replace(src, dst) + + with ( + patch.object(m.os, "replace", side_effect=fail), + self.assertRaises(RuntimeError), + ): + self.install() + recovered = list(self.target.parent.glob(".hyperlex-stage-*/previous/SKILL.md")) + self.assertEqual(len(recovered), 1) + self.assertEqual(recovered[0].read_text(), "old") + + def check_interrupted_rename(self, boundary, after, recovery): + self.previous() + replace = os.replace + interrupted = False + + def fail(src, dst): + nonlocal interrupted + src, dst = Path(src), Path(dst) + if src.name == "previous": + if recovery == "error": + raise OSError("injected restoration failure") + if recovery == "interrupt-before": + raise KeyboardInterrupt("restoration before rename") + if recovery == "interrupt-after": + replace(src, dst) + raise KeyboardInterrupt("restoration after rename") + at_boundary = ( + dst.name == "previous" if boundary == "displacement" + else src.name == "package" + ) + if at_boundary and not interrupted: + interrupted = True + if after: + replace(src, dst) # Real side effect before the exception. + raise KeyboardInterrupt("injected installation interruption") + return replace(src, dst) + + needs_recovery = boundary == "activation" or after + retained = needs_recovery and recovery != "ok" + with ( + patch.object(m.os, "replace", side_effect=fail), + patch("sys.stdout", new_callable=io.StringIO) as output, + self.assertRaises(RuntimeError if retained else KeyboardInterrupt) as caught, + ): + self.install() # Explicit skip_checks=True; no fake runtime success. + self.assertTrue(interrupted) + self.assertNotIn("Installed", output.getvalue()) + self.assertFalse((self.target.parent / ".test.install-lock").exists()) + backups = list((self.root / "profile/backups").glob("*/*/*/SKILL.md")) + self.assertEqual(len(backups), 1) + self.assertEqual(backups[0].read_text(), "old") + workspaces = list(self.target.parent.glob(".*-stage-*")) + if retained: + self.assertEqual(len(workspaces), 1) + self.assertIn(str(workspaces[0]), str(caught.exception)) + self.assertIn(str(backups[0].parent), str(caught.exception)) + old = (self.target if recovery == "interrupt-after" + else workspaces[0] / "previous") + self.assertEqual((old / "SKILL.md").read_text(), "old") + else: + self.assertEqual((self.target / "SKILL.md").read_text(), "old") + self.assertEqual(workspaces, []) + + def test_interrupted_displacement_restores_previous(self): + self.check_interrupted_rename("displacement", True, "ok") + + def test_interrupted_rename_boundaries(self): + for boundary in ("displacement", "activation"): + for after in (False, True): + for recovery in ("ok", "error", "interrupt-before", "interrupt-after"): + with self.subTest(boundary=boundary, after=after, recovery=recovery): + # Each fault gets an independent real filesystem sandbox. + case = TransactionAudit() + case.setUp() + try: + case.check_interrupted_rename(boundary, after, recovery) + finally: + case.doCleanups() + + def test_existing_lock_fails_closed(self): + self.target.parent.mkdir(parents=True) + lock = self.target.parent / ("." + self.target.name + ".install-lock") + lock.mkdir() + with self.assertRaises(FileExistsError): + self.install() + self.assertTrue(lock.is_dir()) + + def test_rollback_is_target_bound_and_preserves_current_outputs(self): + self.previous() + self.install() + other = self.root / "profile/skills/other" + other.mkdir() + (other / "SKILL.md").write_text("other-old") + (other / "VERSION").write_text("1") + m.install(self.source, other, "hyperlex", skip_checks=True) + (self.target / "out").mkdir() + (self.target / "out/keep").write_text("new-output") + m.rollback(self.target, "hyperlex", skip_checks=True) + self.assertEqual((self.target / "SKILL.md").read_text(), "old") + self.assertEqual((self.target / "out/keep").read_text(), "new-output") + self.assertEqual((other / "SKILL.md").read_text(), "new") + + +if __name__ == "__main__": + unittest.main()