diff --git a/src/autoskillit/hook_registry.py b/src/autoskillit/hook_registry.py index 2f9c0c813..6d56419e8 100644 --- a/src/autoskillit/hook_registry.py +++ b/src/autoskillit/hook_registry.py @@ -31,6 +31,7 @@ class HookDef: "works-as-is" ) mechanism: Literal["deny", "additionalContext", "output-rewrite"] = "deny" + enforcement_strength: dict[str, str] = field(default_factory=dict) def __post_init__(self) -> None: if self.event_type != "SessionStart" and not self.matcher: @@ -96,17 +97,20 @@ def __post_init__(self) -> None: "guards/pipeline_step_guard.py", ], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher="mcp__.*autoskillit.*__remove_clone", scripts=["guards/remove_clone_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"mcp__.*autoskillit.*__open_kitchen.*", scripts=["guards/open_kitchen_guard.py"], timeout_seconds=5, mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher="AskUserQuestion", @@ -115,21 +119,25 @@ def __post_init__(self) -> None: session_scope="headless_only", codex_status="not-applicable", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, ), HookDef( matcher=r"mcp__.*autoskillit.*__merge_worktree", scripts=["guards/branch_protection_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"mcp__.*autoskillit.*__push_to_remote", scripts=["guards/branch_protection_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", scripts=["guards/unsafe_install_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", @@ -149,23 +157,27 @@ def __post_init__(self) -> None: # stdlib-only boundary on hook scripts prevents a shared import. exempt_session_types=frozenset({"orchestrator"}), mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", scripts=["guards/compose_pr_body_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", scripts=["guards/planner_gh_discovery_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", scripts=["guards/artifact_download_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", @@ -175,6 +187,7 @@ def __post_init__(self) -> None: # stdlib-only boundary on hook scripts prevents a shared import. exempt_session_types=frozenset({"orchestrator"}), mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__run_cmd", @@ -184,35 +197,41 @@ def __post_init__(self) -> None: # stdlib-only boundary on hook scripts prevents a shared import. exempt_skills=frozenset({"implement-experiment"}), mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Write|Edit", scripts=["guards/generated_file_write_guard.py"], mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), HookDef( matcher=r"Write|Edit|Bash|mcp__.*autoskillit.*__run_cmd", scripts=["guards/write_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Write|Edit", scripts=["guards/planner_result_naming_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), HookDef( matcher=r"Write|Edit", scripts=["guards/recipe_write_advisor.py"], session_scope="interactive_only", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), HookDef( matcher=r"Grep", scripts=["guards/grep_pattern_lint_guard.py"], codex_status="not-applicable", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, ), HookDef( matcher=r"Bash|Write|Edit|Read|Glob|Grep", @@ -221,24 +240,28 @@ def __post_init__(self) -> None: session_scope="interactive_only", codex_status="degraded", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "degraded"}, ), HookDef( matcher=r"mcp__.*autoskillit.*__(run_skill|run_cmd|run_python).*", scripts=["guards/skill_orchestration_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|mcp__.*autoskillit.*__(run_cmd|run_python)", scripts=["guards/recipe_read_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"Bash|Agent", scripts=["guards/background_exec_guard.py"], session_scope="headless_only", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), HookDef( matcher=r"(mcp__.*autoskillit.*__)?dispatch_food_truck", @@ -249,12 +272,14 @@ def __post_init__(self) -> None: "guards/fleet_claim_guard.py", ], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="PostToolUse", matcher="mcp__.*autoskillit.*", scripts=["formatters/pretty_output_hook.py"], mechanism="output-rewrite", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="PostToolUse", @@ -266,18 +291,21 @@ def __post_init__(self) -> None: "recipe_confirmed_post_hook.py", ], mechanism="output-rewrite", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="PostToolUse", matcher=r"mcp__.*autoskillit.*__(run_skill|run_python).*", scripts=["review_gate_post_hook.py"], mechanism="output-rewrite", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="PostToolUse", matcher=r"(mcp__.*autoskillit.*__)?dispatch_food_truck", scripts=["resume_gate_post_hook.py"], mechanism="output-rewrite", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="PostToolUse", @@ -286,6 +314,7 @@ def __post_init__(self) -> None: session_scope="headless_only", codex_status="degraded", mechanism="output-rewrite", + enforcement_strength={"claude_code": "hard", "codex": "degraded"}, ), HookDef( event_type="PostToolUse", @@ -293,6 +322,7 @@ def __post_init__(self) -> None: scripts=["skill_load_post_hook.py"], codex_status="not-applicable", mechanism="additionalContext", + enforcement_strength={"claude_code": "hard", "codex": "not-applicable"}, ), HookDef( matcher=r"Read|Write|Edit|Bash|Grep|Glob", @@ -300,22 +330,26 @@ def __post_init__(self) -> None: session_scope="headless_only", codex_status="works-as-is", mechanism="deny", + enforcement_strength={"claude_code": "hard", "codex": "works-as-is"}, ), HookDef( matcher=r"mcp__.*autoskillit.*__(wait_for_ci|enqueue_pr)", scripts=["guards/review_loop_gate.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( matcher=r"(mcp__.*autoskillit.*__)?reset_dispatch", scripts=["guards/reset_resume_gate.py"], mechanism="deny", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), HookDef( event_type="SessionStart", scripts=["session_start_hook.py"], session_scope="interactive_only", mechanism="additionalContext", + enforcement_strength={"claude_code": "soft", "codex": "works-as-is"}, ), ] @@ -418,6 +452,7 @@ def _canonical_registry_payload( [ { "codex_status": h.codex_status, + "enforcement_strength": dict(sorted(h.enforcement_strength.items())), "event_type": h.event_type, "exempt_session_types": sorted(h.exempt_session_types), "exempt_skills": sorted(h.exempt_skills), @@ -432,7 +467,7 @@ def _canonical_registry_payload( key=lambda row: (row["event_type"], row["matcher"], tuple(row["scripts"])), # type: ignore[arg-type] ) return json.dumps( - {"format_version": 2, "registry": registry_rows, "retired": sorted(retired)}, + {"format_version": 3, "registry": registry_rows, "retired": sorted(retired)}, sort_keys=True, separators=(",", ":"), ) diff --git a/src/autoskillit/hooks/registry.sha256 b/src/autoskillit/hooks/registry.sha256 index e6d2b113b..628f7f8dc 100644 --- a/src/autoskillit/hooks/registry.sha256 +++ b/src/autoskillit/hooks/registry.sha256 @@ -1 +1 @@ -e2026897c0e2c46ff1b6beb448cade141294f1c7b296dad2b4cf46e61d8c646e +cf12c32c7990708aedb676d6fcf239fb2b7c1a90d61444d5cb367daf0cfa7795 diff --git a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json index b91e3dd4c..21a611e27 100644 --- a/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json +++ b/tests/execution/backends/fixtures/codex_ndjson/hook_event_format_snapshot.json @@ -1,6 +1,5 @@ { - "_registry_hash": "e2026897c0e2c46ff1b6beb448cade141294f1c7b296dad2b4cf46e61d8c646e", - "_staleness_note": "Regenerate in the same commit as P5-A6 (B4-hookdef-extension) when mechanism/enforcement_strength fields are added to HookDef.", + "_registry_hash": "cf12c32c7990708aedb676d6fcf239fb2b7c1a90d61444d5cb367daf0cfa7795", "hooks": { "PostToolUse": [ { diff --git a/tests/hooks/test_hook_registry_codex_status.py b/tests/hooks/test_hook_registry_codex_status.py index e04730a8e..4c2e23e8c 100644 --- a/tests/hooks/test_hook_registry_codex_status.py +++ b/tests/hooks/test_hook_registry_codex_status.py @@ -7,7 +7,13 @@ import pytest from autoskillit.execution.backends._codex_hooks import generate_codex_hooks_config -from autoskillit.hook_registry import HookDef +from autoskillit.hook_registry import ( + HOOK_REGISTRY_HASH, + RETIRED_SCRIPT_BASENAMES, + HookDef, + _canonical_registry_payload, + compute_registry_hash, +) from autoskillit.hooks import HOOK_REGISTRY pytestmark = [pytest.mark.layer("hooks"), pytest.mark.medium] @@ -81,3 +87,67 @@ def test_posttooluse_pretty_output_is_output_rewrite(self): f"Hook {hook_def.scripts} has mechanism={hook_def.mechanism!r}, " "expected 'output-rewrite' for pretty_output_hook" ) + + def test_hookdef_has_enforcement_strength_field(self): + field_names = {f.name for f in dataclasses.fields(HookDef)} + assert "enforcement_strength" in field_names + + def test_enforcement_strength_default_is_empty_dict(self): + hd = HookDef(matcher="test", scripts=["s.py"]) + assert hd.enforcement_strength == {} + + def test_all_registry_entries_have_enforcement_strength_keys(self): + for hd in HOOK_REGISTRY: + assert "claude_code" in hd.enforcement_strength, ( + f"Hook {hd.scripts} missing 'claude_code' key" + ) + assert "codex" in hd.enforcement_strength, f"Hook {hd.scripts} missing 'codex' key" + + def test_enforcement_strength_claude_code_values_are_valid(self): + for hd in HOOK_REGISTRY: + assert hd.enforcement_strength["claude_code"] in ("hard", "soft"), ( + f"Hook {hd.scripts} has invalid claude_code: " + f"{hd.enforcement_strength['claude_code']!r}" + ) + + def test_enforcement_strength_codex_matches_codex_status(self): + for hd in HOOK_REGISTRY: + assert hd.enforcement_strength["codex"] == hd.codex_status, ( + f"Hook {hd.scripts}: enforcement_strength codex=" + f"{hd.enforcement_strength['codex']!r} != " + f"codex_status={hd.codex_status!r}" + ) + + def test_compute_registry_hash_changes_on_enforcement_strength(self): + original = HOOK_REGISTRY[0] + mutated_entry = dataclasses.replace( + original, + enforcement_strength={"claude_code": "hard", "codex": "test-sentinel"}, + ) + mutated_registry = [mutated_entry, *HOOK_REGISTRY[1:]] + new_hash = compute_registry_hash(mutated_registry, RETIRED_SCRIPT_BASENAMES) + assert new_hash != HOOK_REGISTRY_HASH + + def test_format_version_is_3(self): + import json + + payload = json.loads(_canonical_registry_payload(HOOK_REGISTRY, RETIRED_SCRIPT_BASENAMES)) + assert payload["format_version"] == 3 + + def test_enforcement_strength_round_trip(self): + es = {"claude_code": "hard", "codex": "works-as-is"} + hd = HookDef(matcher="test", scripts=["s.py"], enforcement_strength=es) + assert hd.enforcement_strength == es + assert hd.enforcement_strength["claude_code"] == "hard" + assert hd.enforcement_strength["codex"] == "works-as-is" + + def test_canonical_registry_payload_includes_enforcement_strength(self): + import json + + payload_str = _canonical_registry_payload(HOOK_REGISTRY, RETIRED_SCRIPT_BASENAMES) + payload = json.loads(payload_str) + for row in payload["registry"]: + assert "enforcement_strength" in row, ( + f"Registry row for matcher={row.get('matcher')!r} is missing" + " 'enforcement_strength'. Add it to _canonical_registry_payload." + )