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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 11 additions & 10 deletions cascade/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,35 +27,35 @@
//
// PreRunCommand hooks control shell command execution:
//
// hookshot.Run(func(input cascade.PreRunCommandInput) cascade.PreRunCommandOutput {
// hookshot.RunE(func(input cascade.PreRunCommandInput) (cascade.PreRunCommandOutput, error) {
// // Block dangerous commands
// if strings.Contains(input.ToolInfo.CommandLine, "rm -rf") {
// return cascade.Deny("Dangerous command blocked")
// return cascade.BlockCommand("Dangerous command blocked")
// }
// return cascade.Allow()
// return cascade.AllowCommand(), nil
// })
//
// # PreWriteCode Hooks
//
// PreWriteCode hooks control file modifications:
//
// hookshot.Run(func(input cascade.PreWriteCodeInput) cascade.PreWriteCodeOutput {
// hookshot.RunE(func(input cascade.PreWriteCodeInput) (cascade.PreWriteCodeOutput, error) {
// // Block writes to sensitive files
// if strings.Contains(input.ToolInfo.FilePath, ".env") {
// return cascade.Deny("Cannot modify environment files")
// return cascade.BlockWrite("Cannot modify environment files")
// }
// return cascade.Allow()
// return cascade.AllowWrite(), nil
// })
//
// # PreUserPrompt Hooks
//
// PreUserPrompt hooks validate or modify user prompts:
//
// hookshot.Run(func(input cascade.PreUserPromptInput) cascade.PreUserPromptOutput {
// hookshot.RunE(func(input cascade.PreUserPromptInput) (cascade.PreUserPromptOutput, error) {
// if containsSecrets(input.ToolInfo.UserPrompt) {
// return cascade.BlockPrompt("Please don't include secrets in prompts")
// return cascade.BlockPromptWithError("Please don't include secrets in prompts")
// }
// return cascade.AllowPrompt()
// return cascade.AllowPrompt(), nil
// })
//
// # Helper Functions
Expand All @@ -64,7 +64,8 @@
//
// Pre-hooks (PreRunCommand, PreWriteCode, PreReadCode, PreMCPToolUse):
// - [Allow]: Permit the action
// - [Deny]: Block the action with a reason
// - [Deny]: Build deny JSON for the action
// - [Block]: Build deny JSON plus the error required for [hookshot.RunE]
// - [Ask]: Prompt user to confirm (where supported)
//
// PreUserPrompt:
Expand Down
44 changes: 39 additions & 5 deletions cascade/helpers.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package cascade

import "errors"

// =============================================================================
// Pre-Hook Decision Helpers (shared by PreRunCommand, PreWriteCode, etc.)
// =============================================================================
Expand All @@ -11,14 +13,21 @@ func Allow() BaseOutput {
}
}

// Deny blocks the action with a message.
// Deny returns deny JSON with a message. Cascade pre-hooks must return this
// from RunE with a non-nil error to block the action.
func Deny(message string) BaseOutput {
return BaseOutput{
Decision: "deny",
Message: message,
}
}

// Block returns a deny output plus the error RunE needs to make Cascade
// block the pre-hook with exit code 2.
func Block(message string) (BaseOutput, error) {
return Deny(message), errors.New(message)
}

// Ask prompts the user to confirm the action.
func Ask(message string) BaseOutput {
return BaseOutput{
Expand All @@ -38,13 +47,18 @@ func AllowCommand() PreRunCommandOutput {
}
}

// DenyCommand blocks the command from executing.
// DenyCommand returns deny JSON for a command.
func DenyCommand(message string) PreRunCommandOutput {
return PreRunCommandOutput{
BaseOutput: Deny(message),
}
}

// BlockCommand blocks the command from executing when returned from RunE.
func BlockCommand(message string) (PreRunCommandOutput, error) {
return DenyCommand(message), errors.New(message)
}

// AskCommand prompts the user to confirm the command.
func AskCommand(message string) PreRunCommandOutput {
return PreRunCommandOutput{
Expand All @@ -63,13 +77,18 @@ func AllowWrite() PreWriteCodeOutput {
}
}

// DenyWrite blocks the file write.
// DenyWrite returns deny JSON for a file write.
func DenyWrite(message string) PreWriteCodeOutput {
return PreWriteCodeOutput{
BaseOutput: Deny(message),
}
}

// BlockWrite blocks the file write when returned from RunE.
func BlockWrite(message string) (PreWriteCodeOutput, error) {
return DenyWrite(message), errors.New(message)
}

// AskWrite prompts the user to confirm the write.
func AskWrite(message string) PreWriteCodeOutput {
return PreWriteCodeOutput{
Expand All @@ -88,13 +107,18 @@ func AllowRead() PreReadCodeOutput {
}
}

// DenyRead blocks the file read.
// DenyRead returns deny JSON for a file read.
func DenyRead(message string) PreReadCodeOutput {
return PreReadCodeOutput{
BaseOutput: Deny(message),
}
}

// BlockRead blocks the file read when returned from RunE.
func BlockRead(message string) (PreReadCodeOutput, error) {
return DenyRead(message), errors.New(message)
}

// AskRead prompts the user to confirm the read.
func AskRead(message string) PreReadCodeOutput {
return PreReadCodeOutput{
Expand All @@ -113,13 +137,18 @@ func AllowMCP() PreMCPToolUseOutput {
}
}

// DenyMCP blocks the MCP tool from executing.
// DenyMCP returns deny JSON for an MCP tool.
func DenyMCP(message string) PreMCPToolUseOutput {
return PreMCPToolUseOutput{
BaseOutput: Deny(message),
}
}

// BlockMCP blocks the MCP tool when returned from RunE.
func BlockMCP(message string) (PreMCPToolUseOutput, error) {
return DenyMCP(message), errors.New(message)
}

// AskMCP prompts the user to confirm the MCP tool.
func AskMCP(message string) PreMCPToolUseOutput {
return PreMCPToolUseOutput{
Expand All @@ -145,6 +174,11 @@ func BlockPrompt(message string) PreUserPromptOutput {
}
}

// BlockPromptWithError blocks the user prompt when returned from RunE.
func BlockPromptWithError(message string) (PreUserPromptOutput, error) {
return BlockPrompt(message), errors.New(message)
}

// =============================================================================
// Post-Hook Helpers (all are fire-and-forget)
// =============================================================================
Expand Down
63 changes: 63 additions & 0 deletions cascade/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@ func TestDeny(t *testing.T) {
}
}

func TestBlock(t *testing.T) {
output, err := Block("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")
}
if err == nil || err.Error() != "Action blocked" {
t.Fatalf("error = %v, want Action blocked", err)
}
}

func TestAsk(t *testing.T) {
output := Ask("Confirm action?")
if output.Decision != "ask" {
Expand Down Expand Up @@ -62,6 +75,16 @@ func TestDenyCommand(t *testing.T) {
}
}

func TestBlockCommand(t *testing.T) {
output, err := BlockCommand("Command not allowed")
if output.Decision != "deny" || output.Message != "Command not allowed" {
t.Fatalf("output = %+v, want deny with message", output)
}
if err == nil || err.Error() != "Command not allowed" {
t.Fatalf("error = %v, want Command not allowed", err)
}
}

func TestAskCommand(t *testing.T) {
output := AskCommand("Run this command?")
if output.Decision != "ask" {
Expand Down Expand Up @@ -96,6 +119,16 @@ func TestDenyWrite(t *testing.T) {
}
}

func TestBlockWrite(t *testing.T) {
output, err := BlockWrite("Write not permitted")
if output.Decision != "deny" || output.Message != "Write not permitted" {
t.Fatalf("output = %+v, want deny with message", output)
}
if err == nil || err.Error() != "Write not permitted" {
t.Fatalf("error = %v, want Write not permitted", err)
}
}

func TestAskWrite(t *testing.T) {
output := AskWrite("Allow file write?")
if output.Decision != "ask" {
Expand Down Expand Up @@ -130,6 +163,16 @@ func TestDenyRead(t *testing.T) {
}
}

func TestBlockRead(t *testing.T) {
output, err := BlockRead("Cannot read file")
if output.Decision != "deny" || output.Message != "Cannot read file" {
t.Fatalf("output = %+v, want deny with message", output)
}
if err == nil || err.Error() != "Cannot read file" {
t.Fatalf("error = %v, want Cannot read file", err)
}
}

func TestAskRead(t *testing.T) {
output := AskRead("Allow file read?")
if output.Decision != "ask" {
Expand Down Expand Up @@ -164,6 +207,16 @@ func TestDenyMCP(t *testing.T) {
}
}

func TestBlockMCP(t *testing.T) {
output, err := BlockMCP("MCP tool blocked")
if output.Decision != "deny" || output.Message != "MCP tool blocked" {
t.Fatalf("output = %+v, want deny with message", output)
}
if err == nil || err.Error() != "MCP tool blocked" {
t.Fatalf("error = %v, want MCP tool blocked", err)
}
}

func TestAskMCP(t *testing.T) {
output := AskMCP("Run MCP tool?")
if output.Decision != "ask" {
Expand Down Expand Up @@ -198,6 +251,16 @@ func TestBlockPrompt(t *testing.T) {
}
}

func TestBlockPromptWithError(t *testing.T) {
output, err := BlockPromptWithError("Prompt blocked")
if output.Decision != "deny" || output.Message != "Prompt blocked" {
t.Fatalf("output = %+v, want deny with message", output)
}
if err == nil || err.Error() != "Prompt blocked" {
t.Fatalf("error = %v, want Prompt blocked", err)
}
}

// =============================================================================
// Post-Hook Helpers Tests
// =============================================================================
Expand Down
Loading