From 2475c21221600ba302495a6885a382bb53e5ea5b Mon Sep 17 00:00:00 2001 From: Christina Date: Thu, 13 Nov 2025 12:03:58 -0600 Subject: [PATCH] feat: Add is_subagent() helper method to all hook context classes This adds the is_subagent() helper method to all hook context classes (PreToolUseContext, PostToolUseContext, SessionStartContext, SessionEndContext, UserPromptSubmitContext, NotificationContext, StopContext, SubagentStopContext, PreCompactContext). The method enables developers to detect whether a hook is running in a sub-agent context vs main Claude context by examining the transcript_path filename pattern. Main Claude sessions use 'session-{id}.jsonl', while sub-agents use 'agent-{id}.jsonl'. This enables tool access gating patterns where main Claude and delegated task contexts can have different authorization and capability requirements. Changes: - Add is_subagent() method to all 9 context classes - Comprehensive unit tests covering main Claude, sub-agent, and edge cases - All 320 existing tests pass without regression The implementation is non-breaking (adds new method only) and has zero performance overhead (just pattern matching on filename). --- src/cchooks/contexts/notification.py | 10 ++ src/cchooks/contexts/post_tool_use.py | 10 ++ src/cchooks/contexts/pre_compact.py | 10 ++ src/cchooks/contexts/pre_tool_use.py | 10 ++ src/cchooks/contexts/session_end.py | 10 ++ src/cchooks/contexts/session_start.py | 10 ++ src/cchooks/contexts/stop.py | 10 ++ src/cchooks/contexts/subagent_stop.py | 10 ++ src/cchooks/contexts/user_prompt_submit.py | 10 ++ tests/contexts/test_post_tool_use.py | 107 +++++++++++++++++++++ tests/contexts/test_pre_tool_use.py | 102 ++++++++++++++++++++ uv.lock | 2 +- 12 files changed, 300 insertions(+), 1 deletion(-) 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]