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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +137 -0
- data/README.md +287 -356
- data/claude_hooks.gemspec +2 -2
- data/docs/1.0.0_MIGRATION_GUIDE.md +228 -0
- data/docs/API/COMMON.md +83 -0
- data/docs/API/NOTIFICATION.md +32 -0
- data/docs/API/POST_TOOL_USE.md +45 -0
- data/docs/API/PRE_COMPACT.md +39 -0
- data/docs/API/PRE_TOOL_USE.md +110 -0
- data/docs/API/SESSION_END.md +100 -0
- data/docs/API/SESSION_START.md +40 -0
- data/docs/API/STOP.md +47 -0
- data/docs/API/SUBAGENT_STOP.md +47 -0
- data/docs/API/USER_PROMPT_SUBMIT.md +47 -0
- data/{WHY.md → docs/WHY.md} +15 -8
- data/docs/external/claude-hooks-reference.md +34 -0
- data/example_dotclaude/hooks/entrypoints/pre_tool_use.rb +25 -0
- data/example_dotclaude/hooks/entrypoints/session_end.rb +35 -0
- data/example_dotclaude/hooks/entrypoints/user_prompt_submit.rb +17 -12
- data/example_dotclaude/hooks/handlers/pre_tool_use/github_guard.rb +253 -0
- data/example_dotclaude/hooks/handlers/session_end/cleanup_handler.rb +55 -0
- data/example_dotclaude/hooks/handlers/session_end/log_session_stats.rb +64 -0
- data/example_dotclaude/hooks/handlers/user_prompt_submit/append_rules.rb +3 -2
- data/example_dotclaude/hooks/handlers/user_prompt_submit/log_user_prompt.rb +2 -2
- data/example_dotclaude/plugins/README.md +175 -0
- data/example_dotclaude/settings.json +22 -0
- data/lib/claude_hooks/base.rb +16 -24
- data/lib/claude_hooks/cli.rb +75 -1
- data/lib/claude_hooks/logger.rb +0 -1
- data/lib/claude_hooks/output/base.rb +152 -0
- data/lib/claude_hooks/output/notification.rb +22 -0
- data/lib/claude_hooks/output/post_tool_use.rb +76 -0
- data/lib/claude_hooks/output/pre_compact.rb +20 -0
- data/lib/claude_hooks/output/pre_tool_use.rb +94 -0
- data/lib/claude_hooks/output/session_end.rb +24 -0
- data/lib/claude_hooks/output/session_start.rb +49 -0
- data/lib/claude_hooks/output/stop.rb +83 -0
- data/lib/claude_hooks/output/subagent_stop.rb +14 -0
- data/lib/claude_hooks/output/user_prompt_submit.rb +78 -0
- data/lib/claude_hooks/post_tool_use.rb +6 -12
- data/lib/claude_hooks/pre_tool_use.rb +0 -37
- data/lib/claude_hooks/session_end.rb +43 -0
- data/lib/claude_hooks/session_start.rb +0 -23
- data/lib/claude_hooks/stop.rb +8 -25
- data/lib/claude_hooks/user_prompt_submit.rb +0 -26
- data/lib/claude_hooks/version.rb +1 -1
- data/lib/claude_hooks.rb +15 -0
- 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 |
|
data/{WHY.md → docs/WHY.md}
RENAMED
|
@@ -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
|
|
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 -
|
|
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
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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
|
+

|
|
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
|
+

|
|
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
|
-
|
|
20
|
+
append_rules.call
|
|
15
21
|
|
|
16
22
|
log_user_prompt = LogUserPrompt.new(input_data)
|
|
17
|
-
|
|
23
|
+
log_user_prompt.call
|
|
18
24
|
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
merged_output = ClaudeHooks::Output::UserPromptSubmit.merge(
|
|
26
|
+
append_rules.output,
|
|
27
|
+
log_user_prompt.output
|
|
28
|
+
)
|
|
21
29
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
|
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
|