From 8a3a638e34f56c38b5c5ea755ce241806bda1726 Mon Sep 17 00:00:00 2001 From: Ashwin Ramaswami Date: Tue, 10 Feb 2026 11:37:27 -0800 Subject: [PATCH 1/4] Fix cascade behavior: denying mcp tool calls should return exit code 2 --- examples/multi-hook/main.go | 83 ++------------ unified.go | 31 +++--- unified_test.go | 214 ++++++++++++++++++++++++++++++++++++ 3 files changed, 238 insertions(+), 90 deletions(-) diff --git a/examples/multi-hook/main.go b/examples/multi-hook/main.go index 2b1ce0b..f5f49e1 100644 --- a/examples/multi-hook/main.go +++ b/examples/multi-hook/main.go @@ -63,13 +63,13 @@ import ( "github.com/CorridorSecurity/hookshot/cascade" "github.com/CorridorSecurity/hookshot/claude" "github.com/CorridorSecurity/hookshot/cursor" - "github.com/CorridorSecurity/hookshot/droid" ) func main() { // ========================================================================== // UNIFIED HANDLERS - // Write once, works on both Claude Code and Cursor automatically. + // Write once, works on Claude Code, Cursor, Windsurf Cascade, and + // Factory Droid automatically. // ========================================================================== hookshot.OnStop(handleStop) @@ -89,20 +89,12 @@ func main() { hookshot.Register("cursor-before-tab-read", handleCursorBeforeTabRead) // ========================================================================== - // WINDSURF CASCADE HANDLERS + // WINDSURF CASCADE: pre-write-code (no unified equivalent) + // Other Cascade hooks (pre-run-command, pre-mcp-tool-use, pre-user-prompt, + // post-cascade-response, post-write-code) are covered by unified 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() } @@ -202,20 +194,11 @@ func handleCursorBeforeTabRead() { } // ============================================================================= -// PLATFORM-SPECIFIC: Windsurf Cascade -// Cascade uses exit code 2 to block actions, so we use RunE with errors +// PLATFORM-SPECIFIC: Windsurf Cascade pre-write-code +// No unified equivalent exists for this hook. +// 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 @@ -225,53 +208,3 @@ func handleCascadePreWriteCode() { 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 87f1b95..96e4331 100644 --- a/unified.go +++ b/unified.go @@ -2,6 +2,7 @@ package hookshot import ( "encoding/json" + "errors" "github.com/CorridorSecurity/hookshot/cascade" "github.com/CorridorSecurity/hookshot/claude" @@ -378,8 +379,10 @@ func OnBeforeExecution(handler ExecutionHandler) { }) // Cascade preRunCommand + // Uses RunE so that blocking decisions exit with code 2, which is how + // Windsurf Cascade detects that a hook has denied an action. Register("cascade-pre-run-command", func() { - Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { + RunE(func(input cascade.PreRunCommandInput) (cascade.PreRunCommandOutput, error) { ctx := ExecutionContext{ Platform: PlatformCascade, Type: ExecutionShell, @@ -390,18 +393,17 @@ func OnBeforeExecution(handler ExecutionHandler) { decision := handler(ctx) if decision.Allow { - return cascade.AllowCommand() + return cascade.AllowCommand(), nil } - if decision.Ask { - return cascade.AskCommand(decision.Reason) - } - return cascade.DenyCommand(decision.Reason) + return cascade.PreRunCommandOutput{}, errors.New(decision.Reason) }) }) // Cascade preMCPToolUse + // Uses RunE so that blocking decisions exit with code 2, which is how + // Windsurf Cascade detects that a hook has denied an action. Register("cascade-pre-mcp-tool-use", func() { - Run(func(input cascade.PreMCPToolUseInput) cascade.PreMCPToolUseOutput { + RunE(func(input cascade.PreMCPToolUseInput) (cascade.PreMCPToolUseOutput, error) { ctx := ExecutionContext{ Platform: PlatformCascade, Type: ExecutionMCP, @@ -413,12 +415,9 @@ func OnBeforeExecution(handler ExecutionHandler) { decision := handler(ctx) if decision.Allow { - return cascade.AllowMCP() - } - if decision.Ask { - return cascade.AskMCP(decision.Reason) + return cascade.AllowMCP(), nil } - return cascade.DenyMCP(decision.Reason) + return cascade.PreMCPToolUseOutput{}, errors.New(decision.Reason) }) }) } @@ -732,8 +731,10 @@ func OnPromptSubmit(handler PromptHandler) { }) // Cascade preUserPrompt + // Uses RunE so that blocking decisions exit with code 2, which is how + // Windsurf Cascade detects that a hook has denied an action. Register("cascade-pre-user-prompt", func() { - Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { + RunE(func(input cascade.PreUserPromptInput) (cascade.PreUserPromptOutput, error) { ctx := PromptContext{ Platform: PlatformCascade, SessionID: input.TrajectoryID, @@ -743,9 +744,9 @@ func OnPromptSubmit(handler PromptHandler) { decision := handler(ctx) if !decision.Allow { - return cascade.BlockPrompt(decision.Reason) + return cascade.PreUserPromptOutput{}, errors.New(decision.Reason) } - return cascade.AllowPrompt() + return cascade.AllowPrompt(), nil }) }) } diff --git a/unified_test.go b/unified_test.go index d3dcb13..f8de69c 100644 --- a/unified_test.go +++ b/unified_test.go @@ -1,7 +1,11 @@ package hookshot import ( + "bytes" "encoding/json" + "os" + "os/exec" + "strings" "testing" ) @@ -284,6 +288,216 @@ func TestExecutionContext_CommandField(t *testing.T) { } } +// ============================================================================= +// Unified Handler Registration Tests +// ============================================================================= + +func TestOnBeforeExecution_RegistersAllHandlers(t *testing.T) { + ClearHandlers() + defer ClearHandlers() + + OnBeforeExecution(func(ctx ExecutionContext) ExecutionDecision { + return AllowExecution() + }) + + expectedHandlers := []string{ + "claude-pre-tool-use", + "cursor-before-shell", + "cursor-before-mcp", + "droid-pre-tool-use", + "cascade-pre-run-command", + "cascade-pre-mcp-tool-use", + } + + for _, name := range expectedHandlers { + if _, ok := handlers[name]; !ok { + t.Errorf("OnBeforeExecution did not register handler %q", name) + } + } +} + +func TestOnPromptSubmit_RegistersAllHandlers(t *testing.T) { + ClearHandlers() + defer ClearHandlers() + + OnPromptSubmit(func(ctx PromptContext) PromptDecision { + return AllowPromptDecision() + }) + + expectedHandlers := []string{ + "claude-user-prompt-submit", + "cursor-before-submit-prompt", + "droid-user-prompt-submit", + "cascade-pre-user-prompt", + } + + for _, name := range expectedHandlers { + if _, ok := handlers[name]; !ok { + t.Errorf("OnPromptSubmit did not register handler %q", name) + } + } +} + +func TestOnStop_RegistersAllHandlers(t *testing.T) { + ClearHandlers() + defer ClearHandlers() + + OnStop(func(ctx StopContext) StopDecision { + return AllowStop() + }) + + expectedHandlers := []string{ + "claude-stop", + "cursor-stop", + "droid-stop", + "cascade-post-cascade-response", + } + + for _, name := range expectedHandlers { + if _, ok := handlers[name]; !ok { + t.Errorf("OnStop did not register handler %q", name) + } + } +} + +func TestOnAfterFileEdit_RegistersAllHandlers(t *testing.T) { + ClearHandlers() + defer ClearHandlers() + + OnAfterFileEdit(func(ctx FileEditContext) FileEditDecision { + return FileEditOK() + }) + + expectedHandlers := []string{ + "claude-after-file-edit", + "cursor-after-file-edit", + "droid-after-file-edit", + "cascade-post-write-code", + } + + for _, name := range expectedHandlers { + if _, ok := handlers[name]; !ok { + t.Errorf("OnAfterFileEdit did not register handler %q", name) + } + } +} + +// ============================================================================= +// Cascade RunE Behavior Tests (exit code 2 on deny, 0 on allow) +// ============================================================================= + +// TestCascadePreHooks_RunE verifies that cascade pre-hooks use RunE so that +// blocking decisions exit with code 2 (how Windsurf Cascade detects denials). +func TestCascadePreHooks_RunE(t *testing.T) { + // Subprocess entry point: when invoked as a subprocess, register handlers + // and execute the specified cascade handler with controlled stdin. + if handler := os.Getenv("HOOKSHOT_TEST_CASCADE_HANDLER"); handler != "" { + decision := os.Getenv("HOOKSHOT_TEST_CASCADE_DECISION") + + ClearHandlers() + OnBeforeExecution(func(ctx ExecutionContext) ExecutionDecision { + if decision == "deny" { + return DenyExecution("blocked by test") + } + return AllowExecution() + }) + OnPromptSubmit(func(ctx PromptContext) PromptDecision { + if decision == "deny" { + return BlockPromptDecision("blocked by test") + } + return AllowPromptDecision() + }) + + handlers[handler]() + return + } + + tests := []struct { + name string + handler string + stdinJSON string + decision string + wantExit int + wantStderr string // substring expected in stderr (empty = don't check) + }{ + { + name: "cascade-pre-run-command deny exits 2", + handler: "cascade-pre-run-command", + stdinJSON: `{"trajectory_id":"test","tool_info":{"command_line":"rm -rf /","cwd":"/tmp"}}`, + decision: "deny", + wantExit: 2, + wantStderr: "blocked by test", + }, + { + name: "cascade-pre-run-command allow exits 0", + handler: "cascade-pre-run-command", + stdinJSON: `{"trajectory_id":"test","tool_info":{"command_line":"ls","cwd":"/tmp"}}`, + decision: "allow", + wantExit: 0, + }, + { + name: "cascade-pre-mcp-tool-use deny exits 2", + handler: "cascade-pre-mcp-tool-use", + stdinJSON: `{"trajectory_id":"test","tool_info":{"tool_name":"analyze","tool_input":"{}","server_url":"https://example.com"}}`, + decision: "deny", + wantExit: 2, + wantStderr: "blocked by test", + }, + { + name: "cascade-pre-mcp-tool-use allow exits 0", + handler: "cascade-pre-mcp-tool-use", + stdinJSON: `{"trajectory_id":"test","tool_info":{"tool_name":"analyze","tool_input":"{}","server_url":"https://example.com"}}`, + decision: "allow", + wantExit: 0, + }, + { + name: "cascade-pre-user-prompt deny exits 2", + handler: "cascade-pre-user-prompt", + stdinJSON: `{"trajectory_id":"test","tool_info":{"prompt":"hello"}}`, + decision: "deny", + wantExit: 2, + wantStderr: "blocked by test", + }, + { + name: "cascade-pre-user-prompt allow exits 0", + handler: "cascade-pre-user-prompt", + stdinJSON: `{"trajectory_id":"test","tool_info":{"prompt":"hello"}}`, + decision: "allow", + wantExit: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := exec.Command(os.Args[0], "-test.run=^TestCascadePreHooks_RunE$") + cmd.Env = append(os.Environ(), + "HOOKSHOT_TEST_CASCADE_HANDLER="+tt.handler, + "HOOKSHOT_TEST_CASCADE_DECISION="+tt.decision, + ) + cmd.Stdin = strings.NewReader(tt.stdinJSON) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + + exitCode := 0 + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else if err != nil { + t.Fatalf("unexpected error running subprocess: %v", err) + } + + if exitCode != tt.wantExit { + t.Errorf("exit code = %d, want %d (stderr: %s)", exitCode, tt.wantExit, stderr.String()) + } + if tt.wantStderr != "" && !strings.Contains(stderr.String(), tt.wantStderr) { + t.Errorf("stderr = %q, want substring %q", stderr.String(), tt.wantStderr) + } + }) + } +} + func TestExecutionContext_ToolInput_JSON(t *testing.T) { toolInput := json.RawMessage(`{"file_path":"/test.ts"}`) ctx := ExecutionContext{ From fc077214e9d1a91a871dc1fd1c02a93be55c15c4 Mon Sep 17 00:00:00 2001 From: Ashwin Ramaswami Date: Tue, 10 Feb 2026 11:39:43 -0800 Subject: [PATCH 2/4] update docs --- README.md | 22 +++++++++++----------- docs/reference-cascade.md | 34 +++++++++++++++++++--------------- docs/reference-unified.md | 39 ++++++++++++++++++++++++--------------- 3 files changed, 54 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 14faa53..8c12988 100644 --- a/README.md +++ b/README.md @@ -99,14 +99,14 @@ hookshot install --binary /path/to/my-hooks ## Unified Handlers -Write once, run on both platforms: +Write once, run on all four platforms: -| Handler | Claude Code | Cursor | -|---------|-------------|--------| -| `OnStop` | Stop | stop | -| `OnBeforeExecution` | PreToolUse | beforeShellExecution, beforeMCPExecution | -| `OnAfterFileEdit` | PostToolUse | afterFileEdit | -| `OnPromptSubmit` | UserPromptSubmit | beforeSubmitPrompt | +| Handler | Claude Code | Cursor | Windsurf Cascade | Factory Droid | +|---------|-------------|--------|------------------|---------------| +| `OnStop` | Stop | stop | post-cascade-response | Stop | +| `OnBeforeExecution` | PreToolUse | beforeShellExecution, beforeMCPExecution | pre-run-command, pre-mcp-tool-use | PreToolUse | +| `OnAfterFileEdit` | PostToolUse | afterFileEdit | post-write-code | PostToolUse | +| `OnPromptSubmit` | UserPromptSubmit | beforeSubmitPrompt | pre-user-prompt | UserPromptSubmit | ## Platform-Specific Handlers @@ -127,10 +127,10 @@ hookshot.Register("cursor-before-tab-read", func() { }) }) -// Windsurf Cascade: Pre-run command -hookshot.Register("cascade-pre-run-command", func() { - hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { - return cascade.AllowCommand() +// Windsurf Cascade: Pre-write-code (not covered by unified API) +hookshot.Register("cascade-pre-write-code", func() { + hookshot.RunE(func(input cascade.PreWriteCodeInput) (cascade.PreWriteCodeOutput, error) { + return cascade.AllowWrite(), nil }) }) diff --git a/docs/reference-cascade.md b/docs/reference-cascade.md index 8458131..68f62c7 100644 --- a/docs/reference-cascade.md +++ b/docs/reference-cascade.md @@ -94,17 +94,21 @@ func AskCommand(message string) PreRunCommandOutput ### Example +Cascade uses exit code 2 to block actions, so pre-hooks should use `RunE`. When the handler returns an error, the process exits with code 2 and Cascade blocks the action. + ```go hookshot.Register("cascade-pre-run-command", func() { - hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput { + hookshot.RunE(func(input cascade.PreRunCommandInput) (cascade.PreRunCommandOutput, error) { if strings.Contains(input.ToolInfo.CommandLine, "rm -rf /") { - return cascade.DenyCommand("Dangerous command blocked") + return cascade.PreRunCommandOutput{}, fmt.Errorf("Dangerous command blocked") } - return cascade.AllowCommand() + return cascade.AllowCommand(), nil }) }) ``` +> **Note:** The unified handler `OnBeforeExecution` already handles this correctly — it uses `RunE` for Cascade pre-hooks automatically. You only need `Register` for Cascade-specific hooks not covered by the unified API. + --- ## PostRunCommand @@ -179,11 +183,11 @@ func AskWrite(message string) PreWriteCodeOutput ```go hookshot.Register("cascade-pre-write-code", func() { - hookshot.Run(func(input cascade.PreWriteCodeInput) cascade.PreWriteCodeOutput { + hookshot.RunE(func(input cascade.PreWriteCodeInput) (cascade.PreWriteCodeOutput, error) { if strings.HasSuffix(input.ToolInfo.FilePath, ".env") { - return cascade.DenyWrite("Cannot write to .env files") + return cascade.PreWriteCodeOutput{}, fmt.Errorf("Cannot write to .env files") } - return cascade.AllowWrite() + return cascade.AllowWrite(), nil }) }) ``` @@ -259,11 +263,11 @@ func AskRead(message string) PreReadCodeOutput ```go hookshot.Register("cascade-pre-read-code", func() { - hookshot.Run(func(input cascade.PreReadCodeInput) cascade.PreReadCodeOutput { + hookshot.RunE(func(input cascade.PreReadCodeInput) (cascade.PreReadCodeOutput, error) { if strings.Contains(input.ToolInfo.FilePath, "secrets") { - return cascade.DenyRead("Cannot read secret files") + return cascade.PreReadCodeOutput{}, fmt.Errorf("Cannot read secret files") } - return cascade.AllowRead() + return cascade.AllowRead(), nil }) }) ``` @@ -341,11 +345,11 @@ func AskMCP(message string) PreMCPToolUseOutput ```go hookshot.Register("cascade-pre-mcp-tool-use", func() { - hookshot.Run(func(input cascade.PreMCPToolUseInput) cascade.PreMCPToolUseOutput { + hookshot.RunE(func(input cascade.PreMCPToolUseInput) (cascade.PreMCPToolUseOutput, error) { if strings.Contains(input.ToolInfo.ServerURL, "blocked.com") { - return cascade.DenyMCP("MCP server not allowed") + return cascade.PreMCPToolUseOutput{}, fmt.Errorf("MCP server not allowed") } - return cascade.AllowMCP() + return cascade.AllowMCP(), nil }) }) ``` @@ -421,11 +425,11 @@ func BlockPrompt(message string) PreUserPromptOutput ```go hookshot.Register("cascade-pre-user-prompt", func() { - hookshot.Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { + hookshot.RunE(func(input cascade.PreUserPromptInput) (cascade.PreUserPromptOutput, error) { if strings.Contains(input.ToolInfo.Prompt, "api_key=") { - return cascade.BlockPrompt("Don't include API keys in prompts") + return cascade.PreUserPromptOutput{}, fmt.Errorf("Don't include API keys in prompts") } - return cascade.AllowPrompt() + return cascade.AllowPrompt(), nil }) }) ``` diff --git a/docs/reference-unified.md b/docs/reference-unified.md index 5617002..28b1397 100644 --- a/docs/reference-unified.md +++ b/docs/reference-unified.md @@ -1,6 +1,6 @@ # Unified API Reference -The unified API provides cross-platform handlers that work on both Claude Code and Cursor. Write once, run on both platforms. +The unified API provides cross-platform handlers that work on Claude Code, Cursor, Windsurf Cascade, and Factory Droid. Write once, run on all platforms. ## Platform Constants @@ -8,8 +8,10 @@ The unified API provides cross-platform handlers that work on both Claude Code a type Platform string const ( - PlatformClaude Platform = "claude" - PlatformCursor Platform = "cursor" + PlatformClaude Platform = "claude" + PlatformCursor Platform = "cursor" + PlatformDroid Platform = "droid" + PlatformCascade Platform = "cascade" ) ``` @@ -17,17 +19,17 @@ const ( Handles stop events when the agent is about to finish. -**Registers:** `claude-stop`, `cursor-stop` +**Registers:** `claude-stop`, `cursor-stop`, `droid-stop`, `cascade-post-cascade-response` ### StopContext ```go type StopContext struct { Platform Platform - SessionID string // Claude: session_id, Cursor: conversation_id - Cwd string // Working directory (Claude only, empty for Cursor) + SessionID string // Claude/Droid: session_id, Cursor: conversation_id, Cascade: trajectory_id + Cwd string // Working directory (Claude/Droid only, empty for Cursor/Cascade) - // Claude-specific + // Claude/Droid-specific StopHookActive bool // True if already continuing from a previous stop hook // Cursor-specific @@ -40,8 +42,9 @@ type StopContext struct { ```go // ShouldSkip returns true if the stop hook should be skipped to prevent loops. -// Claude: checks StopHookActive +// Claude/Droid: checks StopHookActive // Cursor: checks LoopCount >= 3 +// Cascade: always returns false (no loop prevention mechanism) func (c StopContext) ShouldSkip() bool ``` @@ -83,7 +86,7 @@ hookshot.OnStop(func(ctx hookshot.StopContext) hookshot.StopDecision { Handles pre-execution events for shell commands and MCP tools. -**Registers:** `claude-pre-tool-use`, `cursor-before-shell`, `cursor-before-mcp` +**Registers:** `claude-pre-tool-use`, `cursor-before-shell`, `cursor-before-mcp`, `droid-pre-tool-use`, `cascade-pre-run-command`, `cascade-pre-mcp-tool-use` ### ExecutionType @@ -106,18 +109,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 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 access RawClaudeCode *claude.PreToolUseInput RawCursor any // *cursor.BeforeShellExecutionInput or *cursor.BeforeMCPExecutionInput + RawDroid *droid.PreToolUseInput + RawCascade any // *cascade.PreRunCommandInput or *cascade.PreMCPToolUseInput } ``` @@ -173,7 +178,7 @@ hookshot.OnBeforeExecution(func(ctx hookshot.ExecutionContext) hookshot.Executio Handles post-file-edit events. -**Registers:** `claude-after-file-edit`, `cursor-after-file-edit` +**Registers:** `claude-after-file-edit`, `cursor-after-file-edit`, `droid-after-file-edit`, `cascade-post-write-code` ### FileEdit @@ -189,7 +194,7 @@ type FileEdit struct { ```go type FileEditContext struct { Platform Platform - SessionID string // Claude: session_id, Cursor: conversation_id + SessionID string // Claude/Droid: session_id, Cursor: conversation_id, Cascade: trajectory_id FilePath string Edits []FileEdit Cwd string @@ -197,6 +202,8 @@ type FileEditContext struct { // Raw access RawClaudeCode *claude.PostToolUseInput RawCursor *cursor.AfterFileEditInput + RawDroid *droid.PostToolUseInput + RawCascade *cascade.PostWriteCodeInput } ``` @@ -244,19 +251,21 @@ hookshot.OnAfterFileEdit(func(ctx hookshot.FileEditContext) hookshot.FileEditDec Handles prompt submission events. -**Registers:** `claude-user-prompt-submit`, `cursor-before-submit-prompt` +**Registers:** `claude-user-prompt-submit`, `cursor-before-submit-prompt`, `droid-user-prompt-submit`, `cascade-pre-user-prompt` ### PromptContext ```go type PromptContext struct { Platform Platform - SessionID string // Claude: session_id, Cursor: conversation_id + SessionID string // Claude/Droid: session_id, Cursor: conversation_id, Cascade: trajectory_id Prompt string // Raw access RawClaudeCode *claude.UserPromptSubmitInput RawCursor *cursor.BeforeSubmitPromptInput + RawDroid *droid.UserPromptSubmitInput + RawCascade *cascade.PreUserPromptInput } ``` From e3dc19847ca0e27dc9d77ad00de0854b0ab1a1a9 Mon Sep 17 00:00:00 2001 From: Ashwin Ramaswami Date: Wed, 11 Feb 2026 01:01:36 -0800 Subject: [PATCH 3/4] fix windsruf, factory --- cascade/types.go | 31 ++++++++++++++++++++----------- unified.go | 27 +++++++++++++++------------ unified_test.go | 27 +++++++++++++++++++++------ 3 files changed, 56 insertions(+), 29 deletions(-) diff --git a/cascade/types.go b/cascade/types.go index 82515f4..b350b1d 100644 --- a/cascade/types.go +++ b/cascade/types.go @@ -4,6 +4,8 @@ // points in the agent loop. See Windsurf documentation for details. package cascade +import "encoding/json" + // ============================================================================= // Common Types // ============================================================================= @@ -72,10 +74,16 @@ type PostRunCommandOutput struct{} // PreWriteCode // ============================================================================= +// CascadeEdit represents a single edit operation in Cascade write hooks. +type CascadeEdit struct { + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + // PreWriteCodeToolInfo contains details about the file write operation. type PreWriteCodeToolInfo struct { - FilePath string `json:"file_path"` - Content string `json:"content"` + FilePath string `json:"file_path"` + Edits []CascadeEdit `json:"edits"` } // PreWriteCodeInput is received before writing a code file. @@ -95,8 +103,8 @@ type PreWriteCodeOutput struct { // PostWriteCodeToolInfo contains details about the written file. type PostWriteCodeToolInfo struct { - FilePath string `json:"file_path"` - Content string `json:"content"` + FilePath string `json:"file_path"` + Edits []CascadeEdit `json:"edits"` } // PostWriteCodeInput is received after writing a code file. @@ -153,9 +161,9 @@ type PostReadCodeOutput struct{} // 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"` + MCPServerName string `json:"mcp_server_name"` + MCPToolName string `json:"mcp_tool_name"` + MCPToolArguments json.RawMessage `json:"mcp_tool_arguments"` } // PreMCPToolUseInput is received before an MCP tool executes. @@ -175,9 +183,10 @@ type PreMCPToolUseOutput struct { // 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"` + MCPServerName string `json:"mcp_server_name"` + MCPToolName string `json:"mcp_tool_name"` + MCPToolArguments json.RawMessage `json:"mcp_tool_arguments"` + MCPResult string `json:"mcp_result,omitempty"` } // PostMCPToolUseInput is received after an MCP tool executes. @@ -195,7 +204,7 @@ type PostMCPToolUseOutput struct{} // PreUserPromptToolInfo contains details about the user's prompt. type PreUserPromptToolInfo struct { - Prompt string `json:"prompt"` + UserPrompt string `json:"user_prompt"` } // PreUserPromptInput is received when the user submits a prompt. diff --git a/unified.go b/unified.go index 96e4331..9e52395 100644 --- a/unified.go +++ b/unified.go @@ -332,8 +332,10 @@ func OnBeforeExecution(handler ExecutionHandler) { }) // Droid PreToolUse (for Bash and MCP tools) + // Uses RunE so that blocking decisions exit with code 2, which is how + // Factory Droid detects that a hook has denied an action. Register("droid-pre-tool-use", func() { - Run(func(input droid.PreToolUseInput) droid.PreToolUseOutput { + RunE(func(input droid.PreToolUseInput) (droid.PreToolUseOutput, error) { // Determine execution type var execType ExecutionType if input.ToolName == "Bash" { @@ -367,14 +369,12 @@ func OnBeforeExecution(handler ExecutionHandler) { decision := handler(ctx) if decision.Allow { if decision.Reason != "" { - return droid.Allow(decision.Reason) + return droid.Allow(decision.Reason), nil } - return droid.AllowSilent() - } - if decision.Ask { - return droid.Ask(decision.Reason) + return droid.AllowSilent(), nil } - return droid.Deny(decision.Reason) + // Exit code 2 + stderr message for blocking (per Factory docs) + return droid.PreToolUseOutput{}, errors.New(decision.Reason) }) }) @@ -407,9 +407,9 @@ func OnBeforeExecution(handler ExecutionHandler) { ctx := ExecutionContext{ Platform: PlatformCascade, Type: ExecutionMCP, - ToolName: input.ToolInfo.ToolName, - ToolInput: json.RawMessage(input.ToolInfo.ToolInput), - ServerURL: input.ToolInfo.ServerURL, + ToolName: input.ToolInfo.MCPToolName, + ToolInput: input.ToolInfo.MCPToolArguments, + ServerURL: input.ToolInfo.MCPServerName, RawCascade: &input, } @@ -598,7 +598,10 @@ func OnAfterFileEdit(handler FileEditHandler) { // Cascade postWriteCode Register("cascade-post-write-code", func() { Run(func(input cascade.PostWriteCodeInput) cascade.PostWriteCodeOutput { - edits := []FileEdit{{OldString: "", NewString: input.ToolInfo.Content}} + var edits []FileEdit + for _, e := range input.ToolInfo.Edits { + edits = append(edits, FileEdit{OldString: e.OldString, NewString: e.NewString}) + } ctx := FileEditContext{ Platform: PlatformCascade, @@ -738,7 +741,7 @@ func OnPromptSubmit(handler PromptHandler) { ctx := PromptContext{ Platform: PlatformCascade, SessionID: input.TrajectoryID, - Prompt: input.ToolInfo.Prompt, + Prompt: input.ToolInfo.UserPrompt, RawCascade: &input, } diff --git a/unified_test.go b/unified_test.go index f8de69c..9ec799e 100644 --- a/unified_test.go +++ b/unified_test.go @@ -386,8 +386,8 @@ func TestOnAfterFileEdit_RegistersAllHandlers(t *testing.T) { // Cascade RunE Behavior Tests (exit code 2 on deny, 0 on allow) // ============================================================================= -// TestCascadePreHooks_RunE verifies that cascade pre-hooks use RunE so that -// blocking decisions exit with code 2 (how Windsurf Cascade detects denials). +// TestPreHooks_RunE verifies that cascade and droid pre-hooks use RunE so that +// blocking decisions exit with code 2 (how Windsurf Cascade and Factory Droid detect denials). func TestCascadePreHooks_RunE(t *testing.T) { // Subprocess entry point: when invoked as a subprocess, register handlers // and execute the specified cascade handler with controlled stdin. @@ -438,7 +438,7 @@ func TestCascadePreHooks_RunE(t *testing.T) { { name: "cascade-pre-mcp-tool-use deny exits 2", handler: "cascade-pre-mcp-tool-use", - stdinJSON: `{"trajectory_id":"test","tool_info":{"tool_name":"analyze","tool_input":"{}","server_url":"https://example.com"}}`, + stdinJSON: `{"trajectory_id":"test","tool_info":{"mcp_server_name":"example","mcp_tool_name":"analyze","mcp_tool_arguments":{}}}`, decision: "deny", wantExit: 2, wantStderr: "blocked by test", @@ -446,14 +446,14 @@ func TestCascadePreHooks_RunE(t *testing.T) { { name: "cascade-pre-mcp-tool-use allow exits 0", handler: "cascade-pre-mcp-tool-use", - stdinJSON: `{"trajectory_id":"test","tool_info":{"tool_name":"analyze","tool_input":"{}","server_url":"https://example.com"}}`, + stdinJSON: `{"trajectory_id":"test","tool_info":{"mcp_server_name":"example","mcp_tool_name":"analyze","mcp_tool_arguments":{}}}`, decision: "allow", wantExit: 0, }, { name: "cascade-pre-user-prompt deny exits 2", handler: "cascade-pre-user-prompt", - stdinJSON: `{"trajectory_id":"test","tool_info":{"prompt":"hello"}}`, + stdinJSON: `{"trajectory_id":"test","tool_info":{"user_prompt":"hello"}}`, decision: "deny", wantExit: 2, wantStderr: "blocked by test", @@ -461,7 +461,22 @@ func TestCascadePreHooks_RunE(t *testing.T) { { name: "cascade-pre-user-prompt allow exits 0", handler: "cascade-pre-user-prompt", - stdinJSON: `{"trajectory_id":"test","tool_info":{"prompt":"hello"}}`, + stdinJSON: `{"trajectory_id":"test","tool_info":{"user_prompt":"hello"}}`, + decision: "allow", + wantExit: 0, + }, + { + name: "droid-pre-tool-use deny exits 2", + handler: "droid-pre-tool-use", + stdinJSON: `{"session_id":"test","tool_name":"mcp__blocked__tool","tool_input":{},"cwd":"/tmp"}`, + decision: "deny", + wantExit: 2, + wantStderr: "blocked by test", + }, + { + name: "droid-pre-tool-use allow exits 0", + handler: "droid-pre-tool-use", + stdinJSON: `{"session_id":"test","tool_name":"mcp__allowed__tool","tool_input":{},"cwd":"/tmp"}`, decision: "allow", wantExit: 0, }, From 800b7622c75a083242403b561a6cf3ead5c61263 Mon Sep 17 00:00:00 2001 From: Ashwin Ramaswami Date: Wed, 11 Feb 2026 01:04:13 -0800 Subject: [PATCH 4/4] fix --- cascade/doc.go | 2 +- docs/reference-cascade.md | 32 +++++++++++++++++++------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/cascade/doc.go b/cascade/doc.go index c43495d..8f622f2 100644 --- a/cascade/doc.go +++ b/cascade/doc.go @@ -52,7 +52,7 @@ // PreUserPrompt hooks validate or modify user prompts: // // hookshot.Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput { -// if containsSecrets(input.ToolInfo.Prompt) { +// if containsSecrets(input.ToolInfo.UserPrompt) { // return cascade.BlockPrompt("Please don't include secrets in prompts") // } // return cascade.AllowPrompt() diff --git a/docs/reference-cascade.md b/docs/reference-cascade.md index 68f62c7..8cac98e 100644 --- a/docs/reference-cascade.md +++ b/docs/reference-cascade.md @@ -152,9 +152,14 @@ Called before writing a file. ### PreWriteCodeInput ```go +type CascadeEdit struct { + OldString string `json:"old_string"` + NewString string `json:"new_string"` +} + type PreWriteCodeToolInfo struct { - FilePath string `json:"file_path"` - Content string `json:"content"` + FilePath string `json:"file_path"` + Edits []CascadeEdit `json:"edits"` } type PreWriteCodeInput struct { @@ -202,8 +207,8 @@ Called after writing a file. ```go type PostWriteCodeToolInfo struct { - FilePath string `json:"file_path"` - Content string `json:"content"` + FilePath string `json:"file_path"` + Edits []CascadeEdit `json:"edits"` } type PostWriteCodeInput struct { @@ -314,9 +319,9 @@ Called before an MCP tool executes. ```go type PreMCPToolUseToolInfo struct { - ToolName string `json:"tool_name"` - ToolInput string `json:"tool_input"` // JSON string of parameters - ServerURL string `json:"server_url,omitempty"` + MCPServerName string `json:"mcp_server_name"` + MCPToolName string `json:"mcp_tool_name"` + MCPToolArguments json.RawMessage `json:"mcp_tool_arguments"` } type PreMCPToolUseInput struct { @@ -346,7 +351,7 @@ func AskMCP(message string) PreMCPToolUseOutput ```go hookshot.Register("cascade-pre-mcp-tool-use", func() { hookshot.RunE(func(input cascade.PreMCPToolUseInput) (cascade.PreMCPToolUseOutput, error) { - if strings.Contains(input.ToolInfo.ServerURL, "blocked.com") { + if input.ToolInfo.MCPServerName == "blocked-server" { return cascade.PreMCPToolUseOutput{}, fmt.Errorf("MCP server not allowed") } return cascade.AllowMCP(), nil @@ -364,9 +369,10 @@ Called after an MCP tool executes. ```go type PostMCPToolUseToolInfo struct { - ToolName string `json:"tool_name"` - ToolInput string `json:"tool_input"` - ToolOutput string `json:"tool_output,omitempty"` + MCPServerName string `json:"mcp_server_name"` + MCPToolName string `json:"mcp_tool_name"` + MCPToolArguments json.RawMessage `json:"mcp_tool_arguments"` + MCPResult string `json:"mcp_result,omitempty"` } type PostMCPToolUseInput struct { @@ -397,7 +403,7 @@ Called when the user submits a prompt. ```go type PreUserPromptToolInfo struct { - Prompt string `json:"prompt"` + UserPrompt string `json:"user_prompt"` } type PreUserPromptInput struct { @@ -426,7 +432,7 @@ func BlockPrompt(message string) PreUserPromptOutput ```go hookshot.Register("cascade-pre-user-prompt", func() { hookshot.RunE(func(input cascade.PreUserPromptInput) (cascade.PreUserPromptOutput, error) { - if strings.Contains(input.ToolInfo.Prompt, "api_key=") { + if strings.Contains(input.ToolInfo.UserPrompt, "api_key=") { return cascade.PreUserPromptOutput{}, fmt.Errorf("Don't include API keys in prompts") } return cascade.AllowPrompt(), nil