Skip to content
Closed
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
10 changes: 10 additions & 0 deletions src/cchooks/contexts/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ def notification_type(self) -> Optional[str]:
"""Get the notification type if available."""
return self._input_data.get("notification_type")

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "NotificationOutput":
"""Get the Notification-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/post_tool_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ def cwd(self) -> str:
"""Get the current working directory."""
return str(self._input_data["cwd"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "PostToolUseOutput":
"""Get the PostToolUse-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/pre_compact.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,16 @@ def custom_instructions(self) -> str:
"""Get custom instructions for compaction."""
return str(self._input_data["custom_instructions"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "PreCompactOutput":
"""Get the PreCompact-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/pre_tool_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ def cwd(self) -> str:
"""Get the current working directory."""
return str(self._input_data["cwd"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "PreToolUseOutput":
"""Get the PreToolUse-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/session_end.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ def cwd(self) -> str:
"""
return str(self._input_data["cwd"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "SessionEndOutput":
"""Get the output handler for this context.
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/session_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,16 @@ def source(self) -> SessionStartSource:
"""
return str(self._input_data["source"]) # type: ignore

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "SessionStartOutput":
"""Get the output handler for this context.
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ def stop_hook_active(self) -> bool:
"""stop_hook_active is true when Claude Code is already continuing as a result of a stop hook"""
return bool(self._input_data["stop_hook_active"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "StopOutput":
"""Get the Stop-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/subagent_stop.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ def stop_hook_active(self) -> bool:
"""stop_hook_active is true when Claude Code is already continuing as a result of a stop hook"""
return bool(self._input_data["stop_hook_active"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "SubagentStopOutput":
"""Get the SubagentStop-specific output handler."""
Expand Down
10 changes: 10 additions & 0 deletions src/cchooks/contexts/user_prompt_submit.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ def cwd(self) -> str:
"""Get the current working directory."""
return str(self._input_data["cwd"])

def is_subagent(self) -> bool:
"""Check if this hook is running in a sub-agent context.

Returns True if executed by a delegated Task, False if main Claude.
Enables proper authorization and skill isolation patterns.
"""
from pathlib import Path
filename = Path(self.transcript_path).name
return filename.startswith('agent-')

@property
def output(self) -> "UserPromptSubmitOutput":
"""Get the UserPromptSubmit-specific output handler."""
Expand Down
107 changes: 107 additions & 0 deletions tests/contexts/test_post_tool_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -581,3 +581,110 @@ def test_cleanup_operations(self):
if "/tmp/" in file_path:
context.output.accept(suppress_output=True)
assert file_path.endswith(".txt")


class TestPostToolUseContextIsSubagent:
"""Test is_subagent() method for detecting sub-agent context."""

def test_is_subagent_main_claude_context(self):
"""Test is_subagent returns False for main Claude context."""
data = {
"hook_event_name": "PostToolUse",
"session_id": "session-abc123def456",
"transcript_path": "/Users/user/.claude/session-abc123def456.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "Hello"},
"tool_response": {"status": "success"},
}

context = PostToolUseContext(data)

assert context.is_subagent() is False

def test_is_subagent_subagent_context(self):
"""Test is_subagent returns True for sub-agent context."""
data = {
"hook_event_name": "PostToolUse",
"session_id": "agent-xyz789uvw012",
"transcript_path": "/Users/user/.claude/agent-xyz789uvw012.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "Hello"},
"tool_response": {"status": "success"},
}

context = PostToolUseContext(data)

assert context.is_subagent() is True

def test_is_subagent_parallel_subagents(self):
"""Test is_subagent correctly identifies multiple parallel sub-agents."""
agent_ids = [
"agent-001-backend",
"agent-002-frontend",
"agent-003-docs",
"agent-parallel-123",
]

for agent_id in agent_ids:
data = {
"hook_event_name": "PostToolUse",
"session_id": agent_id,
"transcript_path": f"/Users/user/.claude/{agent_id}.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
"tool_response": {"status": "success"},
}

context = PostToolUseContext(data)
assert context.is_subagent() is True, f"Failed for agent_id: {agent_id}"

def test_is_subagent_various_main_claude_paths(self):
"""Test is_subagent returns False for various main Claude transcript paths."""
session_paths = [
"/home/user/.claude/session-abc123.jsonl",
"/Users/macuser/.claude/session-xyz789.jsonl",
"/root/.claude/session-main-001.jsonl",
"/tmp/session-test.jsonl",
]

for path in session_paths:
data = {
"hook_event_name": "PostToolUse",
"session_id": "test-session",
"transcript_path": path,
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
"tool_response": {"status": "success"},
}

context = PostToolUseContext(data)
assert context.is_subagent() is False, f"Failed for path: {path}"

def test_is_subagent_edge_cases(self):
"""Test is_subagent with edge case filenames."""
# Filenames that contain 'agent' but don't start with 'agent-'
non_agent_paths = [
"/tmp/management-agent-123.jsonl", # contains 'agent' in middle
"/tmp/my-agent.jsonl", # doesn't start with 'agent-'
"/tmp/reagent-abc.jsonl", # ends with similar pattern
]

for path in non_agent_paths:
data = {
"hook_event_name": "PostToolUse",
"session_id": "test-session",
"transcript_path": path,
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
"tool_response": {"status": "success"},
}

context = PostToolUseContext(data)
assert (
context.is_subagent() is False
), f"Failed for non-agent path: {path}"
102 changes: 102 additions & 0 deletions tests/contexts/test_pre_tool_use.py
Original file line number Diff line number Diff line change
Expand Up @@ -632,3 +632,105 @@ def test_allow_without_updated_input(self):
assert result["hookSpecificOutput"]["hookEventName"] == "PreToolUse"
assert result["hookSpecificOutput"]["permissionDecision"] == "allow"
assert "updatedInput" not in result["hookSpecificOutput"]


class TestPreToolUseContextIsSubagent:
"""Test is_subagent() method for detecting sub-agent context."""

def test_is_subagent_main_claude_context(self):
"""Test is_subagent returns False for main Claude context."""
data = {
"hook_event_name": "PreToolUse",
"session_id": "session-abc123def456",
"transcript_path": "/Users/user/.claude/session-abc123def456.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "Hello World"},
}

context = PreToolUseContext(data)

assert context.is_subagent() is False

def test_is_subagent_subagent_context(self):
"""Test is_subagent returns True for sub-agent context."""
data = {
"hook_event_name": "PreToolUse",
"session_id": "agent-xyz789uvw012",
"transcript_path": "/Users/user/.claude/agent-xyz789uvw012.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "Hello World"},
}

context = PreToolUseContext(data)

assert context.is_subagent() is True

def test_is_subagent_parallel_subagents(self):
"""Test is_subagent correctly identifies multiple parallel sub-agents."""
agent_ids = [
"agent-001-backend",
"agent-002-frontend",
"agent-003-docs",
"agent-parallel-123",
]

for agent_id in agent_ids:
data = {
"hook_event_name": "PreToolUse",
"session_id": agent_id,
"transcript_path": f"/Users/user/.claude/{agent_id}.jsonl",
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
}

context = PreToolUseContext(data)
assert context.is_subagent() is True, f"Failed for agent_id: {agent_id}"

def test_is_subagent_various_main_claude_paths(self):
"""Test is_subagent returns False for various main Claude transcript paths."""
session_paths = [
"/home/user/.claude/session-abc123.jsonl",
"/Users/macuser/.claude/session-xyz789.jsonl",
"/root/.claude/session-main-001.jsonl",
"/tmp/session-test.jsonl",
]

for path in session_paths:
data = {
"hook_event_name": "PreToolUse",
"session_id": "test-session",
"transcript_path": path,
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
}

context = PreToolUseContext(data)
assert context.is_subagent() is False, f"Failed for path: {path}"

def test_is_subagent_edge_cases(self):
"""Test is_subagent with edge case filenames."""
# Filenames that contain 'agent' but don't start with 'agent-'
non_agent_paths = [
"/tmp/management-agent-123.jsonl", # contains 'agent' in middle
"/tmp/my-agent.jsonl", # doesn't start with 'agent-'
"/tmp/reagent-abc.jsonl", # ends with similar pattern
]

for path in non_agent_paths:
data = {
"hook_event_name": "PreToolUse",
"session_id": "test-session",
"transcript_path": path,
"cwd": "/home/user/project",
"tool_name": "Write",
"tool_input": {"file_path": "/tmp/test.txt", "content": "content"},
}

context = PreToolUseContext(data)
assert (
context.is_subagent() is False
), f"Failed for non-agent path: {path}"
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.