polyhook 0.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyhook
3
+ Version: 0.1.1
4
+ Summary: polyhook Python SDK — write AI coding agent hooks once, run them everywhere
5
+ Author: tupe12334
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tupe12334/polyhook
8
+ Project-URL: Repository, https://github.com/tupe12334/polyhook
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: wasmtime>=20.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
15
+
16
+ # polyhook — Python SDK
17
+
18
+ **Write AI coding agent hooks once. Run them everywhere.**
19
+
20
+ polyhook detects which AI coding tool invoked your hook binary, deserializes the event into a normalized struct, and serializes your response back in the format that tool expects. Your hook runs unchanged whether Claude Code, Cursor, Windsurf, Cline, or Amp invoked it.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install polyhook
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```python
31
+ import sys
32
+ import re
33
+ import polyhook
34
+
35
+ event = polyhook.read()
36
+
37
+ if (
38
+ event.tool == "bash"
39
+ and re.search(r"rm\s+-rf\s+/", event.input.get("command", "") if event.input else "")
40
+ ):
41
+ polyhook.respond(polyhook.block("Refusing to delete from root"))
42
+ else:
43
+ polyhook.respond(polyhook.approve())
44
+ ```
45
+
46
+ More examples: [examples/](examples/)
47
+
48
+ ## Supported Tools
49
+
50
+ | Tool | Status |
51
+ |---|---|
52
+ | [Claude Code](https://claude.ai/code) | ✅ Supported |
53
+ | [Cursor](https://cursor.com) | ✅ Supported |
54
+ | [Windsurf](https://windsurf.ai) | ✅ Supported |
55
+ | [Cline](https://github.com/cline/cline) | ✅ Supported |
56
+ | [Amp](https://ampcode.com) | ✅ Supported |
57
+ | [Continue](https://continue.dev) | 🚧 In progress |
58
+ | [Aider](https://aider.chat) | 🚧 In progress |
59
+ | [Copilot](https://github.com/features/copilot) | 📋 Planned |
60
+
61
+ ## Documentation
62
+
63
+ Full docs and API reference: <https://github.com/tupe12334/polyhook>
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,52 @@
1
+ # polyhook — Python SDK
2
+
3
+ **Write AI coding agent hooks once. Run them everywhere.**
4
+
5
+ polyhook detects which AI coding tool invoked your hook binary, deserializes the event into a normalized struct, and serializes your response back in the format that tool expects. Your hook runs unchanged whether Claude Code, Cursor, Windsurf, Cline, or Amp invoked it.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install polyhook
11
+ ```
12
+
13
+ ## Quick Start
14
+
15
+ ```python
16
+ import sys
17
+ import re
18
+ import polyhook
19
+
20
+ event = polyhook.read()
21
+
22
+ if (
23
+ event.tool == "bash"
24
+ and re.search(r"rm\s+-rf\s+/", event.input.get("command", "") if event.input else "")
25
+ ):
26
+ polyhook.respond(polyhook.block("Refusing to delete from root"))
27
+ else:
28
+ polyhook.respond(polyhook.approve())
29
+ ```
30
+
31
+ More examples: [examples/](examples/)
32
+
33
+ ## Supported Tools
34
+
35
+ | Tool | Status |
36
+ |---|---|
37
+ | [Claude Code](https://claude.ai/code) | ✅ Supported |
38
+ | [Cursor](https://cursor.com) | ✅ Supported |
39
+ | [Windsurf](https://windsurf.ai) | ✅ Supported |
40
+ | [Cline](https://github.com/cline/cline) | ✅ Supported |
41
+ | [Amp](https://ampcode.com) | ✅ Supported |
42
+ | [Continue](https://continue.dev) | 🚧 In progress |
43
+ | [Aider](https://aider.chat) | 🚧 In progress |
44
+ | [Copilot](https://github.com/features/copilot) | 📋 Planned |
45
+
46
+ ## Documentation
47
+
48
+ Full docs and API reference: <https://github.com/tupe12334/polyhook>
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "polyhook"
7
+ version = "0.1.1"
8
+ description = "polyhook Python SDK — write AI coding agent hooks once, run them everywhere"
9
+ license = {text = "MIT"}
10
+ authors = [{name = "tupe12334"}]
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ dependencies = ["wasmtime>=20.0.0"]
14
+
15
+ [project.urls]
16
+ Homepage = "https://github.com/tupe12334/polyhook"
17
+ Repository = "https://github.com/tupe12334/polyhook"
18
+
19
+ [tool.pytest.ini_options]
20
+ testpaths = ["src/polyhook"]
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+
25
+ [tool.setuptools.package-data]
26
+ polyhook = ["polyhook.wasm"]
27
+
28
+ [project.optional-dependencies]
29
+ dev = [
30
+ "pytest>=8.0",
31
+ "pytest-cov>=5.0",
32
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ from .sdk import read, respond, approve, block, modify, HookEvent, HookResponse
2
+ from .generated_models import CallerKind, ApproveResponse, BlockResponse, ModifyResponse
@@ -0,0 +1,60 @@
1
+ # generated by datamodel-codegen:
2
+ # filename: schema.json
3
+ # timestamp: 2026-06-01T22:50:42+00:00
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from enum import Enum
9
+ from typing import Any, Literal, Optional, TypeAlias, Union
10
+
11
+ PolyhookSchema: TypeAlias = Any
12
+
13
+
14
+ class CallerKind(Enum):
15
+ claude_code = 'claude-code'
16
+ cursor = 'cursor'
17
+ windsurf = 'windsurf'
18
+ cline = 'cline'
19
+ amp = 'amp'
20
+ unknown = 'unknown'
21
+
22
+
23
+ class Event(Enum):
24
+ tool_before = 'tool:before'
25
+ tool_after = 'tool:after'
26
+ session_start = 'session:start'
27
+ session_stop = 'session:stop'
28
+ agent_stop = 'agent:stop'
29
+ notification = 'notification'
30
+
31
+
32
+ @dataclass
33
+ class HookEvent:
34
+ event: Event
35
+ sessionId: str
36
+ caller: CallerKind
37
+ tool: Optional[str] = None
38
+ input: Optional[dict[str, Any]] = None
39
+ output: Optional[dict[str, Any]] = None
40
+ agentId: Optional[str] = None
41
+
42
+
43
+ @dataclass
44
+ class ApproveResponse:
45
+ action: Literal['approve']
46
+
47
+
48
+ @dataclass
49
+ class BlockResponse:
50
+ action: Literal['block']
51
+ message: str
52
+
53
+
54
+ @dataclass
55
+ class ModifyResponse:
56
+ action: Literal['modify']
57
+ input: dict[str, Any]
58
+
59
+
60
+ HookResponse: TypeAlias = Union[ApproveResponse, BlockResponse, ModifyResponse]
@@ -0,0 +1,214 @@
1
+ """
2
+ polyhook Python SDK — wraps polyhook.wasm via wasmtime-py.
3
+
4
+ Typical usage::
5
+
6
+ import polyhook
7
+
8
+ event = polyhook.read() # parse stdin → HookEvent
9
+ if event.tool == "bash":
10
+ polyhook.respond(polyhook.block("not allowed"))
11
+ else:
12
+ polyhook.respond(polyhook.approve())
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import sys
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Optional
22
+
23
+ from .generated_models import HookResponse, CallerKind
24
+
25
+
26
+ @dataclass
27
+ class HookEvent:
28
+ """Normalised hook event with Python-conventional snake_case field names."""
29
+ event: str
30
+ tool: Optional[str]
31
+ input: Optional[dict[str, Any]]
32
+ output: Optional[dict[str, Any]]
33
+ session_id: str
34
+ agent_id: Optional[str]
35
+ caller: str
36
+
37
+
38
+ # Re-export for backwards compatibility
39
+ __all__ = ["HookEvent", "HookResponse", "CallerKind"]
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Convenience constructors
43
+ # ---------------------------------------------------------------------------
44
+
45
+ def approve() -> HookResponse:
46
+ """Return an *approve* response (allow the action unchanged)."""
47
+ return {"action": "approve"}
48
+
49
+
50
+ def block(message: str) -> HookResponse:
51
+ """Return a *block* response (reject the action with *message*)."""
52
+ return {"action": "block", "message": message}
53
+
54
+
55
+ def modify(input: dict[str, Any]) -> HookResponse:
56
+ """Return a *modify* response (replace tool input with *input*)."""
57
+ return {"action": "modify", "input": input}
58
+
59
+
60
+ # ---------------------------------------------------------------------------
61
+ # Internal WASM state (lazy-initialised on first read() call)
62
+ # ---------------------------------------------------------------------------
63
+
64
+ _store: Any = None
65
+ _instance: Any = None
66
+ _memory: Any = None
67
+ _last_caller: str = "unknown"
68
+
69
+
70
+ def _init_wasm() -> None:
71
+ """Lazily initialise the wasmtime Store / Instance from polyhook.wasm."""
72
+ global _store, _instance, _memory
73
+
74
+ if _instance is not None:
75
+ return
76
+
77
+ wasm_path = Path(__file__).parent / "polyhook.wasm"
78
+ if not wasm_path.exists():
79
+ raise ImportError(
80
+ f"polyhook.wasm not found at {wasm_path}.\n"
81
+ "Build the WASM module first (e.g. `cargo build --target wasm32-unknown-unknown --release`) "
82
+ "and copy polyhook.wasm into the package directory alongside sdk.py."
83
+ )
84
+
85
+ try:
86
+ from wasmtime import Instance, Module, Store
87
+ except ImportError as exc:
88
+ raise ImportError(
89
+ "wasmtime is required to use the polyhook SDK. "
90
+ "Install it with: pip install wasmtime"
91
+ ) from exc
92
+
93
+ _store = Store()
94
+ module = Module.from_file(_store.engine, str(wasm_path))
95
+ _instance = Instance(_store, module, [])
96
+ _memory = _instance.exports(_store)["memory"]
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Low-level WASM helpers
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def _alloc(n: int) -> int:
104
+ return _instance.exports(_store)["alloc"](_store, n)
105
+
106
+
107
+ def _dealloc(ptr: int, n: int) -> None:
108
+ _instance.exports(_store)["dealloc"](_store, ptr, n)
109
+
110
+
111
+ def _parse(ptr: int, n: int) -> int:
112
+ return _instance.exports(_store)["parse"](_store, ptr, n)
113
+
114
+
115
+ def _serialize(ptr: int, n: int) -> int:
116
+ return _instance.exports(_store)["serialize"](_store, ptr, n)
117
+
118
+
119
+ def _write_to_wasm(data: bytes) -> tuple[int, int]:
120
+ """Copy *data* into WASM linear memory and return ``(ptr, len)``."""
121
+ ptr = _alloc(len(data))
122
+ _memory.write(_store, data, ptr)
123
+ return ptr, len(data)
124
+
125
+
126
+ def _read_from_wasm(ptr: int) -> bytes:
127
+ """Read a length-prefixed payload from WASM linear memory.
128
+
129
+ Memory layout::
130
+
131
+ offset 0 4 4+len
132
+ ┌─────────────┬──────────────────┐
133
+ │ len (i32 LE)│ payload (UTF-8) │
134
+ └─────────────┴──────────────────┘
135
+ """
136
+ length_bytes = bytes(_memory.read(_store, ptr, ptr + 4))
137
+ length = int.from_bytes(length_bytes, "little")
138
+ payload = bytes(_memory.read(_store, ptr + 4, ptr + 4 + length))
139
+ return payload
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Public API
144
+ # ---------------------------------------------------------------------------
145
+
146
+ def read() -> HookEvent:
147
+ """Read the hook event from *stdin* and return a :class:`HookEvent`.
148
+
149
+ This must be called exactly once per invocation, before :func:`respond`.
150
+ The WASM module is initialised on the first call.
151
+ """
152
+ global _last_caller
153
+
154
+ _init_wasm()
155
+
156
+ stdin_bytes: bytes = sys.stdin.buffer.read()
157
+
158
+ # Write stdin into WASM memory, call parse, then free the input buffer.
159
+ in_ptr, in_len = _write_to_wasm(stdin_bytes)
160
+ result_ptr = _parse(in_ptr, in_len)
161
+ _dealloc(in_ptr, in_len)
162
+
163
+ # Read the length-prefixed result, then free the result buffer.
164
+ payload = _read_from_wasm(result_ptr)
165
+ payload_len = int.from_bytes(bytes(_memory.read(_store, result_ptr, result_ptr + 4)), "little")
166
+ _dealloc(result_ptr, 4 + payload_len)
167
+
168
+ data: dict[str, Any] = json.loads(payload)
169
+
170
+ # Surface WASM-level parse errors as Python exceptions.
171
+ if "error" in data:
172
+ raise ValueError(
173
+ f"polyhook.wasm parse error: {data['error']}"
174
+ + (f" (raw: {data.get('raw', '')})" if "raw" in data else "")
175
+ )
176
+
177
+ # Map camelCase JSON keys to snake_case dataclass fields.
178
+ caller = data.get("caller", "unknown")
179
+ _last_caller = caller
180
+
181
+ return HookEvent(
182
+ event=data["event"],
183
+ tool=data.get("tool"),
184
+ input=data.get("input"),
185
+ output=data.get("output"),
186
+ session_id=data["sessionId"],
187
+ agent_id=data.get("agentId"),
188
+ caller=caller,
189
+ )
190
+
191
+
192
+ def respond(r: HookResponse) -> None:
193
+ """Serialize *r* via the WASM module and write the result to *stdout*.
194
+
195
+ Must be called after :func:`read` (the WASM module uses caller-detection
196
+ state set during the preceding ``parse`` call to select the correct output
197
+ format).
198
+ """
199
+ _init_wasm()
200
+
201
+ response_json: bytes = json.dumps(r, separators=(",", ":")).encode("utf-8")
202
+
203
+ # Write the response into WASM memory, call serialize, free the input.
204
+ in_ptr, in_len = _write_to_wasm(response_json)
205
+ out_ptr = _serialize(in_ptr, in_len)
206
+ _dealloc(in_ptr, in_len)
207
+
208
+ # Read the length-prefixed output, then free the result buffer.
209
+ out_bytes = _read_from_wasm(out_ptr)
210
+ out_len = int.from_bytes(bytes(_memory.read(_store, out_ptr, out_ptr + 4)), "little")
211
+ _dealloc(out_ptr, 4 + out_len)
212
+
213
+ sys.stdout.buffer.write(out_bytes)
214
+ sys.stdout.buffer.flush()
@@ -0,0 +1,600 @@
1
+ """
2
+ Unit tests for the polyhook Python SDK.
3
+
4
+ The WASM module is mocked out so tests run without a real polyhook.wasm.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import builtins
10
+ import io
11
+ import json
12
+ import struct
13
+ import sys
14
+ import types
15
+ from dataclasses import dataclass
16
+ from typing import Any
17
+ from unittest.mock import MagicMock, patch
18
+
19
+ import pytest
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Helpers for building mock WASM memory payloads
24
+ # ---------------------------------------------------------------------------
25
+
26
+ def _length_prefix(payload: bytes) -> bytes:
27
+ """Produce a 4-byte LE length prefix followed by *payload*."""
28
+ return struct.pack("<I", len(payload)) + payload
29
+
30
+
31
+ def _json_payload(obj: Any) -> bytes:
32
+ return json.dumps(obj, separators=(",", ":")).encode()
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Fixtures
37
+ # ---------------------------------------------------------------------------
38
+
39
+ @pytest.fixture(autouse=True)
40
+ def reset_wasm_state():
41
+ """Reset module-level WASM singletons before every test."""
42
+ import polyhook.sdk as sdk
43
+ sdk._store = None
44
+ sdk._instance = None
45
+ sdk._memory = None
46
+ sdk._last_caller = "unknown"
47
+ yield
48
+ sdk._store = None
49
+ sdk._instance = None
50
+ sdk._memory = None
51
+ sdk._last_caller = "unknown"
52
+
53
+
54
+ def _make_wasm_mock(parse_response: Any, serialize_response: bytes | None = None) -> tuple[MagicMock, MagicMock]:
55
+ """
56
+ Build a (store_mock, instance_mock) pair whose exports behave like a real
57
+ WASM instance for a single round-trip.
58
+
59
+ *parse_response* — Python object to return from ``parse`` (will be JSON-
60
+ serialised and length-prefixed into a fake memory region).
61
+ *serialize_response* — raw bytes the fake ``serialize`` call returns
62
+ (defaults to b'{"action":"approve"}').
63
+ """
64
+ if serialize_response is None:
65
+ serialize_response = b'{"action":"approve"}'
66
+
67
+ parse_blob = _length_prefix(_json_payload(parse_response))
68
+ serialize_blob = _length_prefix(serialize_response)
69
+
70
+ # Shared fake memory: we place parse result at offset 0x1000
71
+ # and serialize result at offset 0x2000 so they don't overlap.
72
+ PARSE_PTR = 0x1000
73
+ SERIALIZE_PTR = 0x2000
74
+
75
+ fake_mem: dict[tuple[int, int], bytes] = {
76
+ (PARSE_PTR, PARSE_PTR + len(parse_blob)): parse_blob,
77
+ (SERIALIZE_PTR, SERIALIZE_PTR + len(serialize_blob)): serialize_blob,
78
+ }
79
+
80
+ class FakeMemory:
81
+ def write(self, store, data: bytes, offset: int) -> None:
82
+ pass # accept input writes
83
+
84
+ def read(self, store, start: int, end: int) -> bytes:
85
+ key = (start, end)
86
+ if key in fake_mem:
87
+ return fake_mem[key]
88
+ # Allow reading individual bytes for length prefix
89
+ for (s, e), blob in fake_mem.items():
90
+ if s <= start and end <= e:
91
+ off = start - s
92
+ return blob[off : off + (end - start)]
93
+ return b"\x00" * (end - start)
94
+
95
+ alloc_counter = {"next": 0x100}
96
+
97
+ def fake_alloc(store, n):
98
+ ptr = alloc_counter["next"]
99
+ alloc_counter["next"] += n
100
+ return ptr
101
+
102
+ def fake_dealloc(store, ptr, n):
103
+ pass
104
+
105
+ call_count = {"parse": 0, "serialize": 0}
106
+
107
+ def fake_parse(store, ptr, n):
108
+ call_count["parse"] += 1
109
+ return PARSE_PTR
110
+
111
+ def fake_serialize(store, ptr, n):
112
+ call_count["serialize"] += 1
113
+ return SERIALIZE_PTR
114
+
115
+ exports_map = {
116
+ "memory": FakeMemory(),
117
+ "alloc": fake_alloc,
118
+ "dealloc": fake_dealloc,
119
+ "parse": fake_parse,
120
+ "serialize": fake_serialize,
121
+ }
122
+
123
+ instance_mock = MagicMock()
124
+ instance_mock.exports.return_value = exports_map
125
+
126
+ store_mock = MagicMock()
127
+
128
+ return store_mock, instance_mock, call_count
129
+
130
+
131
+ def _patch_wasm(parse_response: Any, serialize_response: bytes | None = None):
132
+ """Context manager that replaces WASM internals with mock objects."""
133
+ import polyhook.sdk as sdk
134
+
135
+ store_mock, instance_mock, call_count = _make_wasm_mock(parse_response, serialize_response)
136
+
137
+ sdk._store = store_mock
138
+ sdk._instance = instance_mock
139
+ sdk._memory = instance_mock.exports(store_mock)["memory"]
140
+ return call_count
141
+
142
+
143
+ # ---------------------------------------------------------------------------
144
+ # HookEvent / convenience constructor tests (no WASM needed)
145
+ # ---------------------------------------------------------------------------
146
+
147
+ class TestConvenienceConstructors:
148
+ def test_approve(self):
149
+ from polyhook import approve
150
+ r = approve()
151
+ assert r == {"action": "approve"}
152
+
153
+ def test_block(self):
154
+ from polyhook import block
155
+ r = block("dangerous command")
156
+ assert r == {"action": "block", "message": "dangerous command"}
157
+
158
+ def test_modify(self):
159
+ from polyhook import modify
160
+ new_input = {"command": "ls -la /tmp"}
161
+ r = modify(new_input)
162
+ assert r == {"action": "modify", "input": new_input}
163
+
164
+ def test_block_empty_message(self):
165
+ from polyhook import block
166
+ r = block("")
167
+ assert r["action"] == "block"
168
+ assert r["message"] == ""
169
+
170
+ def test_modify_nested(self):
171
+ from polyhook import modify
172
+ r = modify({"key": {"nested": [1, 2, 3]}})
173
+ assert r["input"]["key"]["nested"] == [1, 2, 3]
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # HookEvent dataclass tests
178
+ # ---------------------------------------------------------------------------
179
+
180
+ class TestHookEvent:
181
+ def test_fields_present(self):
182
+ from polyhook.sdk import HookEvent
183
+ ev = HookEvent(
184
+ event="tool:before",
185
+ tool="bash",
186
+ input={"command": "ls"},
187
+ output=None,
188
+ session_id="sess_abc",
189
+ agent_id="agent_xyz",
190
+ caller="claude-code",
191
+ )
192
+ assert ev.event == "tool:before"
193
+ assert ev.tool == "bash"
194
+ assert ev.input == {"command": "ls"}
195
+ assert ev.output is None
196
+ assert ev.session_id == "sess_abc"
197
+ assert ev.agent_id == "agent_xyz"
198
+ assert ev.caller == "claude-code"
199
+
200
+ def test_optional_fields_none(self):
201
+ from polyhook.sdk import HookEvent
202
+ ev = HookEvent(
203
+ event="session:start",
204
+ tool=None,
205
+ input=None,
206
+ output=None,
207
+ session_id="sess_001",
208
+ agent_id=None,
209
+ caller="cursor",
210
+ )
211
+ assert ev.tool is None
212
+ assert ev.input is None
213
+ assert ev.agent_id is None
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # read() tests
218
+ # ---------------------------------------------------------------------------
219
+
220
+ class TestRead:
221
+ def _event_dict(self, **overrides):
222
+ base = {
223
+ "event": "tool:before",
224
+ "tool": "bash",
225
+ "input": {"command": "echo hi"},
226
+ "output": None,
227
+ "sessionId": "sess_123",
228
+ "agentId": "agent_abc",
229
+ "caller": "claude-code",
230
+ }
231
+ base.update(overrides)
232
+ return base
233
+
234
+ def test_basic_parse(self):
235
+ from polyhook import read
236
+ _patch_wasm(self._event_dict())
237
+ fake_stdin = b'{"hook_event_type": "PreToolUse"}'
238
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(fake_stdin))):
239
+ event = read()
240
+ assert event.event == "tool:before"
241
+ assert event.tool == "bash"
242
+ assert event.input == {"command": "echo hi"}
243
+ assert event.session_id == "sess_123"
244
+ assert event.agent_id == "agent_abc"
245
+ assert event.caller == "claude-code"
246
+
247
+ def test_optional_fields_absent(self):
248
+ from polyhook import read
249
+ d = self._event_dict(tool=None, input=None, agentId=None)
250
+ del d["tool"]
251
+ del d["input"]
252
+ del d["agentId"]
253
+ _patch_wasm(d)
254
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(b"{}"))):
255
+ event = read()
256
+ assert event.tool is None
257
+ assert event.input is None
258
+ assert event.agent_id is None
259
+
260
+ def test_parse_error_raises_value_error(self):
261
+ from polyhook import read
262
+ _patch_wasm({"error": "unknown caller", "raw": "{}"})
263
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(b"{}"))):
264
+ with pytest.raises(ValueError, match="polyhook.wasm parse error: unknown caller"):
265
+ read()
266
+
267
+ def test_caller_stored(self):
268
+ from polyhook import read
269
+ import polyhook.sdk as sdk
270
+ _patch_wasm(self._event_dict(caller="windsurf"))
271
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(b"{}"))):
272
+ read()
273
+ assert sdk._last_caller == "windsurf"
274
+
275
+ def test_tool_after_event(self):
276
+ from polyhook import read
277
+ d = self._event_dict(
278
+ event="tool:after",
279
+ tool="write_file",
280
+ input=None,
281
+ output={"success": True},
282
+ )
283
+ _patch_wasm(d)
284
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(b"{}"))):
285
+ event = read()
286
+ assert event.event == "tool:after"
287
+ assert event.output == {"success": True}
288
+
289
+ def test_session_start_event(self):
290
+ from polyhook import read
291
+ d = {
292
+ "event": "session:start",
293
+ "tool": None,
294
+ "input": None,
295
+ "output": None,
296
+ "sessionId": "sess_new",
297
+ "caller": "amp",
298
+ }
299
+ _patch_wasm(d)
300
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(b"{}"))):
301
+ event = read()
302
+ assert event.event == "session:start"
303
+ assert event.tool is None
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # respond() tests
308
+ # ---------------------------------------------------------------------------
309
+
310
+ class TestRespond:
311
+ def _run_respond(self, response_obj: Any, wasm_output: bytes = b'{"ok":true}') -> bytes:
312
+ """Run respond() and return what was written to stdout."""
313
+ from polyhook import respond
314
+ _patch_wasm(
315
+ parse_response={"event": "tool:before", "sessionId": "s", "caller": "c"},
316
+ serialize_response=wasm_output,
317
+ )
318
+ # respond() writes to sys.stdout.buffer, so patch that directly.
319
+ buf = io.BytesIO()
320
+ mock_stdout = MagicMock()
321
+ mock_stdout.buffer = buf
322
+ with patch("sys.stdout", mock_stdout):
323
+ respond(response_obj)
324
+ return buf.getvalue()
325
+
326
+ def test_approve_written(self):
327
+ from polyhook import approve
328
+ output = self._run_respond(approve(), b'{"action":"approve"}')
329
+ assert output == b'{"action":"approve"}'
330
+
331
+ def test_block_written(self):
332
+ from polyhook import block
333
+ output = self._run_respond(block("stop it"), b'{"decision":"block","reason":"stop it"}')
334
+ assert output == b'{"decision":"block","reason":"stop it"}'
335
+
336
+ def test_modify_written(self):
337
+ from polyhook import modify
338
+ output = self._run_respond(
339
+ modify({"command": "ls /tmp"}),
340
+ b'{"decision":"modify","input":{"command":"ls /tmp"}}',
341
+ )
342
+ assert b"modify" in output
343
+
344
+ def test_serialize_called_once(self):
345
+ """Verify that the serialize WASM export is called exactly once."""
346
+ import polyhook.sdk as sdk
347
+ from polyhook import approve, respond
348
+
349
+ store_mock, instance_mock, call_count = _make_wasm_mock(
350
+ {"event": "tool:before", "sessionId": "s", "caller": "c"},
351
+ b'{"action":"approve"}',
352
+ )
353
+ sdk._store = store_mock
354
+ sdk._instance = instance_mock
355
+ sdk._memory = instance_mock.exports(store_mock)["memory"]
356
+
357
+ buf = io.BytesIO()
358
+ mock_stdout = MagicMock()
359
+ mock_stdout.buffer = buf
360
+ with patch("sys.stdout", mock_stdout):
361
+ respond(approve())
362
+
363
+ assert call_count["serialize"] == 1
364
+
365
+ def test_respond_json_encoded(self):
366
+ """Ensure respond() JSON-encodes the dict before passing to WASM."""
367
+ import polyhook.sdk as sdk
368
+
369
+ captured_input: list[bytes] = []
370
+
371
+ original_write = sdk._write_to_wasm
372
+
373
+ def spy_write(data: bytes):
374
+ captured_input.append(data)
375
+ return original_write(data)
376
+
377
+ _patch_wasm(
378
+ {"event": "tool:before", "sessionId": "s", "caller": "c"},
379
+ b'{"action":"approve"}',
380
+ )
381
+
382
+ buf = io.BytesIO()
383
+ mock_stdout = MagicMock()
384
+ mock_stdout.buffer = buf
385
+ with patch.object(sdk, "_write_to_wasm", side_effect=spy_write):
386
+ with patch("sys.stdout", mock_stdout):
387
+ from polyhook import approve, respond
388
+ respond(approve())
389
+
390
+ # At least one write should be valid JSON
391
+ assert any(json.loads(d) is not None for d in captured_input)
392
+
393
+
394
+ # ---------------------------------------------------------------------------
395
+ # Missing WASM file test
396
+ # ---------------------------------------------------------------------------
397
+
398
+ class TestMissingWasm:
399
+ def test_missing_wasm_raises_import_error(self):
400
+ import polyhook.sdk as sdk
401
+
402
+ # Ensure state is clean
403
+ sdk._store = None
404
+ sdk._instance = None
405
+ sdk._memory = None
406
+
407
+ # Build a fake path object whose .exists() returns False.
408
+ fake_wasm_path = MagicMock()
409
+ fake_wasm_path.exists.return_value = False
410
+ fake_wasm_path.__str__ = MagicMock(return_value="/fake/path/polyhook.wasm")
411
+
412
+ # The code does: Path(__file__).parent / "polyhook.wasm"
413
+ # We need to intercept that division so it returns our fake path.
414
+ fake_parent = MagicMock()
415
+ fake_parent.__truediv__ = MagicMock(return_value=fake_wasm_path)
416
+
417
+ fake_path_instance = MagicMock()
418
+ fake_path_instance.parent = fake_parent
419
+
420
+ def fake_path_constructor(*args):
421
+ return fake_path_instance
422
+
423
+ with patch("polyhook.sdk.Path", side_effect=fake_path_constructor):
424
+ with pytest.raises(ImportError, match="polyhook.wasm not found"):
425
+ sdk._init_wasm()
426
+
427
+
428
+ # ---------------------------------------------------------------------------
429
+ # _init_wasm() specific path tests
430
+ # ---------------------------------------------------------------------------
431
+
432
+ class TestInitWasm:
433
+ def test_early_return_when_instance_already_set(self):
434
+ """_init_wasm() must return immediately when _instance is already set."""
435
+ import polyhook.sdk as sdk
436
+
437
+ sentinel = object()
438
+ sdk._instance = sentinel # pre-set so _init_wasm should bail out
439
+
440
+ # If _init_wasm does NOT early-return it will try to import wasmtime
441
+ # or check the wasm file; both would fail/have side-effects.
442
+ # Patching Path to guarantee a failure if the early-return is skipped.
443
+ with patch("polyhook.sdk.Path") as mock_path:
444
+ sdk._init_wasm()
445
+ # Path should never have been called — we returned before reaching it.
446
+ mock_path.assert_not_called()
447
+
448
+ # _instance must still be the sentinel we set.
449
+ assert sdk._instance is sentinel
450
+
451
+ def test_happy_path_with_mocked_wasmtime(self):
452
+ """_init_wasm() happy path: file exists + wasmtime importable."""
453
+ import polyhook.sdk as sdk
454
+
455
+ # Start from a clean state (autouse fixture already does this, but be explicit).
456
+ sdk._store = None
457
+ sdk._instance = None
458
+ sdk._memory = None
459
+
460
+ # --- Fake wasmtime objects ----------------------------------------
461
+ fake_memory = MagicMock(name="memory")
462
+
463
+ fake_exports = {"memory": fake_memory}
464
+
465
+ fake_instance = MagicMock(name="Instance")
466
+ fake_instance.exports.return_value = fake_exports
467
+
468
+ fake_store = MagicMock(name="Store")
469
+
470
+ fake_module = MagicMock(name="Module")
471
+
472
+ FakeStore = MagicMock(return_value=fake_store)
473
+ FakeModule = MagicMock(name="Module_class")
474
+ FakeModule.from_file.return_value = fake_module
475
+ FakeInstance = MagicMock(return_value=fake_instance)
476
+
477
+ # --- Fake wasmtime module -----------------------------------------
478
+ fake_wasmtime_module = types.ModuleType("wasmtime")
479
+ fake_wasmtime_module.Store = FakeStore
480
+ fake_wasmtime_module.Module = FakeModule
481
+ fake_wasmtime_module.Instance = FakeInstance
482
+
483
+ # --- Fake path that reports the file exists -----------------------
484
+ fake_wasm_path = MagicMock()
485
+ fake_wasm_path.exists.return_value = True
486
+ fake_wasm_path.__str__ = MagicMock(return_value="/fake/polyhook.wasm")
487
+
488
+ fake_parent = MagicMock()
489
+ fake_parent.__truediv__ = MagicMock(return_value=fake_wasm_path)
490
+
491
+ fake_path_instance = MagicMock()
492
+ fake_path_instance.parent = fake_parent
493
+
494
+ def fake_path_constructor(*args):
495
+ return fake_path_instance
496
+
497
+ with patch("polyhook.sdk.Path", side_effect=fake_path_constructor):
498
+ with patch.dict(sys.modules, {"wasmtime": fake_wasmtime_module}):
499
+ sdk._init_wasm()
500
+
501
+ # After a successful _init_wasm the globals must be populated.
502
+ assert sdk._store is fake_store
503
+ assert sdk._instance is fake_instance
504
+ assert sdk._memory is fake_memory
505
+
506
+ # Verify the wasmtime objects were constructed correctly.
507
+ FakeStore.assert_called_once_with()
508
+ FakeModule.from_file.assert_called_once_with(
509
+ fake_store.engine, "/fake/polyhook.wasm"
510
+ )
511
+ FakeInstance.assert_called_once_with(fake_store, fake_module, [])
512
+
513
+ def test_wasmtime_import_error(self):
514
+ """_init_wasm() raises ImportError with a helpful message when wasmtime is absent."""
515
+ import polyhook.sdk as sdk
516
+
517
+ sdk._store = None
518
+ sdk._instance = None
519
+ sdk._memory = None
520
+
521
+ # Fake path that claims the file exists.
522
+ fake_wasm_path = MagicMock()
523
+ fake_wasm_path.exists.return_value = True
524
+ fake_wasm_path.__str__ = MagicMock(return_value="/fake/polyhook.wasm")
525
+
526
+ fake_parent = MagicMock()
527
+ fake_parent.__truediv__ = MagicMock(return_value=fake_wasm_path)
528
+
529
+ fake_path_instance = MagicMock()
530
+ fake_path_instance.parent = fake_parent
531
+
532
+ def fake_path_constructor(*args):
533
+ return fake_path_instance
534
+
535
+ # Remove wasmtime from sys.modules so "import wasmtime" inside
536
+ # _init_wasm raises ImportError naturally.
537
+ cleaned_modules = {k: v for k, v in sys.modules.items() if k != "wasmtime"}
538
+
539
+ original_import = builtins.__import__
540
+
541
+ def blocking_import(name, *args, **kwargs):
542
+ if name == "wasmtime":
543
+ raise ImportError("No module named 'wasmtime'")
544
+ return original_import(name, *args, **kwargs)
545
+
546
+ with patch("polyhook.sdk.Path", side_effect=fake_path_constructor):
547
+ with patch.dict(sys.modules, cleaned_modules, clear=True):
548
+ with patch("builtins.__import__", side_effect=blocking_import):
549
+ with pytest.raises(ImportError, match="wasmtime is required"):
550
+ sdk._init_wasm()
551
+
552
+
553
+ # ---------------------------------------------------------------------------
554
+ # Round-trip integration test (parse → respond) with mock WASM
555
+ # ---------------------------------------------------------------------------
556
+
557
+ class TestRoundTrip:
558
+ def test_full_round_trip(self):
559
+ """
560
+ Simulate a complete hook invocation: read an event then respond.
561
+ Asserts that stdout receives the serialized bytes from WASM.
562
+ """
563
+ import polyhook.sdk as sdk
564
+ from polyhook import approve, block, read, respond
565
+
566
+ event_dict = {
567
+ "event": "tool:before",
568
+ "tool": "bash",
569
+ "input": {"command": "rm -rf /"},
570
+ "output": None,
571
+ "sessionId": "sess_danger",
572
+ "agentId": None,
573
+ "caller": "claude-code",
574
+ }
575
+ wasm_block_output = b'{"decision":"block","reason":"dangerous command"}'
576
+
577
+ store_mock, instance_mock, call_count = _make_wasm_mock(event_dict, wasm_block_output)
578
+ sdk._store = store_mock
579
+ sdk._instance = instance_mock
580
+ sdk._memory = instance_mock.exports(store_mock)["memory"]
581
+
582
+ stdin_data = b'{"hook_event_type": "PreToolUse", "tool_name": "Bash"}'
583
+ stdout_buf = io.BytesIO()
584
+
585
+ with patch("sys.stdin", io.TextIOWrapper(io.BytesIO(stdin_data))):
586
+ event = read()
587
+
588
+ assert event.tool == "bash"
589
+ assert event.input is not None and "rm -rf /" in event.input.get("command", "")
590
+
591
+ response = block("dangerous command")
592
+ mock_stdout = MagicMock()
593
+ mock_stdout.buffer = stdout_buf
594
+ with patch("sys.stdout", mock_stdout):
595
+ respond(response)
596
+
597
+ result = stdout_buf.getvalue()
598
+ assert result == wasm_block_output
599
+ assert call_count["parse"] == 1
600
+ assert call_count["serialize"] == 1
@@ -0,0 +1,67 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyhook
3
+ Version: 0.1.1
4
+ Summary: polyhook Python SDK — write AI coding agent hooks once, run them everywhere
5
+ Author: tupe12334
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/tupe12334/polyhook
8
+ Project-URL: Repository, https://github.com/tupe12334/polyhook
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+ Requires-Dist: wasmtime>=20.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == "dev"
14
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
15
+
16
+ # polyhook — Python SDK
17
+
18
+ **Write AI coding agent hooks once. Run them everywhere.**
19
+
20
+ polyhook detects which AI coding tool invoked your hook binary, deserializes the event into a normalized struct, and serializes your response back in the format that tool expects. Your hook runs unchanged whether Claude Code, Cursor, Windsurf, Cline, or Amp invoked it.
21
+
22
+ ## Install
23
+
24
+ ```bash
25
+ pip install polyhook
26
+ ```
27
+
28
+ ## Quick Start
29
+
30
+ ```python
31
+ import sys
32
+ import re
33
+ import polyhook
34
+
35
+ event = polyhook.read()
36
+
37
+ if (
38
+ event.tool == "bash"
39
+ and re.search(r"rm\s+-rf\s+/", event.input.get("command", "") if event.input else "")
40
+ ):
41
+ polyhook.respond(polyhook.block("Refusing to delete from root"))
42
+ else:
43
+ polyhook.respond(polyhook.approve())
44
+ ```
45
+
46
+ More examples: [examples/](examples/)
47
+
48
+ ## Supported Tools
49
+
50
+ | Tool | Status |
51
+ |---|---|
52
+ | [Claude Code](https://claude.ai/code) | ✅ Supported |
53
+ | [Cursor](https://cursor.com) | ✅ Supported |
54
+ | [Windsurf](https://windsurf.ai) | ✅ Supported |
55
+ | [Cline](https://github.com/cline/cline) | ✅ Supported |
56
+ | [Amp](https://ampcode.com) | ✅ Supported |
57
+ | [Continue](https://continue.dev) | 🚧 In progress |
58
+ | [Aider](https://aider.chat) | 🚧 In progress |
59
+ | [Copilot](https://github.com/features/copilot) | 📋 Planned |
60
+
61
+ ## Documentation
62
+
63
+ Full docs and API reference: <https://github.com/tupe12334/polyhook>
64
+
65
+ ## License
66
+
67
+ MIT
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/polyhook/__init__.py
4
+ src/polyhook/generated_models.py
5
+ src/polyhook/polyhook.wasm
6
+ src/polyhook/sdk.py
7
+ src/polyhook/test_sdk.py
8
+ src/polyhook.egg-info/PKG-INFO
9
+ src/polyhook.egg-info/SOURCES.txt
10
+ src/polyhook.egg-info/dependency_links.txt
11
+ src/polyhook.egg-info/requires.txt
12
+ src/polyhook.egg-info/top_level.txt
@@ -0,0 +1,5 @@
1
+ wasmtime>=20.0.0
2
+
3
+ [dev]
4
+ pytest>=8.0
5
+ pytest-cov>=5.0
@@ -0,0 +1 @@
1
+ polyhook