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.
- hanzo_hooks-0.1.0/PKG-INFO +13 -0
- hanzo_hooks-0.1.0/hanzo_hooks/__init__.py +6 -0
- hanzo_hooks-0.1.0/hanzo_hooks/runner.py +97 -0
- hanzo_hooks-0.1.0/hanzo_hooks/types.py +72 -0
- hanzo_hooks-0.1.0/hanzo_hooks.egg-info/PKG-INFO +13 -0
- hanzo_hooks-0.1.0/hanzo_hooks.egg-info/SOURCES.txt +9 -0
- hanzo_hooks-0.1.0/hanzo_hooks.egg-info/dependency_links.txt +1 -0
- hanzo_hooks-0.1.0/hanzo_hooks.egg-info/top_level.txt +1 -0
- hanzo_hooks-0.1.0/pyproject.toml +26 -0
- hanzo_hooks-0.1.0/setup.cfg +4 -0
- hanzo_hooks-0.1.0/tests/test_hooks.py +161 -0
|
@@ -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,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 @@
|
|
|
1
|
+
|
|
@@ -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,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()
|