@@ -192,3 +192,209 @@ async def test_provider_override_allows_skill_requiring_claude_code(
192192 assert mock_ssm .init_session .called , (
193193 "Expected init_session to be called after provider override rerouted codex→claude-code"
194194 )
195+
196+
197+ class TestHookFixRequiredDispatchGate :
198+ """Dispatch-time gate for fix-required hook entries in HOOK_REGISTRY."""
199+
200+ def test_refuses_codex_dispatch_when_fix_required_hook_exists (
201+ self , monkeypatch : pytest .MonkeyPatch
202+ ) -> None :
203+ import json
204+ from unittest .mock import MagicMock
205+
206+ from autoskillit .core import AGENT_BACKEND_CODEX , CodingAgentBackend
207+ from autoskillit .hook_registry import HookDef
208+ from autoskillit .server .tools import tools_execution
209+ from autoskillit .server .tools .tools_execution import _check_backend_compat
210+
211+ registry = [
212+ HookDef (
213+ matcher = r"Read|Write" ,
214+ scripts = ["guards/test_guard.py" ],
215+ codex_status = "fix-required" ,
216+ ),
217+ ]
218+ monkeypatch .setattr (tools_execution , "HOOK_REGISTRY" , registry )
219+
220+ backend = MagicMock (spec = CodingAgentBackend )
221+ backend .name = AGENT_BACKEND_CODEX
222+ backend .capabilities .applicable_guards = frozenset ({"write_guard" })
223+
224+ skill_info = MagicMock ()
225+ skill_info .backend_requirements = frozenset ({AGENT_BACKEND_CODEX })
226+
227+ result = _check_backend_compat (
228+ skill_command = "/autoskillit:test" ,
229+ resolved_command = "/autoskillit:test" ,
230+ effective_order_id = "ord-1" ,
231+ target_name = "test" ,
232+ skill_info = skill_info ,
233+ effective_backend_obj = backend ,
234+ skill_resolver = MagicMock (),
235+ )
236+ assert result is not None
237+ parsed = json .loads (result )
238+ assert parsed ["subtype" ] == "crashed"
239+ error_text = parsed ["result" ]
240+ assert "Read|Write" in error_text
241+
242+ def test_allows_claude_code_dispatch_even_with_fix_required_hook (
243+ self , monkeypatch : pytest .MonkeyPatch
244+ ) -> None :
245+ from unittest .mock import MagicMock
246+
247+ from autoskillit .core import AGENT_BACKEND_CLAUDE_CODE , CodingAgentBackend
248+ from autoskillit .hook_registry import HookDef
249+ from autoskillit .server .tools import tools_execution
250+ from autoskillit .server .tools .tools_execution import _check_backend_compat
251+
252+ registry = [
253+ HookDef (
254+ matcher = r"Read|Write" ,
255+ scripts = ["guards/test_guard.py" ],
256+ codex_status = "fix-required" ,
257+ ),
258+ ]
259+ monkeypatch .setattr (tools_execution , "HOOK_REGISTRY" , registry )
260+
261+ backend = MagicMock (spec = CodingAgentBackend )
262+ backend .name = AGENT_BACKEND_CLAUDE_CODE
263+ backend .capabilities .applicable_guards = frozenset ({"test_guard" })
264+
265+ skill_info = MagicMock ()
266+ skill_info .backend_requirements = frozenset ({AGENT_BACKEND_CLAUDE_CODE })
267+
268+ result = _check_backend_compat (
269+ skill_command = "/autoskillit:test" ,
270+ resolved_command = "/autoskillit:test" ,
271+ effective_order_id = "" ,
272+ target_name = "test" ,
273+ skill_info = skill_info ,
274+ effective_backend_obj = backend ,
275+ skill_resolver = MagicMock (),
276+ )
277+ assert result is None
278+
279+ def test_refuses_codex_dispatch_when_fix_required_hook_has_empty_scripts (
280+ self , monkeypatch : pytest .MonkeyPatch
281+ ) -> None :
282+ import json
283+ from unittest .mock import MagicMock
284+
285+ from autoskillit .core import AGENT_BACKEND_CODEX , CodingAgentBackend
286+ from autoskillit .hook_registry import HookDef
287+ from autoskillit .server .tools import tools_execution
288+ from autoskillit .server .tools .tools_execution import _check_backend_compat
289+
290+ # scripts=[] means the hook has no guard scripts to check coverage against;
291+ # the `not h.scripts` guard treats it as always-unenforced and blocks dispatch.
292+ registry = [
293+ HookDef (
294+ matcher = r"Read|Write" ,
295+ scripts = [],
296+ codex_status = "fix-required" ,
297+ ),
298+ ]
299+ monkeypatch .setattr (tools_execution , "HOOK_REGISTRY" , registry )
300+
301+ backend = MagicMock (spec = CodingAgentBackend )
302+ backend .name = AGENT_BACKEND_CODEX
303+ backend .capabilities .applicable_guards = frozenset ({"any_guard" })
304+
305+ skill_info = MagicMock ()
306+ skill_info .backend_requirements = frozenset ({AGENT_BACKEND_CODEX })
307+
308+ result = _check_backend_compat (
309+ skill_command = "/autoskillit:test" ,
310+ resolved_command = "/autoskillit:test" ,
311+ effective_order_id = "ord-empty" ,
312+ target_name = "test" ,
313+ skill_info = skill_info ,
314+ effective_backend_obj = backend ,
315+ skill_resolver = MagicMock (),
316+ )
317+ assert result is not None
318+ parsed = json .loads (result )
319+ assert parsed ["subtype" ] == "crashed"
320+ assert "Read|Write" in parsed ["result" ]
321+
322+ def test_allows_codex_dispatch_when_no_fix_required_hooks (
323+ self , monkeypatch : pytest .MonkeyPatch
324+ ) -> None :
325+ from unittest .mock import MagicMock
326+
327+ from autoskillit .core import AGENT_BACKEND_CODEX , CodingAgentBackend
328+ from autoskillit .hook_registry import HookDef
329+ from autoskillit .server .tools import tools_execution
330+ from autoskillit .server .tools .tools_execution import _check_backend_compat
331+
332+ registry = [
333+ HookDef (matcher = r"Read|Write" , codex_status = "works-as-is" ),
334+ HookDef (matcher = r"Bash" , codex_status = "not-applicable" ),
335+ ]
336+ monkeypatch .setattr (tools_execution , "HOOK_REGISTRY" , registry )
337+
338+ backend = MagicMock (spec = CodingAgentBackend )
339+ backend .name = AGENT_BACKEND_CODEX
340+ backend .capabilities .applicable_guards = frozenset ({"write_guard" })
341+
342+ skill_info = MagicMock ()
343+ skill_info .backend_requirements = frozenset ({AGENT_BACKEND_CODEX })
344+
345+ result = _check_backend_compat (
346+ skill_command = "/autoskillit:test" ,
347+ resolved_command = "/autoskillit:test" ,
348+ effective_order_id = "" ,
349+ target_name = "test" ,
350+ skill_info = skill_info ,
351+ effective_backend_obj = backend ,
352+ skill_resolver = MagicMock (),
353+ )
354+ assert result is None
355+
356+ def test_crash_message_lists_affected_matchers (self , monkeypatch : pytest .MonkeyPatch ) -> None :
357+ import json
358+ from unittest .mock import MagicMock
359+
360+ from autoskillit .core import AGENT_BACKEND_CODEX , CodingAgentBackend
361+ from autoskillit .hook_registry import HookDef
362+ from autoskillit .server .tools import tools_execution
363+ from autoskillit .server .tools .tools_execution import _check_backend_compat
364+
365+ registry = [
366+ HookDef (
367+ matcher = r"Read|Write" ,
368+ scripts = ["guards/guard_a.py" ],
369+ codex_status = "fix-required" ,
370+ ),
371+ HookDef (
372+ matcher = r"Bash|Grep" ,
373+ scripts = ["guards/guard_b.py" ],
374+ codex_status = "fix-required" ,
375+ ),
376+ ]
377+ monkeypatch .setattr (tools_execution , "HOOK_REGISTRY" , registry )
378+
379+ backend = MagicMock (spec = CodingAgentBackend )
380+ backend .name = AGENT_BACKEND_CODEX
381+ backend .capabilities .applicable_guards = frozenset ({"write_guard" })
382+
383+ skill_info = MagicMock ()
384+ skill_info .backend_requirements = frozenset ({AGENT_BACKEND_CODEX })
385+
386+ result = _check_backend_compat (
387+ skill_command = "/autoskillit:test" ,
388+ resolved_command = "/autoskillit:test" ,
389+ effective_order_id = "ord-2" ,
390+ target_name = "test" ,
391+ skill_info = skill_info ,
392+ effective_backend_obj = backend ,
393+ skill_resolver = MagicMock (),
394+ )
395+ assert result is not None
396+ parsed = json .loads (result )
397+ assert parsed ["subtype" ] == "crashed"
398+ error_text = parsed ["result" ]
399+ assert "Read|Write" in error_text
400+ assert "Bash|Grep" in error_text
0 commit comments