diff --git a/src/cchooks/contexts/notification.py b/src/cchooks/contexts/notification.py index 89d9d2a..68b1739 100644 --- a/src/cchooks/contexts/notification.py +++ b/src/cchooks/contexts/notification.py @@ -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.""" diff --git a/src/cchooks/contexts/post_tool_use.py b/src/cchooks/contexts/post_tool_use.py index 6fe56ce..5715b55 100644 --- a/src/cchooks/contexts/post_tool_use.py +++ b/src/cchooks/contexts/post_tool_use.py @@ -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.""" diff --git a/src/cchooks/contexts/pre_compact.py b/src/cchooks/contexts/pre_compact.py index f1f2023..72105e3 100644 --- a/src/cchooks/contexts/pre_compact.py +++ b/src/cchooks/contexts/pre_compact.py @@ -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.""" diff --git a/src/cchooks/contexts/pre_tool_use.py b/src/cchooks/contexts/pre_tool_use.py index 0e9702a..762f787 100644 --- a/src/cchooks/contexts/pre_tool_use.py +++ b/src/cchooks/contexts/pre_tool_use.py @@ -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.""" diff --git a/src/cchooks/contexts/session_end.py b/src/cchooks/contexts/session_end.py index edf8360..5ba80ff 100644 --- a/src/cchooks/contexts/session_end.py +++ b/src/cchooks/contexts/session_end.py @@ -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. diff --git a/src/cchooks/contexts/session_start.py b/src/cchooks/contexts/session_start.py index 06b5ddf..984cbd7 100644 --- a/src/cchooks/contexts/session_start.py +++ b/src/cchooks/contexts/session_start.py @@ -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. diff --git a/src/cchooks/contexts/stop.py b/src/cchooks/contexts/stop.py index 6e9901d..c197999 100644 --- a/src/cchooks/contexts/stop.py +++ b/src/cchooks/contexts/stop.py @@ -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.""" diff --git a/src/cchooks/contexts/subagent_stop.py b/src/cchooks/contexts/subagent_stop.py index b35cb30..50faafa 100644 --- a/src/cchooks/contexts/subagent_stop.py +++ b/src/cchooks/contexts/subagent_stop.py @@ -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.""" diff --git a/src/cchooks/contexts/user_prompt_submit.py b/src/cchooks/contexts/user_prompt_submit.py index 04df15d..e2b40b6 100644 --- a/src/cchooks/contexts/user_prompt_submit.py +++ b/src/cchooks/contexts/user_prompt_submit.py @@ -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.""" diff --git a/tests/contexts/test_post_tool_use.py b/tests/contexts/test_post_tool_use.py index 2656cbc..2f3c9de 100644 --- a/tests/contexts/test_post_tool_use.py +++ b/tests/contexts/test_post_tool_use.py @@ -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}" diff --git a/tests/contexts/test_pre_tool_use.py b/tests/contexts/test_pre_tool_use.py index 48b72a7..54c4533 100644 --- a/tests/contexts/test_pre_tool_use.py +++ b/tests/contexts/test_pre_tool_use.py @@ -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}" diff --git a/uv.lock b/uv.lock index be2b170..d584c62 100644 --- a/uv.lock +++ b/uv.lock @@ -9,7 +9,7 @@ resolution-markers = [ [[package]] name = "cchooks" -version = "0.1.4" +version = "0.1.5" source = { virtual = "." } [package.dev-dependencies]