claude_hooks 0.2.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +137 -0
  3. data/README.md +287 -356
  4. data/claude_hooks.gemspec +2 -2
  5. data/docs/1.0.0_MIGRATION_GUIDE.md +228 -0
  6. data/docs/API/COMMON.md +83 -0
  7. data/docs/API/NOTIFICATION.md +32 -0
  8. data/docs/API/POST_TOOL_USE.md +45 -0
  9. data/docs/API/PRE_COMPACT.md +39 -0
  10. data/docs/API/PRE_TOOL_USE.md +110 -0
  11. data/docs/API/SESSION_END.md +100 -0
  12. data/docs/API/SESSION_START.md +40 -0
  13. data/docs/API/STOP.md +47 -0
  14. data/docs/API/SUBAGENT_STOP.md +47 -0
  15. data/docs/API/USER_PROMPT_SUBMIT.md +47 -0
  16. data/{WHY.md → docs/WHY.md} +15 -8
  17. data/docs/external/claude-hooks-reference.md +34 -0
  18. data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -0
  19. data/example_dotclaude/hooks/entrypoints/session_end.rb +35 -0
  20. data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +17 -12
  21. data/example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb +253 -0
  22. data/example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb +55 -0
  23. data/example_dotclaude/hooks/handlers/session_end/log_session_stats.rb +64 -0
  24. data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +3 -2
  25. data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
  26. data/example_dotclaude/plugins/README.md +175 -0
  27. data/example_dotclaude/settings.json +22 -0
  28. data/lib/claude_hooks/base.rb +16 -24
  29. data/lib/claude_hooks/cli.rb +75 -1
  30. data/lib/claude_hooks/logger.rb +0 -1
  31. data/lib/claude_hooks/output/base.rb +152 -0
  32. data/lib/claude_hooks/output/notification.rb +22 -0
  33. data/lib/claude_hooks/output/post_tool_use.rb +76 -0
  34. data/lib/claude_hooks/output/pre_compact.rb +20 -0
  35. data/lib/claude_hooks/output/pre_tool_use.rb +94 -0
  36. data/lib/claude_hooks/output/session_end.rb +24 -0
  37. data/lib/claude_hooks/output/session_start.rb +49 -0
  38. data/lib/claude_hooks/output/stop.rb +83 -0
  39. data/lib/claude_hooks/output/subagent_stop.rb +14 -0
  40. data/lib/claude_hooks/output/user_prompt_submit.rb +78 -0
  41. data/lib/claude_hooks/post_tool_use.rb +6 -12
  42. data/lib/claude_hooks/pre_tool_use.rb +0 -37
  43. data/lib/claude_hooks/session_end.rb +43 -0
  44. data/lib/claude_hooks/session_start.rb +0 -23
  45. data/lib/claude_hooks/stop.rb +8 -25
  46. data/lib/claude_hooks/user_prompt_submit.rb +0 -26
  47. data/lib/claude_hooks/version.rb +1 -1
  48. data/lib/claude_hooks.rb +15 -0
  49. metadata +37 -8
data/docs/API/STOP.md ADDED
@@ -0,0 +1,47 @@
1
+ # Stop API
2
+
3
+ Available when inheriting from `ClaudeHooks::Stop`:
4
+
5
+ ## Input Helpers
6
+ Input helpers to access the data provided by Claude Code through `STDIN`.
7
+
8
+ [📚 Shared input helpers](COMMON.md#input-helpers)
9
+
10
+ | Method | Description |
11
+ |--------|-------------|
12
+ | `stop_hook_active` | Check if Claude Code is already continuing as a result of a stop hook |
13
+
14
+ ## Hook State Helpers
15
+ Hook state methods are helpers to modify the hook's internal state (`output_data`) before yielding back to Claude Code.
16
+
17
+ [📚 Shared hook state methods](COMMON.md#hook-state-methods)
18
+
19
+ > [!NOTE]
20
+ > In Stop hooks, the decision to "block" actually means to "force Claude to continue", we are "blocking" the blockage.
21
+ > This is counterintuitive and the reason why the method `block!` is aliased to `continue_with_instructions!`
22
+
23
+ | Method | Description |
24
+ |--------|-------------|
25
+ | `continue_with_instructions!(instructions)` | Block Claude from stopping and provide instructions to continue |
26
+ | `block!(instructions)` | Alias for `continue_with_instructions!` |
27
+ | `ensure_stopping!` | Allow Claude to stop normally (default behavior) |
28
+
29
+ ## Output Helpers
30
+ Output helpers provide access to the hook's output data and helper methods for working with the output state.
31
+
32
+ [📚 Shared output helpers](COMMON.md#output-helpers)
33
+
34
+ | Method | Description |
35
+ |--------|-------------|
36
+ | `output.should_continue?` | Check if Claude should be forced to continue (decision == 'block') |
37
+ | `output.should_stop?` | Check if Claude should stop normally (decision != 'block') |
38
+ | `output.continue_instructions` | Get the continue instructions (alias for `reason`) |
39
+ | `output.reason` | Get the reason for the decision |
40
+
41
+ ## Hook Exit Codes
42
+
43
+ | Exit Code | Behavior |
44
+ |-----------|----------|
45
+ | `exit 0` | Agent will stop<br/>`STDOUT` shown to user in transcript mode |
46
+ | `exit 1` | Non-blocking error<br/>`STDERR` shown to user |
47
+ | `exit 2` | **Blocks stoppage**<br/>`STDERR` shown to Claude |
@@ -0,0 +1,47 @@
1
+ # SubagentStop API
2
+
3
+ Available when inheriting from `ClaudeHooks::SubagentStop` (inherits from `ClaudeHooks::Stop`):
4
+
5
+ ## Input Helpers
6
+ Input helpers to access the data provided by Claude Code through `STDIN`.
7
+
8
+ [📚 Shared input helpers](COMMON.md#input-helpers)
9
+
10
+ | Method | Description |
11
+ |--------|-------------|
12
+ | `stop_hook_active` | Check if Claude Code is already continuing as a result of a stop hook |
13
+
14
+ ## Hook State Helpers
15
+ Hook state methods are helpers to modify the hook's internal state (`output_data`) before yielding back to Claude Code.
16
+
17
+ [📚 Shared hook state methods](COMMON.md#hook-state-methods)
18
+
19
+ > [!NOTE]
20
+ > In Stop hooks, the decision to "block" actually means to "force Claude to continue", we are "blocking" the blockage.
21
+ > This is counterintuitive and the reason why the method `block!` is aliased to `continue_with_instructions!`
22
+
23
+ | Method | Description |
24
+ |--------|-------------|
25
+ | `continue_with_instructions!(instructions)` | Block Claude from stopping and provide instructions to continue |
26
+ | `block!(instructions)` | Alias for `continue_with_instructions!` |
27
+ | `ensure_stopping!` | Allow Claude to stop normally (default behavior) |
28
+
29
+ ## Output Helpers
30
+ Output helpers provide access to the hook's output data and helper methods for working with the output state.
31
+
32
+ [📚 Shared output helpers](COMMON.md#output-helpers)
33
+
34
+ | Method | Description |
35
+ |--------|-------------|
36
+ | `output.should_continue?` | Check if Claude should be forced to continue (decision == 'block') |
37
+ | `output.should_stop?` | Check if Claude should stop normally (decision != 'block') |
38
+ | `output.continue_instructions` | Get the continue instructions (alias for `reason`) |
39
+ | `output.reason` | Get the reason for the decision |
40
+
41
+ ## Hook Exit Codes
42
+
43
+ | Exit Code | Behavior |
44
+ |-----------|----------|
45
+ | `exit 0` | Subagent will stop<br/>`STDOUT` shown to user in transcript mode |
46
+ | `exit 1` | Non-blocking error<br/>`STDERR` shown to user |
47
+ | `exit 2` | **Blocks stoppage**<br/>`STDERR` shown to Claude subagent |
@@ -0,0 +1,47 @@
1
+ # UserPromptSubmit API
2
+
3
+ Available when inheriting from `ClaudeHooks::UserPromptSubmit`:
4
+
5
+ ## Input Helpers
6
+ Input helpers to access the data provided by Claude Code through `STDIN`.
7
+
8
+ [📚 Shared input helpers](COMMON.md#input-helpers)
9
+
10
+ | Method | Description |
11
+ |--------|-------------|
12
+ | `prompt` | Get the user's prompt text |
13
+ | `user_prompt` | Alias for `prompt` |
14
+ | `current_prompt` | Alias for `prompt` |
15
+
16
+ ## Hook State Helpers
17
+ Hook state methods are helpers to modify the hook's internal state (`output_data`) before yielding back to Claude Code.
18
+
19
+ [📚 Shared hook state methods](COMMON.md#hook-state-methods)
20
+
21
+ | Method | Description |
22
+ |--------|-------------|
23
+ | `add_additional_context!(context)` | Add context to the prompt |
24
+ | `add_context!(context)` | Alias for `add_additional_context!` |
25
+ | `empty_additional_context!` | Remove additional context |
26
+ | `block_prompt!(reason)` | Block the prompt from processing |
27
+ | `unblock_prompt!` | Unblock a previously blocked prompt |
28
+
29
+ ## Output Helpers
30
+ Output helpers provide access to the hook's output data and helper methods for working with the output state.
31
+
32
+ [📚 Shared output helpers](COMMON.md#output-helpers)
33
+
34
+ | Method | Description |
35
+ |--------|-------------|
36
+ | `output.decision` | Get the decision: "block" or nil (default) |
37
+ | `output.reason` | Get the reason for the decision |
38
+ | `output.blocked?` | Check if the prompt has been blocked (decision == 'block') |
39
+ | `output.additional_context` | Get the additional context that was added |
40
+
41
+ ## Hook Exit Codes
42
+
43
+ | Exit Code | Behavior |
44
+ |-----------|----------|
45
+ | `exit 0` | Operation continues<br/>**`STDOUT` added as context to Claude** |
46
+ | `exit 1` | Non-blocking error<br/>`STDERR` shown to user |
47
+ | `exit 2` | **Blocks prompt processing**<br/>**Erases prompt**<br/>`STDERR` shown to user only |
@@ -3,6 +3,7 @@
3
3
  When creating fairly complex hook systems for Claude Code it is easy to end up either with:
4
4
  - Fairly monolithic scripts that becomes hard to maintain and have to handle complex merging logic for the output.
5
5
  - A lot of scripts set up in the `settings.json` that all use the same hook input / output logic that needs to be rewritten multiple times.
6
+ - Having to understand the very chaotic exit code and output steam logic of Claude Code and how to handle it.
6
7
 
7
8
  Furthermore, Claude's documentation on hooks is a bit hard to navigate and setting up hooks is not necessarily intuitive or straightforward having to play around with STDIN, STDOUT, STDERR, exit codes, etc.
8
9
 
@@ -25,9 +26,9 @@ settings.json
25
26
 
26
27
  ## For both approaches it will bring
27
28
 
28
- 1. Normalized Input/Output handling - input parsing, validation, straightforward output helpers (block_tool!, add_context!).
29
+ 1. Normalized Input/Output handling - input parsing, validation, straightforward output helpers (`ask_for_permission!`, `approve_tool!`, `block_tool!`, `block_prompt!`, `add_context!`).
29
30
 
30
- 2. Hook-Specific APIs - 8 different hook types with tailored methods (e.g., ask_for_permission! vs block_tool!) and smart merge logic for combining outputs.
31
+ 2. Hook-Specific APIs - all 9 hook types with tailored methods and output objects (with `output_and_exit`) and smart merge logic for combining outputs.
31
32
 
32
33
  3. Session-Based Logging - Dedicated logger to understand the flow of what happens in Claude Code and write it out to a `session-{session_id}.log` file.
33
34
 
@@ -35,7 +36,6 @@ settings.json
35
36
 
36
37
  5. Testing Support - Standalone execution mode for individual hook testing and CLI testing with sample JSON input.
37
38
 
38
-
39
39
  ## For a monolithic approach it will additionally bring
40
40
 
41
41
  1. Composable Hook Handlers
@@ -43,11 +43,19 @@ For instance, `entrypoints/user_prompt_submit.rb` orchestrates multiple handlers
43
43
  ```ruby
44
44
  # Add contextual rules
45
45
  append_rules_result = AppendRules.new(input_data).call
46
+ append_rules_result.call
47
+
46
48
  # Audit logging
47
49
  log_result = LogUserPrompt.new(input_data).call
48
-
49
- # Merge outputs to Claude Code
50
- puts ClaudeHooks::UserPromptSubmit.merge_outputs(append_rules_result, log_result)
50
+ log_result.call
51
+
52
+ # Merge outputs and yield back to Claude Code
53
+ merged = ClaudeHooks::Output::UserPromptSubmit.merge(
54
+ append_rules.output,
55
+ log_handler.output
56
+ )
57
+ merged.output_and_exit
58
+ end
51
59
  ```
52
60
 
53
61
  2. Intelligent Output Merging
@@ -59,7 +67,6 @@ puts ClaudeHooks::UserPromptSubmit.merge_outputs(append_rules_result, log_result
59
67
  Each handler can run standalone for testing:
60
68
  ```ruby
61
69
  if __FILE__ == $0
62
- hook = AppendRules.new(JSON.parse(STDIN.read))
63
- puts hook.stringify_output
70
+ ClaudeHooks::CLI.test_runner(AppendRules)
64
71
  end
65
72
  ```
@@ -0,0 +1,34 @@
1
+ Documentation
2
+
3
+ Page
4
+
5
+ ## Page Not Found
6
+
7
+ ### The requested page could not be found.
8
+
9
+ Go back
10
+
11
+ Ask Docs
12
+ ![Chat avatar](https://platform.claude.com/docs/images/book-icon-light.svg)
13
+
14
+ a.claude.ai
15
+
16
+ # a.claude.ai is blocked
17
+
18
+ **a.claude.ai** refused to connect.
19
+
20
+ ERR\_BLOCKED\_BY\_RESPONSE
21
+
22
+ **a.claude.ai** refused to connect.
23
+
24
+ ![](<Base64-Image-Removed>)![](<Base64-Image-Removed>)
25
+
26
+ Invalid domain for site key.
27
+
28
+ ERROR for site owner:
29
+
30
+ Invalid domain for site key
31
+
32
+ reCAPTCHA
33
+
34
+ [Privacy](https://www.google.com/intl/en/policies/privacy/) \- [Terms](https://www.google.com/intl/en/policies/terms/)
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'claude_hooks'
5
+ require_relative '../handlers/pre_tool_use/github_guard'
6
+
7
+ begin
8
+ # Read Claude Code input from stdin
9
+ input_data = JSON.parse($stdin.read)
10
+
11
+ github_guard = GithubGuard.new(input_data)
12
+ github_guard.call
13
+
14
+ github_guard.output_and_exit
15
+ rescue StandardError => e
16
+ puts JSON.generate(
17
+ {
18
+ continue: false,
19
+ stopReason: "Error in PreToolUse hook, #{e.message}, #{e.backtrace.join("\n")}",
20
+ suppressOutput: false,
21
+ },
22
+ )
23
+ # Allow anyway, to not block developers if there is an issue with the hook
24
+ exit 1
25
+ end
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'json'
4
+ require_relative '../handlers/session_end/cleanup_handler'
5
+ require_relative '../handlers/session_end/log_session_stats'
6
+
7
+ begin
8
+ # Read input from stdin
9
+ input_data = JSON.parse(STDIN.read)
10
+
11
+ # Initialize handlers
12
+ cleanup_handler = CleanupHandler.new(input_data)
13
+ log_handler = LogSessionStats.new(input_data)
14
+
15
+ # Execute handlers
16
+ cleanup_handler.call
17
+ log_handler.call
18
+
19
+ # Merge outputs using the SessionEnd output merger
20
+ merged_output = ClaudeHooks::Output::SessionEnd.merge(
21
+ cleanup_handler.output,
22
+ log_handler.output
23
+ )
24
+
25
+ # Output result and exit with appropriate code
26
+ merged_output.output_and_exit
27
+
28
+ rescue StandardError => e
29
+ STDERR.puts JSON.generate({
30
+ continue: false,
31
+ stopReason: "Hook execution error: #{e.message}",
32
+ suppressOutput: false
33
+ })
34
+ exit 2
35
+ end
@@ -1,7 +1,13 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ # Example of the NEW simplified entrypoint pattern using output objects
4
+ # Compare this to the existing user_prompt_submit.rb to see the difference!
5
+
3
6
  require 'claude_hooks'
4
7
  require 'json'
8
+ # Require the output classes
9
+ require_relative '../../../lib/claude_hooks/output/base'
10
+ require_relative '../../../lib/claude_hooks/output/user_prompt_submit'
5
11
  require_relative '../handlers/user_prompt_submit/append_rules'
6
12
  require_relative '../handlers/user_prompt_submit/log_user_prompt'
7
13
 
@@ -11,25 +17,24 @@ begin
11
17
 
12
18
  # Execute all hook scripts
13
19
  append_rules = AppendRules.new(input_data)
14
- appended_rules_result = append_rules.call
20
+ append_rules.call
15
21
 
16
22
  log_user_prompt = LogUserPrompt.new(input_data)
17
- log_user_prompt_result = log_user_prompt.call
23
+ log_user_prompt.call
18
24
 
19
- # Merge all hook results intelligently using the UserPromptSubmit class method
20
- hook_output = ClaudeHooks::UserPromptSubmit.merge_outputs(appended_rules_result, log_user_prompt_result)
25
+ merged_output = ClaudeHooks::Output::UserPromptSubmit.merge(
26
+ append_rules.output,
27
+ log_user_prompt.output
28
+ )
21
29
 
22
- # Output final merged result to Claude Code
23
- puts JSON.generate(hook_output)
30
+ merged_output.output_and_exit
24
31
 
25
- exit 0 # Don't forget to exit with the right exit code (0, 1, 2)
26
32
  rescue StandardError => e
27
- STDERR.puts "Error in UserPromptSubmit hook: #{e.message} #{e.backtrace.join("\n")}"
28
-
29
- puts JSON.generate({
33
+ # Same simple error pattern
34
+ STDERR.puts JSON.generate({
30
35
  continue: false,
31
36
  stopReason: "Hook execution error: #{e.message} #{e.backtrace.join("\n")}",
32
37
  suppressOutput: false
33
38
  })
34
- exit 1
35
- end
39
+ exit 2
40
+ end
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'claude_hooks'
5
+ require 'json'
6
+ require 'open3'
7
+
8
+ # GitHub Guard hook to prevent unauthorized or dangerous GitHub/Git actions
9
+ class GithubGuard < ClaudeHooks::PreToolUse
10
+ BLOCKED_TOOL_TIP = 'If they are sure they want to proceed, the user should run the command themselves using `!` (e.g. `!gh pr merge`, `!git push --force`, etc...)'
11
+
12
+ RULES = {
13
+ # MCP GitHub tools
14
+ mcp_tools: {
15
+ always_blocked: %w[
16
+ mcp__github__delete_repository
17
+ mcp__github__delete_file
18
+ ],
19
+ owner_restricted_pr: %w[
20
+ mcp__github__merge_pull_request
21
+ mcp__github__update_pull_request
22
+ ],
23
+ pr_draft_required: %w[
24
+ mcp__github__create_pull_request
25
+ ],
26
+ },
27
+
28
+ # Bash command patterns (gh & github)
29
+ bash_patterns: {
30
+ always_blocked: [
31
+ /\Agh\s+pr\s+merge.*--rebase/,
32
+ /\Agh\s+repo\s+delete/,
33
+ /\Agh\s+secret\s+(set|delete)/,
34
+ /\Agit\s+push\s+.*--force/,
35
+ /\Agit\s+reset\s+--hard/,
36
+ /\Agit\s+reset\s+--hard\s+HEAD~[0-9]+/,
37
+ /\Agit\s+clean\s+-[fd]/,
38
+ /\Agit\s+reflog\s+expire/,
39
+ /\Agit\s+filter-branch/,
40
+ /\Agit\s+checkout\s+--\s+\./,
41
+ ],
42
+ requires_permission: [
43
+ /\Agh\s+api/,
44
+ /\Agit\s+branch\s+(-D|-d|--delete)/,
45
+ /\Agit\s+rebase\s+(master|main)/,
46
+ /\Agit\s+commit\s+--amend/,
47
+ /\Agit\s+rebase\s+-i/,
48
+ ],
49
+ owner_restricted_pr: [
50
+ 'gh pr merge',
51
+ 'gh pr edit',
52
+ 'gh pr close',
53
+ 'gh pr ready',
54
+ 'gh pr lock',
55
+ ],
56
+ pr_draft_required: [
57
+ 'gh pr create',
58
+ ],
59
+ },
60
+ }.freeze
61
+
62
+ CURRENT_USER = begin
63
+ github_user_data = Open3.capture2('gh api user').then { |data, _| JSON.parse(data) }
64
+ git_user, = Open3.capture2('git config user.name')
65
+
66
+ {
67
+ name: github_user_data['name'] || git_user.strip,
68
+ login: github_user_data['login'] || '',
69
+ }
70
+ rescue StandardError => e
71
+ log "Error fetching github user data, #{e.message}, make sure Github CLI is installed and you are logged in.",
72
+ :error
73
+ { name: '', login: '' }
74
+ end
75
+
76
+ def call
77
+ log "Input data: #{input_data.inspect}"
78
+
79
+ case tool_name
80
+ when /\Amcp__github__/
81
+ log "Checking MCP tool: #{tool_name}"
82
+ validate_mcp_github_tool!
83
+ when 'Bash'
84
+ log "Checking tool: #{tool_name}(#{command})"
85
+ validate_bash_command!
86
+ else
87
+ approve_tool!("Tool #{tool_name} is allowed")
88
+ end
89
+
90
+ output_data
91
+ end
92
+
93
+ private
94
+
95
+ # Main validation methods
96
+ def validate_mcp_github_tool!
97
+ return block_with_tip!("#{tool_name} is dangerous and not allowed.") if always_blocked_mcp_tool?
98
+ return validate_pr_ownership!(tool_name, extract_pr_number_from_input) if owner_restricted_mcp_tool?
99
+ return validate_mcp_pr_draft_mode! if pr_draft_required_mcp_tool?
100
+
101
+ approve_tool!('Safe github MCP call')
102
+ end
103
+
104
+ def validate_bash_command!
105
+ return block_with_tip!("Command blocked: #{command} - dangerous pattern.") if always_blocked_command?
106
+ return ask_for_permission!("Command requires permission: #{command}") if requires_permission_command?
107
+ return validate_pr_ownership!(command, extract_pr_number_from_command) if owner_restricted_pr_command?
108
+ return prevent_remote_branch_deletion! if remote_branch_deletion_command?
109
+ return validate_bash_pr_draft_mode! if pr_draft_required_command?
110
+
111
+ approve_tool!('Safe bash command')
112
+ end
113
+
114
+ # Helper validation methods
115
+ def validate_pr_ownership!(context, pr_number)
116
+ return ask_for_permission!("Could not determine PR number from #{context}") unless pr_number
117
+
118
+ pr_owner = get_pr_owner(pr_number)
119
+ return ask_for_permission!("Could not determine owner of PR ##{pr_number}") unless pr_owner
120
+
121
+ if user_owns_pr?(pr_owner)
122
+ approve_tool!("PR ##{pr_number} belongs to current user (#{pr_owner})")
123
+ else
124
+ block_with_tip!("Cannot execute '#{context}' - PR ##{pr_number} belongs to #{pr_owner}, you are #{CURRENT_USER[:login]}") # rubocop:disable Layout/LineLength
125
+ end
126
+ end
127
+
128
+ def validate_mcp_pr_draft_mode!
129
+ if tool_input&.dig('draft') == true
130
+ approve_tool!('PR creation allowed (draft mode)')
131
+ else
132
+ block_with_tip!('PR creation must use draft: true. All PRs should be created as drafts.')
133
+ end
134
+ end
135
+
136
+ def validate_bash_pr_draft_mode!
137
+ if command.include?('--draft')
138
+ approve_tool!('PR creation allowed (draft mode)')
139
+ else
140
+ block_with_tip!('gh pr create must use --draft flag. All PRs should be created as drafts.')
141
+ end
142
+ end
143
+
144
+ def prevent_remote_branch_deletion!
145
+ branch = extract_branch_from_deletion
146
+ return ask_for_permission!('Could not determine branch name') unless branch
147
+
148
+ if remote_branch_deletion?
149
+ block_with_tip!("Cannot delete remote branch '#{branch}'")
150
+ else
151
+ approve_tool!('Branch deletion allowed')
152
+ end
153
+ end
154
+
155
+ # Rule matching methods
156
+ def always_blocked_mcp_tool?
157
+ RULES[:mcp_tools][:always_blocked].include?(tool_name)
158
+ end
159
+
160
+ def owner_restricted_mcp_tool?
161
+ RULES[:mcp_tools][:owner_restricted_pr].include?(tool_name)
162
+ end
163
+
164
+ def pr_draft_required_mcp_tool?
165
+ RULES[:mcp_tools][:pr_draft_required].include?(tool_name)
166
+ end
167
+
168
+ def always_blocked_command?
169
+ RULES[:bash_patterns][:always_blocked].any? { |pattern| command.match?(pattern) }
170
+ end
171
+
172
+ def requires_permission_command?
173
+ RULES[:bash_patterns][:requires_permission].any? { |pattern| command.match?(pattern) }
174
+ end
175
+
176
+ def owner_restricted_pr_command?
177
+ RULES[:bash_patterns][:owner_restricted_pr].any? { |cmd| command.start_with?(cmd) }
178
+ end
179
+
180
+ def pr_draft_required_command?
181
+ RULES[:bash_patterns][:pr_draft_required].any? { |cmd| command.start_with?(cmd) }
182
+ end
183
+
184
+ def remote_branch_deletion_command?
185
+ command.match?(/\Agit\s+push.*(--delete|-d|-D)/)
186
+ end
187
+
188
+ def remote_branch_deletion?
189
+ command.include?('origin') || command.include?('upstream')
190
+ end
191
+
192
+ # Utility methods
193
+ def user_owns_pr?(pr_owner)
194
+ pr_owner == CURRENT_USER[:login] || pr_owner == CURRENT_USER[:name]
195
+ end
196
+
197
+ # Extraction methods
198
+ def command
199
+ @command ||= tool_input&.dig('command') || ''
200
+ end
201
+
202
+ def extract_pr_number_from_input
203
+ params = tool_input || {}
204
+ params['pullNumber'] || params['pull_number'] || params['number']
205
+ end
206
+
207
+ def extract_pr_number_from_command
208
+ command.match(/\s+(\d+)/)&.[](1)&.to_i
209
+ end
210
+
211
+ def extract_branch_from_deletion
212
+ command.match(/(?:--delete|-d|-D)\s+(\S+)/)&.[](1)
213
+ end
214
+
215
+ def get_pr_owner(pr_number)
216
+ return nil unless pr_number
217
+
218
+ begin
219
+ # Try to get PR info using gh CLI
220
+ pr_info, status = Open3.capture2e("gh pr view #{pr_number} --json author")
221
+
222
+ if status.success?
223
+ data = JSON.parse(pr_info)
224
+ data.dig('author', 'login')
225
+ else
226
+ log "Could not fetch PR info: #{pr_info}", :warn
227
+ nil
228
+ end
229
+ rescue StandardError => e
230
+ log "Error fetching PR owner: #{e.message}", :error
231
+ nil
232
+ end
233
+ end
234
+
235
+ # Utility methods
236
+ def block_with_tip!(message)
237
+ block_tool!("#{message}\n#{BLOCKED_TOOL_TIP}")
238
+ end
239
+ end
240
+
241
+ # When running this file directly (for debugging)
242
+ if __FILE__ == $PROGRAM_NAME
243
+ ClaudeHooks::CLI.run_with_sample_data(GithubGuard) do |data|
244
+ data.merge!(
245
+ 'session_id' => 'GithubGuardTest',
246
+ 'transcript_path' => '',
247
+ 'cwd' => Dir.pwd,
248
+ 'hook_event_name' => 'PreToolUse',
249
+ 'tool_name' => 'mcp__github__create_pull_request',
250
+ 'tool_input' => { 'draft' => false },
251
+ )
252
+ end
253
+ end
@@ -0,0 +1,55 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../../../../lib/claude_hooks'
4
+
5
+ class CleanupHandler < ClaudeHooks::SessionEnd
6
+ def call
7
+ log "Session ended with reason: #{reason}"
8
+
9
+ case reason
10
+ when 'clear'
11
+ log "Performing cleanup after /clear command"
12
+ cleanup_temp_files
13
+ when 'logout'
14
+ log "Performing cleanup after logout"
15
+ save_session_state
16
+ when 'prompt_input_exit'
17
+ log "User exited during prompt input"
18
+ save_partial_state
19
+ else
20
+ log "General session cleanup"
21
+ general_cleanup
22
+ end
23
+
24
+ output
25
+ end
26
+
27
+ private
28
+
29
+ def cleanup_temp_files
30
+ log "Cleaning up temporary files for session #{session_id}"
31
+ # Example cleanup logic
32
+ end
33
+
34
+ def save_session_state
35
+ log "Saving session state for session #{session_id}"
36
+ # Example state saving logic
37
+ end
38
+
39
+ def save_partial_state
40
+ log "Saving partial state for session #{session_id}"
41
+ # Example partial state saving logic
42
+ end
43
+
44
+ def general_cleanup
45
+ log "Performing general cleanup for session #{session_id}"
46
+ # Example general cleanup logic
47
+ end
48
+ end
49
+
50
+ # CLI testing support
51
+ if __FILE__ == $0
52
+ ClaudeHooks::CLI.test_runner(CleanupHandler) do |input_data|
53
+ input_data['reason'] ||= 'other'
54
+ end
55
+ end