mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Rename hanzo-repl → hanzo-dev to match @hanzo/dev (Rust/TS). New packages: - hanzo-lsp: async LSP client (diagnostics, go-to-def, references, context enrichment) - hanzo-hooks: pre/post tool use hooks (shell scripts, exit-code deny) - hanzo-sandbox: container detection, filesystem isolation, Linux unshare hanzo-dev TUI: - Vim keybindings (Normal/Insert/Visual/Command modes, 37 tests) - Git slash commands (/branch /commit /worktree /diff /stash, 13 tests) - All slash commands at parity: /model /permissions /clear /resume /memory /init /export /session /plan /solve /code /auto /fast /remote-control - Multi-provider auth: Anthropic OAuth, OpenAI device code, Hanzo PKCE, auto-detect Auth: - login_with_anthropic() — PKCE via console.hanzo.ai - login_with_openai() — device code flow via auth.openai.com - login_auto() — env var detection + saved credential fallback - OAuthCredentialStore supports provider keys (hanzo/anthropic/openai) Parity test suite (38 tests): SSE, MCP normalization, permissions, compaction, token usage, PKCE RFC vector, config, backoff, session roundtrip, hooks. 348 tests total, 0 failures across Python + Rust + Universe E2E.
162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
"""Tests for hanzo_hooks."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from hanzo_hooks import HookConfig, HookEvent, HookRunner, HookRunResult
|
|
|
|
|
|
class TestHookEvent:
|
|
def test_values(self):
|
|
assert HookEvent.PreToolUse.value == "PreToolUse"
|
|
assert HookEvent.PostToolUse.value == "PostToolUse"
|
|
|
|
|
|
class TestHookRunResult:
|
|
def test_allow(self):
|
|
r = HookRunResult.allow()
|
|
assert not r.denied
|
|
assert r.messages == []
|
|
|
|
def test_allow_with_messages(self):
|
|
r = HookRunResult.allow(["msg1", "msg2"])
|
|
assert not r.denied
|
|
assert r.messages == ["msg1", "msg2"]
|
|
|
|
def test_denied(self):
|
|
r = HookRunResult(denied=True, messages=["blocked"])
|
|
assert r.denied
|
|
assert r.messages == ["blocked"]
|
|
|
|
def test_to_permission_outcome_allow(self):
|
|
r = HookRunResult.allow()
|
|
outcome = r.to_permission_outcome()
|
|
assert outcome.allowed is True
|
|
|
|
def test_to_permission_outcome_deny(self):
|
|
r = HookRunResult(denied=True, messages=["reason A", "reason B"])
|
|
outcome = r.to_permission_outcome()
|
|
assert outcome.allowed is False
|
|
assert "reason A" in outcome.reason
|
|
assert "reason B" in outcome.reason
|
|
|
|
|
|
class TestHookConfig:
|
|
def test_from_dict(self):
|
|
cfg = HookConfig.from_dict({
|
|
"pre_tool_use": ["echo pre"],
|
|
"post_tool_use": ["echo post"],
|
|
})
|
|
assert cfg.pre_tool_use == ["echo pre"]
|
|
assert cfg.post_tool_use == ["echo post"]
|
|
|
|
def test_from_dict_camel_case(self):
|
|
cfg = HookConfig.from_dict({
|
|
"PreToolUse": ["echo pre"],
|
|
"PostToolUse": ["echo post"],
|
|
})
|
|
assert cfg.pre_tool_use == ["echo pre"]
|
|
assert cfg.post_tool_use == ["echo post"]
|
|
|
|
def test_from_dict_empty(self):
|
|
cfg = HookConfig.from_dict({})
|
|
assert cfg.pre_tool_use == []
|
|
assert cfg.post_tool_use == []
|
|
|
|
def test_from_json(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump({"hooks": {"pre_tool_use": ["echo hi"]}}, f)
|
|
f.flush()
|
|
cfg = HookConfig.from_json(f.name)
|
|
assert cfg.pre_tool_use == ["echo hi"]
|
|
assert cfg.post_tool_use == []
|
|
Path(f.name).unlink()
|
|
|
|
|
|
class TestHookRunner:
|
|
def test_no_commands_returns_allow(self):
|
|
runner = HookRunner(HookConfig())
|
|
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
|
|
assert not result.denied
|
|
assert result.messages == []
|
|
|
|
def test_exit_zero_captures_stdout(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'pre ok'"]))
|
|
result = runner.run_pre_tool_use("Read", '{"path":"README.md"}')
|
|
assert not result.denied
|
|
assert result.messages == ["pre ok"]
|
|
|
|
def test_exit_two_denies(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'blocked'; exit 2"]))
|
|
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
|
|
assert result.denied
|
|
assert result.messages == ["blocked"]
|
|
|
|
def test_exit_two_without_stdout_uses_default_message(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=["exit 2"]))
|
|
result = runner.run_pre_tool_use("Bash", '{"command":"pwd"}')
|
|
assert result.denied
|
|
assert "denied" in result.messages[0]
|
|
assert "Bash" in result.messages[0]
|
|
|
|
def test_other_exit_code_warns_but_allows(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=["printf 'oops'; exit 1"]))
|
|
result = runner.run_pre_tool_use("Edit", '{"file":"lib.py"}')
|
|
assert not result.denied
|
|
assert len(result.messages) == 1
|
|
assert "allowing tool execution to continue" in result.messages[0]
|
|
|
|
def test_short_circuit_on_deny(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=[
|
|
"printf 'first ok'",
|
|
"printf 'deny'; exit 2",
|
|
"printf 'never reached'",
|
|
]))
|
|
result = runner.run_pre_tool_use("Bash", '{}')
|
|
assert result.denied
|
|
assert result.messages == ["first ok", "deny"]
|
|
|
|
def test_post_tool_use(self):
|
|
runner = HookRunner(HookConfig(post_tool_use=["printf 'post ok'"]))
|
|
result = runner.run_post_tool_use("Read", '{}', "file contents", is_error=False)
|
|
assert not result.denied
|
|
assert result.messages == ["post ok"]
|
|
|
|
def test_env_vars_passed(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=[
|
|
'printf "%s %s" "$HOOK_EVENT" "$HOOK_TOOL_NAME"'
|
|
]))
|
|
result = runner.run_pre_tool_use("Bash", '{"command":"ls"}')
|
|
assert not result.denied
|
|
assert result.messages == ["PreToolUse Bash"]
|
|
|
|
def test_stdin_payload(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=[
|
|
"""python3 -c "import sys, json; d=json.load(sys.stdin); print(d['tool_name'])" """
|
|
]))
|
|
result = runner.run_pre_tool_use("Grep", '{"pattern":"foo"}')
|
|
assert not result.denied
|
|
assert result.messages == ["Grep"]
|
|
|
|
def test_multiple_commands_collect_messages(self):
|
|
runner = HookRunner(HookConfig(pre_tool_use=[
|
|
"printf 'msg1'",
|
|
"printf 'msg2'",
|
|
]))
|
|
result = runner.run_pre_tool_use("Read", '{}')
|
|
assert not result.denied
|
|
assert result.messages == ["msg1", "msg2"]
|
|
|
|
def test_from_settings(self):
|
|
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
|
json.dump({"hooks": {"pre_tool_use": ["printf 'from settings'"]}}, f)
|
|
f.flush()
|
|
runner = HookRunner.from_settings(f.name)
|
|
result = runner.run_pre_tool_use("Bash", '{}')
|
|
assert not result.denied
|
|
assert result.messages == ["from settings"]
|
|
Path(f.name).unlink()
|