Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 34 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# hookshot

A Go library for building hooks for AI coding agents like [Cursor](https://cursor.com/docs/agent/hooks), [Claude Code](https://docs.claude.com/en/docs/claude-code/hooks), [Windsurf Cascade](https://docs.codeium.com/windsurf/memories#hooks), and [Factory Droid](https://docs.factory.ai/reference/hooks-reference).
A Go library for building hooks for AI coding agents like [Cursor](https://cursor.com/docs/agent/hooks), [Claude Code](https://docs.claude.com/en/docs/claude-code/hooks), [Windsurf Cascade](https://docs.codeium.com/windsurf/memories#hooks), [Factory Droid](https://docs.factory.ai/reference/hooks-reference), and [OpenAI Codex](https://developers.openai.com/codex/hooks).

Hooks are a key component of [Agentic Coding Security Management (ACSM)](https://corridor.dev/blog/introducing-acsm/) — they let you observe, control, and secure AI agent behavior in your development environment.

Expand Down Expand Up @@ -97,16 +97,33 @@ hookshot install --binary /path/to/my-hooks
}
```

### OpenAI Codex (`~/.codex/hooks.json`)

Codex hooks are enabled by default (the `hooks` feature flag in Codex is
stable and on). No `~/.codex/config.toml` change is required. If your
organization disabled hooks, set `[features].hooks = true` to turn them
back on.

```json
{
"hooks": {
"Stop": [{ "hooks": [{ "type": "command", "command": "/path/to/my-hooks codex-stop" }] }],
"PreToolUse": [{ "matcher": "Bash|apply_patch|mcp__.*", "hooks": [{ "type": "command", "command": "/path/to/my-hooks codex-pre-tool-use" }] }],
"PostToolUse": [{ "matcher": "Bash|apply_patch|mcp__.*", "hooks": [{ "type": "command", "command": "/path/to/my-hooks codex-post-tool-use" }] }]
}
}
```

## Unified Handlers

Write once, run on all four platforms:
Write once, run on all five platforms:

| Handler | Claude Code | Cursor | Windsurf Cascade | Factory Droid |
|---------|-------------|--------|------------------|---------------|
| `OnStop` | Stop | stop | post-cascade-response | Stop |
| `OnBeforeExecution` | PreToolUse | beforeShellExecution, beforeMCPExecution | pre-run-command, pre-mcp-tool-use | PreToolUse |
| `OnAfterFileEdit` | PostToolUse | afterFileEdit | post-write-code | PostToolUse |
| `OnPromptSubmit` | UserPromptSubmit | beforeSubmitPrompt | pre-user-prompt | UserPromptSubmit |
| Handler | Claude Code | Cursor | Windsurf Cascade | Factory Droid | OpenAI Codex |
|---------|-------------|--------|------------------|---------------|--------------|
| `OnStop` | Stop | stop | post-cascade-response | Stop | Stop |
| `OnBeforeExecution` | PreToolUse | beforeShellExecution, beforeMCPExecution | pre-run-command, pre-mcp-tool-use | PreToolUse | PreToolUse |
| `OnAfterFileEdit` | PostToolUse | afterFileEdit | post-write-code | PostToolUse | PostToolUse |
| `OnPromptSubmit` | UserPromptSubmit | beforeSubmitPrompt | pre-user-prompt | UserPromptSubmit | UserPromptSubmit |

## Platform-Specific Handlers

Expand Down Expand Up @@ -140,6 +157,13 @@ hookshot.Register("droid-pre-tool-use", func() {
return droid.PassThrough()
})
})

// OpenAI Codex: Pre-tool use (matches Bash, apply_patch, and MCP tools)
hookshot.Register("codex-pre-tool-use", func() {
hookshot.Run(func(input codex.PreToolUseInput) codex.PreToolUseOutput {
return codex.PassThrough()
})
})
```

## Documentation
Expand All @@ -149,6 +173,7 @@ hookshot.Register("droid-pre-tool-use", func() {
- [Cursor Reference](docs/reference-cursor.md)
- [Windsurf Cascade Reference](docs/reference-cascade.md)
- [Factory Droid Reference](docs/reference-droid.md)
- [OpenAI Codex Reference](docs/reference-codex.md)

Full API documentation is available via godoc:

Expand All @@ -158,6 +183,7 @@ go doc github.com/CorridorSecurity/hookshot/claude
go doc github.com/CorridorSecurity/hookshot/cursor
go doc github.com/CorridorSecurity/hookshot/cascade
go doc github.com/CorridorSecurity/hookshot/droid
go doc github.com/CorridorSecurity/hookshot/codex
```

Or view online at [pkg.go.dev/github.com/CorridorSecurity/hookshot](https://pkg.go.dev/github.com/CorridorSecurity/hookshot).
Expand Down
134 changes: 118 additions & 16 deletions cmd/hookshot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
//
// hookshot install --binary ./my-hooks
// hookshot install --binary ./my-hooks --claude --cursor
// hookshot install --binary ./my-hooks --codex
package main

import (
Expand Down Expand Up @@ -56,7 +57,7 @@ Usage:

Commands:
build Build hooks binary for one or more platforms
install Install hooks to AI coding agent config files (Claude Code, Cursor, Droid, Cascade)
install Install hooks to AI coding agent config files (Claude Code, Cursor, Droid, Cascade, Codex)

Run 'hookshot <command> -h' for command-specific help.`)
}
Expand Down Expand Up @@ -199,18 +200,20 @@ func runInstall(args []string) {
fs := flag.NewFlagSet("install", flag.ExitOnError)

var (
binaryPath string
claudeFlag bool
cursorFlag bool
droidFlag bool
cascadeFlag bool
binaryPath string
claudeFlag bool
cursorFlag bool
droidFlag bool
cascadeFlag bool
codexFlag bool
)

fs.StringVar(&binaryPath, "binary", "", "Path to hooks binary (required)")
fs.BoolVar(&claudeFlag, "claude", false, "Install to Claude Code only")
fs.BoolVar(&cursorFlag, "cursor", false, "Install to Cursor only")
fs.BoolVar(&droidFlag, "droid", false, "Install to Factory Droid only")
fs.BoolVar(&cascadeFlag, "cascade", false, "Install to Windsurf Cascade only")
fs.BoolVar(&codexFlag, "codex", false, "Install to OpenAI Codex only")

fs.Usage = func() {
fmt.Println(`Install hooks to AI coding agent config files.
Expand All @@ -224,6 +227,7 @@ Examples:
hookshot install --binary ./my-hooks --cursor # Cursor only
hookshot install --binary ./my-hooks --droid # Factory Droid only
hookshot install --binary ./my-hooks --cascade # Windsurf Cascade only
hookshot install --binary ./my-hooks --codex # OpenAI Codex only

Flags:`)
fs.PrintDefaults()
Expand Down Expand Up @@ -251,11 +255,12 @@ Flags:`)
}

// If none specified, install to all
if !claudeFlag && !cursorFlag && !droidFlag && !cascadeFlag {
if !claudeFlag && !cursorFlag && !droidFlag && !cascadeFlag && !codexFlag {
claudeFlag = true
cursorFlag = true
droidFlag = true
cascadeFlag = true
codexFlag = true
}

if claudeFlag {
Expand Down Expand Up @@ -286,11 +291,21 @@ Flags:`)
}
}

if codexFlag {
if err := installCodex(absPath); err != nil {
fmt.Fprintf(os.Stderr, "Error installing to OpenAI Codex: %v\n", err)
os.Exit(1)
}
}

fmt.Println("\nInstallation complete!")
}

func installClaude(binaryPath string) error {
homeDir, _ := os.UserHomeDir()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".claude", "settings.json")

fmt.Printf("Installing to Claude Code (%s)...\n", configPath)
Expand Down Expand Up @@ -355,7 +370,10 @@ func installClaude(binaryPath string) error {
}

func installCursor(binaryPath string) error {
homeDir, _ := os.UserHomeDir()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".cursor", "hooks.json")

fmt.Printf("Installing to Cursor (%s)...\n", configPath)
Expand Down Expand Up @@ -390,7 +408,10 @@ func installCursor(binaryPath string) error {
}

func installDroid(binaryPath string) error {
homeDir, _ := os.UserHomeDir()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".factory", "settings.json")

fmt.Printf("Installing to Factory Droid (%s)...\n", configPath)
Expand Down Expand Up @@ -454,20 +475,101 @@ func installDroid(binaryPath string) error {
return nil
}

func installCodex(binaryPath string) error {
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".codex", "hooks.json")

fmt.Printf("Installing to OpenAI Codex (%s)...\n", configPath)

// Read existing config or create new
var config map[string]any
data, err := os.ReadFile(configPath)
if err == nil {
json.Unmarshal(data, &config)
}
if config == nil {
config = make(map[string]any)
}

// Codex hook config follows the same JSON shape as Claude Code's
// settings but lives in ~/.codex/hooks.json. Matchers include
// "mcp__.*" so MCP tool calls reach the hook binary. "apply_patch"
// alone covers Codex file edits — Codex emits "Edit" and "Write" as
// matcher aliases for apply_patch, so they're redundant here.
hooks := map[string]any{

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Elsewhere, we are using strongly-typed structs when working JSON that has a know schema: claude/types.go, codex/types.go, etc.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Stop": []map[string]any{{
"hooks": []map[string]any{{
"type": "command",
"command": binaryPath + " codex-stop",
}},
}},
"PreToolUse": []map[string]any{{
"matcher": "Bash|apply_patch|mcp__.*",
"hooks": []map[string]any{{
"type": "command",
"command": binaryPath + " codex-pre-tool-use",
}},
}},
Comment thread
ashwin-corridor marked this conversation as resolved.
"PostToolUse": []map[string]any{{
// Bash is required to catch the heredoc-style file edits
// (`apply_patch <<'PATCH' … PATCH`) and greenfield writes
// (`cat <<'EOF' > FILE … EOF`) Codex 0.130.0+ routes
// through plain Bash in addition to the apply_patch tool. The
// unified codex-post-tool-use bridge parses both shapes via
// codex.ParseApplyPatchFromBash / codex.ParseBashRedirectWrite
// — but only sees the events if the matcher itself lets
// them through.
"matcher": "Bash|apply_patch|mcp__.*",
"hooks": []map[string]any{{
"type": "command",
"command": binaryPath + " codex-post-tool-use",
}},
}},
"UserPromptSubmit": []map[string]any{{
"hooks": []map[string]any{{
"type": "command",
"command": binaryPath + " codex-user-prompt-submit",
}},
}},
}

config["hooks"] = hooks

os.MkdirAll(filepath.Dir(configPath), 0755)

output, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}

if err := os.WriteFile(configPath, output, 0644); err != nil {
return err
}

fmt.Println(" Installed hooks: Stop, PreToolUse, PostToolUse, UserPromptSubmit")
return nil
}

func installCascade(binaryPath string) error {
homeDir, _ := os.UserHomeDir()
homeDir, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("resolving home directory: %w", err)
}
configPath := filepath.Join(homeDir, ".codeium", "windsurf", "hooks.json")

fmt.Printf("Installing to Windsurf Cascade (%s)...\n", configPath)

// Build hooks config
config := map[string]any{
"hooks": map[string]any{
"pre_run_command": []map[string]any{{"command": binaryPath + " cascade-pre-run-command"}},
"pre_mcp_tool_use": []map[string]any{{"command": binaryPath + " cascade-pre-mcp-tool-use"}},
"pre_user_prompt": []map[string]any{{"command": binaryPath + " cascade-pre-user-prompt"}},
"post_write_code": []map[string]any{{"command": binaryPath + " cascade-post-write-code"}},
"post_cascade_response": []map[string]any{{"command": binaryPath + " cascade-post-cascade-response"}},
"pre_run_command": []map[string]any{{"command": binaryPath + " cascade-pre-run-command"}},
"pre_mcp_tool_use": []map[string]any{{"command": binaryPath + " cascade-pre-mcp-tool-use"}},
"pre_user_prompt": []map[string]any{{"command": binaryPath + " cascade-pre-user-prompt"}},
"post_write_code": []map[string]any{{"command": binaryPath + " cascade-post-write-code"}},
"post_cascade_response": []map[string]any{{"command": binaryPath + " cascade-post-cascade-response"}},
},
}

Expand Down
Loading