Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

## [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.
- **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:
- `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.
Expand Down
26 changes: 17 additions & 9 deletions src/hook_master/providers/invariants.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -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),
Expand All @@ -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
Expand Down
13 changes: 12 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
from __future__ import annotations

import json

import pytest

from hook_master.consent import ConsentStore
Expand All @@ -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",
Expand All @@ -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"},
Expand Down
46 changes: 45 additions & 1 deletion tests/test_providers.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import json
import shutil
import sys

import pytest
Expand Down Expand Up @@ -96,6 +97,44 @@ 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.
#
# 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).
# ---------------------------------------------------------------------------


@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_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)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -240,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"}]}
]
}
}),
Expand Down
Loading