diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index 7b451ba..14faa53 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # hookshot -A Go library for building hooks for AI coding agents like [Cursor](https://cursor.com/docs/agent/hooks) and [Claude Code](https://docs.claude.com/en/docs/claude-code/hooks). +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). 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. @@ -126,6 +126,20 @@ hookshot.Register("cursor-before-tab-read", func() { return cursor.AllowTabRead() }) }) + +// Windsurf Cascade: Pre-run command +hookshot.Register("cascade-pre-run-command", func() { + hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { + return cascade.AllowCommand() + }) +}) + +// Factory Droid: Pre-tool use +hookshot.Register("droid-pre-tool-use", func() { + hookshot.Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { + return droid.PassThrough() + }) +}) ``` ## Documentation @@ -133,6 +147,8 @@ hookshot.Register("cursor-before-tab-read", func() { - [Unified API Reference](docs/reference-unified.md) - [Claude Code Reference](docs/reference-claude.md) - [Cursor Reference](docs/reference-cursor.md) +- [Windsurf Cascade Reference](docs/reference-cascade.md) +- [Factory Droid Reference](docs/reference-droid.md) Full API documentation is available via godoc: @@ -140,6 +156,8 @@ Full API documentation is available via godoc: go doc github.com/CorridorSecurity/hookshot 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 ``` Or view online at [pkg.go.dev/github.com/CorridorSecurity/hookshot](https://pkg.go.dev/github.com/CorridorSecurity/hookshot). diff --git a/cascade/doc.go b/cascade/doc.go new file mode 100644 index 0000000..c43495d --- /dev/null +++ b/cascade/doc.go @@ -0,0 +1,96 @@ +// Package cascade provides types and helpers for Windsurf Cascade hooks. +// +// Windsurf Cascade hooks allow you to observe, control, and extend the AI agent loop +// using custom scripts. Hooks are spawned processes that communicate over stdio +// using JSON. +// +// # Hook Events +// +// Cascade supports the following hook events: +// +// Pre-hooks (can block or modify): +// - [PreReadCodeInput]/[PreReadCodeOutput]: Before reading code files +// - [PreWriteCodeInput]/[PreWriteCodeOutput]: Before writing code files +// - [PreRunCommandInput]/[PreRunCommandOutput]: Before running shell commands +// - [PreMCPToolUseInput]/[PreMCPToolUseOutput]: Before MCP tool execution +// - [PreUserPromptInput]/[PreUserPromptOutput]: Before processing user prompt +// +// Post-hooks (observe only): +// - [PostReadCodeInput]/[PostReadCodeOutput]: After reading code files +// - [PostWriteCodeInput]/[PostWriteCodeOutput]: After writing code files +// - [PostRunCommandInput]/[PostRunCommandOutput]: After running shell commands +// - [PostMCPToolUseInput]/[PostMCPToolUseOutput]: After MCP tool execution +// - [PostCascadeResponseInput]/[PostCascadeResponseOutput]: After Cascade responds +// - [PostSetupWorktreeInput]/[PostSetupWorktreeOutput]: After worktree setup +// +// # PreRunCommand Hooks +// +// PreRunCommand hooks control shell command execution: +// +// hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { +// // Block dangerous commands +// if strings.Contains(input.ToolInfo.CommandLine, "rm -rf") { +// return cascade.Deny("Dangerous command blocked") +// } +// return cascade.Allow() +// }) +// +// # PreWriteCode Hooks +// +// PreWriteCode hooks control file modifications: +// +// hookshot.Run(func(input cascade.PreWriteCodeInput) cascade.PreWriteCodeOutput { +// // Block writes to sensitive files +// if strings.Contains(input.ToolInfo.FilePath, ".env") { +// return cascade.Deny("Cannot modify environment files") +// } +// return cascade.Allow() +// }) +// +// # PreUserPrompt Hooks +// +// PreUserPrompt hooks validate or modify user prompts: +// +// hookshot.Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { +// if containsSecrets(input.ToolInfo.Prompt) { +// return cascade.BlockPrompt("Please don't include secrets in prompts") +// } +// return cascade.AllowPrompt() +// }) +// +// # Helper Functions +// +// This package provides helper functions for common responses: +// +// Pre-hooks (PreRunCommand, PreWriteCode, PreReadCode, PreMCPToolUse): +// - [Allow]: Permit the action +// - [Deny]: Block the action with a reason +// - [Ask]: Prompt user to confirm (where supported) +// +// PreUserPrompt: +// - [AllowPrompt]: Process normally +// - [BlockPrompt]: Reject with reason +// +// Post-hooks: +// - [PostOK]: Normal flow (all post-hooks are fire-and-forget) +// +// # Configuration +// +// Configure Cascade hooks in ~/.codeium/windsurf/hooks.json: +// +// { +// "hooks": { +// "pre_run_command": [ +// { "command": "/path/to/hooks cascade-pre-run-command" } +// ], +// "pre_write_code": [ +// { "command": "/path/to/hooks cascade-pre-write-code" } +// ], +// "pre_user_prompt": [ +// { "command": "/path/to/hooks cascade-pre-user-prompt" } +// ] +// } +// } +// +// See Windsurf documentation for full details. +package cascade diff --git a/cascade/helpers.go b/cascade/helpers.go new file mode 100644 index 0000000..3e29c1a --- /dev/null +++ b/cascade/helpers.go @@ -0,0 +1,180 @@ +package cascade + +// ============================================================================= +// Pre-Hook Decision Helpers (shared by PreRunCommand, PreWriteCode, etc.) +// ============================================================================= + +// Allow permits the action to proceed. +func Allow() BaseOutput { + return BaseOutput{ + Decision: "allow", + } +} + +// Deny blocks the action with a message. +func Deny(message string) BaseOutput { + return BaseOutput{ + Decision: "deny", + Message: message, + } +} + +// Ask prompts the user to confirm the action. +func Ask(message string) BaseOutput { + return BaseOutput{ + Decision: "ask", + Message: message, + } +} + +// ============================================================================= +// PreRunCommand Helpers +// ============================================================================= + +// AllowCommand permits the command to execute. +func AllowCommand() PreRunCommandOutput { + return PreRunCommandOutput{ + BaseOutput: Allow(), + } +} + +// DenyCommand blocks the command from executing. +func DenyCommand(message string) PreRunCommandOutput { + return PreRunCommandOutput{ + BaseOutput: Deny(message), + } +} + +// AskCommand prompts the user to confirm the command. +func AskCommand(message string) PreRunCommandOutput { + return PreRunCommandOutput{ + BaseOutput: Ask(message), + } +} + +// ============================================================================= +// PreWriteCode Helpers +// ============================================================================= + +// AllowWrite permits the file write. +func AllowWrite() PreWriteCodeOutput { + return PreWriteCodeOutput{ + BaseOutput: Allow(), + } +} + +// DenyWrite blocks the file write. +func DenyWrite(message string) PreWriteCodeOutput { + return PreWriteCodeOutput{ + BaseOutput: Deny(message), + } +} + +// AskWrite prompts the user to confirm the write. +func AskWrite(message string) PreWriteCodeOutput { + return PreWriteCodeOutput{ + BaseOutput: Ask(message), + } +} + +// ============================================================================= +// PreReadCode Helpers +// ============================================================================= + +// AllowRead permits the file read. +func AllowRead() PreReadCodeOutput { + return PreReadCodeOutput{ + BaseOutput: Allow(), + } +} + +// DenyRead blocks the file read. +func DenyRead(message string) PreReadCodeOutput { + return PreReadCodeOutput{ + BaseOutput: Deny(message), + } +} + +// AskRead prompts the user to confirm the read. +func AskRead(message string) PreReadCodeOutput { + return PreReadCodeOutput{ + BaseOutput: Ask(message), + } +} + +// ============================================================================= +// PreMCPToolUse Helpers +// ============================================================================= + +// AllowMCP permits the MCP tool to execute. +func AllowMCP() PreMCPToolUseOutput { + return PreMCPToolUseOutput{ + BaseOutput: Allow(), + } +} + +// DenyMCP blocks the MCP tool from executing. +func DenyMCP(message string) PreMCPToolUseOutput { + return PreMCPToolUseOutput{ + BaseOutput: Deny(message), + } +} + +// AskMCP prompts the user to confirm the MCP tool. +func AskMCP(message string) PreMCPToolUseOutput { + return PreMCPToolUseOutput{ + BaseOutput: Ask(message), + } +} + +// ============================================================================= +// PreUserPrompt Helpers +// ============================================================================= + +// AllowPrompt allows the prompt to be processed. +func AllowPrompt() PreUserPromptOutput { + return PreUserPromptOutput{ + BaseOutput: Allow(), + } +} + +// BlockPrompt prevents the prompt from being processed. +func BlockPrompt(message string) PreUserPromptOutput { + return PreUserPromptOutput{ + BaseOutput: Deny(message), + } +} + +// ============================================================================= +// Post-Hook Helpers (all are fire-and-forget) +// ============================================================================= + +// PostRunCommandOK returns an empty output for post-run-command. +func PostRunCommandOK() PostRunCommandOutput { + return PostRunCommandOutput{} +} + +// PostWriteCodeOK returns an empty output for post-write-code. +func PostWriteCodeOK() PostWriteCodeOutput { + return PostWriteCodeOutput{} +} + +// PostReadCodeOK returns an empty output for post-read-code. +func PostReadCodeOK() PostReadCodeOutput { + return PostReadCodeOutput{} +} + +// PostMCPToolUseOK returns an empty output for post-mcp-tool-use. +func PostMCPToolUseOK() PostMCPToolUseOutput { + return PostMCPToolUseOutput{} +} + +// PostCascadeResponseOK returns an empty output for post-cascade-response. +func PostCascadeResponseOK() PostCascadeResponseOutput { + return PostCascadeResponseOutput{} +} + +// PostSetupWorktreeOK returns an empty output for post-setup-worktree. +func PostSetupWorktreeOK() PostSetupWorktreeOutput { + return PostSetupWorktreeOutput{} +} diff --git a/cascade/helpers_test.go b/cascade/helpers_test.go new file mode 100644 index 0000000..8566e12 --- /dev/null +++ b/cascade/helpers_test.go @@ -0,0 +1,234 @@ +package cascade + +import ( + "testing" +) + +// ============================================================================= +// Base Helpers Tests +// ============================================================================= + +func TestAllow(t *testing.T) { + output := Allow() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestDeny(t *testing.T) { + output := Deny("Action blocked") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "Action blocked" { + t.Errorf("Message = %q, want %q", output.Message, "Action blocked") + } +} + +func TestAsk(t *testing.T) { + output := Ask("Confirm action?") + if output.Decision != "ask" { + t.Errorf("Decision = %q, want %q", output.Decision, "ask") + } + if output.Message != "Confirm action?" { + t.Errorf("Message = %q, want %q", output.Message, "Confirm action?") + } +} + +// ============================================================================= +// PreRunCommand Helpers Tests +// ============================================================================= + +func TestAllowCommand(t *testing.T) { + output := AllowCommand() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestDenyCommand(t *testing.T) { + output := DenyCommand("Command not allowed") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "Command not allowed" { + t.Errorf("Message = %q, want %q", output.Message, "Command not allowed") + } +} + +func TestAskCommand(t *testing.T) { + output := AskCommand("Run this command?") + if output.Decision != "ask" { + t.Errorf("Decision = %q, want %q", output.Decision, "ask") + } + if output.Message != "Run this command?" { + t.Errorf("Message = %q, want %q", output.Message, "Run this command?") + } +} + +// ============================================================================= +// PreWriteCode Helpers Tests +// ============================================================================= + +func TestAllowWrite(t *testing.T) { + output := AllowWrite() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestDenyWrite(t *testing.T) { + output := DenyWrite("Write not permitted") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "Write not permitted" { + t.Errorf("Message = %q, want %q", output.Message, "Write not permitted") + } +} + +func TestAskWrite(t *testing.T) { + output := AskWrite("Allow file write?") + if output.Decision != "ask" { + t.Errorf("Decision = %q, want %q", output.Decision, "ask") + } + if output.Message != "Allow file write?" { + t.Errorf("Message = %q, want %q", output.Message, "Allow file write?") + } +} + +// ============================================================================= +// PreReadCode Helpers Tests +// ============================================================================= + +func TestAllowRead(t *testing.T) { + output := AllowRead() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestDenyRead(t *testing.T) { + output := DenyRead("Cannot read file") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "Cannot read file" { + t.Errorf("Message = %q, want %q", output.Message, "Cannot read file") + } +} + +func TestAskRead(t *testing.T) { + output := AskRead("Allow file read?") + if output.Decision != "ask" { + t.Errorf("Decision = %q, want %q", output.Decision, "ask") + } + if output.Message != "Allow file read?" { + t.Errorf("Message = %q, want %q", output.Message, "Allow file read?") + } +} + +// ============================================================================= +// PreMCPToolUse Helpers Tests +// ============================================================================= + +func TestAllowMCP(t *testing.T) { + output := AllowMCP() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestDenyMCP(t *testing.T) { + output := DenyMCP("MCP tool blocked") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "MCP tool blocked" { + t.Errorf("Message = %q, want %q", output.Message, "MCP tool blocked") + } +} + +func TestAskMCP(t *testing.T) { + output := AskMCP("Run MCP tool?") + if output.Decision != "ask" { + t.Errorf("Decision = %q, want %q", output.Decision, "ask") + } + if output.Message != "Run MCP tool?" { + t.Errorf("Message = %q, want %q", output.Message, "Run MCP tool?") + } +} + +// ============================================================================= +// PreUserPrompt Helpers Tests +// ============================================================================= + +func TestAllowPrompt(t *testing.T) { + output := AllowPrompt() + if output.Decision != "allow" { + t.Errorf("Decision = %q, want %q", output.Decision, "allow") + } + if output.Message != "" { + t.Errorf("Message should be empty, got %q", output.Message) + } +} + +func TestBlockPrompt(t *testing.T) { + output := BlockPrompt("Prompt blocked") + if output.Decision != "deny" { + t.Errorf("Decision = %q, want %q", output.Decision, "deny") + } + if output.Message != "Prompt blocked" { + t.Errorf("Message = %q, want %q", output.Message, "Prompt blocked") + } +} + +// ============================================================================= +// Post-Hook Helpers Tests +// ============================================================================= + +func TestPostRunCommandOK(t *testing.T) { + output := PostRunCommandOK() + // Just verify it returns without error (empty struct) + _ = output +} + +func TestPostWriteCodeOK(t *testing.T) { + output := PostWriteCodeOK() + _ = output +} + +func TestPostReadCodeOK(t *testing.T) { + output := PostReadCodeOK() + _ = output +} + +func TestPostMCPToolUseOK(t *testing.T) { + output := PostMCPToolUseOK() + _ = output +} + +func TestPostCascadeResponseOK(t *testing.T) { + output := PostCascadeResponseOK() + _ = output +} + +func TestPostSetupWorktreeOK(t *testing.T) { + output := PostSetupWorktreeOK() + _ = output +} diff --git a/cascade/types.go b/cascade/types.go new file mode 100644 index 0000000..82515f4 --- /dev/null +++ b/cascade/types.go @@ -0,0 +1,246 @@ +// Package cascade provides types and helpers for Windsurf Cascade hooks. +// +// Windsurf Cascade hooks are configured in hooks.json files and execute at various +// points in the agent loop. See Windsurf documentation for details. +package cascade + +// ============================================================================= +// Common Types +// ============================================================================= + +// BaseInput contains fields present in all Cascade hook inputs. +type BaseInput struct { + AgentActionName string `json:"agent_action_name"` // The hook event name + TrajectoryID string `json:"trajectory_id"` // Session identifier + ExecutionID string `json:"execution_id"` // Unique execution identifier + Timestamp string `json:"timestamp"` // ISO 8601 timestamp +} + +// BaseOutput contains common fields for hook outputs that support decisions. +type BaseOutput struct { + // Decision controls whether the action should proceed. + // Values: "allow", "deny", "ask" (where supported) + Decision string `json:"decision,omitempty"` + + // Message is shown to the user or agent when Decision is "deny". + Message string `json:"message,omitempty"` +} + +// ============================================================================= +// PreRunCommand +// ============================================================================= + +// PreRunCommandToolInfo contains details about the command to be executed. +type PreRunCommandToolInfo struct { + CommandLine string `json:"command_line"` + Cwd string `json:"cwd"` +} + +// PreRunCommandInput is received before a shell command executes. +type PreRunCommandInput struct { + BaseInput + ToolInfo PreRunCommandToolInfo `json:"tool_info"` +} + +// PreRunCommandOutput controls whether the command should execute. +type PreRunCommandOutput struct { + BaseOutput +} + +// ============================================================================= +// PostRunCommand +// ============================================================================= + +// PostRunCommandToolInfo contains details about the executed command. +type PostRunCommandToolInfo struct { + CommandLine string `json:"command_line"` + Cwd string `json:"cwd"` + ExitCode int `json:"exit_code"` + Output string `json:"output,omitempty"` +} + +// PostRunCommandInput is received after a shell command executes. +type PostRunCommandInput struct { + BaseInput + ToolInfo PostRunCommandToolInfo `json:"tool_info"` +} + +// PostRunCommandOutput has no decision control - hooks just run for side effects. +type PostRunCommandOutput struct{} + +// ============================================================================= +// PreWriteCode +// ============================================================================= + +// PreWriteCodeToolInfo contains details about the file write operation. +type PreWriteCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +// PreWriteCodeInput is received before writing a code file. +type PreWriteCodeInput struct { + BaseInput + ToolInfo PreWriteCodeToolInfo `json:"tool_info"` +} + +// PreWriteCodeOutput controls whether the write should proceed. +type PreWriteCodeOutput struct { + BaseOutput +} + +// ============================================================================= +// PostWriteCode +// ============================================================================= + +// PostWriteCodeToolInfo contains details about the written file. +type PostWriteCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +// PostWriteCodeInput is received after writing a code file. +type PostWriteCodeInput struct { + BaseInput + ToolInfo PostWriteCodeToolInfo `json:"tool_info"` +} + +// PostWriteCodeOutput has no decision control - hooks just run for side effects. +type PostWriteCodeOutput struct{} + +// ============================================================================= +// PreReadCode +// ============================================================================= + +// PreReadCodeToolInfo contains details about the file read operation. +type PreReadCodeToolInfo struct { + FilePath string `json:"file_path"` +} + +// PreReadCodeInput is received before reading a code file. +type PreReadCodeInput struct { + BaseInput + ToolInfo PreReadCodeToolInfo `json:"tool_info"` +} + +// PreReadCodeOutput controls whether the read should proceed. +type PreReadCodeOutput struct { + BaseOutput +} + +// ============================================================================= +// PostReadCode +// ============================================================================= + +// PostReadCodeToolInfo contains details about the read file. +type PostReadCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content,omitempty"` +} + +// PostReadCodeInput is received after reading a code file. +type PostReadCodeInput struct { + BaseInput + ToolInfo PostReadCodeToolInfo `json:"tool_info"` +} + +// PostReadCodeOutput has no decision control - hooks just run for side effects. +type PostReadCodeOutput struct{} + +// ============================================================================= +// PreMCPToolUse +// ============================================================================= + +// PreMCPToolUseToolInfo contains details about the MCP tool call. +type PreMCPToolUseToolInfo struct { + ToolName string `json:"tool_name"` + ToolInput string `json:"tool_input"` // JSON string of parameters + ServerURL string `json:"server_url,omitempty"` +} + +// PreMCPToolUseInput is received before an MCP tool executes. +type PreMCPToolUseInput struct { + BaseInput + ToolInfo PreMCPToolUseToolInfo `json:"tool_info"` +} + +// PreMCPToolUseOutput controls whether the MCP tool should execute. +type PreMCPToolUseOutput struct { + BaseOutput +} + +// ============================================================================= +// PostMCPToolUse +// ============================================================================= + +// PostMCPToolUseToolInfo contains details about the executed MCP tool. +type PostMCPToolUseToolInfo struct { + ToolName string `json:"tool_name"` + ToolInput string `json:"tool_input"` + ToolOutput string `json:"tool_output,omitempty"` +} + +// PostMCPToolUseInput is received after an MCP tool executes. +type PostMCPToolUseInput struct { + BaseInput + ToolInfo PostMCPToolUseToolInfo `json:"tool_info"` +} + +// PostMCPToolUseOutput has no decision control - hooks just run for side effects. +type PostMCPToolUseOutput struct{} + +// ============================================================================= +// PreUserPrompt +// ============================================================================= + +// PreUserPromptToolInfo contains details about the user's prompt. +type PreUserPromptToolInfo struct { + Prompt string `json:"prompt"` +} + +// PreUserPromptInput is received when the user submits a prompt. +type PreUserPromptInput struct { + BaseInput + ToolInfo PreUserPromptToolInfo `json:"tool_info"` +} + +// PreUserPromptOutput controls whether the prompt should be processed. +type PreUserPromptOutput struct { + BaseOutput +} + +// ============================================================================= +// PostCascadeResponse +// ============================================================================= + +// PostCascadeResponseToolInfo contains details about Cascade's response. +type PostCascadeResponseToolInfo struct { + Response string `json:"response"` +} + +// PostCascadeResponseInput is received after Cascade completes a response. +type PostCascadeResponseInput struct { + BaseInput + ToolInfo PostCascadeResponseToolInfo `json:"tool_info"` +} + +// PostCascadeResponseOutput has no decision control - hooks just run for side effects. +type PostCascadeResponseOutput struct{} + +// ============================================================================= +// PostSetupWorktree +// ============================================================================= + +// PostSetupWorktreeToolInfo contains details about the worktree setup. +type PostSetupWorktreeToolInfo struct { + WorktreePath string `json:"worktree_path"` +} + +// PostSetupWorktreeInput is received after worktree setup completes. +type PostSetupWorktreeInput struct { + BaseInput + ToolInfo PostSetupWorktreeToolInfo `json:"tool_info"` +} + +// PostSetupWorktreeOutput has no decision control - hooks just run for side effects. +type PostSetupWorktreeOutput struct{} diff --git a/cmd/hookshot/main.go b/cmd/hookshot/main.go index c8a29f7..5696631 100644 --- a/cmd/hookshot/main.go +++ b/cmd/hookshot/main.go @@ -56,7 +56,7 @@ Usage: Commands: build Build hooks binary for one or more platforms - install Install hooks to Cursor and/or Claude Code config files + install Install hooks to AI coding agent config files (Claude Code, Cursor, Droid, Cascade) Run 'hookshot -h' for command-specific help.`) } @@ -199,25 +199,31 @@ func runInstall(args []string) { fs := flag.NewFlagSet("install", flag.ExitOnError) var ( - binaryPath string - claude bool - cursor bool + binaryPath string + claudeFlag bool + cursorFlag bool + droidFlag bool + cascadeFlag bool ) fs.StringVar(&binaryPath, "binary", "", "Path to hooks binary (required)") - fs.BoolVar(&claude, "claude", false, "Install to Claude Code only") - fs.BoolVar(&cursor, "cursor", false, "Install to Cursor only") + 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.Usage = func() { - fmt.Println(`Install hooks to Cursor and/or Claude Code config files. + fmt.Println(`Install hooks to AI coding agent config files. Usage: hookshot install --binary [flags] Examples: - hookshot install --binary ./my-hooks # Install to both - hookshot install --binary ./my-hooks --claude # Claude only - hookshot install --binary ./my-hooks --cursor # Cursor only + hookshot install --binary ./my-hooks # Install to all platforms + hookshot install --binary ./my-hooks --claude # Claude Code only + 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 Flags:`) fs.PrintDefaults() @@ -244,26 +250,42 @@ Flags:`) os.Exit(1) } - // If neither specified, install to both - if !claude && !cursor { - claude = true - cursor = true + // If none specified, install to all + if !claudeFlag && !cursorFlag && !droidFlag && !cascadeFlag { + claudeFlag = true + cursorFlag = true + droidFlag = true + cascadeFlag = true } - if claude { + if claudeFlag { if err := installClaude(absPath); err != nil { fmt.Fprintf(os.Stderr, "Error installing to Claude Code: %v\n", err) os.Exit(1) } } - if cursor { + if cursorFlag { if err := installCursor(absPath); err != nil { fmt.Fprintf(os.Stderr, "Error installing to Cursor: %v\n", err) os.Exit(1) } } + if droidFlag { + if err := installDroid(absPath); err != nil { + fmt.Fprintf(os.Stderr, "Error installing to Factory Droid: %v\n", err) + os.Exit(1) + } + } + + if cascadeFlag { + if err := installCascade(absPath); err != nil { + fmt.Fprintf(os.Stderr, "Error installing to Windsurf Cascade: %v\n", err) + os.Exit(1) + } + } + fmt.Println("\nInstallation complete!") } @@ -366,3 +388,102 @@ func installCursor(binaryPath string) error { fmt.Println(" Installed hooks: stop, beforeShellExecution, beforeMCPExecution, afterFileEdit, beforeSubmitPrompt") return nil } + +func installDroid(binaryPath string) error { + homeDir, _ := os.UserHomeDir() + configPath := filepath.Join(homeDir, ".factory", "settings.json") + + fmt.Printf("Installing to Factory Droid (%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) + } + + // Build hooks config (same structure as Claude Code) + hooks := map[string]any{ + "Stop": []map[string]any{{ + "hooks": []map[string]any{{ + "type": "command", + "command": binaryPath + " droid-stop", + }}, + }}, + "PreToolUse": []map[string]any{{ + "matcher": "*", + "hooks": []map[string]any{{ + "type": "command", + "command": binaryPath + " droid-pre-tool-use", + }}, + }}, + "PostToolUse": []map[string]any{{ + "matcher": "Write|Edit", + "hooks": []map[string]any{{ + "type": "command", + "command": binaryPath + " droid-after-file-edit", + }}, + }}, + "UserPromptSubmit": []map[string]any{{ + "hooks": []map[string]any{{ + "type": "command", + "command": binaryPath + " droid-user-prompt-submit", + }}, + }}, + } + + config["hooks"] = hooks + + // Ensure directory exists + os.MkdirAll(filepath.Dir(configPath), 0755) + + // Write config + 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() + 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"}}, + }, + } + + // Ensure directory exists + os.MkdirAll(filepath.Dir(configPath), 0755) + + // Write config + 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: pre_run_command, pre_mcp_tool_use, pre_user_prompt, post_write_code, post_cascade_response") + return nil +} diff --git a/docs/reference-cascade.md b/docs/reference-cascade.md new file mode 100644 index 0000000..8458131 --- /dev/null +++ b/docs/reference-cascade.md @@ -0,0 +1,493 @@ +# Windsurf Cascade API Reference + +Platform-specific types and helpers for Windsurf Cascade hooks. Use these when you need features not available in the unified API. + +See [Windsurf Cascade Hooks Documentation](https://docs.codeium.com/windsurf/memories#hooks) for official docs. + +## Events + +| Event | Input | Output | Description | +|-------|-------|--------|-------------| +| pre-run-command | `PreRunCommandInput` | `PreRunCommandOutput` | Before shell command | +| post-run-command | `PostRunCommandInput` | `PostRunCommandOutput` | After shell command | +| pre-write-code | `PreWriteCodeInput` | `PreWriteCodeOutput` | Before file write | +| post-write-code | `PostWriteCodeInput` | `PostWriteCodeOutput` | After file write | +| pre-read-code | `PreReadCodeInput` | `PreReadCodeOutput` | Before file read | +| post-read-code | `PostReadCodeInput` | `PostReadCodeOutput` | After file read | +| pre-mcp-tool-use | `PreMCPToolUseInput` | `PreMCPToolUseOutput` | Before MCP tool | +| post-mcp-tool-use | `PostMCPToolUseInput` | `PostMCPToolUseOutput` | After MCP tool | +| pre-user-prompt | `PreUserPromptInput` | `PreUserPromptOutput` | Before prompt processing | +| post-cascade-response | `PostCascadeResponseInput` | `PostCascadeResponseOutput` | After Cascade response | +| post-setup-worktree | `PostSetupWorktreeInput` | `PostSetupWorktreeOutput` | After worktree setup | + +--- + +## Common Types + +### BaseInput + +All Cascade hook inputs include these fields: + +```go +type BaseInput struct { + AgentActionName string `json:"agent_action_name"` // The hook event name + TrajectoryID string `json:"trajectory_id"` // Session identifier + ExecutionID string `json:"execution_id"` // Unique execution identifier + Timestamp string `json:"timestamp"` // ISO 8601 timestamp +} +``` + +### BaseOutput + +Pre-hooks that support decisions use this structure: + +```go +type BaseOutput struct { + Decision string `json:"decision,omitempty"` // "allow", "deny", "ask" + Message string `json:"message,omitempty"` // Shown when denied +} +``` + +### Base Helper Functions + +```go +func Allow() BaseOutput // Permit the action +func Deny(message string) BaseOutput // Block the action +func Ask(message string) BaseOutput // Prompt user to confirm +``` + +--- + +## PreRunCommand + +Called before a shell command executes. + +### PreRunCommandInput + +```go +type PreRunCommandToolInfo struct { + CommandLine string `json:"command_line"` + Cwd string `json:"cwd"` +} + +type PreRunCommandInput struct { + BaseInput + ToolInfo PreRunCommandToolInfo `json:"tool_info"` +} +``` + +### PreRunCommandOutput + +```go +type PreRunCommandOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func AllowCommand() PreRunCommandOutput +func DenyCommand(message string) PreRunCommandOutput +func AskCommand(message string) PreRunCommandOutput +``` + +### Example + +```go +hookshot.Register("cascade-pre-run-command", func() { + hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { + if strings.Contains(input.ToolInfo.CommandLine, "rm -rf /") { + return cascade.DenyCommand("Dangerous command blocked") + } + return cascade.AllowCommand() + }) +}) +``` + +--- + +## PostRunCommand + +Called after a shell command executes. + +### PostRunCommandInput + +```go +type PostRunCommandToolInfo struct { + CommandLine string `json:"command_line"` + Cwd string `json:"cwd"` + ExitCode int `json:"exit_code"` + Output string `json:"output,omitempty"` +} + +type PostRunCommandInput struct { + BaseInput + ToolInfo PostRunCommandToolInfo `json:"tool_info"` +} +``` + +### PostRunCommandOutput + +```go +type PostRunCommandOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostRunCommandOK() PostRunCommandOutput +``` + +--- + +## PreWriteCode + +Called before writing a file. + +### PreWriteCodeInput + +```go +type PreWriteCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +type PreWriteCodeInput struct { + BaseInput + ToolInfo PreWriteCodeToolInfo `json:"tool_info"` +} +``` + +### PreWriteCodeOutput + +```go +type PreWriteCodeOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func AllowWrite() PreWriteCodeOutput +func DenyWrite(message string) PreWriteCodeOutput +func AskWrite(message string) PreWriteCodeOutput +``` + +### Example + +```go +hookshot.Register("cascade-pre-write-code", func() { + hookshot.Run(func(input cascade.PreWriteCodeInput) cascade.PreWriteCodeOutput { + if strings.HasSuffix(input.ToolInfo.FilePath, ".env") { + return cascade.DenyWrite("Cannot write to .env files") + } + return cascade.AllowWrite() + }) +}) +``` + +--- + +## PostWriteCode + +Called after writing a file. + +### PostWriteCodeInput + +```go +type PostWriteCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +type PostWriteCodeInput struct { + BaseInput + ToolInfo PostWriteCodeToolInfo `json:"tool_info"` +} +``` + +### PostWriteCodeOutput + +```go +type PostWriteCodeOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostWriteCodeOK() PostWriteCodeOutput +``` + +--- + +## PreReadCode + +Called before reading a file. + +### PreReadCodeInput + +```go +type PreReadCodeToolInfo struct { + FilePath string `json:"file_path"` +} + +type PreReadCodeInput struct { + BaseInput + ToolInfo PreReadCodeToolInfo `json:"tool_info"` +} +``` + +### PreReadCodeOutput + +```go +type PreReadCodeOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func AllowRead() PreReadCodeOutput +func DenyRead(message string) PreReadCodeOutput +func AskRead(message string) PreReadCodeOutput +``` + +### Example + +```go +hookshot.Register("cascade-pre-read-code", func() { + hookshot.Run(func(input cascade.PreReadCodeInput) cascade.PreReadCodeOutput { + if strings.Contains(input.ToolInfo.FilePath, "secrets") { + return cascade.DenyRead("Cannot read secret files") + } + return cascade.AllowRead() + }) +}) +``` + +--- + +## PostReadCode + +Called after reading a file. + +### PostReadCodeInput + +```go +type PostReadCodeToolInfo struct { + FilePath string `json:"file_path"` + Content string `json:"content,omitempty"` +} + +type PostReadCodeInput struct { + BaseInput + ToolInfo PostReadCodeToolInfo `json:"tool_info"` +} +``` + +### PostReadCodeOutput + +```go +type PostReadCodeOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostReadCodeOK() PostReadCodeOutput +``` + +--- + +## PreMCPToolUse + +Called before an MCP tool executes. + +### PreMCPToolUseInput + +```go +type PreMCPToolUseToolInfo struct { + ToolName string `json:"tool_name"` + ToolInput string `json:"tool_input"` // JSON string of parameters + ServerURL string `json:"server_url,omitempty"` +} + +type PreMCPToolUseInput struct { + BaseInput + ToolInfo PreMCPToolUseToolInfo `json:"tool_info"` +} +``` + +### PreMCPToolUseOutput + +```go +type PreMCPToolUseOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func AllowMCP() PreMCPToolUseOutput +func DenyMCP(message string) PreMCPToolUseOutput +func AskMCP(message string) PreMCPToolUseOutput +``` + +### Example + +```go +hookshot.Register("cascade-pre-mcp-tool-use", func() { + hookshot.Run(func(input cascade.PreMCPToolUseInput) cascade.PreMCPToolUseOutput { + if strings.Contains(input.ToolInfo.ServerURL, "blocked.com") { + return cascade.DenyMCP("MCP server not allowed") + } + return cascade.AllowMCP() + }) +}) +``` + +--- + +## PostMCPToolUse + +Called after an MCP tool executes. + +### PostMCPToolUseInput + +```go +type PostMCPToolUseToolInfo struct { + ToolName string `json:"tool_name"` + ToolInput string `json:"tool_input"` + ToolOutput string `json:"tool_output,omitempty"` +} + +type PostMCPToolUseInput struct { + BaseInput + ToolInfo PostMCPToolUseToolInfo `json:"tool_info"` +} +``` + +### PostMCPToolUseOutput + +```go +type PostMCPToolUseOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostMCPToolUseOK() PostMCPToolUseOutput +``` + +--- + +## PreUserPrompt + +Called when the user submits a prompt. + +### PreUserPromptInput + +```go +type PreUserPromptToolInfo struct { + Prompt string `json:"prompt"` +} + +type PreUserPromptInput struct { + BaseInput + ToolInfo PreUserPromptToolInfo `json:"tool_info"` +} +``` + +### PreUserPromptOutput + +```go +type PreUserPromptOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func AllowPrompt() PreUserPromptOutput +func BlockPrompt(message string) PreUserPromptOutput +``` + +### Example + +```go +hookshot.Register("cascade-pre-user-prompt", func() { + hookshot.Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { + if strings.Contains(input.ToolInfo.Prompt, "api_key=") { + return cascade.BlockPrompt("Don't include API keys in prompts") + } + return cascade.AllowPrompt() + }) +}) +``` + +--- + +## PostCascadeResponse + +Called after Cascade completes a response. + +### PostCascadeResponseInput + +```go +type PostCascadeResponseToolInfo struct { + Response string `json:"response"` +} + +type PostCascadeResponseInput struct { + BaseInput + ToolInfo PostCascadeResponseToolInfo `json:"tool_info"` +} +``` + +### PostCascadeResponseOutput + +```go +type PostCascadeResponseOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostCascadeResponseOK() PostCascadeResponseOutput +``` + +--- + +## PostSetupWorktree + +Called after worktree setup completes. + +### PostSetupWorktreeInput + +```go +type PostSetupWorktreeToolInfo struct { + WorktreePath string `json:"worktree_path"` +} + +type PostSetupWorktreeInput struct { + BaseInput + ToolInfo PostSetupWorktreeToolInfo `json:"tool_info"` +} +``` + +### PostSetupWorktreeOutput + +```go +type PostSetupWorktreeOutput struct{} // Fire-and-forget +``` + +### Helper Functions + +```go +func PostSetupWorktreeOK() PostSetupWorktreeOutput +``` diff --git a/docs/reference-droid.md b/docs/reference-droid.md new file mode 100644 index 0000000..689fdc6 --- /dev/null +++ b/docs/reference-droid.md @@ -0,0 +1,442 @@ +# Factory Droid API Reference + +Platform-specific types and helpers for Factory Droid hooks. Use these when you need features not available in the unified API. + +Factory Droid hooks use the same event model as Claude Code, configured in settings files that execute shell commands at various points in the agent loop. + +## Events + +| Event | Input | Output | Description | +|-------|-------|--------|-------------| +| Stop | `StopInput` | `StopOutput` | Agent finished responding | +| SubagentStop | `SubagentStopInput` | `SubagentStopOutput` | Subagent (Task tool) finished | +| SessionStart | `SessionStartInput` | `SessionStartOutput` | Session started or resumed | +| SessionEnd | `SessionEndInput` | `SessionEndOutput` | Session ending | +| PreToolUse | `PreToolUseInput` | `PreToolUseOutput` | Before tool execution | +| PostToolUse | `PostToolUseInput` | `PostToolUseOutput` | After tool execution | +| PermissionRequest | `PermissionRequestInput` | `PermissionRequestOutput` | Permission dialog shown | +| UserPromptSubmit | `UserPromptSubmitInput` | `UserPromptSubmitOutput` | User submitted a prompt | +| Notification | `NotificationInput` | `NotificationOutput` | Notification sent | +| PreCompact | `PreCompactInput` | `PreCompactOutput` | Before context compaction | + +--- + +## Common Types + +### BaseInput + +All Droid hook inputs include these fields: + +```go +type BaseInput struct { + SessionID string `json:"session_id"` + TranscriptPath string `json:"transcript_path"` + Cwd string `json:"cwd"` + PermissionMode string `json:"permission_mode"` // "default", "plan", "acceptEdits", "bypassPermissions" + HookEventName string `json:"hook_event_name"` +} +``` + +### BaseOutput + +Common fields for any hook output: + +```go +type BaseOutput struct { + Continue *bool `json:"continue,omitempty"` // false to stop Droid + StopReason string `json:"stopReason,omitempty"` // Shown when Continue is false + SuppressOutput bool `json:"suppressOutput,omitempty"` // Hide stdout from transcript + SystemMessage string `json:"systemMessage,omitempty"` // Warning message shown to user +} +``` + +--- + +## Stop / SubagentStop + +Called when the agent finishes responding. + +### StopInput + +```go +type StopInput struct { + BaseInput + StopHookActive bool `json:"stop_hook_active"` // Check to prevent infinite loops +} + +type SubagentStopInput = StopInput +``` + +### StopOutput + +```go +type StopOutput struct { + BaseOutput + Decision string `json:"decision,omitempty"` // "block" to prevent stopping + Reason string `json:"reason,omitempty"` // Shown to Droid when blocked +} + +type SubagentStopOutput = StopOutput +``` + +### Helper Functions + +```go +func Continue() StopOutput // Allow stopping +func Block(reason string) StopOutput // Prevent stopping, continue working +func StopWith(reason string) StopOutput // Halt Droid entirely +``` + +### Example + +```go +hookshot.Register("droid-stop", func() { + hookshot.Run(func(input droid.StopInput) droid.StopOutput { + // IMPORTANT: Check StopHookActive to prevent infinite loops + if input.StopHookActive { + return droid.Continue() + } + return droid.Continue() + }) +}) +``` + +--- + +## SessionStart + +Called when a session starts or resumes. + +### SessionStartInput + +```go +type SessionStartInput struct { + BaseInput + Source string `json:"source"` // "startup", "resume", "clear", "compact" +} +``` + +### SessionStartOutput + +```go +type SessionStartOutput struct { + BaseOutput + HookSpecificOutput *SessionStartHookOutput `json:"hookSpecificOutput,omitempty"` +} + +type SessionStartHookOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` +} +``` + +### Helper Functions + +```go +func SessionStartOK() SessionStartOutput +func SessionStartContext(context string) SessionStartOutput +``` + +### Example + +```go +hookshot.Register("droid-session-start", func() { + hookshot.Run(func(input droid.SessionStartInput) droid.SessionStartOutput { + return droid.SessionStartContext("Project uses Go 1.21+") + }) +}) +``` + +--- + +## SessionEnd + +Called when a session is ending. + +### SessionEndInput + +```go +type SessionEndInput struct { + BaseInput + Reason string `json:"reason"` // "clear", "logout", "prompt_input_exit", "other" +} +``` + +### SessionEndOutput + +```go +type SessionEndOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func SessionEndOK() SessionEndOutput +``` + +--- + +## PreToolUse + +Called before a tool is executed. + +### PreToolUseInput + +```go +type PreToolUseInput struct { + BaseInput + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` // Tool-specific JSON + ToolUseID string `json:"tool_use_id"` +} +``` + +### PreToolUseOutput + +```go +type PreToolUseOutput struct { + BaseOutput + HookSpecificOutput *PreToolUseHookOutput `json:"hookSpecificOutput,omitempty"` +} + +type PreToolUseHookOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + PermissionDecision string `json:"permissionDecision,omitempty"` // "allow", "deny", "ask" + PermissionDecisionReason string `json:"permissionDecisionReason,omitempty"` // Shown to user or Droid + UpdatedInput map[string]any `json:"updatedInput,omitempty"` // Modified tool input +} +``` + +### Helper Functions + +```go +func Allow(reason string) PreToolUseOutput +func AllowSilent() PreToolUseOutput +func AllowWithInput(reason string, updatedInput map[string]any) PreToolUseOutput +func Deny(reason string) PreToolUseOutput +func Ask(reason string) PreToolUseOutput +func PassThrough() PreToolUseOutput +``` + +### Example + +```go +hookshot.Register("droid-pre-tool-use", func() { + hookshot.Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { + // Block specific MCP servers + if strings.HasPrefix(input.ToolName, "mcp__blocked__") { + return droid.Deny("MCP server not allowed") + } + + // Auto-approve Read tool + if input.ToolName == "Read" { + return droid.AllowSilent() + } + + return droid.PassThrough() + }) +}) +``` + +--- + +## PermissionRequest + +Called when a permission dialog is shown. + +### PermissionRequestInput + +```go +type PermissionRequestInput struct { + BaseInput + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolUseID string `json:"tool_use_id"` +} +``` + +### PermissionRequestOutput + +```go +type PermissionRequestOutput struct { + BaseOutput + HookSpecificOutput *PermissionRequestHookOutput `json:"hookSpecificOutput,omitempty"` +} + +type PermissionRequestHookOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + Decision *PermissionRequestDecision `json:"decision,omitempty"` +} + +type PermissionRequestDecision struct { + Behavior string `json:"behavior"` // "allow" or "deny" + UpdatedInput map[string]any `json:"updatedInput,omitempty"` // For "allow": modified input + Message string `json:"message,omitempty"` // For "deny": shown to Droid + Interrupt bool `json:"interrupt,omitempty"` // For "deny": stop Droid if true +} +``` + +### Helper Functions + +```go +func AllowPermission() PermissionRequestOutput +func AllowPermissionWithInput(updatedInput map[string]any) PermissionRequestOutput +func DenyPermission(message string) PermissionRequestOutput +func DenyPermissionAndStop(message string) PermissionRequestOutput +``` + +--- + +## PostToolUse + +Called after a tool is executed. + +### PostToolUseInput + +```go +type PostToolUseInput struct { + BaseInput + ToolName string `json:"tool_name"` + ToolInput json.RawMessage `json:"tool_input"` + ToolResponse json.RawMessage `json:"tool_response"` // Tool-specific response JSON + ToolUseID string `json:"tool_use_id"` +} +``` + +### PostToolUseOutput + +```go +type PostToolUseOutput struct { + BaseOutput + Decision string `json:"decision,omitempty"` // "block" to provide feedback + Reason string `json:"reason,omitempty"` // Shown to Droid when blocked + HookSpecificOutput *PostToolUseHookOutput `json:"hookSpecificOutput,omitempty"` +} + +type PostToolUseHookOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` // Added to context for Droid +} +``` + +### Helper Functions + +```go +func PostToolOK() PostToolUseOutput +func PostToolBlock(reason string) PostToolUseOutput +func PostToolContext(context string) PostToolUseOutput +``` + +--- + +## UserPromptSubmit + +Called when the user submits a prompt. + +### UserPromptSubmitInput + +```go +type UserPromptSubmitInput struct { + BaseInput + Prompt string `json:"prompt"` +} +``` + +### UserPromptSubmitOutput + +```go +type UserPromptSubmitOutput struct { + BaseOutput + Decision string `json:"decision,omitempty"` // "block" to prevent + Reason string `json:"reason,omitempty"` // Shown to user when blocked + HookSpecificOutput *UserPromptSubmitHookOutput `json:"hookSpecificOutput,omitempty"` +} + +type UserPromptSubmitHookOutput struct { + HookEventName string `json:"hookEventName,omitempty"` + AdditionalContext string `json:"additionalContext,omitempty"` // Added to context +} +``` + +### Helper Functions + +```go +func AllowPrompt() UserPromptSubmitOutput +func BlockPrompt(reason string) UserPromptSubmitOutput +func AddContext(context string) UserPromptSubmitOutput +``` + +### Example + +```go +hookshot.Register("droid-user-prompt-submit", func() { + hookshot.Run(func(input droid.UserPromptSubmitInput) droid.UserPromptSubmitOutput { + if strings.Contains(input.Prompt, "api_key=") { + return droid.BlockPrompt("Don't include API keys in prompts") + } + return droid.AllowPrompt() + }) +}) +``` + +--- + +## Notification + +Called when a notification is sent. + +### NotificationInput + +```go +type NotificationInput struct { + BaseInput + Message string `json:"message"` + NotificationType string `json:"notification_type"` // "permission_prompt", "idle_prompt", "auth_success", "elicitation_dialog" +} +``` + +### NotificationOutput + +```go +type NotificationOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func NotificationOK() NotificationOutput +``` + +--- + +## PreCompact + +Called before context compaction. + +### PreCompactInput + +```go +type PreCompactInput struct { + BaseInput + Trigger string `json:"trigger"` // "manual" or "auto" + CustomInstructions string `json:"custom_instructions"` // For manual trigger only +} +``` + +### PreCompactOutput + +```go +type PreCompactOutput struct { + BaseOutput +} +``` + +### Helper Functions + +```go +func PreCompactOK() PreCompactOutput +``` diff --git a/droid/doc.go b/droid/doc.go new file mode 100644 index 0000000..9b64830 --- /dev/null +++ b/droid/doc.go @@ -0,0 +1,41 @@ +// Package droid provides types and helpers for Factory Droid hooks. +// +// Factory Droid hooks use the same API as Claude Code hooks, so this package +// re-exports all types and helpers from the claude package for convenience. +// +// # Configuration +// +// Configure hooks in ~/.factory/settings.json: +// +// { +// "hooks": { +// "PreToolUse": [ +// { +// "matcher": "*", +// "hooks": [{ "type": "command", "command": "/path/to/hook droid-pre-tool-use" }] +// } +// ] +// } +// } +// +// # Example Usage +// +// package main +// +// import ( +// "github.com/CorridorSecurity/hookshot" +// "github.com/CorridorSecurity/hookshot/droid" +// ) +// +// func main() { +// hookshot.Register("droid-pre-tool-use", func() { +// hookshot.Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { +// if input.ToolName == "Read" { +// return droid.AllowSilent() +// } +// return droid.PassThrough() +// }) +// }) +// hookshot.RunCommand() +// } +package droid diff --git a/droid/helpers.go b/droid/helpers.go new file mode 100644 index 0000000..b34a0cb --- /dev/null +++ b/droid/helpers.go @@ -0,0 +1,111 @@ +package droid + +import "github.com/CorridorSecurity/hookshot/claude" + +// ============================================================================= +// Stop / SubagentStop Helpers (re-exported from claude) +// ============================================================================= + +// Continue allows Droid to stop normally. +var Continue = claude.Continue + +// Block prevents Droid from stopping and sends a message. +var Block = claude.Block + +// StopWith creates a StopOutput that halts Droid entirely. +var StopWith = claude.StopWith + +// ============================================================================= +// PreToolUse Helpers (re-exported from claude) +// ============================================================================= + +// Allow permits the tool to execute without asking the user. +var Allow = claude.Allow + +// AllowSilent permits the tool to execute without showing output. +var AllowSilent = claude.AllowSilent + +// AllowWithInput permits the tool with modified input parameters. +var AllowWithInput = claude.AllowWithInput + +// Deny blocks the tool from executing. +var Deny = claude.Deny + +// Ask prompts the user to confirm the tool execution. +var Ask = claude.Ask + +// PassThrough returns an empty output, letting the normal permission flow proceed. +var PassThrough = claude.PassThrough + +// ============================================================================= +// PermissionRequest Helpers (re-exported from claude) +// ============================================================================= + +// AllowPermission grants the permission request. +var AllowPermission = claude.AllowPermission + +// AllowPermissionWithInput grants the permission with modified tool input. +var AllowPermissionWithInput = claude.AllowPermissionWithInput + +// DenyPermission rejects the permission request. +var DenyPermission = claude.DenyPermission + +// DenyPermissionAndStop rejects the permission and stops Droid. +var DenyPermissionAndStop = claude.DenyPermissionAndStop + +// ============================================================================= +// PostToolUse Helpers (re-exported from claude) +// ============================================================================= + +// PostToolOK returns an empty output, allowing normal flow to continue. +var PostToolOK = claude.PostToolOK + +// PostToolBlock provides feedback to Droid that something needs attention. +var PostToolBlock = claude.PostToolBlock + +// PostToolContext adds context for Droid to consider. +var PostToolContext = claude.PostToolContext + +// ============================================================================= +// UserPromptSubmit Helpers (re-exported from claude) +// ============================================================================= + +// AllowPrompt allows the prompt to be processed normally. +var AllowPrompt = claude.AllowPrompt + +// BlockPrompt prevents the prompt from being processed. +var BlockPrompt = claude.BlockPrompt + +// AddContext allows the prompt and adds context for Droid. +var AddContext = claude.AddContext + +// ============================================================================= +// SessionStart Helpers (re-exported from claude) +// ============================================================================= + +// SessionStartOK returns an empty output for session start. +var SessionStartOK = claude.SessionStartOK + +// SessionStartContext adds context at the start of a session. +var SessionStartContext = claude.SessionStartContext + +// ============================================================================= +// SessionEnd Helpers (re-exported from claude) +// ============================================================================= + +// SessionEndOK returns an empty output for session end. +var SessionEndOK = claude.SessionEndOK + +// ============================================================================= +// Notification Helpers (re-exported from claude) +// ============================================================================= + +// NotificationOK returns an empty output for notifications. +var NotificationOK = claude.NotificationOK + +// ============================================================================= +// PreCompact Helpers (re-exported from claude) +// ============================================================================= + +// PreCompactOK returns an empty output for pre-compact. +var PreCompactOK = claude.PreCompactOK diff --git a/droid/helpers_test.go b/droid/helpers_test.go new file mode 100644 index 0000000..da7e6b7 --- /dev/null +++ b/droid/helpers_test.go @@ -0,0 +1,293 @@ +package droid + +import ( + "testing" +) + +// ============================================================================= +// Stop / SubagentStop Helpers Tests +// ============================================================================= + +func TestContinue(t *testing.T) { + output := Continue() + if output.Decision != "" { + t.Errorf("Decision should be empty, got %q", output.Decision) + } + if output.Reason != "" { + t.Errorf("Reason should be empty, got %q", output.Reason) + } +} + +func TestBlock(t *testing.T) { + output := Block("Please fix issues") + if output.Decision != "block" { + t.Errorf("Decision = %q, want %q", output.Decision, "block") + } + if output.Reason != "Please fix issues" { + t.Errorf("Reason = %q, want %q", output.Reason, "Please fix issues") + } +} + +func TestStopWith(t *testing.T) { + output := StopWith("Stopping due to error") + if output.Continue == nil || *output.Continue != false { + t.Error("Continue should be false") + } + if output.StopReason != "Stopping due to error" { + t.Errorf("StopReason = %q, want %q", output.StopReason, "Stopping due to error") + } +} + +// ============================================================================= +// PreToolUse Helpers Tests +// ============================================================================= + +func TestAllow(t *testing.T) { + output := Allow("Tool is trusted") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.HookEventName != "PreToolUse" { + t.Errorf("HookEventName = %q, want %q", output.HookSpecificOutput.HookEventName, "PreToolUse") + } + if output.HookSpecificOutput.PermissionDecision != "allow" { + t.Errorf("PermissionDecision = %q, want %q", output.HookSpecificOutput.PermissionDecision, "allow") + } + if output.HookSpecificOutput.PermissionDecisionReason != "Tool is trusted" { + t.Errorf("PermissionDecisionReason = %q, want %q", output.HookSpecificOutput.PermissionDecisionReason, "Tool is trusted") + } +} + +func TestAllowSilent(t *testing.T) { + output := AllowSilent() + if !output.SuppressOutput { + t.Error("SuppressOutput should be true") + } + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.PermissionDecision != "allow" { + t.Errorf("PermissionDecision = %q, want %q", output.HookSpecificOutput.PermissionDecision, "allow") + } +} + +func TestAllowWithInput(t *testing.T) { + updatedInput := map[string]any{"file_path": "/new/path.ts"} + output := AllowWithInput("Modified input", updatedInput) + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.PermissionDecision != "allow" { + t.Errorf("PermissionDecision = %q, want %q", output.HookSpecificOutput.PermissionDecision, "allow") + } + if output.HookSpecificOutput.PermissionDecisionReason != "Modified input" { + t.Errorf("PermissionDecisionReason = %q, want %q", output.HookSpecificOutput.PermissionDecisionReason, "Modified input") + } + if output.HookSpecificOutput.UpdatedInput["file_path"] != "/new/path.ts" { + t.Errorf("UpdatedInput[file_path] = %v, want %v", output.HookSpecificOutput.UpdatedInput["file_path"], "/new/path.ts") + } +} + +func TestDeny(t *testing.T) { + output := Deny("Tool not allowed") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.PermissionDecision != "deny" { + t.Errorf("PermissionDecision = %q, want %q", output.HookSpecificOutput.PermissionDecision, "deny") + } + if output.HookSpecificOutput.PermissionDecisionReason != "Tool not allowed" { + t.Errorf("PermissionDecisionReason = %q, want %q", output.HookSpecificOutput.PermissionDecisionReason, "Tool not allowed") + } +} + +func TestAsk(t *testing.T) { + output := Ask("Confirm execution?") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.PermissionDecision != "ask" { + t.Errorf("PermissionDecision = %q, want %q", output.HookSpecificOutput.PermissionDecision, "ask") + } + if output.HookSpecificOutput.PermissionDecisionReason != "Confirm execution?" { + t.Errorf("PermissionDecisionReason = %q, want %q", output.HookSpecificOutput.PermissionDecisionReason, "Confirm execution?") + } +} + +func TestPassThrough(t *testing.T) { + output := PassThrough() + if output.HookSpecificOutput != nil { + t.Error("HookSpecificOutput should be nil for PassThrough") + } +} + +// ============================================================================= +// PermissionRequest Helpers Tests +// ============================================================================= + +func TestAllowPermission(t *testing.T) { + output := AllowPermission() + if output.HookSpecificOutput == nil || output.HookSpecificOutput.Decision == nil { + t.Fatal("HookSpecificOutput and Decision should not be nil") + } + if output.HookSpecificOutput.HookEventName != "PermissionRequest" { + t.Errorf("HookEventName = %q, want %q", output.HookSpecificOutput.HookEventName, "PermissionRequest") + } + if output.HookSpecificOutput.Decision.Behavior != "allow" { + t.Errorf("Behavior = %q, want %q", output.HookSpecificOutput.Decision.Behavior, "allow") + } +} + +func TestAllowPermissionWithInput(t *testing.T) { + updatedInput := map[string]any{"key": "value"} + output := AllowPermissionWithInput(updatedInput) + if output.HookSpecificOutput == nil || output.HookSpecificOutput.Decision == nil { + t.Fatal("HookSpecificOutput and Decision should not be nil") + } + if output.HookSpecificOutput.Decision.Behavior != "allow" { + t.Errorf("Behavior = %q, want %q", output.HookSpecificOutput.Decision.Behavior, "allow") + } + if output.HookSpecificOutput.Decision.UpdatedInput["key"] != "value" { + t.Errorf("UpdatedInput[key] = %v, want %v", output.HookSpecificOutput.Decision.UpdatedInput["key"], "value") + } +} + +func TestDenyPermission(t *testing.T) { + output := DenyPermission("Not allowed") + if output.HookSpecificOutput == nil || output.HookSpecificOutput.Decision == nil { + t.Fatal("HookSpecificOutput and Decision should not be nil") + } + if output.HookSpecificOutput.Decision.Behavior != "deny" { + t.Errorf("Behavior = %q, want %q", output.HookSpecificOutput.Decision.Behavior, "deny") + } + if output.HookSpecificOutput.Decision.Message != "Not allowed" { + t.Errorf("Message = %q, want %q", output.HookSpecificOutput.Decision.Message, "Not allowed") + } +} + +func TestDenyPermissionAndStop(t *testing.T) { + output := DenyPermissionAndStop("Critical error") + if output.HookSpecificOutput == nil || output.HookSpecificOutput.Decision == nil { + t.Fatal("HookSpecificOutput and Decision should not be nil") + } + if output.HookSpecificOutput.Decision.Behavior != "deny" { + t.Errorf("Behavior = %q, want %q", output.HookSpecificOutput.Decision.Behavior, "deny") + } + if output.HookSpecificOutput.Decision.Message != "Critical error" { + t.Errorf("Message = %q, want %q", output.HookSpecificOutput.Decision.Message, "Critical error") + } + if !output.HookSpecificOutput.Decision.Interrupt { + t.Error("Interrupt should be true") + } +} + +// ============================================================================= +// PostToolUse Helpers Tests +// ============================================================================= + +func TestPostToolOK(t *testing.T) { + output := PostToolOK() + if output.Decision != "" { + t.Errorf("Decision should be empty, got %q", output.Decision) + } +} + +func TestPostToolBlock(t *testing.T) { + output := PostToolBlock("Issue found") + if output.Decision != "block" { + t.Errorf("Decision = %q, want %q", output.Decision, "block") + } + if output.Reason != "Issue found" { + t.Errorf("Reason = %q, want %q", output.Reason, "Issue found") + } +} + +func TestPostToolContext(t *testing.T) { + output := PostToolContext("Additional info") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.HookEventName != "PostToolUse" { + t.Errorf("HookEventName = %q, want %q", output.HookSpecificOutput.HookEventName, "PostToolUse") + } + if output.HookSpecificOutput.AdditionalContext != "Additional info" { + t.Errorf("AdditionalContext = %q, want %q", output.HookSpecificOutput.AdditionalContext, "Additional info") + } +} + +// ============================================================================= +// UserPromptSubmit Helpers Tests +// ============================================================================= + +func TestAllowPrompt(t *testing.T) { + output := AllowPrompt() + if output.Decision != "" { + t.Errorf("Decision should be empty, got %q", output.Decision) + } +} + +func TestBlockPrompt(t *testing.T) { + output := BlockPrompt("Invalid prompt") + if output.Decision != "block" { + t.Errorf("Decision = %q, want %q", output.Decision, "block") + } + if output.Reason != "Invalid prompt" { + t.Errorf("Reason = %q, want %q", output.Reason, "Invalid prompt") + } +} + +func TestAddContext(t *testing.T) { + output := AddContext("Project uses Go 1.21") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.HookEventName != "UserPromptSubmit" { + t.Errorf("HookEventName = %q, want %q", output.HookSpecificOutput.HookEventName, "UserPromptSubmit") + } + if output.HookSpecificOutput.AdditionalContext != "Project uses Go 1.21" { + t.Errorf("AdditionalContext = %q, want %q", output.HookSpecificOutput.AdditionalContext, "Project uses Go 1.21") + } +} + +// ============================================================================= +// SessionStart Helpers Tests +// ============================================================================= + +func TestSessionStartOK(t *testing.T) { + output := SessionStartOK() + if output.HookSpecificOutput != nil { + t.Error("HookSpecificOutput should be nil for SessionStartOK") + } +} + +func TestSessionStartContext(t *testing.T) { + output := SessionStartContext("Welcome context") + if output.HookSpecificOutput == nil { + t.Fatal("HookSpecificOutput should not be nil") + } + if output.HookSpecificOutput.HookEventName != "SessionStart" { + t.Errorf("HookEventName = %q, want %q", output.HookSpecificOutput.HookEventName, "SessionStart") + } + if output.HookSpecificOutput.AdditionalContext != "Welcome context" { + t.Errorf("AdditionalContext = %q, want %q", output.HookSpecificOutput.AdditionalContext, "Welcome context") + } +} + +// ============================================================================= +// Other Helpers Tests +// ============================================================================= + +func TestSessionEndOK(t *testing.T) { + output := SessionEndOK() + _ = output // Just verify it returns without error +} + +func TestNotificationOK(t *testing.T) { + output := NotificationOK() + _ = output +} + +func TestPreCompactOK(t *testing.T) { + output := PreCompactOK() + _ = output +} diff --git a/droid/types.go b/droid/types.go new file mode 100644 index 0000000..4d1e11a --- /dev/null +++ b/droid/types.go @@ -0,0 +1,131 @@ +// Package droid provides types and helpers for Factory Droid hooks. +// +// Factory Droid hooks use the same API as Claude Code hooks. +// This package re-exports the claude package types for convenience. +package droid + +import "github.com/CorridorSecurity/hookshot/claude" + +// ============================================================================= +// Common Types (re-exported from claude) +// ============================================================================= + +// BaseInput contains fields present in all Droid hook inputs. +type BaseInput = claude.BaseInput + +// BaseOutput contains common fields that can be included in any hook output. +type BaseOutput = claude.BaseOutput + +// ============================================================================= +// Stop / SubagentStop (re-exported from claude) +// ============================================================================= + +// StopInput is received when the main Droid agent finishes responding. +type StopInput = claude.StopInput + +// StopOutput controls whether Droid should stop or continue. +type StopOutput = claude.StopOutput + +// SubagentStopInput is received when a Droid subagent (Task tool) finishes. +type SubagentStopInput = claude.SubagentStopInput + +// SubagentStopOutput controls whether a subagent should stop or continue. +type SubagentStopOutput = claude.SubagentStopOutput + +// ============================================================================= +// SessionStart (re-exported from claude) +// ============================================================================= + +// SessionStartInput is received when Droid starts or resumes a session. +type SessionStartInput = claude.SessionStartInput + +// SessionStartOutput can add context to the session. +type SessionStartOutput = claude.SessionStartOutput + +// SessionStartHookOutput contains session-start-specific output fields. +type SessionStartHookOutput = claude.SessionStartHookOutput + +// ============================================================================= +// SessionEnd (re-exported from claude) +// ============================================================================= + +// SessionEndInput is received when a Droid session ends. +type SessionEndInput = claude.SessionEndInput + +// SessionEndOutput has no decision control - hooks just run for cleanup. +type SessionEndOutput = claude.SessionEndOutput + +// ============================================================================= +// PreToolUse (re-exported from claude) +// ============================================================================= + +// PreToolUseInput is received before Droid executes a tool. +type PreToolUseInput = claude.PreToolUseInput + +// PreToolUseOutput controls whether the tool should execute. +type PreToolUseOutput = claude.PreToolUseOutput + +// PreToolUseHookOutput contains pre-tool-use-specific output fields. +type PreToolUseHookOutput = claude.PreToolUseHookOutput + +// ============================================================================= +// PermissionRequest (re-exported from claude) +// ============================================================================= + +// PermissionRequestInput is received when the user is shown a permission dialog. +type PermissionRequestInput = claude.PermissionRequestInput + +// PermissionRequestOutput controls the permission dialog response. +type PermissionRequestOutput = claude.PermissionRequestOutput + +// PermissionRequestHookOutput contains permission-request-specific output fields. +type PermissionRequestHookOutput = claude.PermissionRequestHookOutput + +// PermissionRequestDecision controls how the permission request is handled. +type PermissionRequestDecision = claude.PermissionRequestDecision + +// ============================================================================= +// PostToolUse (re-exported from claude) +// ============================================================================= + +// PostToolUseInput is received after a tool executes successfully. +type PostToolUseInput = claude.PostToolUseInput + +// PostToolUseOutput can provide feedback to Droid after tool execution. +type PostToolUseOutput = claude.PostToolUseOutput + +// PostToolUseHookOutput contains post-tool-use-specific output fields. +type PostToolUseHookOutput = claude.PostToolUseHookOutput + +// ============================================================================= +// UserPromptSubmit (re-exported from claude) +// ============================================================================= + +// UserPromptSubmitInput is received when the user submits a prompt. +type UserPromptSubmitInput = claude.UserPromptSubmitInput + +// UserPromptSubmitOutput controls whether the prompt should be processed. +type UserPromptSubmitOutput = claude.UserPromptSubmitOutput + +// UserPromptSubmitHookOutput contains user-prompt-submit-specific output fields. +type UserPromptSubmitHookOutput = claude.UserPromptSubmitHookOutput + +// ============================================================================= +// Notification (re-exported from claude) +// ============================================================================= + +// NotificationInput is received when Droid sends a notification. +type NotificationInput = claude.NotificationInput + +// NotificationOutput has no decision control - hooks just run for side effects. +type NotificationOutput = claude.NotificationOutput + +// ============================================================================= +// PreCompact (re-exported from claude) +// ============================================================================= + +// PreCompactInput is received before Droid runs a compact operation. +type PreCompactInput = claude.PreCompactInput + +// PreCompactOutput has no decision control - hooks just run for side effects. +type PreCompactOutput = claude.PreCompactOutput diff --git a/examples/multi-hook/main.go b/examples/multi-hook/main.go index 66ac5b1..2b1ce0b 100644 --- a/examples/multi-hook/main.go +++ b/examples/multi-hook/main.go @@ -33,6 +33,26 @@ // "beforeTabFileRead": [{ "command": "/path/to/my-hooks cursor-before-tab-read" }] // } // } +// +// Configure in Windsurf Cascade (hooks.json in workspace): +// +// { +// "hooks": { +// "pre-run-command": { "command": "/path/to/my-hooks cascade-pre-run-command" }, +// "pre-write-code": { "command": "/path/to/my-hooks cascade-pre-write-code" }, +// "pre-user-prompt": { "command": "/path/to/my-hooks cascade-pre-user-prompt" } +// } +// } +// +// Configure in Factory Droid (similar to Claude Code): +// +// { +// "hooks": { +// "Stop": [{ "command": "/path/to/my-hooks droid-stop" }], +// "PreToolUse": [{ "matcher": "*", "command": "/path/to/my-hooks droid-pre-tool-use" }], +// "UserPromptSubmit": [{ "command": "/path/to/my-hooks droid-user-prompt-submit" }] +// } +// } package main import ( @@ -40,8 +60,10 @@ import ( "strings" "github.com/CorridorSecurity/hookshot" + "github.com/CorridorSecurity/hookshot/cascade" "github.com/CorridorSecurity/hookshot/claude" "github.com/CorridorSecurity/hookshot/cursor" + "github.com/CorridorSecurity/hookshot/droid" ) func main() { @@ -66,6 +88,22 @@ func main() { // Cursor only: Tab completion file read (Claude Code has no equivalent) hookshot.Register("cursor-before-tab-read", handleCursorBeforeTabRead) + // ========================================================================== + // WINDSURF CASCADE HANDLERS + // ========================================================================== + + hookshot.Register("cascade-pre-run-command", handleCascadePreRunCommand) + hookshot.Register("cascade-pre-write-code", handleCascadePreWriteCode) + hookshot.Register("cascade-pre-user-prompt", handleCascadePreUserPrompt) + + // ========================================================================== + // FACTORY DROID HANDLERS + // ========================================================================== + + hookshot.Register("droid-stop", handleDroidStop) + hookshot.Register("droid-pre-tool-use", handleDroidPreToolUse) + hookshot.Register("droid-user-prompt-submit", handleDroidUserPromptSubmit) + hookshot.RunCommand() } @@ -162,3 +200,78 @@ func handleCursorBeforeTabRead() { return cursor.AllowTabRead() }) } + +// ============================================================================= +// PLATFORM-SPECIFIC: Windsurf Cascade +// Cascade uses exit code 2 to block actions, so we use RunE with errors +// ============================================================================= + +func handleCascadePreRunCommand() { + hookshot.RunE(func(input cascade.PreRunCommandInput) (cascade.PreRunCommandOutput, error) { + // Block dangerous shell commands + if strings.Contains(input.ToolInfo.CommandLine, "rm -rf /") { + return cascade.PreRunCommandOutput{}, fmt.Errorf("Dangerous command blocked") + } + return cascade.AllowCommand(), nil + }) +} + +func handleCascadePreWriteCode() { + hookshot.RunE(func(input cascade.PreWriteCodeInput) (cascade.PreWriteCodeOutput, error) { + // Block writes to sensitive files + if strings.HasSuffix(input.ToolInfo.FilePath, ".env") { + return cascade.PreWriteCodeOutput{}, fmt.Errorf("Cannot write to .env files") + } + return cascade.AllowWrite(), nil + }) +} + +func handleCascadePreUserPrompt() { + hookshot.RunE(func(input cascade.PreUserPromptInput) (cascade.PreUserPromptOutput, error) { + // Block prompts with API keys + if strings.Contains(strings.ToLower(input.ToolInfo.Prompt), "api_key=") { + return cascade.PreUserPromptOutput{}, fmt.Errorf("Don't include API keys in prompts") + } + return cascade.AllowPrompt(), nil + }) +} + +// ============================================================================= +// PLATFORM-SPECIFIC: Factory Droid +// ============================================================================= + +func handleDroidStop() { + hookshot.Run(func(input droid.StopInput) droid.StopOutput { + // IMPORTANT: Check StopHookActive to prevent infinite loops + if input.StopHookActive { + return droid.Continue() + } + return droid.Continue() + }) +} + +func handleDroidPreToolUse() { + hookshot.Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { + // Block specific MCP servers + if strings.HasPrefix(input.ToolName, "mcp__blocked__") { + return droid.Deny("MCP server not allowed") + } + + // Auto-approve Read tool + if input.ToolName == "Read" { + return droid.AllowSilent() + } + + return droid.PassThrough() + }) +} + +func handleDroidUserPromptSubmit() { + hookshot.Run(func(input droid.UserPromptSubmitInput) droid.UserPromptSubmitOutput { + // Block prompts with API keys + if strings.Contains(strings.ToLower(input.Prompt), "api_key=") { + return droid.BlockPrompt("Don't include API keys in prompts") + } + return droid.AllowPrompt() + }) +} diff --git a/unified.go b/unified.go index 011a485..87f1b95 100644 --- a/unified.go +++ b/unified.go @@ -3,8 +3,10 @@ package hookshot import ( "encoding/json" + "github.com/CorridorSecurity/hookshot/cascade" "github.com/CorridorSecurity/hookshot/claude" "github.com/CorridorSecurity/hookshot/cursor" + "github.com/CorridorSecurity/hookshot/droid" "github.com/CorridorSecurity/hookshot/internal" ) @@ -12,8 +14,10 @@ import ( type Platform string const ( - PlatformClaude Platform = "claude" - PlatformCursor Platform = "cursor" + PlatformClaude Platform = "claude" + PlatformCursor Platform = "cursor" + PlatformDroid Platform = "droid" + PlatformCascade Platform = "cascade" ) // ============================================================================= @@ -35,12 +39,17 @@ type StopContext struct { } // ShouldSkip returns true if the stop hook should be skipped to prevent loops. -// For Claude Code, this checks StopHookActive. For Cursor, this checks LoopCount >= 3. +// For Claude Code and Droid, this checks StopHookActive. For Cursor, this checks LoopCount >= 3. +// For Cascade, there is no loop prevention mechanism (returns false). func (c StopContext) ShouldSkip() bool { - if c.Platform == PlatformClaude { + if c.Platform == PlatformClaude || c.Platform == PlatformDroid { return c.StopHookActive } - return c.LoopCount >= 3 + if c.Platform == PlatformCursor { + return c.LoopCount >= 3 + } + // Cascade has no loop prevention mechanism + return false } // StopDecision represents the unified decision for stop hooks. @@ -68,8 +77,9 @@ func PreventStop(message string) StopDecision { // StopHandler is the function signature for unified stop handlers. type StopHandler func(StopContext) StopDecision -// OnStop registers a unified handler for stop events on both platforms. -// It automatically registers handlers for "claude-stop" and "cursor-stop". +// OnStop registers a unified handler for stop events on all platforms. +// It automatically registers handlers for "claude-stop", "cursor-stop", +// "droid-stop", and "cascade-post-cascade-response". func OnStop(handler StopHandler) { Register("claude-stop", func() { Run(func(input claude.StopInput) claude.StopOutput { @@ -102,6 +112,36 @@ func OnStop(handler StopHandler) { return cursor.Followup(decision.Message) }) }) + + Register("droid-stop", func() { + Run(func(input droid.StopInput) droid.StopOutput { + ctx := StopContext{ + Platform: PlatformDroid, + SessionID: input.SessionID, + Cwd: input.Cwd, + StopHookActive: input.StopHookActive, + } + decision := handler(ctx) + if decision.Continue { + return droid.Continue() + } + return droid.Block(decision.Message) + }) + }) + + // Cascade uses post_cascade_response as the closest equivalent to stop hooks + Register("cascade-post-cascade-response", func() { + Run(func(input cascade.PostCascadeResponseInput) cascade.PostCascadeResponseOutput { + ctx := StopContext{ + Platform: PlatformCascade, + SessionID: input.TrajectoryID, + } + // Cascade post hooks are fire-and-forget, but we still call the handler + // for side effects (logging, telemetry, etc.) + handler(ctx) + return cascade.PostCascadeResponseOK() + }) + }) } // ============================================================================= @@ -124,18 +164,20 @@ type ExecutionContext struct { // For shell execution (Cursor beforeShellExecution, Claude Code Bash tool) // Also used for local MCP servers on Cursor (command-based MCP servers) - // NOTE: Only populated for Cursor, not Claude Code + // NOTE: Only populated for Cursor and Cascade, not Claude Code or Droid Command string Cwd string // Working directory // For MCP execution ToolName string // MCP tool name (e.g., "mcp__server__tool") ToolInput json.RawMessage // Tool input parameters as JSON - ServerURL string // MCP server URL (Cursor only, for URL-based servers) + ServerURL string // MCP server URL (Cursor/Cascade only, for URL-based servers) // Raw input for advanced use cases RawClaudeCode *claude.PreToolUseInput RawCursor any // *cursor.BeforeShellExecutionInput or *cursor.BeforeMCPExecutionInput + RawDroid *droid.PreToolUseInput + RawCascade any // *cascade.PreRunCommandInput or *cascade.PreMCPToolUseInput } // IsMCP returns true if this is an MCP tool execution. @@ -185,6 +227,9 @@ type ExecutionHandler func(ExecutionContext) ExecutionDecision // - "claude-pre-tool-use" (filters to Bash and mcp__* tools) // - "cursor-before-shell" // - "cursor-before-mcp" +// - "droid-pre-tool-use" (filters to Bash and mcp__* tools) +// - "cascade-pre-run-command" +// - "cascade-pre-mcp-tool-use" func OnBeforeExecution(handler ExecutionHandler) { // Claude Code PreToolUse (for Bash and MCP tools) Register("claude-pre-tool-use", func() { @@ -284,6 +329,98 @@ func OnBeforeExecution(handler ExecutionHandler) { return cursor.Deny(decision.Reason, decision.Reason) }) }) + + // Droid PreToolUse (for Bash and MCP tools) + Register("droid-pre-tool-use", func() { + Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { + // Determine execution type + var execType ExecutionType + if input.ToolName == "Bash" { + execType = ExecutionShell + } else if len(input.ToolName) > 5 && input.ToolName[:5] == "mcp__" { + execType = ExecutionMCP + } else { + execType = ExecutionTool + } + + // Extract command for Bash tool + var command string + if execType == ExecutionShell { + var bashInput struct { + Command string `json:"command"` + } + json.Unmarshal(input.ToolInput, &bashInput) + command = bashInput.Command + } + + ctx := ExecutionContext{ + Platform: PlatformDroid, + Type: execType, + Command: command, + Cwd: input.Cwd, + ToolName: input.ToolName, + ToolInput: input.ToolInput, + RawDroid: &input, + } + + decision := handler(ctx) + if decision.Allow { + if decision.Reason != "" { + return droid.Allow(decision.Reason) + } + return droid.AllowSilent() + } + if decision.Ask { + return droid.Ask(decision.Reason) + } + return droid.Deny(decision.Reason) + }) + }) + + // Cascade preRunCommand + Register("cascade-pre-run-command", func() { + Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { + ctx := ExecutionContext{ + Platform: PlatformCascade, + Type: ExecutionShell, + Command: input.ToolInfo.CommandLine, + Cwd: input.ToolInfo.Cwd, + RawCascade: &input, + } + + decision := handler(ctx) + if decision.Allow { + return cascade.AllowCommand() + } + if decision.Ask { + return cascade.AskCommand(decision.Reason) + } + return cascade.DenyCommand(decision.Reason) + }) + }) + + // Cascade preMCPToolUse + Register("cascade-pre-mcp-tool-use", func() { + Run(func(input cascade.PreMCPToolUseInput) cascade.PreMCPToolUseOutput { + ctx := ExecutionContext{ + Platform: PlatformCascade, + Type: ExecutionMCP, + ToolName: input.ToolInfo.ToolName, + ToolInput: json.RawMessage(input.ToolInfo.ToolInput), + ServerURL: input.ToolInfo.ServerURL, + RawCascade: &input, + } + + decision := handler(ctx) + if decision.Allow { + return cascade.AllowMCP() + } + if decision.Ask { + return cascade.AskMCP(decision.Reason) + } + return cascade.DenyMCP(decision.Reason) + }) + }) } // ============================================================================= @@ -299,7 +436,7 @@ type FileEdit struct { // FileEditContext provides a unified view of file edit events. type FileEditContext struct { Platform Platform - SessionID string // Claude Code: session_id, Cursor: conversation_id + SessionID string // Claude Code: session_id, Cursor: conversation_id, Cascade: trajectory_id FilePath string Edits []FileEdit Cwd string @@ -307,6 +444,8 @@ type FileEditContext struct { // Raw input for advanced use cases RawClaudeCode *claude.PostToolUseInput RawCursor *cursor.AfterFileEditInput + RawDroid *droid.PostToolUseInput + RawCascade *cascade.PostWriteCodeInput } // FileEditDecision represents the unified decision for file edit hooks. @@ -343,6 +482,8 @@ type FileEditHandler func(FileEditContext) FileEditDecision // It automatically registers handlers for: // - "claude-after-file-edit" (PostToolUse for Write/Edit) // - "cursor-after-file-edit" +// - "droid-after-file-edit" (PostToolUse for Write/Edit) +// - "cascade-post-write-code" func OnAfterFileEdit(handler FileEditHandler) { // Claude Code PostToolUse (for Write/Edit) Register("claude-after-file-edit", func() { @@ -410,6 +551,70 @@ func OnAfterFileEdit(handler FileEditHandler) { return cursor.AfterFileEditOK() }) }) + + // Droid PostToolUse (for Write/Edit) + Register("droid-after-file-edit", func() { + Run(func(input droid.PostToolUseInput) droid.PostToolUseOutput { + // Only handle Write and Edit tools + if input.ToolName != "Write" && input.ToolName != "Edit" { + return droid.PostToolOK() + } + + // Extract file path and edits from tool input + var toolInput struct { + FilePath string `json:"file_path"` + Content string `json:"content"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` + } + json.Unmarshal(input.ToolInput, &toolInput) + + var edits []FileEdit + if input.ToolName == "Edit" { + edits = []FileEdit{{OldString: toolInput.OldString, NewString: toolInput.NewString}} + } else { + edits = []FileEdit{{OldString: "", NewString: toolInput.Content}} + } + + ctx := FileEditContext{ + Platform: PlatformDroid, + SessionID: input.SessionID, + FilePath: toolInput.FilePath, + Edits: edits, + Cwd: input.Cwd, + RawDroid: &input, + } + + decision := handler(ctx) + if decision.Block { + return droid.PostToolBlock(decision.Reason) + } + if decision.Context != "" { + return droid.PostToolContext(decision.Context) + } + return droid.PostToolOK() + }) + }) + + // Cascade postWriteCode + Register("cascade-post-write-code", func() { + Run(func(input cascade.PostWriteCodeInput) cascade.PostWriteCodeOutput { + edits := []FileEdit{{OldString: "", NewString: input.ToolInfo.Content}} + + ctx := FileEditContext{ + Platform: PlatformCascade, + SessionID: input.TrajectoryID, + FilePath: input.ToolInfo.FilePath, + Edits: edits, + RawCascade: &input, + } + + // Cascade post hooks are fire-and-forget, but we still call the handler + // for side effects (logging, telemetry, etc.) + handler(ctx) + return cascade.PostWriteCodeOK() + }) + }) } // ============================================================================= @@ -419,12 +624,14 @@ func OnAfterFileEdit(handler FileEditHandler) { // PromptContext provides a unified view of prompt submission events. type PromptContext struct { Platform Platform - SessionID string // Claude Code: session_id, Cursor: conversation_id + SessionID string // Claude Code: session_id, Cursor: conversation_id, Cascade: trajectory_id Prompt string // Raw input for advanced use cases RawClaudeCode *claude.UserPromptSubmitInput RawCursor *cursor.BeforeSubmitPromptInput + RawDroid *droid.UserPromptSubmitInput + RawCascade *cascade.PreUserPromptInput } // PromptDecision represents the unified decision for prompt hooks. @@ -461,6 +668,8 @@ type PromptHandler func(PromptContext) PromptDecision // It automatically registers handlers for: // - "claude-user-prompt-submit" // - "cursor-before-submit-prompt" +// - "droid-user-prompt-submit" +// - "cascade-pre-user-prompt" func OnPromptSubmit(handler PromptHandler) { // Claude Code UserPromptSubmit Register("claude-user-prompt-submit", func() { @@ -500,6 +709,45 @@ func OnPromptSubmit(handler PromptHandler) { return cursor.AllowPrompt() }) }) + + // Droid UserPromptSubmit + Register("droid-user-prompt-submit", func() { + Run(func(input droid.UserPromptSubmitInput) droid.UserPromptSubmitOutput { + ctx := PromptContext{ + Platform: PlatformDroid, + SessionID: input.SessionID, + Prompt: input.Prompt, + RawDroid: &input, + } + + decision := handler(ctx) + if !decision.Allow { + return droid.BlockPrompt(decision.Reason) + } + if decision.Context != "" { + return droid.AddContext(decision.Context) + } + return droid.AllowPrompt() + }) + }) + + // Cascade preUserPrompt + Register("cascade-pre-user-prompt", func() { + Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { + ctx := PromptContext{ + Platform: PlatformCascade, + SessionID: input.TrajectoryID, + Prompt: input.ToolInfo.Prompt, + RawCascade: &input, + } + + decision := handler(ctx) + if !decision.Allow { + return cascade.BlockPrompt(decision.Reason) + } + return cascade.AllowPrompt() + }) + }) } // =============================================================================