|
| 1 | +"""The F3 `cursor` family: shape-inferred claims and the G4 permission-object dialect. |
| 2 | +
|
| 3 | +Shape inference stays code (dialect-families.md §3.3); everything word- or chain-shaped |
| 4 | +comes from the vendor's `data/vendors/cursor.json` entry. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import json as _json |
| 10 | + |
| 11 | +from ..contract import ( |
| 12 | + DENY, |
| 13 | + ESCALATE, |
| 14 | + FILE_CHANGED, |
| 15 | + POST_TOOL, |
| 16 | + PRE_TOOL, |
| 17 | + PROMPT_SUBMIT, |
| 18 | + TOOL_FAILURE, |
| 19 | + TRANSFORM, |
| 20 | + degraded_from, |
| 21 | +) |
| 22 | +from ._payload import hj_parse |
| 23 | + |
| 24 | +#: Wire names other vendors also spell this way; a payload naming one is claimed only on |
| 25 | +#: Cursor's own base-schema envelope markers. |
| 26 | +_AMBIGUOUS_NAMES = ( |
| 27 | + "preToolUse", |
| 28 | + "postToolUse", |
| 29 | + "sessionStart", |
| 30 | + "sessionEnd", |
| 31 | + "preCompact", |
| 32 | + "stop", |
| 33 | + "subagentStart", |
| 34 | + "subagentStop", |
| 35 | +) |
| 36 | + |
| 37 | +_MARKERS = ("conversation_id", "generation_id", "cursor_version", "workspace_roots") |
| 38 | + |
| 39 | + |
| 40 | +def cursor_wire(raw): |
| 41 | + """The wire event name, inferred from shape when the payload names none.""" |
| 42 | + name = raw.get("hook_event_name") |
| 43 | + if name is None: |
| 44 | + return "afterFileEdit" if isinstance(raw.get("edits"), list) else "beforeShellExecution" |
| 45 | + return name |
| 46 | + |
| 47 | + |
| 48 | +def cursor_claims(cfg, raw): |
| 49 | + """True when this payload looks like Cursor's shape.""" |
| 50 | + if not isinstance(raw, dict): |
| 51 | + return False |
| 52 | + name = raw.get("hook_event_name") |
| 53 | + if name in cfg["events"]: |
| 54 | + if name in _AMBIGUOUS_NAMES: |
| 55 | + return any(k in raw for k in _MARKERS) |
| 56 | + return True |
| 57 | + if isinstance(raw.get("command"), str) and ("sandbox" in raw or "cwd" in raw) and "tool_input" not in raw: |
| 58 | + return True |
| 59 | + return "file_path" in raw and isinstance(raw.get("edits"), list) and "tool_name" not in raw |
| 60 | + |
| 61 | + |
| 62 | +def cursor_parse(cfg, raw): |
| 63 | + name = cursor_wire(raw) |
| 64 | + event = hj_parse(cfg, raw, wire=name) |
| 65 | + event.tool = event.tool or name |
| 66 | + return event |
| 67 | + |
| 68 | + |
| 69 | +def _because(reason, note): |
| 70 | + """Keep the handler's own reason and add why the outcome changed shape.""" |
| 71 | + return "%s (%s)" % (reason, note) if reason else note |
| 72 | + |
| 73 | + |
| 74 | +def _wire_of(cfg, event): |
| 75 | + """The wire name to answer at: the payload's own, `tool` where `parse` kept it there, |
| 76 | + else the entry's default gate.""" |
| 77 | + name = (event.raw or {}).get("hook_event_name") |
| 78 | + if name in cfg["events"]: |
| 79 | + return name |
| 80 | + return event.tool if event.tool in cfg["events"] else cfg["verdicts"].get("default_wire_event") |
| 81 | + |
| 82 | + |
| 83 | +def cursor_respond(cfg, decision, event): |
| 84 | + v = cfg["verdicts"] |
| 85 | + name = _wire_of(cfg, event) |
| 86 | + canonical = cfg["events"].get(name) |
| 87 | + |
| 88 | + if canonical == FILE_CHANGED: |
| 89 | + return "", 0 |
| 90 | + |
| 91 | + if canonical in (POST_TOOL, TOOL_FAILURE): |
| 92 | + if decision.outcome in (DENY, ESCALATE): |
| 93 | + note = v["flag_note"] % (name, decision.reason or v["flag_note_default"]) |
| 94 | + return _json.dumps({"additional_context": note}), 0 |
| 95 | + return "", 0 |
| 96 | + |
| 97 | + if canonical == PROMPT_SUBMIT: |
| 98 | + payload = {"continue": decision.outcome not in (DENY, ESCALATE, TRANSFORM)} |
| 99 | + if decision.reason: |
| 100 | + payload["user_message"] = decision.reason |
| 101 | + return _json.dumps(payload), 0 |
| 102 | + |
| 103 | + gate = v["gates"].get(name) |
| 104 | + if gate is None or canonical != PRE_TOOL: |
| 105 | + return "", 0 |
| 106 | + |
| 107 | + words = v["words"] |
| 108 | + notes = v["degrade_notes"] |
| 109 | + reason = decision.reason |
| 110 | + |
| 111 | + if decision.outcome == TRANSFORM: |
| 112 | + if gate["honours_transform"] and decision.updated_input is not None: |
| 113 | + payload = {"permission": words["allow"], "updated_input": decision.updated_input} |
| 114 | + else: |
| 115 | + payload = {"permission": words["block"]} |
| 116 | + reason = _because(reason, notes["transform"]) |
| 117 | + elif decision.outcome == DENY: |
| 118 | + payload = {"permission": words["block"]} |
| 119 | + elif decision.outcome == ESCALATE: |
| 120 | + if gate["honours_escalate"]: |
| 121 | + payload = {"permission": words["escalate"]} |
| 122 | + else: |
| 123 | + note = notes["escalate_from_transform"] if degraded_from(decision) == TRANSFORM else notes["escalate"] |
| 124 | + payload = {"permission": words["block"]} |
| 125 | + reason = _because(reason, note % name) |
| 126 | + else: |
| 127 | + payload = {"permission": words["allow"]} |
| 128 | + |
| 129 | + if reason and payload["permission"] != words["allow"]: |
| 130 | + payload["user_message"] = reason |
| 131 | + payload["agent_message"] = reason |
| 132 | + return _json.dumps(payload), 0 |
0 commit comments