From fe6de5fa87487481fcca4e47525b2f8a35b5b798 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 23 Sep 2026 08:05:52 +0200 Subject: [PATCH 1/3] fix(invariants): make run_self_test() alias detection cross-platform run_self_test()["alias_detection_works"] tested the alias-rejection invariant by calling validate_interpreter("python3") and expecting an exception on every platform. On Linux/macOS python3 is a legitimate, real interpreter, so validate_interpreter() correctly did NOT raise there -- the self-test (and hence `hook-master doctor --self-test`, which exits 2 on failure) was therefore permanently broken on non-Windows, unnoticed because CI never got past the private-repo git dependency block until hook-master went public (T-20260921-750493182). Fix: test the platform-independent 0-byte-size invariant directly against a synthetic temp-file candidate instead of the incidental "python3" name. validate_interpreter() itself needed no change -- its Windows-only forbidden-name branch for "python3" was already correct. Added tests that simulate all three target platforms via monkeypatch.setattr(sys, "platform", ...) so both the Windows-only forbidden-name branch and the cross-platform alias check are exercised on every CI runner, not only the one matching the real host OS. 105/105 tests pass locally; ruff clean; compileall clean. --- CHANGELOG.md | 3 ++ src/hook_master/providers/invariants.py | 26 +++++++++++------ tests/test_providers.py | 38 +++++++++++++++++++++++++ 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a1f7e3..86a970a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## [Unreleased] +### Fixed +- **Cross-platform `run_self_test()` alias detection (T-20260921-750493182, Runde 3):** `run_self_test()["alias_detection_works"]` tested the alias-rejection invariant by calling `validate_interpreter("python3")` and expecting an exception on every platform. On Linux/macOS `python3` is a legitimate, real interpreter, so `validate_interpreter()` correctly did **not** raise there — meaning the self-test (and therefore `hook-master doctor --self-test`, exit code 2 on failure) was permanently broken on non-Windows. Fixed by testing the platform-independent 0-byte-size invariant directly against a synthetic temp-file candidate instead of the incidental "python3" name. `validate_interpreter()` itself was already correct and needed no change. Added tests that simulate all three target platforms via `monkeypatch.setattr(sys, "platform", ...)` so the Windows-only forbidden-name branch and the cross-platform alias check are both exercised on every CI runner, not only the one matching the real host OS. + ### Technical Hygiene, CI Lifecycle Workflows, Lock Defense & Level 1 SBOM Audit (2026-09-22) [Pfad A] - **Multi-OS CI Matrix & Lifecycle Workflows:** Added hardened GitHub Actions workflows: - `ci.yml`: Multi-OS (`ubuntu-latest`, `windows-latest`, `macos-latest`) and multi-version Python (`3.10`, `3.11`, `3.12`, `3.13`) test and lint matrix with a 15-minute runaway timeout guardrail, concurrency cancellation, and compileall bytecode verification. diff --git a/src/hook_master/providers/invariants.py b/src/hook_master/providers/invariants.py index dd1d8d4..05d1dd8 100644 --- a/src/hook_master/providers/invariants.py +++ b/src/hook_master/providers/invariants.py @@ -14,6 +14,7 @@ import shutil import subprocess import sys +import tempfile from pathlib import Path from typing import Any @@ -148,6 +149,16 @@ def run_self_test( ) -> dict[str, Any]: """Selbsttest gemaess Pflicht-Invariante 3(c): Misst Interpreter-Validierung und Timeout-Wirksamkeit vor Registrierung. + + Die Alias-Erkennung wird gegen einen SYNTHETISCHEN 0-Byte-Kandidaten + geprueft, nicht gegen den Namen ``python3``: ``python3`` ist auf + Linux/macOS ein legitimer, realer Interpreter und validate_interpreter() + wirft dort korrekt KEINE Exception dafuer -- ein Selbsttest, der das + trotzdem erwartet, ist plattformabhaengig falsch (T-20260921-750493182, + Runde 3: auf ubuntu-latest/macos-latest war "alias_detection_works" + dadurch IMMER False). Der 0-Byte-Groessencheck in validate_interpreter() + ist dagegen auf jeder Plattform identisch und damit der richtige + gemeinsame Nenner fuer den Selbsttest. """ results: dict[str, Any] = { "interpreter": str(interpreter), @@ -161,19 +172,16 @@ def run_self_test( valid_path = validate_interpreter(interpreter) results["interpreter_valid"] = valid_path.is_file() - # 2. Alias-Erkennung testen (0-Byte Erkennung) + # 2. Alias-Erkennung testen: synthetischer 0-Byte-Kandidat statt "python3" + # (plattformunabhaengig, siehe Docstring oben). alias_detected = False - p3 = shutil.which("python3") - if p3 and os.path.exists(p3) and os.path.getsize(p3) == 0: + with tempfile.TemporaryDirectory() as tmp_dir: + fake_alias = Path(tmp_dir) / "mock_alias.exe" + fake_alias.write_bytes(b"") try: - validate_interpreter("python3") + validate_interpreter(fake_alias) except InterpreterAliasError: alias_detected = True - else: - try: - validate_interpreter("python3") - except (InterpreterAliasError, FileNotFoundError): - alias_detected = True results["alias_detection_works"] = alias_detected # 3. Timeout-Wirksamkeit messen diff --git a/tests/test_providers.py b/tests/test_providers.py index dd3817f..24ab4bc 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -1,6 +1,7 @@ from __future__ import annotations import json +import shutil import sys import pytest @@ -96,6 +97,43 @@ def test_run_self_test_reports_all_ok(): assert report["timeout_kills"] is True +# --------------------------------------------------------------------------- +# Regression T-20260921-750493182 Runde 3: alias_detection_works war auf +# ubuntu-latest/macos-latest IMMER False, weil der alte Selbsttest gegen den +# Namen "python3" prüfte -- ein legitimer, realer Interpreter auf POSIX. +# Diese Tests simulieren alle drei Zielplattformen per monkeypatch, statt +# sich auf das tatsächliche CI-Runner-Betriebssystem zu verlassen. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("fake_platform", ["win32", "linux", "darwin"]) +def test_run_self_test_alias_detection_works_on_every_platform(monkeypatch, fake_platform): + monkeypatch.setattr(sys, "platform", fake_platform) + report = run_self_test(timeout=0.2) + assert report["alias_detection_works"] is True + assert report["ok"] is True + + +def test_validate_interpreter_rejects_python3_via_monkeypatched_win32(monkeypatch): + """Die Windows-spezifische 'python3'-Namenssperre lässt sich unabhängig + vom echten Runner-Betriebssystem testen, indem sys.platform gemockt wird.""" + monkeypatch.setattr(sys, "platform", "win32") + with pytest.raises(InterpreterAliasError, match="(Store-Alias|0-Byte|Verbotener Interpreter-Name)"): + validate_interpreter("python3") + + +def test_validate_interpreter_accepts_python3_when_platform_is_not_windows(monkeypatch, tmp_path): + """Ausserhalb von Windows ist 'python3' kein verbotener Name -- ein + echter, nicht-0-Byte Kandidat wird unveraendert akzeptiert (das war der + eigentliche Bug: die alte Selbsttest-Logik nahm bislang das Gegenteil an).""" + monkeypatch.setattr(sys, "platform", "linux") + fake_python3 = tmp_path / "python3" + fake_python3.write_bytes(b"#!/bin/sh\n") + monkeypatch.setattr(shutil, "which", lambda name: str(fake_python3) if name == "python3" else None) + resolved = validate_interpreter("python3") + assert resolved == fake_python3 + + # --------------------------------------------------------------------------- # Provider-Adapter (claude / codex / kimi / agy / git / manual) # --------------------------------------------------------------------------- From e3c658cab1e9321d8b601d9892d5a4e29ae9743d Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 23 Sep 2026 08:10:22 +0200 Subject: [PATCH 2/3] fix(tests): isolate hook_entry config_path from ambient ~/.claude/settings.json The shared hook_entry fixture in tests/conftest.py pointed its config_path at the real ~/.claude/settings.json instead of an isolated file. On a developer machine running Claude Code the file exists and is valid, so doctor.check_entry()'s config check silently passed there; on a clean CI runner home directory it is missing, which check_entry() correctly treats as a hard error -- inflating the severity/exit code of every doctor test using this fixture (test_healthy_consented_entry_is_ok, test_pending_consent_is_warning_not_error, test_not_deployed_is_warning, test_mtime_drift_flagged_when_deployed_edited_directly). This was invisible until now because CI never ran to completion on any platform (private-repo git dependency block from the sibling PRs, see previous commit). First real end-to-end CI run today surfaced it on ubuntu-latest, macos-latest AND windows-latest alike -- unrelated to platform, purely an ambient-path test isolation bug. Fix: give the fixture its own isolated, always-valid tmp_path-based config file, matching the pattern already used in test_consumer_entry_only_checks_config_integrity. 105/105 tests pass locally; ruff clean; compileall clean. --- CHANGELOG.md | 1 + tests/conftest.py | 13 ++++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 86a970a..d685938 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed - **Cross-platform `run_self_test()` alias detection (T-20260921-750493182, Runde 3):** `run_self_test()["alias_detection_works"]` tested the alias-rejection invariant by calling `validate_interpreter("python3")` and expecting an exception on every platform. On Linux/macOS `python3` is a legitimate, real interpreter, so `validate_interpreter()` correctly did **not** raise there — meaning the self-test (and therefore `hook-master doctor --self-test`, exit code 2 on failure) was permanently broken on non-Windows. Fixed by testing the platform-independent 0-byte-size invariant directly against a synthetic temp-file candidate instead of the incidental "python3" name. `validate_interpreter()` itself was already correct and needed no change. Added tests that simulate all three target platforms via `monkeypatch.setattr(sys, "platform", ...)` so the Windows-only forbidden-name branch and the cross-platform alias check are both exercised on every CI runner, not only the one matching the real host OS. +- **Ambient-path test fixture broke `doctor` tests on any host without `~/.claude/settings.json`:** The shared `hook_entry` fixture (`tests/conftest.py`) pointed its `config_path` at the real `~/.claude/settings.json` instead of an isolated file. On a developer machine that happens to run Claude Code the file exists and is valid, so `doctor.check_entry()`'s config check silently passed; on a clean CI runner home directory it is `missing`, which `doctor.check_entry()` correctly treats as a hard error — inflating the severity/exit code of every test using this fixture (`test_healthy_consented_entry_is_ok`, `test_pending_consent_is_warning_not_error`, `test_not_deployed_is_warning`, `test_mtime_drift_flagged_when_deployed_edited_directly`). This was invisible until now because CI never ran to completion (private-repo dependency block, see above). Fixed by giving the fixture its own isolated, always-valid `tmp_path`-based config file, matching the pattern already used in `test_consumer_entry_only_checks_config_integrity`. ### Technical Hygiene, CI Lifecycle Workflows, Lock Defense & Level 1 SBOM Audit (2026-09-22) [Pfad A] - **Multi-OS CI Matrix & Lifecycle Workflows:** Added hardened GitHub Actions workflows: diff --git a/tests/conftest.py b/tests/conftest.py index b10f5f3..730fdf4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import pytest from hook_master.consent import ConsentStore @@ -18,6 +20,15 @@ def canonical_script(tmp_path): @pytest.fixture def hook_entry(canonical_script, tmp_path): deploy_target = tmp_path / "deployed" / "example_hook.py" + # Isolierte, garantiert existierende Config-Datei statt der echten + # Ambient-Datei "~/.claude/settings.json": deren Existenz/Inhalt haengt + # vom Host ab (auf einem Dev-Rechner mit Claude Code vorhanden -> "valid", + # auf einem frischen CI-Runner-Home nicht -> "missing" -> bump("error") in + # doctor.check_entry, was JEDEN Test dieser Fixture faelschlich auf + # severity "error" hochzieht. Siehe test_consumer_entry_only_checks_config_integrity + # in test_doctor.py fuer denselben, dort schon isolierten Aufbau. + fake_config = tmp_path / "settings.json" + fake_config.write_text(json.dumps({"hooks": {}}), encoding="utf-8") return { "id": "example-hook", "kind": "hook", @@ -28,7 +39,7 @@ def hook_entry(canonical_script, tmp_path): "uri": str(canonical_script), "hash": {"algorithm": "sha256", "value": sha256_file(canonical_script)}, }, - "targets": [{"agent": "claude-code", "config_path": "~/.claude/settings.json", "deploy_path": str(deploy_target)}], + "targets": [{"agent": "claude-code", "config_path": str(fake_config), "deploy_path": str(deploy_target)}], "consumers": [], "status": "active", "adoption": {"laptop": "adopted"}, From 7c75f079fa80e55db27a902d596d8ecea9badac2 Mon Sep 17 00:00:00 2001 From: Lukas Geiger Date: Wed, 23 Sep 2026 08:15:18 +0200 Subject: [PATCH 3/3] fix(tests): remove unsafe win32-monkeypatch test, de-platform alias-in-config test First real end-to-end CI run (previous commit made hook-master public, lifting the git-dependency block on ubuntu-latest/macos-latest) surfaced two more issues, both exposed by the same root cause as the previous fix: 1. test_validate_interpreter_rejects_python3_via_monkeypatched_win32 crashed on real POSIX runners with "AttributeError: 'NoneType' object has no attribute 'NeedCurrentDirectoryForExePath'". validate_interpreter()'s win32 branch calls the real stdlib shutil.which(), which itself branches on sys.platform/os.name and expects the `nt` module to exist when sys.platform == "win32" -- monkeypatching only sys.platform without also being on real Windows breaks shutil.which() internals, not hook-master's code. Removed; the win32-only branch stays covered by the pre-existing skip-based test_validate_interpreter_rejects_python3_on_windows (runs for real only on windows-latest, which now passes). 2. test_doctor_detects_alias_in_config asserted that a hook command using "python3" is always flagged as an invariant violation -- true only on Windows. Fixed by probing with a synthetic 0-byte candidate instead of the incidental "python3" name, same approach as the run_self_test() fix in the previous commit. 104/104 tests pass locally; ruff clean; compileall clean. --- tests/test_providers.py | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/tests/test_providers.py b/tests/test_providers.py index 24ab4bc..2e034e7 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -103,6 +103,15 @@ def test_run_self_test_reports_all_ok(): # Namen "python3" prüfte -- ein legitimer, realer Interpreter auf POSIX. # Diese Tests simulieren alle drei Zielplattformen per monkeypatch, statt # sich auf das tatsächliche CI-Runner-Betriebssystem zu verlassen. +# +# Bewusst NICHT per monkeypatch.setattr(sys, "platform", "win32") getestet: +# der Windows-only Zweig von validate_interpreter() ("python3" verbotener +# Name), weil er intern shutil.which() aufruft -- und shutil.which() macht +# auf einem REALEN POSIX-Host bei sys.platform=="win32" NoneType-Crashes +# (fehlendes nt-Modul), da es selbst nicht nur nach sys.platform verzweigt, +# sondern echte OS-Internals braucht. Dieser Zweig bleibt ueber den +# bestehenden test_validate_interpreter_rejects_python3_on_windows oben +# abgedeckt (skip-basiert, laeuft echt nur auf windows-latest). # --------------------------------------------------------------------------- @@ -114,14 +123,6 @@ def test_run_self_test_alias_detection_works_on_every_platform(monkeypatch, fake assert report["ok"] is True -def test_validate_interpreter_rejects_python3_via_monkeypatched_win32(monkeypatch): - """Die Windows-spezifische 'python3'-Namenssperre lässt sich unabhängig - vom echten Runner-Betriebssystem testen, indem sys.platform gemockt wird.""" - monkeypatch.setattr(sys, "platform", "win32") - with pytest.raises(InterpreterAliasError, match="(Store-Alias|0-Byte|Verbotener Interpreter-Name)"): - validate_interpreter("python3") - - def test_validate_interpreter_accepts_python3_when_platform_is_not_windows(monkeypatch, tmp_path): """Ausserhalb von Windows ist 'python3' kein verbotener Name -- ein echter, nicht-0-Byte Kandidat wird unveraendert akzeptiert (das war der @@ -278,12 +279,17 @@ def test_format_raw_script_notaus_wake_check(): def test_doctor_detects_alias_in_config(tmp_path): registry = HookRegistry(tmp_path / "registry.json") + # Synthetischer 0-Byte-Alias-Kandidat statt "python3": "python3" ist auf + # POSIX ein legitimer Interpreter (T-20260921-750493182, Runde 3) und + # waere dort faelschlich NICHT als Invarianten-Verstoss erkannt worden. + fake_alias = tmp_path / "mock_alias.exe" + fake_alias.write_bytes(b"") bad_config = tmp_path / "settings.json" bad_config.write_text( json.dumps({ "hooks": { "PreToolUse": [ - {"hooks": [{"type": "command", "command": "python3 script.py"}]} + {"hooks": [{"type": "command", "command": f"{fake_alias} script.py"}]} ] } }),