Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,8 @@ jobs:
run: |
python examples/quick_start.py
timeout 120 python examples/streaming_mode.py all
timeout 120 python examples/hooks.py PreToolUse
timeout 120 python examples/hooks.py DecisionFields

- name: Run example scripts (Windows)
if: runner.os == 'Windows'
Expand All @@ -136,4 +138,14 @@ jobs:
Wait-Job $job -Timeout 120 | Out-Null
Stop-Job $job
Receive-Job $job

$job = Start-Job { python examples/hooks.py PreToolUse }
Wait-Job $job -Timeout 120 | Out-Null
Stop-Job $job
Receive-Job $job

$job = Start-Job { python examples/hooks.py DecisionFields }
Wait-Job $job -Timeout 120 | Out-Null
Stop-Job $job
Receive-Job $job
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we actually running on Windows for test-examples? Since runs-on: ubuntu-latest

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, good catch

shell: pwsh
149 changes: 149 additions & 0 deletions e2e-tests/test_hooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
"""End-to-end tests for hook callbacks with real Claude API calls."""

import pytest

from claude_agent_sdk import (
ClaudeAgentOptions,
ClaudeSDKClient,
HookContext,
HookJSONOutput,
HookMatcher,
)


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_permission_decision_and_reason():
"""Test that hooks with permissionDecision and reason fields work end-to-end."""
hook_invocations = []

async def test_hook(
input_data: dict, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Hook that uses permissionDecision and reason fields."""
tool_name = input_data.get("tool_name", "")
print(f"Hook called for tool: {tool_name}")
hook_invocations.append(tool_name)

# Block Bash commands for this test
if tool_name == "Bash":
return {
"reason": "Bash commands are blocked in this test for safety",
"systemMessage": "⚠️ Command blocked by hook",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would love to better understand the difference between decision and permissionDecision

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decision and reason are actually deprecated, so it's a little confusing right now

"permissionDecisionReason": "Security policy: Bash blocked",
},
}

return {
"reason": "Tool approved by security review",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Tool passed security checks",
},
}

options = ClaudeAgentOptions(
allowed_tools=["Bash", "Write"],
hooks={
"PreToolUse": [
HookMatcher(matcher="Bash", hooks=[test_hook]),
],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Run this bash command: echo 'hello'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "Bash" in hook_invocations, f"Hook should have been invoked for Bash tool, got: {hook_invocations}"


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_continue_and_stop_reason():
"""Test that hooks with continue_=False and stopReason fields work end-to-end."""
hook_invocations = []

async def post_tool_hook(
input_data: dict, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""PostToolUse hook that stops execution with stopReason."""
tool_name = input_data.get("tool_name", "")
hook_invocations.append(tool_name)

# Actually test continue_=False and stopReason fields
return {
"continue_": False,
"stopReason": "Execution halted by test hook for validation",
"reason": "Testing continue and stopReason fields",
"systemMessage": "🛑 Test hook stopped execution",
}

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[post_tool_hook]),
],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'test message'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "Bash" in hook_invocations, f"PostToolUse hook should have been invoked, got: {hook_invocations}"


@pytest.mark.e2e
@pytest.mark.asyncio
async def test_hook_with_additional_context():
"""Test that hooks with hookSpecificOutput work end-to-end."""
hook_invocations = []

async def context_hook(
input_data: dict, tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Hook that provides additional context."""
hook_invocations.append("context_added")

return {
"systemMessage": "Additional context provided by hook",
"reason": "Hook providing monitoring feedback",
"suppressOutput": False,
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "The command executed successfully with hook monitoring",
},
}

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[context_hook]),
],
},
)

async with ClaudeSDKClient(options=options) as client:
await client.query("Run: echo 'testing hooks'")

async for message in client.receive_response():
print(f"Got message: {message}")

print(f"Hook invocations: {hook_invocations}")
# Verify hook was called
assert "context_added" in hook_invocations, "Hook with hookSpecificOutput should have been invoked"
165 changes: 165 additions & 0 deletions examples/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,77 @@ async def add_custom_instructions(
}


async def review_tool_output(
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Review tool output and provide additional context or warnings."""
tool_response = input_data.get("tool_response", "")

# If the tool produced an error, add helpful context
if "error" in str(tool_response).lower():
return {
"systemMessage": "⚠️ The command produced an error",
"reason": "Tool execution failed - consider checking the command syntax",
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"additionalContext": "The command encountered an error. You may want to try a different approach.",
}
}

return {}


async def strict_approval_hook(
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Demonstrates using permissionDecision to control tool execution."""
tool_name = input_data.get("tool_name")
tool_input = input_data.get("tool_input", {})

# Block any Write operations to specific files
if tool_name == "Write":
file_path = tool_input.get("file_path", "")
if "important" in file_path.lower():
logger.warning(f"Blocked Write to: {file_path}")
return {
"reason": "Writes to files containing 'important' in the name are not allowed for safety",
"systemMessage": "🚫 Write operation blocked by security policy",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "Security policy blocks writes to important files",
},
}

# Allow everything else explicitly
return {
"reason": "Tool use approved after security review",
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": "Tool passed security checks",
},
}


async def stop_on_error_hook(
input_data: dict[str, Any], tool_use_id: str | None, context: HookContext
) -> HookJSONOutput:
"""Demonstrates using continue=False to stop execution on certain conditions."""
tool_response = input_data.get("tool_response", "")

# Stop execution if we see a critical error
if "critical" in str(tool_response).lower():
logger.error("Critical error detected - stopping execution")
return {
"continue_": False,
"stopReason": "Critical error detected in tool output - execution halted for safety",
"systemMessage": "🛑 Execution stopped due to critical error",
}

return {"continue_": True}


async def example_pretooluse() -> None:
"""Basic example demonstrating hook protection."""
print("=== PreToolUse Example ===")
Expand Down Expand Up @@ -143,11 +214,99 @@ async def example_userpromptsubmit() -> None:
print("\n")


async def example_posttooluse() -> None:
"""Demonstrate PostToolUse hook with reason and systemMessage fields."""
print("=== PostToolUse Example ===")
print("This example shows how PostToolUse can provide feedback with reason and systemMessage.\n")

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[review_tool_output]),
],
}
)

async with ClaudeSDKClient(options=options) as client:
print("User: Run a command that will produce an error: ls /nonexistent_directory")
await client.query("Run this command: ls /nonexistent_directory")

async for msg in client.receive_response():
display_message(msg)

print("\n")


async def example_decision_fields() -> None:
"""Demonstrate permissionDecision, reason, and systemMessage fields."""
print("=== Permission Decision Example ===")
print("This example shows how to use permissionDecision='allow'/'deny' with reason and systemMessage.\n")

options = ClaudeAgentOptions(
allowed_tools=["Write", "Bash"],
model="claude-sonnet-4-5-20250929",
hooks={
"PreToolUse": [
HookMatcher(matcher="Write", hooks=[strict_approval_hook]),
],
}
)

async with ClaudeSDKClient(options=options) as client:
# Test 1: Try to write to a file with "important" in the name (should be blocked)
print("Test 1: Trying to write to important_config.txt (should be blocked)...")
print("User: Write 'test' to important_config.txt")
await client.query("Write the text 'test data' to a file called important_config.txt")

async for msg in client.receive_response():
display_message(msg)

print("\n" + "=" * 50 + "\n")

# Test 2: Write to a regular file (should be approved)
print("Test 2: Trying to write to regular_file.txt (should be approved)...")
print("User: Write 'test' to regular_file.txt")
await client.query("Write the text 'test data' to a file called regular_file.txt")

async for msg in client.receive_response():
display_message(msg)

print("\n")


async def example_continue_control() -> None:
"""Demonstrate continue and stopReason fields for execution control."""
print("=== Continue/Stop Control Example ===")
print("This example shows how to use continue_=False with stopReason to halt execution.\n")

options = ClaudeAgentOptions(
allowed_tools=["Bash"],
hooks={
"PostToolUse": [
HookMatcher(matcher="Bash", hooks=[stop_on_error_hook]),
],
}
)

async with ClaudeSDKClient(options=options) as client:
print("User: Run a command that outputs 'CRITICAL ERROR'")
await client.query("Run this bash command: echo 'CRITICAL ERROR: system failure'")

async for msg in client.receive_response():
display_message(msg)

print("\n")


async def main() -> None:
"""Run all examples or a specific example based on command line argument."""
examples = {
"PreToolUse": example_pretooluse,
"UserPromptSubmit": example_userpromptsubmit,
"PostToolUse": example_posttooluse,
"DecisionFields": example_decision_fields,
"ContinueControl": example_continue_control,
}

if len(sys.argv) < 2:
Expand All @@ -157,6 +316,12 @@ async def main() -> None:
print(" all - Run all examples")
for name in examples:
print(f" {name}")
print("\nExample descriptions:")
print(" PreToolUse - Block commands using PreToolUse hook")
print(" UserPromptSubmit - Add context at prompt submission")
print(" PostToolUse - Review tool output with reason and systemMessage")
print(" DecisionFields - Use permissionDecision='allow'/'deny' with reason")
print(" ContinueControl - Control execution with continue_ and stopReason")
sys.exit(0)

example_name = sys.argv[1]
Expand Down
2 changes: 2 additions & 0 deletions src/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
ContentBlock,
HookCallback,
HookContext,
HookJSONOutput,
HookMatcher,
McpSdkServerConfig,
McpServerConfig,
Expand Down Expand Up @@ -308,6 +309,7 @@ async def call_tool(name: str, arguments: dict[str, Any]) -> Any:
"PermissionUpdate",
"HookCallback",
"HookContext",
"HookJSONOutput",
"HookMatcher",
# Agent support
"AgentDefinition",
Expand Down
Loading