Skip to content

Commit 590ccdf

Browse files
authored
Merge pull request #314 from neongreen/copilot/add-aihook-stop-hook
Add aihook tool with PreToolUse hook for validating cd commands in Bash
2 parents e40ff9f + 6b0ec53 commit 590ccdf

9 files changed

Lines changed: 797 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ This repository contains multiple independent projects.
2121
| [jj-run](jj-run/) | alpha | Jujutsu subcommand to execute shell commands against multiple revisions. |
2222
| [jj-run-py](jj-run-py/) | deprecated | Old version of jj-run written in Python. |
2323
| [tk-vscode](tk-vscode/) | alpha | VS Code extension that lists tk tasks by running `tk ls --json`. |
24+
| [aihook](aihook/) | alpha | Claude Code hook validator that enforces shell scripting best practices. |
2425

2526
## Libraries
2627

aihook/AGENTS.md

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
# Agent Guidelines for aihook
2+
3+
## Project Overview
4+
5+
`aihook` is a validator for Claude Code hooks that enforces shell scripting best practices. The primary use case is validating that `cd` commands are always executed within subshells.
6+
7+
## Project Structure
8+
9+
```
10+
aihook/
11+
├── main.go # CLI entry point (Cobra setup)
12+
├── pkg/
13+
│ └── validator/
14+
│ ├── validator.go # Core validation logic
15+
│ └── validator_test.go # Comprehensive unit tests
16+
├── README.md # User documentation
17+
└── AGENTS.md # This file
18+
```
19+
20+
**Separation of concerns:**
21+
- `main.go`: Cobra CLI setup, flag handling, output formatting
22+
- `pkg/validator`: Pure validation logic (AST walking, cd detection)
23+
- Tests are in the validator package, testing the pure logic
24+
25+
## Development Guidelines
26+
27+
### Building and Testing
28+
29+
Always run tests and build from the repository root:
30+
31+
```bash
32+
# Run tests
33+
go test ./aihook/...
34+
35+
# Build
36+
go build ./aihook
37+
38+
# Run
39+
./aihook stop < script.sh
40+
```
41+
42+
### Code Structure
43+
44+
- `main.go` (86 lines) - CLI entry point with Cobra framework
45+
- Command definitions and flag handling
46+
- Output formatting (regular and --claude JSON)
47+
- Calls into validator package for logic
48+
49+
- `pkg/validator/validator.go` (106 lines) - Core validation logic
50+
- `Validator` type with `ValidateScript()` method
51+
- AST walking to detect cd commands
52+
- Tracks subshell context (both `(...)` and `$(...)`)
53+
- `FormatViolations()` for user-friendly error messages
54+
55+
- `pkg/validator/validator_test.go` (330 lines) - Comprehensive tests
56+
- 41 test scenarios covering all edge cases
57+
- Tests the validator package directly
58+
59+
### Key Implementation Details
60+
61+
1. **Shell Parsing**: Uses `mvdan.cc/sh/v3/syntax` for accurate shell script parsing
62+
2. **AST Walking**: Traverses the syntax tree to find `cd` commands and track subshell context
63+
3. **Subshell Detection**: Handles both explicit subshells `(...)` and command substitutions `$(...)`
64+
4. **Output Formats**: Supports both human-readable and Claude Code JSON formats
65+
66+
### Testing Philosophy
67+
68+
Tests cover:
69+
- Basic cd detection (inside and outside subshells)
70+
- Complex nesting scenarios
71+
- Command substitution
72+
- Edge cases (loops, conditionals, functions)
73+
- Invalid shell syntax
74+
- Multiple cd commands in one script
75+
76+
All tests must pass before any changes are committed.
77+
78+
### Common Pitfalls
79+
80+
1. **Arithmetic Expansion**: `$((...))` is NOT a subshell, it's arithmetic expansion
81+
2. **Command Substitution**: `$(...)` IS a subshell and cd is allowed inside
82+
3. **Here Documents**: Content inside here-docs should not be parsed as commands
83+
84+
### Claude Code Hook Format
85+
86+
When `--claude` flag is used, output must be JSON with:
87+
- `exit_code`: Integer (0 for success, 2 for violations, 1 for errors)
88+
- `message`: String containing the human-readable message
89+
90+
Example:
91+
```json
92+
{
93+
"exit_code": 2,
94+
"message": "Found cd commands outside subshells:\n Line 1: 'cd' command found outside subshell\n\nAll 'cd' commands must be in a subshell. Example:\n # Bad: cd /tmp && ls\n # Good: (cd /tmp && ls)\n"
95+
}
96+
```
97+
98+
## Adding New Hook Types
99+
100+
When adding new hook types in the future:
101+
102+
1. Add a new subcommand in `main.go`
103+
2. Create a new validator in `pkg/validator` if needed
104+
3. Add comprehensive unit tests in the validator package
105+
4. Update README.md with usage examples
106+
5. Update this AGENTS.md with implementation notes
107+
108+
## Dependencies
109+
110+
- `github.com/spf13/cobra` - CLI framework
111+
- `mvdan.cc/sh/v3/syntax` - Shell script parser
112+
- `github.com/neongreen/mono/lib/version` - Shared version command
113+
114+
All dependencies are managed via the repository's root go.mod file.

aihook/README.md

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# aihook
2+
3+
A validator for Claude Code hooks that enforces shell scripting best practices.
4+
5+
## Overview
6+
7+
`aihook` is a command-line tool designed to validate shell scripts for use in Claude Code PreToolUse hooks. It parses shell syntax and enforces specific rules to ensure code quality and consistency.
8+
9+
## Features
10+
11+
- **PreToolUse Hook**: Validates Bash commands before execution to forbid `cd` invocations outside subshells
12+
- **Shell Parser**: Uses `mvdan.cc/sh/v3/syntax` for accurate shell script parsing
13+
- **Claude Code Integration**: Supports `--claude` flag for JSON output compatible with Claude Code hooks
14+
- **Comprehensive Validation**: Handles complex scenarios including:
15+
- Nested subshells
16+
- Command substitution (`$(...)`)
17+
- Conditional statements
18+
- Loops and functions
19+
- Here documents
20+
21+
## Installation
22+
23+
### From Source
24+
25+
```bash
26+
go install github.com/neongreen/mono/aihook@latest
27+
```
28+
29+
### Local Development
30+
31+
```bash
32+
go build ./aihook
33+
```
34+
35+
## Usage
36+
37+
### Stop Hook
38+
39+
The `stop` subcommand validates shell scripts and ensures all `cd` commands are executed within subshells:
40+
41+
```bash
42+
# Check a shell script from stdin
43+
echo 'cd /tmp' | aihook stop
44+
45+
# With Claude Code hook format output
46+
echo 'cd /tmp' | aihook stop --claude
47+
```
48+
49+
### Examples
50+
51+
**Bad (will fail validation):**
52+
```bash
53+
cd /tmp && ls
54+
```
55+
56+
**Good (will pass validation):**
57+
```bash
58+
(cd /tmp && ls)
59+
```
60+
61+
### Exit Codes
62+
63+
- `0`: No violations found
64+
- `2`: Violations detected (cd commands outside subshells)
65+
- `1`: Parse error or other failure
66+
67+
### Output Formats
68+
69+
#### Standard Output
70+
```
71+
Found cd commands outside subshells:
72+
Line 1: 'cd' command found outside subshell
73+
74+
All 'cd' commands must be in a subshell. Example:
75+
# Bad: cd /tmp && ls
76+
# Good: (cd /tmp && ls)
77+
```
78+
79+
#### Claude Code Hook Format (`--claude`)
80+
```json
81+
{
82+
"exit_code": 2,
83+
"message": "Found cd commands outside subshells:\n Line 1: 'cd' command found outside subshell\n\nAll 'cd' commands must be in a subshell. Example:\n # Bad: cd /tmp && ls\n # Good: (cd /tmp && ls)\n"
84+
}
85+
```
86+
87+
## Claude Code Integration
88+
89+
To use `aihook` as a Claude Code PreToolUse hook that validates Bash commands, add this to your `.claude/settings.json`:
90+
91+
```json
92+
{
93+
"hooks": {
94+
"PreToolUse": [
95+
{
96+
"matcher": "Bash",
97+
"hooks": [
98+
{
99+
"type": "command",
100+
"command": "aihook stop --claude"
101+
}
102+
]
103+
}
104+
]
105+
}
106+
}
107+
```
108+
109+
The hook will:
110+
- Match any Bash tool invocation (via the `"Bash"` matcher)
111+
- Receive the bash command script on stdin
112+
- Validate that all `cd` commands are in subshells
113+
- Return exit code 0 to allow, or exit code 2 to block with error message
114+
- Use `--claude` flag to output in the expected JSON format
115+
116+
For more flexible matching, you can use regex patterns like `"Bash.*cd"` to only check Bash commands containing `cd`.
117+
118+
## Why Forbid cd Outside Subshells?
119+
120+
Using `cd` outside subshells can cause unexpected behavior in shell scripts:
121+
122+
1. **Side Effects**: Changes the current directory for the entire script and subsequent commands
123+
2. **Hard to Debug**: Directory changes can happen far from where they're used
124+
3. **Error Prone**: Easy to forget to change back to the original directory
125+
4. **Not Composable**: Makes scripts harder to use in pipelines or as building blocks
126+
127+
By requiring `cd` in subshells `(cd /path && command)`, you ensure:
128+
- Directory changes are localized
129+
- The original directory is automatically restored
130+
- Scripts are more predictable and safer
131+
132+
## Development
133+
134+
### Running Tests
135+
136+
```bash
137+
go test ./aihook -v
138+
```
139+
140+
### Building
141+
142+
```bash
143+
go build ./aihook
144+
```
145+
146+
## License
147+
148+
See the root LICENSE file in the repository.

aihook/main.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/neongreen/mono/aihook/pkg/validator"
11+
"github.com/neongreen/mono/lib/version"
12+
)
13+
14+
var rootCmd = &cobra.Command{
15+
Use: "aihook",
16+
Short: "Claude Code hook validator",
17+
Long: `aihook validates shell commands and code patterns for Claude Code hooks.`,
18+
}
19+
20+
var stopCmd = &cobra.Command{
21+
Use: "stop",
22+
Short: "Stop hook that validates shell commands",
23+
Long: `Stop hook that parses shell syntax and forbids 'cd' invocations outside subshells.`,
24+
RunE: runStop,
25+
}
26+
27+
var claudeFlag bool
28+
29+
func init() {
30+
rootCmd.AddCommand(stopCmd)
31+
rootCmd.AddCommand(version.NewVersionCommand("aihook"))
32+
33+
stopCmd.Flags().BoolVar(&claudeFlag, "claude", false, "Output in Claude Code hook format")
34+
}
35+
36+
func main() {
37+
if err := rootCmd.Execute(); err != nil {
38+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
39+
os.Exit(1)
40+
}
41+
}
42+
43+
func runStop(cmd *cobra.Command, args []string) error {
44+
v := validator.New()
45+
violations, err := v.ValidateScript(os.Stdin)
46+
if err != nil {
47+
return formatOutput(err.Error(), 1)
48+
}
49+
50+
if len(violations) > 0 {
51+
msg := validator.FormatViolations(violations)
52+
return formatOutput(msg, 2)
53+
}
54+
55+
return formatOutput("No violations found", 0)
56+
}
57+
58+
// formatOutput formats the output according to the --claude flag
59+
func formatOutput(message string, exitCode int) error {
60+
if claudeFlag {
61+
// Claude Code hook format
62+
output := map[string]interface{}{
63+
"message": message,
64+
"exit_code": exitCode,
65+
}
66+
encoder := json.NewEncoder(os.Stdout)
67+
encoder.SetIndent("", " ")
68+
if err := encoder.Encode(output); err != nil {
69+
return fmt.Errorf("failed to encode JSON output: %w", err)
70+
}
71+
if exitCode != 0 {
72+
os.Exit(exitCode)
73+
}
74+
return nil
75+
}
76+
77+
// Regular output
78+
if exitCode == 0 {
79+
fmt.Println(message)
80+
return nil
81+
}
82+
83+
fmt.Fprintln(os.Stderr, message)
84+
os.Exit(exitCode)
85+
return nil
86+
}

0 commit comments

Comments
 (0)