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.
- polyhook-0.1.1/PKG-INFO +67 -0
- polyhook-0.1.1/README.md +52 -0
- polyhook-0.1.1/pyproject.toml +32 -0
- polyhook-0.1.1/setup.cfg +4 -0
- polyhook-0.1.1/src/polyhook/__init__.py +2 -0
- polyhook-0.1.1/src/polyhook/generated_models.py +60 -0
- polyhook-0.1.1/src/polyhook/polyhook.wasm +0 -0
- polyhook-0.1.1/src/polyhook/sdk.py +214 -0
- polyhook-0.1.1/src/polyhook/test_sdk.py +600 -0
- polyhook-0.1.1/src/polyhook.egg-info/PKG-INFO +67 -0
- polyhook-0.1.1/src/polyhook.egg-info/SOURCES.txt +12 -0
- polyhook-0.1.1/src/polyhook.egg-info/dependency_links.txt +1 -0
- polyhook-0.1.1/src/polyhook.egg-info/requires.txt +5 -0
- polyhook-0.1.1/src/polyhook.egg-info/top_level.txt +1 -0
polyhook-0.1.1/PKG-INFO
ADDED
|
@@ -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
|
polyhook-0.1.1/README.md
ADDED
|
@@ -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
|
+
]
|
polyhook-0.1.1/setup.cfg
ADDED
|
@@ -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]
|
|
Binary file
|
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
polyhook
|