diff --git a/.chock/bin/claude_code.py b/.chock/bin/claude_code.py index 2f67f78..840ec58 100755 --- a/.chock/bin/claude_code.py +++ b/.chock/bin/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -585,13 +585,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -964,7 +965,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/codex_cli.py b/.chock/bin/codex_cli.py index 20bd657..6c1249d 100755 --- a/.chock/bin/codex_cli.py +++ b/.chock/bin/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -571,13 +571,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -950,7 +951,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/cursor.py b/.chock/bin/cursor.py index 10d48db..95f6520 100755 --- a/.chock/bin/cursor.py +++ b/.chock/bin/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -372,13 +372,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -513,6 +514,13 @@ def _refusal_reason(v, gate, decision, name): return _because(decision.reason, note % name) return decision.reason +def _followup_payload(v, decision, name): + """STOP: the turn has ended; a refusal comes back as the follow-up the agent runs next.""" + gate = v["gates"].get(name) + if gate is None or decision.outcome not in (DENY, ESCALATE, TRANSFORM): + return "", 0 + return _json.dumps({"followup_message": _refusal_reason(v, gate, decision, name)}), 0 + def _gate_payload(v, gate, decision, name): """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" words = v["words"] @@ -533,6 +541,8 @@ def cursor_respond(cfg, decision, event): return "", 0 if canonical in (POST_TOOL, TOOL_FAILURE): return _flag_payload(v, decision, name) + if canonical == STOP: + return _followup_payload(v, decision, name) gate = v["gates"].get(name) if canonical == PROMPT_SUBMIT and gate is not None: return _prompt_submit_payload(v, gate, decision, name) @@ -551,7 +561,7 @@ def cursor_respond(cfg, decision, event): AGENT = "cursor" -VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify the input, so this is a block', 'transform': 'input requires modification, which this gate cannot express', 'transform_missing_input': 'no replacement input was supplied'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} +VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_lookups.py::test_cursor_write_tools_are_recorded_from_the_live_witness'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {'write': ('Write',)}, 'verdicts': {'allow_silent_events': ('stop',), 'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse', 'stop'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify the input, so this is a block', 'transform': 'input requires modification, which this gate cannot express', 'transform_missing_input': 'no replacement input was supplied'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}, 'stop': {'grammar': 'G6', 'honours_escalate': False, 'honours_transform': False}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} def claims(raw): @@ -869,7 +879,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/devin.py b/.chock/bin/devin.py index 53e3899..af3a770 100755 --- a/.chock/bin/devin.py +++ b/.chock/bin/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -553,13 +553,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -932,7 +933,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/gemini_cli.py b/.chock/bin/gemini_cli.py index b5d9b11..46b848f 100755 --- a/.chock/bin/gemini_cli.py +++ b/.chock/bin/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -553,13 +553,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -932,7 +933,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/grok.py b/.chock/bin/grok.py index a742bd2..9d57746 100755 --- a/.chock/bin/grok.py +++ b/.chock/bin/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -536,13 +536,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -915,7 +916,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/tabnine.py b/.chock/bin/tabnine.py index 04aa4fe..e397868 100755 --- a/.chock/bin/tabnine.py +++ b/.chock/bin/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -536,13 +536,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -915,7 +916,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/vscode_copilot.py b/.chock/bin/vscode_copilot.py index cdba2e6..1f61ccd 100755 --- a/.chock/bin/vscode_copilot.py +++ b/.chock/bin/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -740,7 +740,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/bin/windsurf.py b/.chock/bin/windsurf.py index 3dfd4a5..9fe9909 100755 --- a/.chock/bin/windsurf.py +++ b/.chock/bin/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -409,13 +409,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -829,7 +830,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json new file mode 100644 index 0000000..9297163 --- /dev/null +++ b/.chock/compiled/pin-github-actions/pre-tool-use/cursor-write-hooks.json @@ -0,0 +1,8 @@ +{ + "preToolUse": [ + { + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/compiled/pin-github-actions/stop/cursor-hooks.json b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json new file mode 100644 index 0000000..af2f34f --- /dev/null +++ b/.chock/compiled/pin-github-actions/stop/cursor-hooks.json @@ -0,0 +1,8 @@ +{ + "stop": [ + { + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/.chock/coverage.json b/.chock/coverage.json index c0b726b..765aa37 100644 --- a/.chock/coverage.json +++ b/.chock/coverage.json @@ -758,8 +758,8 @@ "witnessed": false }, "cursor": { - "level": "enforced-at-commit", - "basis": null, + "level": "enforceable", + "basis": "live-run-partial", "witnessed": false }, "devin": { diff --git a/.claude/settings.json b/.claude/settings.json index 92285ad..5036b51 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -26,7 +26,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", "timeout": 30 } ] @@ -68,7 +68,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/claude_code.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", "timeout": 30 } ] diff --git a/.codex/hooks.json b/.codex/hooks.json index 4c2b96c..a6c16da 100644 --- a/.codex/hooks.json +++ b/.codex/hooks.json @@ -47,8 +47,8 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", - "commandWindows": "& \"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "commandWindows": "& \"/tmp/chock-cursor/bin/python3\" \".chock/bin/codex_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.cursor/hooks.json b/.cursor/hooks.json index 750d558..81942b3 100644 --- a/.cursor/hooks.json +++ b/.cursor/hooks.json @@ -17,6 +17,18 @@ "command": "\"/usr/local/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --guard \"${CLAUDE_PROJECT_DIR}/.agents/policies/protect-commit-privacy/implementations/protect-commit-privacy.sh\"", "timeout": 30 } + ], + "preToolUse": [ + { + "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/pre-tool-use/gate.json\"", + "timeout": 30 + } + ], + "stop": [ + { + "command": "\"/tmp/chock-cursor/bin/python3\" \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/pin-github-actions/stop/gate.json\"", + "timeout": 30 + } ] }, "version": 1 diff --git a/.devin/hooks.v1.json b/.devin/hooks.v1.json index 00fb687..a40fc7c 100644 --- a/.devin/hooks.v1.json +++ b/.devin/hooks.v1.json @@ -38,7 +38,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/devin.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.gemini/settings.json b/.gemini/settings.json index 4180fd9..ff176da 100644 --- a/.gemini/settings.json +++ b/.gemini/settings.json @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" + "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/gemini_cli.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"" } ] } diff --git a/.tabnine/agent/settings.json b/.tabnine/agent/settings.json index 00ed8f5..63988e9 100644 --- a/.tabnine/agent/settings.json +++ b/.tabnine/agent/settings.json @@ -43,7 +43,7 @@ "hooks": [ { "type": "command", - "command": "\"/tmp/claude-0/-home-user/48152925-4fa4-5342-ac22-0d73478e5b55/scratchpad/ck312/bin/python3.12\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", + "command": "\"/tmp/chock-cursor/bin/python3\" \".chock/bin/tabnine.py\" --gate \".chock/compiled/pin-github-actions/stop/gate.json\"", "name": "agentseam" } ] diff --git a/CHANGELOG.md b/CHANGELOG.md index d30b679..11a009d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- **Cursor gates a write and reports at the turn's end.** agentseam 0.3.3 records what a live + probe of Cursor 3.21.18 showed: the generic `preToolUse` event fires for `Write` with the + file's path and full content and honours a deny, and `stop` honours a `followup_message` + that sends the agent back into the turn (a silent stop ends it, so that surface fails open). + `chock sync` now compiles a Cursor write fragment and a Cursor stop fragment for a policy + whose gate declares `tool_use`, and merges both into `.cursor/hooks.json` beside the shell + guard, under their own event keys; Cursor's row gains the `stop` column. A flat Cursor entry + carries no matcher, so the runtime answers every tool under `preToolUse` and judges only a + write it recognises. A stop that already re-entered once (`loop_count`, Cursor's spelling of + Claude Code's `stop_hook_active`) is not judged again. Pin: `agentseam==0.3.3`. - **A policy's gate rides in its plugin, in every hook-carrying format.** A plugin installs at the agent, not in a repository, so it carried a command guard or nothing: a gate lived only where `chock sync` compiled it. `chock plugin build` now packages a policy whose gate declares @@ -11,8 +21,9 @@ the vendor and no other: Claude Code's package gates the recorded write tools at `PreToolUse` and the turn's end at `Stop`; Codex, Devin and Copilot record no write-tool vocabulary but block at the turn's end, so their packages carry the gate at `Stop` alone and say that the - write itself is not judged; Cursor records neither, so its package stays advisory rather - than installing a hook that could only refuse. Each package states its posture (what is + write itself is not judged; Cursor records `Write` at its generic `preToolUse` and a turn-end + hook that hands a refusal back as a follow-up message, so its package gates the write and + reports at `stop`, in Cursor's own flat entry shape. Each package states its posture (what is judged and when, refuses when it cannot decide, needs python3, the vendor's own caveat) and the skill claims its hooks. The bundled runtime looks for the runner beside the gate before the repository layout, and takes the repository from the event's working directory when the diff --git a/chock.lock b/chock.lock index 7f19e56..d4ed938 100644 --- a/chock.lock +++ b/chock.lock @@ -96,7 +96,7 @@ "managed": false, "sha256": "e73dbc039cdcbf137049a1f5035e4b595d12afe74a12a0f78e2e102d20d29479", "source": "local", - "artifacts_sha256": "635b7255986d30064f7e5ff8c564098b07a964c1880e654d814c77e5c8e6ce0e" + "artifacts_sha256": "c352efb451fe61dc6aad8f799fa8628d3adb6305e22f3ae2efd1ab1a215b641f" }, { "id": "pre-generated-scripts", diff --git a/docs/agent-plugins.md b/docs/agent-plugins.md index 047d3b2..66dd989 100644 --- a/docs/agent-plugins.md +++ b/docs/agent-plugins.md @@ -213,9 +213,9 @@ no other: | :--- | :--- | :--- | :--- | | claude | `PreToolUse` on `Write\|Edit\|MultiEdit\|NotebookEdit` | `Stop` | judges the write, re-reads the turn | | codex, devin, copilot | none recorded | `Stop` | re-reads the turn; the write itself is not judged | -| cursor | none recorded | no blocking stop hook | advisory; no hook is installed | +| cursor | `preToolUse` on `Write` (flat entry, no matcher) | `stop`, as a `followup_message` | judges the write, sends the agent back to the turn's leftovers | -A file written through a shell heredoc is judged at the turn's end wherever `Stop` reaches. +A file written through a shell heredoc is judged at the turn's end wherever `Stop` reaches. In Cursor the turn is not held: the refusal returns to the agent as a follow-up message, once, and a hook that fails to answer lets the turn end. The runtime finds the runner beside the gate and takes the repository from the event's working directory, which is where `.chock/` config such as a policy's selection file is read from. A gate that declares only `commit` stays advisory in every package: a hook that could only diff --git a/docs/enforcement-surfaces.md b/docs/enforcement-surfaces.md index 2593774..a225a20 100644 --- a/docs/enforcement-surfaces.md +++ b/docs/enforcement-surfaces.md @@ -111,7 +111,7 @@ Which surfaces each agent supports today (from `src/chock/compile/surfaces.py`): | Agent | ambient | git-hook | ci-gate | pre-tool-use | stop | managed-setting | agent-hooks | | :--- | :---: | :---: | :---: | :---: | :---: | :---: | :---: | | **Claude Code** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — | -| **Cursor** | ✅ | ✅ | ✅ | ✅ | — | — | — | +| **Cursor** | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | | Copilot | ✅ | ✅ | ✅ | — | — | — | ✅ | | Codex | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | | Gemini | ✅ | ✅ | ✅ | ✅ | ✅ | — | — | @@ -129,8 +129,11 @@ Which surfaces each agent supports today (from `src/chock/compile/surfaces.py`): In-agent membership derives from agentseam's matrix: every adapted vendor whose row can block a pre-tool call from a repo-level JSON hook config gets `pre-tool-use` (Copilot CLI and VS Code: `agent-hooks`, chock's owned file). `stop` is derived the same way from the **turn-end** row, which -is a different question with a different answer: Cursor, Grok and Windsurf can observe a finished -turn but not refuse one, so they are `detect` and get no column mark. Copilot and VS Code *can* +is a different question with a different answer: Grok and Windsurf can observe a finished turn +but not refuse one, so they are `detect` and get no column mark. Cursor cannot hold a turn either, +but its `stop` hook hands a refusal back to the agent as a `followup_message` (witnessed live on +3.21.18), which is `best-effort` and worth a mark: the agent is sent back to what the turn left +behind rather than the user being told after the fact. Copilot and VS Code *can* refuse one and are still held back -- their hooks live in chock's own file in a shape witnessed live, that witness covers the pre-tool key alone, and a guessed turn-end key installs a hook that silently never runs while the table claims it does. Junie and Kimi Code block only via home-level configs (Kimi diff --git a/docs/figures/surfaces-dark.svg b/docs/figures/surfaces-dark.svg index 1181733..e9406e1 100644 --- a/docs/figures/surfaces-dark.svg +++ b/docs/figures/surfaces-dark.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 6 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 7 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -40,7 +40,7 @@ stop - 6 agents + 7 agents backstop, no grade diff --git a/docs/figures/surfaces-light.svg b/docs/figures/surfaces-light.svg index badec95..bf60646 100644 --- a/docs/figures/surfaces-light.svg +++ b/docs/figures/surfaces-light.svg @@ -1,6 +1,6 @@ One policy, 9 surfaces - A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 6 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. + A fan-out diagram: one chock policy compiles into 9 enforcement surfaces. All 15 supported agent names get the advisory ambient rule and the two commit-time gates, git hook and CI gate. 9 also get a native pre-tool-use hook, enforced live in the agent, and 2 (vscode, copilot -- one underlying vendor) get chock's own agent-hooks file, also enforced in-agent. 7 get an end-of-turn hook that reads what the turn wrote: a backstop for what a pre-tool hook cannot see, carrying no coverage grade of its own. Three surfaces are named for honesty though no agent reaches them yet: managed-setting is compiled for Claude but not installed, gateway is modelled but not emitted, and mcp-gateway emits but is not yet credited to any agent. one policy @@ -40,7 +40,7 @@ stop - 6 agents + 7 agents backstop, no grade diff --git a/pyproject.toml b/pyproject.toml index 330b43e..de45f0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,7 +60,7 @@ dependencies = [ # It never ships to an adopter repo -- the vendored PreToolUse/SessionStart runners # stay self-contained stdlib-only files, unaffected by this dependency. Pinned exact # per plan/spine-a/contract.md: the wave boundary is the published PyPI artifact. - "agentseam==0.3.2", + "agentseam==0.3.3", ] [project.optional-dependencies] diff --git a/requirements/brand-assets.in b/requirements/brand-assets.in index bd48b58..04fc285 100644 --- a/requirements/brand-assets.in +++ b/requirements/brand-assets.in @@ -1,5 +1,5 @@ pyyaml>=6.0 jsonschema>=4.18,<5 referencing>=0.35,<0.38 -agentseam==0.3.2 +agentseam==0.3.3 cairosvg==2.9.0 diff --git a/requirements/brand-assets.txt b/requirements/brand-assets.txt index 1360237..952c2bc 100644 --- a/requirements/brand-assets.txt +++ b/requirements/brand-assets.txt @@ -4,9 +4,9 @@ # # pip-compile --generate-hashes --output-file=requirements/brand-assets.txt --strip-extras requirements/brand-assets.in # -agentseam==0.3.2 \ - --hash=sha256:63616579111f24db9045113a3ef123f6cbb399333c2affb65f8bbb6956d2fb10 \ - --hash=sha256:869448a47184dc523491932557f4d87243a6fd7e05d5c51913804013395e643c +agentseam==0.3.3 \ + --hash=sha256:2e7c832988711bf183955a6e77d570f783d99cc394e57f456eb520a8243b334d \ + --hash=sha256:cf8e8c5aad79ba1521c564fcab4dc03b688d91b12307db5ca7173f961ad15e1e # via -r requirements/brand-assets.in attrs==26.1.0 \ --hash=sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309 \ diff --git a/src/chock/compile/emitters/in_agent.py b/src/chock/compile/emitters/in_agent.py index 3a38f04..2686315 100644 --- a/src/chock/compile/emitters/in_agent.py +++ b/src/chock/compile/emitters/in_agent.py @@ -142,7 +142,13 @@ def _gate_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l command = f'@CHOCK_PYTHON@ "{adapter}" --gate "{reference}"' name = WRITE_FRAGMENT if vendor == "claude_code" else f"{vendor}-write-hooks.json" dest = output_dir / name - write_generated_json(dest, hook_entry(command, matcher=matcher)) + if vendors.hook_entry_flat(vendor): + # A flat entry carries no matcher: the runtime answers every tool under the event + # and judges only a write it recognises; an unmatched tool is allowed unremarked. + doc: dict[str, Any] = {vendors.pre_tool_event(vendor): [cursor_entry(command)]} + else: + doc = hook_entry(command, matcher=matcher) + write_generated_json(dest, doc) written.append(dest) return written @@ -162,7 +168,7 @@ def _stop_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l Every vendor `stop_vendors` admits gets one. A turn-end hook carries no tool to match on, so nothing here depends on a write vocabulary -- the reason this surface reaches - six vendors where the write path reaches two. + seven vendors where the write path reaches two. """ gate = output_dir / GATE_FILE write_generated_json(gate, spec) @@ -174,6 +180,11 @@ def _stop_fragments(policy_id: str, spec: dict[str, Any], output_dir: Path) -> l command = f'@CHOCK_PYTHON@ "{root}{_adapter_rel(vendor)}" --gate "{root}{_stop_rel(policy_id)}/{GATE_FILE}"' if vendor == "claude_code": dest, doc = output_dir / STOP_FRAGMENT, hook_entry(command) + elif vendors.hook_entry_flat(vendor): + # Cursor's fragment is the event's entry list, the shape its merged installer reads, + # rooted the way its shell and write entries are so the installer recognises it. + command = f'@CHOCK_PYTHON@ "{PROJECT_DIR_TOKEN}/{_adapter_rel(vendor)}" --gate "{PROJECT_DIR_TOKEN}/{_stop_rel(policy_id)}/{GATE_FILE}"' + dest, doc = output_dir / f"{vendor}-hooks.json", {vendors.stop_event(vendor): [cursor_entry(command)]} else: dest, doc = output_dir / f"{vendor}-hooks.json", vendors.stop_hook_config(vendor, command) write_generated_json(dest, doc) diff --git a/src/chock/gate/write_gate.py b/src/chock/gate/write_gate.py index bf3b331..356a6f0 100644 --- a/src/chock/gate/write_gate.py +++ b/src/chock/gate/write_gate.py @@ -171,8 +171,10 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get("stop_hook_active"): - # A refusal that re-entered its own stop hook would never terminate. + raw = event.raw or {} + if raw.get("stop_hook_active") or raw.get("loop_count"): + # A refusal that re-entered its own stop hook would never terminate: Claude Code marks + # the re-entry `stop_hook_active`, Cursor counts it in `loop_count`. return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/src/chock/hooks/in_agent_merged.py b/src/chock/hooks/in_agent_merged.py index 0e48762..9897fd4 100644 --- a/src/chock/hooks/in_agent_merged.py +++ b/src/chock/hooks/in_agent_merged.py @@ -116,6 +116,20 @@ class VendorWiring(NamedTuple): flat=True, report_key="command", ), + # The generic pre-tool event, a different key from the shell one above: a content + # gate judges the file a Write would leave, the shell guard judges a command. + Wiring( + event=vendors.pre_tool_event("cursor"), + fragment_glob="*/pre-tool-use/cursor-write-hooks.json", + flat=True, + report_key="command", + ), + Wiring( + event=vendors.stop_event("cursor"), + fragment_glob="*/stop/cursor-hooks.json", + flat=True, + report_key="command", + ), ), unlink_runtime_when_empty=False, write_when_absent=False, diff --git a/src/chock/plugin/cursor.py b/src/chock/plugin/cursor.py index daab8b8..9df2cd0 100644 --- a/src/chock/plugin/cursor.py +++ b/src/chock/plugin/cursor.py @@ -62,7 +62,11 @@ def _hook_command(script: str) -> str: return f'python3 "{adapter}" --guard "{guard}"' -POSTURE_GATE_CURSOR = gate_package.gate_posture("cursor", "") +POSTURE_GATE_CURSOR = gate_package.gate_posture( + "cursor", + "In Cursor the turn's end is not held: a refusal there is handed back to the agent as a " + "follow-up message, once, and a hook that fails to answer lets the turn end.", +) _GATE_NOTE_CURSOR = gate_package.gate_skill_note("cursor") diff --git a/src/chock/plugin/gate_package.py b/src/chock/plugin/gate_package.py index a33a9ce..67ef976 100644 --- a/src/chock/plugin/gate_package.py +++ b/src/chock/plugin/gate_package.py @@ -17,7 +17,7 @@ from chock import vendors from chock.compile.emitters.in_agent import GATE_FILE -from chock.compile.emitters.in_agent_hooks import hook_entry +from chock.compile.emitters.in_agent_hooks import cursor_entry, hook_entry from chock.gate import runtime_bundle from chock.gate.runner import SCRIPT_BASE_GATE @@ -68,11 +68,18 @@ def gate_reaches(vendor: str) -> bool: def gate_hooks_file(vendor: str, command: str) -> dict[str, Any]: """The hooks document running `command` on every surface the gate reaches in `vendor`.""" matcher, stop = gate_reach(vendor) + flat = vendors.hook_entry_flat(vendor) entries: dict[str, list[dict[str, Any]]] = {} if matcher is not None: - entries[vendors.pre_tool_event(vendor)] = [hook_entry(command, matcher=matcher)] + # A flat entry carries no matcher: the runtime answers every tool and judges only a + # write it recognises, so an unmatched tool is allowed with nothing said. + entries[vendors.pre_tool_event(vendor)] = [ + cursor_entry(command) if flat else hook_entry(command, matcher=matcher) + ] if stop: - entries[vendors.stop_event(vendor)] = [hook_entry(command)] + entries[vendors.stop_event(vendor)] = [cursor_entry(command) if flat else hook_entry(command)] + if flat: + return {**vendors.config_envelope(vendor), "hooks": entries} return entries if vendors.hook_entry_bare(vendor) else {"hooks": entries} diff --git a/src/chock/vendors.py b/src/chock/vendors.py index 869b3f3..874b30d 100644 --- a/src/chock/vendors.py +++ b/src/chock/vendors.py @@ -64,9 +64,9 @@ def stop_vendors() -> tuple[str, ...]: """Vendors the stop surface covers: the same predicate, asked about the turn-end event. A different set from `in_agent_vendors` in both directions, which is the point of - deriving each one rather than inheriting: cursor, grok and windsurf can only observe a - finished turn, while three vendors here can refuse one without recording any write - vocabulary. vscode_copilot can refuse one too and is still held back -- see + deriving each one rather than inheriting: grok and windsurf can only observe a finished + turn, while three vendors here can refuse one without recording any write vocabulary + (cursor hands a refusal back as a follow-up message, witnessed live). vscode_copilot can refuse one too and is still held back -- see AGENT_HOOKS_VENDORS for the file chock would have to guess a key in. """ return tuple( @@ -148,6 +148,18 @@ def hook_entry_bare(vendor: str) -> bool: return bool((entry or {}).get("bare")) +def hook_entry_flat(vendor: str) -> bool: + """Whether `vendor`'s hook entries are the flat `{"command": ...}` shape, with no matcher. + + Cursor's `hooks.json` lists each event's entries as bare command objects, recorded upstream + as `CONFIG["hook_entry"] == {"matcher": False, "wrapper": "cursor"}`; every other vendor + with a recorded entry nests the command under `hooks` and may carry a matcher. + """ + config = getattr(_adapters.get(vendor), "CONFIG", None) + entry = (config or {}).get("hook_entry") if isinstance(config, dict) else None + return (entry or {}).get("wrapper") == "cursor" + + def pre_tool_hook_config(vendor: str, command: str, matcher: str | None = None) -> dict[str, Any]: """The vendor's complete hook-config document gating pre-tool with `command`.""" return _adapters.get(vendor).hook_config((_contract.PRE_TOOL,), command, matcher) diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/git-hook/git-pre-commit.sh b/tests/fixtures/emitter_stability/golden/stability-hook/git-hook/git-pre-commit.sh old mode 100644 new mode 100755 diff --git a/tests/fixtures/emitter_stability/golden/stability-hook/git-hook/git-pre-push.sh b/tests/fixtures/emitter_stability/golden/stability-hook/git-hook/git-pre-push.sh old mode 100644 new mode 100755 diff --git a/tests/fixtures/emitter_stability/golden/stability-script/git-hook/git-pre-commit.sh b/tests/fixtures/emitter_stability/golden/stability-script/git-hook/git-pre-commit.sh old mode 100644 new mode 100755 diff --git a/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json new file mode 100644 index 0000000..5f2a244 --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/pre-tool-use/cursor-write-hooks.json @@ -0,0 +1,8 @@ +{ + "preToolUse": [ + { + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/pre-tool-use/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json new file mode 100644 index 0000000..e040f0b --- /dev/null +++ b/tests/fixtures/emitter_stability/golden/stability-script/stop/cursor-hooks.json @@ -0,0 +1,8 @@ +{ + "stop": [ + { + "command": "@CHOCK_PYTHON@ \"${CLAUDE_PROJECT_DIR}/.chock/bin/cursor.py\" --gate \"${CLAUDE_PROJECT_DIR}/.chock/compiled/stability-script/stop/gate.json\"", + "timeout": 30 + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/runtime_goldens/antigravity.py b/tests/fixtures/runtime_goldens/antigravity.py index 16f5823..91b201f 100644 --- a/tests/fixtures/runtime_goldens/antigravity.py +++ b/tests/fixtures/runtime_goldens/antigravity.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("antigravity"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("antigravity"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -497,13 +497,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -898,7 +899,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/claude_code.py b/tests/fixtures/runtime_goldens/claude_code.py index 2f67f78..840ec58 100644 --- a/tests/fixtures/runtime_goldens/claude_code.py +++ b/tests/fixtures/runtime_goldens/claude_code.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("claude_code"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("claude_code"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -23,7 +23,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -585,13 +585,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -964,7 +965,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/codex_cli.py b/tests/fixtures/runtime_goldens/codex_cli.py index 20bd657..6c1249d 100644 --- a/tests/fixtures/runtime_goldens/codex_cli.py +++ b/tests/fixtures/runtime_goldens/codex_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("codex_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("codex_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -571,13 +571,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -950,7 +951,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/cursor.py b/tests/fixtures/runtime_goldens/cursor.py index 10d48db..95f6520 100644 --- a/tests/fixtures/runtime_goldens/cursor.py +++ b/tests/fixtures/runtime_goldens/cursor.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("cursor"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("cursor"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -372,13 +372,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -513,6 +514,13 @@ def _refusal_reason(v, gate, decision, name): return _because(decision.reason, note % name) return decision.reason +def _followup_payload(v, decision, name): + """STOP: the turn has ended; a refusal comes back as the follow-up the agent runs next.""" + gate = v["gates"].get(name) + if gate is None or decision.outcome not in (DENY, ESCALATE, TRANSFORM): + return "", 0 + return _json.dumps({"followup_message": _refusal_reason(v, gate, decision, name)}), 0 + def _gate_payload(v, gate, decision, name): """The PRE_TOOL gate's (permission, reason) pair, before the shared trailing message rule.""" words = v["words"] @@ -533,6 +541,8 @@ def cursor_respond(cfg, decision, event): return "", 0 if canonical in (POST_TOOL, TOOL_FAILURE): return _flag_payload(v, decision, name) + if canonical == STOP: + return _followup_payload(v, decision, name) gate = v["gates"].get(name) if canonical == PROMPT_SUBMIT and gate is not None: return _prompt_submit_payload(v, gate, decision, name) @@ -551,7 +561,7 @@ def cursor_respond(cfg, decision, event): AGENT = "cursor" -VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-08-27', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {}, 'verdicts': {'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify the input, so this is a block', 'transform': 'input requires modification, which this gate cannot express', 'transform_missing_input': 'no replacement input was supplied'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} +VENDOR = {'agent': 'cursor', 'claims': {'mode': 'shape_inferred'}, 'config_format': 'json', 'config_path': '.cursor/hooks.json', 'display': 'Cursor', 'events': {'afterFileEdit': 'file_changed', 'afterMCPExecution': 'post_tool', 'afterShellExecution': 'post_tool', 'afterTabFileEdit': 'file_changed', 'beforeMCPExecution': 'pre_tool', 'beforeReadFile': 'pre_tool', 'beforeShellExecution': 'pre_tool', 'beforeSubmitPrompt': 'prompt_submit', 'beforeTabFileRead': 'pre_tool', 'postToolUse': 'post_tool', 'postToolUseFailure': 'tool_failure', 'preCompact': 'pre_compact', 'preToolUse': 'pre_tool', 'sessionEnd': 'session_end', 'sessionStart': 'session_start', 'stop': 'stop', 'subagentStart': 'subagent_start', 'subagentStop': 'subagent_stop'}, 'evidence': {'claims': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_examples.py::test_each_payload_is_claimed_by_its_own_adapter'}, 'config_path': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_config.py::test_config_path_agrees_with_matrix'}, 'events': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_examples.py::test_each_payload_parses_to_the_event_it_is_filed_under'}, 'family': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}, 'fields': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_config.py::test_entries_match_recount'}, 'hook_entry': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_hook_config_matches_the_frozen_fixture_on_both_matcher_paths'}, 'tools': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_vendor_lookups.py::test_cursor_write_tools_are_recorded_from_the_live_witness'}, 'verdicts': {'basis': 'live-run-partial', 'date': '2026-09-23', 'test': 'tests/test_golden_fixtures.py::test_wire_output_matches_the_frozen_fixture'}}, 'family': 'cursor', 'fields': {'command': ('command', 'tool_input.command'), 'content': ('edits[].new_string', 'tool_input.content', 'tool_input.new_string', 'content'), 'cwd': ('cwd',), 'output': ('tool_output', 'output', 'result_json'), 'path': ('file_path', 'tool_input.file_path', 'tool_input.path'), 'prompt': ('prompt',), 'session_id': ('conversation_id',), 'tool': ('tool_name',), 'tool_use_id': ('tool_use_id',)}, 'hook_entry': {'matcher': False, 'wrapper': 'cursor'}, 'needs_trust': False, 'tools': {'write': ('Write',)}, 'verdicts': {'allow_silent_events': ('stop',), 'answer_events': ('beforeMCPExecution', 'beforeReadFile', 'beforeShellExecution', 'beforeSubmitPrompt', 'beforeTabFileRead', 'preToolUse', 'stop'), 'bare_allow': 'required', 'default_wire_event': 'beforeShellExecution', 'degrade_notes': {'escalate': '%s cannot prompt for confirmation, so this is a block', 'escalate_from_transform': '%s cannot modify the input, so this is a block', 'transform': 'input requires modification, which this gate cannot express', 'transform_missing_input': 'no replacement input was supplied'}, 'flag_note': 'observed after the fact (%s cannot prevent it): %s', 'flag_note_default': 'policy violation', 'gates': {'beforeMCPExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeReadFile': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeShellExecution': {'grammar': 'G4', 'honours_escalate': True, 'honours_transform': False}, 'beforeSubmitPrompt': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'beforeTabFileRead': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': False}, 'preToolUse': {'grammar': 'G4', 'honours_escalate': False, 'honours_transform': True}, 'stop': {'grammar': 'G6', 'honours_escalate': False, 'honours_transform': False}}, 'transform_grammar': 'permission_updated_input', 'vocabulary': ('allow', 'ask', 'deny'), 'vocabulary_basis': 'verified', 'words': {'allow': 'allow', 'block': 'deny', 'escalate': 'ask'}}, 'wire_events': {'file_changed': 'afterFileEdit', 'post_tool': 'postToolUse', 'pre_tool': 'preToolUse'}} def claims(raw): @@ -869,7 +879,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/devin.py b/tests/fixtures/runtime_goldens/devin.py index 53e3899..af3a770 100644 --- a/tests/fixtures/runtime_goldens/devin.py +++ b/tests/fixtures/runtime_goldens/devin.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("devin"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("devin"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -553,13 +553,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -932,7 +933,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/gemini_cli.py b/tests/fixtures/runtime_goldens/gemini_cli.py index b5d9b11..46b848f 100644 --- a/tests/fixtures/runtime_goldens/gemini_cli.py +++ b/tests/fixtures/runtime_goldens/gemini_cli.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("gemini_cli"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("gemini_cli"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -553,13 +553,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -932,7 +933,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/grok.py b/tests/fixtures/runtime_goldens/grok.py index a742bd2..9d57746 100644 --- a/tests/fixtures/runtime_goldens/grok.py +++ b/tests/fixtures/runtime_goldens/grok.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("grok"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("grok"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -536,13 +536,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -915,7 +916,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/tabnine.py b/tests/fixtures/runtime_goldens/tabnine.py index 04aa4fe..e397868 100644 --- a/tests/fixtures/runtime_goldens/tabnine.py +++ b/tests/fixtures/runtime_goldens/tabnine.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("tabnine"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("tabnine"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -536,13 +536,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -915,7 +916,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/vscode_copilot.py b/tests/fixtures/runtime_goldens/vscode_copilot.py index cdba2e6..1f61ccd 100644 --- a/tests/fixtures/runtime_goldens/vscode_copilot.py +++ b/tests/fixtures/runtime_goldens/vscode_copilot.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("vscode_copilot"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("vscode_copilot"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -740,7 +740,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/fixtures/runtime_goldens/windsurf.py b/tests/fixtures/runtime_goldens/windsurf.py index 3dfd4a5..9fe9909 100644 --- a/tests/fixtures/runtime_goldens/windsurf.py +++ b/tests/fixtures/runtime_goldens/windsurf.py @@ -1,4 +1,4 @@ -# Generated by agentseam 0.3.2 -- bundle("windsurf"). Do not hand-edit, except the +# Generated by agentseam 0.3.3 -- bundle("windsurf"). Do not hand-edit, except the # HANDLER block below (between "agentseam handler >>>" and "<<< agentseam handler"), # which is exactly what this file leaves for you to fill in. # @@ -22,7 +22,7 @@ import warnings as _warnings # ------------------------------------------------------------------------------ -# contract (agentseam 0.3.2) +# contract (agentseam 0.3.3) """Canonical event vocabulary, normalized envelope, and decision type.""" @@ -409,13 +409,14 @@ def _flat_list_wrapper(hook_entry, reverse, canonical_events, command, matcher): def _cursor_wrapper(cfg, reverse, canonical_events, command, *, fail_closed): gates = cfg["verdicts"]["answer_events"] + allow_silent = cfg["verdicts"].get("allow_silent_events", ()) hooks = {} for ev in canonical_events: name = reverse.get(ev) if not name: continue entry = {"command": command} - if fail_closed and name in gates: + if fail_closed and name in gates and name not in allow_silent: entry["failClosed"] = True hooks.setdefault(name, []).append(entry) return {"version": 1, "hooks": hooks} @@ -829,7 +830,8 @@ def writes_for(event, gate): """What this event puts under judgement: the call's own text, or what the turn left behind.""" if event.event == PRE_TOOL: return writes_from_event(event) - if (event.raw or {}).get('stop_hook_active'): + raw = event.raw or {} + if raw.get('stop_hook_active') or raw.get('loop_count'): return {} return writes_from_worktree(repo_root_for(event, gate)) diff --git a/tests/test_plugin_gate_stores.py b/tests/test_plugin_gate_stores.py index 847cc27..ab5fd3e 100644 --- a/tests/test_plugin_gate_stores.py +++ b/tests/test_plugin_gate_stores.py @@ -2,14 +2,17 @@ Claude Code records a write-tool vocabulary and a blocking turn-end hook, so its package gates both. Codex, Devin and Copilot record no write tools but block at the turn's end, so their -packages carry the gate at `Stop` alone and say so. Cursor records neither, so a gate has no -surface there and its package stays advisory rather than installing a hook that could only -refuse. None of that is typed here: the test asks agentseam the same question the emitter does. +packages carry the gate at `Stop` alone and say so. Cursor records `Write` at its generic +`preToolUse` and a turn-end hook that hands a refusal back as a follow-up message, so its +package gates the write and reports at `stop`, in Cursor's own flat entry shape. None of that +is typed here: the test asks agentseam the same question the emitter does. """ from __future__ import annotations import json +import os +import subprocess from pathlib import Path from types import SimpleNamespace @@ -56,7 +59,8 @@ def test_the_gate_reaches_what_the_vendor_records(gate_policy, tmp_path: Path, s assert ("PreToolUse" in events or "preToolUse" in events) == (matcher is not None) assert ("Stop" in events or "stop" in events) == stop for entries in hooks.values(): - assert "--gate" in entries[0]["hooks"][0]["command"] + entry = entries[0] + assert "--gate" in (entry["command"] if "command" in entry else entry["hooks"][0]["command"]) gate = json.loads((out / "scripts" / "gate.json").read_text(encoding="utf-8")) assert gate["script_base"] == "gate" and gate["params"]["script"] == f"implementations/{SCRIPT}" assert (out / "scripts" / "gate.py").exists() @@ -69,7 +73,7 @@ def test_which_vendors_the_gate_reaches_is_agentseam_s_answer() -> None: assert gate_reach("codex_cli") == (None, True) assert gate_reach("devin") == (None, True) assert gate_reach("vscode_copilot") == (None, True) - assert gate_reach("cursor") == (None, False) + assert gate_reach("cursor") == ("Write", True) @pytest.mark.parametrize("store", ["codex", "devin", "copilot"]) @@ -86,12 +90,64 @@ def test_a_stop_only_package_says_the_write_is_not_judged(gate_policy, tmp_path: assert "advisory: the client reading it" not in skill -def test_cursor_stays_advisory_for_a_gate(gate_policy, tmp_path: Path) -> None: +def _cursor_payload(repo: Path, **fields) -> str: + """A Cursor 3.21.18 payload: its envelope markers, and a BOM the way the client writes it.""" + base = {"conversation_id": "c", "generation_id": "g", "cursor_version": "3.21.18", "workspace_roots": [str(repo)]} + return "\ufeff" + json.dumps({**base, "cwd": str(repo), **fields}) + + +def _run_cursor_hook(out: Path, repo: Path, payload: str) -> subprocess.CompletedProcess: + hooks = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8"))["hooks"] + command = hooks["preToolUse"][0]["command"].replace("${CURSOR_PLUGIN_ROOT}", str(out)) + return subprocess.run( + command, + cwd=repo, + shell=True, + env={**os.environ, "CURSOR_PLUGIN_ROOT": str(out)}, + capture_output=True, + text=True, + input=payload, + check=False, + ) + + +def test_the_cursor_package_gates_the_write_flat_and_reports_at_stop(gate_policy, tmp_path: Path) -> None: + """Cursor's hooks file is its own shape: flat entries, no matcher, no failClosed, `version: 1`.""" manifest = _manifest() out = tmp_path / "dist" / "cursor" / POLICY_ID cursor.build_cursor_plugin(gate_policy(manifest), manifest, tmp_path, out) + doc = json.loads((out / cursor.HOOKS_REL).read_text(encoding="utf-8")) + assert doc["version"] == 1 and set(doc["hooks"]) == {"preToolUse", "stop"} + for entries in doc["hooks"].values(): + assert set(entries[0]) == {"command", "timeout"}, "flat, unmatched, and never failClosed" description = json.loads((out / ".cursor-plugin" / "plugin.json").read_text(encoding="utf-8"))["description"] - assert cursor.POSTURE_ADVISORY in description + assert "PreToolUse and Stop hooks" in description and "follow-up message" in description + assert cursor.POSTURE_ADVISORY not in description + + +def test_the_cursor_package_runs_against_the_witnessed_payloads(gate_policy, tmp_path: Path) -> None: + """End to end through the bundled adapter: deny at preToolUse, followup_message at stop, once.""" + manifest = _manifest() + out = tmp_path / "dist" / "cursor" / POLICY_ID + cursor.build_cursor_plugin(gate_policy(manifest), manifest, tmp_path, out) + repo = tmp_path / "project" + repo.mkdir() + init_repo(repo) + + write = {"hook_event_name": "preToolUse", "tool_name": "Write"} + bad = _cursor_payload(repo, **write, tool_input={"file_path": str(repo / "A.java"), "content": "FORBIDDEN"}) + proc = _run_cursor_hook(out, repo, bad) + assert proc.returncode == 0, proc.stderr + assert json.loads(proc.stdout)["permission"] == "deny" + fine = _cursor_payload(repo, **write, tool_input={"file_path": str(repo / "A.java"), "content": "ok"}) + assert json.loads(_run_cursor_hook(out, repo, fine).stdout)["permission"] == "allow" + + (repo / "Leak.java").write_text("FORBIDDEN\n", encoding="utf-8") + stop = _run_cursor_hook(out, repo, _cursor_payload(repo, hook_event_name="stop", status="completed", loop_count=0)) + assert stop.returncode == 0, stop.stderr + assert "Leak.java" in json.loads(stop.stdout)["followup_message"] + again = _run_cursor_hook(out, repo, _cursor_payload(repo, hook_event_name="stop", status="completed", loop_count=1)) + assert again.returncode == 0 and again.stdout.strip() == "", "a follow-up that re-entered once is not sent twice" @pytest.mark.parametrize("store", sorted(STORES)) diff --git a/tests/test_stop_surface.py b/tests/test_stop_surface.py index 9c1e674..1ee0b36 100644 --- a/tests/test_stop_surface.py +++ b/tests/test_stop_surface.py @@ -71,7 +71,7 @@ def test_membership_is_the_matrix_answer_for_the_turn_end_event() -> None: assert sorted(a for a, s in SURFACE_AGENTS.items() if Surface.STOP in s) == expected -@pytest.mark.parametrize("agent", ["cursor", "grok", "windsurf"]) +@pytest.mark.parametrize("agent", ["grok", "windsurf"]) def test_an_agent_that_can_only_watch_a_turn_end_gets_no_stop_surface(agent: str) -> None: """`detect` is not `block`. A hook that cannot refuse would be a log line sold as a control.""" assert matrix.enforcement_level(CHOCK_AGENT[agent], contract.STOP) == "detect" @@ -79,6 +79,14 @@ def test_an_agent_that_can_only_watch_a_turn_end_gets_no_stop_surface(agent: str assert Surface.PRE_TOOL_USE in SURFACE_AGENTS[agent], "the pre-tool answer is unchanged" +def test_cursor_s_turn_end_is_a_follow_up_and_counts_as_a_stop_surface() -> None: + """Witnessed on 3.21.18: `stop` honours `followup_message`, so the turn is not held but the + agent is sent back to the refusal -- best-effort, and a surface worth wiring.""" + assert matrix.enforcement_level("cursor", contract.STOP) == "best-effort" + assert "cursor" in vendors.stop_vendors() + assert Surface.STOP in SURFACE_AGENTS["cursor"] + + @pytest.mark.parametrize("agent", ["copilot", "vscode"]) def test_the_owned_hooks_file_vendors_are_held_back_for_want_of_a_witness(agent: str) -> None: """They CAN refuse a finished turn. chock still cannot say which key to write.