hanzo-hooks 0.1.0__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,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanzo-hooks
3
+ Version: 0.1.0
4
+ Summary: Shell hook runner for pre/post tool-use lifecycle events.
5
+ Author-email: Hanzo Industries Inc <dev@hanzo.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hanzoai/python-sdk
8
+ Project-URL: Bug Tracker, https://github.com/hanzoai/python-sdk/issues
9
+ Keywords: hanzo,hooks,tools,ai
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.12
@@ -0,0 +1,6 @@
1
+ """hanzo-hooks: shell hook runner for pre/post tool-use lifecycle events."""
2
+
3
+ from .runner import HookRunner
4
+ from .types import HookConfig, HookEvent, HookRunResult
5
+
6
+ __all__ = ["HookConfig", "HookEvent", "HookRunner", "HookRunResult"]
@@ -0,0 +1,97 @@
1
+ """HookRunner: execute shell commands around tool invocations."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+
9
+ from .types import HookConfig, HookEvent, HookRunResult
10
+
11
+
12
+ class HookRunner:
13
+ __slots__ = ("_config",)
14
+
15
+ def __init__(self, config: HookConfig) -> None:
16
+ self._config = config
17
+
18
+ @classmethod
19
+ def from_settings(cls, path: str) -> HookRunner:
20
+ return cls(HookConfig.from_json(path))
21
+
22
+ def run_pre_tool_use(self, tool_name: str, tool_input: str) -> HookRunResult:
23
+ return self._run_commands(
24
+ HookEvent.PreToolUse, self._config.pre_tool_use, tool_name, tool_input,
25
+ )
26
+
27
+ def run_post_tool_use(
28
+ self, tool_name: str, tool_input: str, tool_output: str, is_error: bool = False,
29
+ ) -> HookRunResult:
30
+ return self._run_commands(
31
+ HookEvent.PostToolUse, self._config.post_tool_use,
32
+ tool_name, tool_input, tool_output=tool_output, is_error=is_error,
33
+ )
34
+
35
+ def _run_commands(
36
+ self, event: HookEvent, commands: list[str], tool_name: str, tool_input: str,
37
+ tool_output: str | None = None, is_error: bool = False,
38
+ ) -> HookRunResult:
39
+ if not commands:
40
+ return HookRunResult.allow()
41
+
42
+ try:
43
+ parsed_input = json.loads(tool_input)
44
+ except (json.JSONDecodeError, TypeError):
45
+ parsed_input = {"raw": tool_input}
46
+
47
+ payload = json.dumps({
48
+ "hook_event_name": event.value, "tool_name": tool_name,
49
+ "tool_input": parsed_input, "tool_input_json": tool_input,
50
+ "tool_output": tool_output, "tool_result_is_error": is_error,
51
+ })
52
+ env = {
53
+ "HOOK_EVENT": event.value, "HOOK_TOOL_NAME": tool_name,
54
+ "HOOK_TOOL_INPUT": tool_input, "HOOK_TOOL_IS_ERROR": "1" if is_error else "0",
55
+ }
56
+ if tool_output is not None:
57
+ env["HOOK_TOOL_OUTPUT"] = tool_output
58
+
59
+ messages: list[str] = []
60
+ for command in commands:
61
+ kind, msg = _run_one(command, event, tool_name, env, payload)
62
+ if kind == "allow":
63
+ if msg:
64
+ messages.append(msg)
65
+ elif kind == "deny":
66
+ messages.append(msg or f"{event.value} hook denied tool `{tool_name}`")
67
+ return HookRunResult(denied=True, messages=messages)
68
+ else:
69
+ messages.append(msg)
70
+ return HookRunResult.allow(messages)
71
+
72
+
73
+ def _run_one(
74
+ command: str, event: HookEvent, tool_name: str,
75
+ env: dict[str, str], payload: str,
76
+ ) -> tuple[str, str]:
77
+ """Returns (outcome_type, message). outcome_type: allow/deny/warn."""
78
+ args = ["cmd", "/C", command] if sys.platform == "win32" else ["sh", "-lc", command]
79
+ try:
80
+ proc = subprocess.run(
81
+ args, input=payload, capture_output=True, text=True,
82
+ env={**os.environ, **env},
83
+ )
84
+ except OSError as exc:
85
+ return ("warn", f"{event.value} hook `{command}` failed to start for `{tool_name}`: {exc}")
86
+
87
+ stdout, stderr = proc.stdout.strip(), proc.stderr.strip()
88
+ if proc.returncode == 0:
89
+ return ("allow", stdout)
90
+ if proc.returncode == 2:
91
+ return ("deny", stdout)
92
+ msg = f"Hook `{command}` exited with status {proc.returncode}; allowing tool execution to continue"
93
+ if stdout:
94
+ msg += f": {stdout}"
95
+ elif stderr:
96
+ msg += f": {stderr}"
97
+ return ("warn", msg)
@@ -0,0 +1,72 @@
1
+ """Types for the hook runner system."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+
9
+
10
+ class HookEvent(Enum):
11
+ PreToolUse = "PreToolUse"
12
+ PostToolUse = "PostToolUse"
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class HookRunResult:
17
+ denied: bool
18
+ messages: list[str]
19
+
20
+ @classmethod
21
+ def allow(cls, messages: list[str] | None = None) -> HookRunResult:
22
+ return cls(denied=False, messages=messages or [])
23
+
24
+ def to_permission_outcome(self) -> object:
25
+ """Convert to hanzoai.protocols.PermissionOutcome if available.
26
+
27
+ Returns a duck-typed object with .allowed and .reason when the
28
+ hanzoai package is not installed, so callers can use it without
29
+ a hard dependency.
30
+ """
31
+ try:
32
+ from hanzoai.protocols import PermissionOutcome
33
+ except ImportError:
34
+ PermissionOutcome = None
35
+
36
+ if PermissionOutcome is not None:
37
+ if self.denied:
38
+ return PermissionOutcome.deny("; ".join(self.messages))
39
+ return PermissionOutcome.allow()
40
+
41
+ # Fallback: return a simple namespace matching the protocol.
42
+ if self.denied:
43
+ return _Outcome(allowed=False, reason="; ".join(self.messages))
44
+ return _Outcome(allowed=True, reason="")
45
+
46
+
47
+ @dataclass(frozen=True, slots=True)
48
+ class _Outcome:
49
+ """Minimal stand-in for PermissionOutcome when hanzoai is not installed."""
50
+ allowed: bool
51
+ reason: str
52
+
53
+
54
+ @dataclass(frozen=True, slots=True)
55
+ class HookConfig:
56
+ """Loadable from settings.json ``hooks`` key."""
57
+ pre_tool_use: list[str] = field(default_factory=list)
58
+ post_tool_use: list[str] = field(default_factory=list)
59
+
60
+ @classmethod
61
+ def from_dict(cls, d: dict) -> HookConfig:
62
+ return cls(
63
+ pre_tool_use=list(d.get("pre_tool_use") or d.get("PreToolUse") or []),
64
+ post_tool_use=list(d.get("post_tool_use") or d.get("PostToolUse") or []),
65
+ )
66
+
67
+ @classmethod
68
+ def from_json(cls, path: str) -> HookConfig:
69
+ with open(path) as f:
70
+ data = json.load(f)
71
+ hooks = data.get("hooks", data)
72
+ return cls.from_dict(hooks)
@@ -0,0 +1,13 @@
1
+ Metadata-Version: 2.4
2
+ Name: hanzo-hooks
3
+ Version: 0.1.0
4
+ Summary: Shell hook runner for pre/post tool-use lifecycle events.
5
+ Author-email: Hanzo Industries Inc <dev@hanzo.ai>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/hanzoai/python-sdk
8
+ Project-URL: Bug Tracker, https://github.com/hanzoai/python-sdk/issues
9
+ Keywords: hanzo,hooks,tools,ai
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.12
@@ -0,0 +1,9 @@
1
+ pyproject.toml
2
+ hanzo_hooks/__init__.py
3
+ hanzo_hooks/runner.py
4
+ hanzo_hooks/types.py
5
+ hanzo_hooks.egg-info/PKG-INFO
6
+ hanzo_hooks.egg-info/SOURCES.txt
7
+ hanzo_hooks.egg-info/dependency_links.txt
8
+ hanzo_hooks.egg-info/top_level.txt
9
+ tests/test_hooks.py
@@ -0,0 +1 @@
1
+ hanzo_hooks
@@ -0,0 +1,26 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0.0", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hanzo-hooks"
7
+ version = "0.1.0"
8
+ description = "Shell hook runner for pre/post tool-use lifecycle events."
9
+ requires-python = ">=3.12"
10
+ license = { text = "MIT" }
11
+ authors = [{ name = "Hanzo Industries Inc", email = "dev@hanzo.ai" }]
12
+ classifiers = [
13
+ "Programming Language :: Python :: 3",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Operating System :: OS Independent",
16
+ ]
17
+ keywords = ["hanzo", "hooks", "tools", "ai"]
18
+ dependencies = []
19
+
20
+ [project.urls]
21
+ "Homepage" = "https://github.com/hanzoai/python-sdk"
22
+ "Bug Tracker" = "https://github.com/hanzoai/python-sdk/issues"
23
+
24
+ [tool.setuptools.packages.find]
25
+ where = ["."]
26
+ include = ["hanzo_hooks*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,161 @@
1
+ """Tests for hanzo_hooks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from hanzo_hooks import HookConfig, HookEvent, HookRunner, HookRunResult
10
+
11
+
12
+ class TestHookEvent:
13
+ def test_values(self):
14
+ assert HookEvent.PreToolUse.value == "PreToolUse"
15
+ assert HookEvent.PostToolUse.value == "PostToolUse"
16
+
17
+
18
+ class TestHookRunResult:
19
+ def test_allow(self):
20
+ r = HookRunResult.allow()
21
+ assert not r.denied
22
+ assert r.messages == []
23
+
24
+ def test_allow_with_messages(self):
25
+ r = HookRunResult.allow(["msg1", "msg2"])
26
+ assert not r.denied
27
+ assert r.messages == ["msg1", "msg2"]
28
+
29
+ def test_denied(self):
30
+ r = HookRunResult(denied=True, messages=["blocked"])
31
+ assert r.denied
32
+ assert r.messages == ["blocked"]
33
+
34
+ def test_to_permission_outcome_allow(self):
35
+ r = HookRunResult.allow()
36
+ outcome = r.to_permission_outcome()
37
+ assert outcome.allowed is True
38
+
39
+ def test_to_permission_outcome_deny(self):
40
+ r = HookRunResult(denied=True, messages=["reason A", "reason B"])
41
+ outcome = r.to_permission_outcome()
42
+ assert outcome.allowed is False
43
+ assert "reason A" in outcome.reason
44
+ assert "reason B" in outcome.reason
45
+
46
+
47
+ class TestHookConfig:
48
+ def test_from_dict(self):
49
+ cfg = HookConfig.from_dict({
50
+ "pre_tool_use": ["echo pre"],
51
+ "post_tool_use": ["echo post"],
52
+ })
53
+ assert cfg.pre_tool_use == ["echo pre"]
54
+ assert cfg.post_tool_use == ["echo post"]
55
+
56
+ def test_from_dict_camel_case(self):
57
+ cfg = HookConfig.from_dict({
58
+ "PreToolUse": ["echo pre"],
59
+ "PostToolUse": ["echo post"],
60
+ })
61
+ assert cfg.pre_tool_use == ["echo pre"]
62
+ assert cfg.post_tool_use == ["echo post"]
63
+
64
+ def test_from_dict_empty(self):
65
+ cfg = HookConfig.from_dict({})
66
+ assert cfg.pre_tool_use == []
67
+ assert cfg.post_tool_use == []
68
+
69
+ def test_from_json(self):
70
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
71
+ json.dump({"hooks": {"pre_tool_use": ["echo hi"]}}, f)
72
+ f.flush()
73
+ cfg = HookConfig.from_json(f.name)
74
+ assert cfg.pre_tool_use == ["echo hi"]
75
+ assert cfg.post_tool_use == []
76
+ Path(f.name).unlink()
77
+
78
+
79
+ class TestHookRunner:
80
+ def test_no_commands_returns_allow(self):
81
+ runner = HookRunner(HookConfig())
82
+ result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
83
+ assert not result.denied
84
+ assert result.messages == []
85
+
86
+ def test_exit_zero_captures_stdout(self):
87
+ runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
88
+ result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
89
+ assert not result.denied
90
+ assert result.messages == ["pre ok"]
91
+
92
+ def test_exit_two_denies(self):
93
+ runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked'; exit 2"]))
94
+ result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
95
+ assert result.denied
96
+ assert result.messages == ["blocked"]
97
+
98
+ def test_exit_two_without_stdout_uses_default_message(self):
99
+ runner = HookRunner(HookConfig(pre_tool_use=["exit 2"]))
100
+ result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
101
+ assert result.denied
102
+ assert "denied" in result.messages[0]
103
+ assert "Bash" in result.messages[0]
104
+
105
+ def test_other_exit_code_warns_but_allows(self):
106
+ runner = HookRunner(HookConfig(pre_tool_use=["printf 'oops'; exit 1"]))
107
+ result = runner.run_pre_tool_use("Edit", '{"file":"lib.py"}')
108
+ assert not result.denied
109
+ assert len(result.messages) == 1
110
+ assert "allowing tool execution to continue" in result.messages[0]
111
+
112
+ def test_short_circuit_on_deny(self):
113
+ runner = HookRunner(HookConfig(pre_tool_use=[
114
+ "printf 'first ok'",
115
+ "printf 'deny'; exit 2",
116
+ "printf 'never reached'",
117
+ ]))
118
+ result = runner.run_pre_tool_use("Bash", '{}')
119
+ assert result.denied
120
+ assert result.messages == ["first ok", "deny"]
121
+
122
+ def test_post_tool_use(self):
123
+ runner = HookRunner(HookConfig(post_tool_use=["printf 'post ok'"]))
124
+ result = runner.run_post_tool_use("Read", '{}', "file contents", is_error=False)
125
+ assert not result.denied
126
+ assert result.messages == ["post ok"]
127
+
128
+ def test_env_vars_passed(self):
129
+ runner = HookRunner(HookConfig(pre_tool_use=[
130
+ 'printf "%s %s" "$HOOK_EVENT" "$HOOK_TOOL_NAME"'
131
+ ]))
132
+ result = runner.run_pre_tool_use("Bash", '{"command":"ls"}')
133
+ assert not result.denied
134
+ assert result.messages == ["PreToolUse Bash"]
135
+
136
+ def test_stdin_payload(self):
137
+ runner = HookRunner(HookConfig(pre_tool_use=[
138
+ """python3 -c "import sys, json; d=json.load(sys.stdin); print(d['tool_name'])" """
139
+ ]))
140
+ result = runner.run_pre_tool_use("Grep", '{"pattern":"foo"}')
141
+ assert not result.denied
142
+ assert result.messages == ["Grep"]
143
+
144
+ def test_multiple_commands_collect_messages(self):
145
+ runner = HookRunner(HookConfig(pre_tool_use=[
146
+ "printf 'msg1'",
147
+ "printf 'msg2'",
148
+ ]))
149
+ result = runner.run_pre_tool_use("Read", '{}')
150
+ assert not result.denied
151
+ assert result.messages == ["msg1", "msg2"]
152
+
153
+ def test_from_settings(self):
154
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
155
+ json.dump({"hooks": {"pre_tool_use": ["printf 'from settings'"]}}, f)
156
+ f.flush()
157
+ runner = HookRunner.from_settings(f.name)
158
+ result = runner.run_pre_tool_use("Bash", '{}')
159
+ assert not result.denied
160
+ assert result.messages == ["from settings"]
161
+ Path(f.name).unlink()