Detailed changes
@@ -69,7 +69,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/RealAlexandreAI/json-repair v0.0.14 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
- github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect
+ github.com/aws/aws-sdk-go-v2 v1.40.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 // indirect
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
@@ -168,7 +168,7 @@ require (
golang.org/x/term v0.36.0 // indirect
golang.org/x/time v0.12.0 // indirect
google.golang.org/api v0.239.0 // indirect
- google.golang.org/genai v1.34.0 // indirect
+ google.golang.org/genai v1.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 // indirect
google.golang.org/grpc v1.74.2 // indirect
google.golang.org/protobuf v1.36.10 // indirect
@@ -176,3 +176,5 @@ require (
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+
+replace charm.land/fantasy => ../../fantasy
@@ -2,8 +2,6 @@ charm.land/bubbles/v2 v2.0.0-rc.1 h1:EiIFVAc3Zi/yY86td+79mPhHR7AqZ1OxF+6ztpOCRaM
charm.land/bubbles/v2 v2.0.0-rc.1/go.mod h1:5AbN6cEd/47gkEf8TgiQ2O3RZ5QxMS14l9W+7F9fPC4=
charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973 h1:Ay8VWyn/CbwltswomzWXj0m5KKfSJavFfCDCxI+j8qo=
charm.land/bubbletea/v2 v2.0.0-rc.1.0.20251117161017-15f884bd2973/go.mod h1:IXFmnCnMLTWw/KQ9rEatSYqbAPAYi8kA3Yqwa1SFnLk=
-charm.land/fantasy v0.3.2 h1:yHTsSZ25LcICMRw3xzdz3OkaZtDQch+B5ljJo17HxgU=
-charm.land/fantasy v0.3.2/go.mod h1:sV8Ns/JTJHOaYOHPgVRDugMheAyxsW/nmdpVGrycYEk=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k=
charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU=
charm.land/x/vcr v0.1.1 h1:PXCFMUG0rPtyk35rhfzYCJEduOzWXCIbrXTFq4OF/9Q=
@@ -44,8 +42,8 @@ github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kk
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
-github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
-github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
+github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
+github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3 h1:tW1/Rkad38LA15X4UQtjXZXNKsCgkshC3EbmcUmghTg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.3/go.mod h1:UbnqO+zjqk3uIt9yCACHJ9IVNhyhOCnYk8yA19SAWrM=
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
@@ -456,8 +454,8 @@ golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.239.0 h1:2hZKUnFZEy81eugPs4e2XzIJ5SOwQg0G82bpXD65Puo=
google.golang.org/api v0.239.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
-google.golang.org/genai v1.34.0 h1:lPRJRO+HqRX1SwFo1Xb/22nZ5MBEPUbXDl61OoDxlbY=
-google.golang.org/genai v1.34.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg=
+google.golang.org/genai v1.36.0 h1:sJCIjqTAmwrtAIaemtTiKkg2TO1RxnYEusTmEQ3nGxM=
+google.golang.org/genai v1.36.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822 h1:fc6jSaCT0vBduLYZHYrBBNY4dsWuvgyff9noRNDdBeE=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
@@ -11,6 +11,7 @@ import (
"cmp"
"context"
_ "embed"
+ "encoding/json"
"errors"
"fmt"
"log/slog"
@@ -196,6 +197,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
defer cancel()
defer a.activeRequests.Del(call.SessionID)
+ // Track completion reason for stop hook
+ var stopReason string
+ defer func() {
+ if stopReason != "" {
+ a.executeStopHook(ctx, call.SessionID, stopReason)
+ }
+ }()
+
// create the agent message asap to show loading
var currentAssistant *message.Message
assistantMessage, err := a.messages.Create(genCtx, call.SessionID, message.CreateMessageParams{
@@ -212,6 +221,7 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
hookErr := a.executePromptSubmitHook(genCtx, &msg, len(msgs) == 0)
if hookErr != nil {
+ stopReason = "error"
// Delete the assistant message
// use the ctx since this could be a cancellation
deleteErr := a.messages.Delete(ctx, currentAssistant.ID)
@@ -223,6 +233,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
startTime := time.Now()
a.eventPromptSent(call.SessionID)
+ // Map to store post-tool-use hook results for OnToolResult callback
+ postToolHookResults := csync.NewMap[string, hooks.HookResult]()
+
var shouldSummarize bool
result, err := agent.Stream(genCtx, fantasy.AgentStreamCall{
Prompt: msg.ContentWithHookContext(),
@@ -359,6 +372,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
currentAssistant.AddToolCall(toolCall)
return a.messages.Update(genCtx, *currentAssistant)
},
+ PreToolExecute: func(ctx context.Context, toolCall fantasy.ToolCall) (context.Context, *fantasy.ToolCall, error) {
+ return a.executePreToolUseHook(ctx, call.SessionID, toolCall, currentAssistant)
+ },
OnToolResult: func(result fantasy.ToolResultContent) error {
var resultContent string
isError := false
@@ -384,6 +400,10 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
IsError: isError,
Metadata: result.ClientMetadata,
}
+ // Attach hook result if available
+ if hookRes, ok := postToolHookResults.Get(result.ToolCallID); ok {
+ toolResult.HookResult = &hookRes
+ }
_, createMsgErr := a.messages.Create(genCtx, currentAssistant.SessionID, message.CreateMessageParams{
Role: message.Tool,
Parts: []message.ContentPart{
@@ -395,6 +415,14 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
return nil
},
+ PostToolExecute: func(ctx context.Context, toolCall fantasy.ToolCall, response fantasy.ToolResponse, executionTimeMs int64) (*fantasy.ToolResponse, error) {
+ modifiedResponse, hookResult, err := a.executePostToolUseHook(ctx, call.SessionID, toolCall, response, executionTimeMs)
+ if hookResult != nil {
+ // Store for OnToolResult callback
+ postToolHookResults.Set(toolCall.ID, *hookResult)
+ }
+ return modifiedResponse, err
+ },
OnStepFinish: func(stepResult fantasy.StepResult) error {
finishReason := message.FinishReasonUnknown
switch stepResult.FinishReason {
@@ -440,6 +468,17 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
if err != nil {
isCancelErr := errors.Is(err, context.Canceled)
isPermissionErr := errors.Is(err, permission.ErrorPermissionDenied)
+ isHookDenied := errors.Is(err, ErrHookDenied)
+
+ // Set stop reason for defer
+ if isCancelErr {
+ stopReason = "cancelled"
+ } else if isPermissionErr || isHookDenied {
+ stopReason = "permission_denied"
+ } else {
+ stopReason = "error"
+ }
+
if currentAssistant == nil {
return result, err
}
@@ -484,6 +523,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
content = "Tool execution canceled by user"
} else if isPermissionErr {
content = "User denied permission"
+ } else if isHookDenied {
+ content = "Hook denied execution"
}
toolResult := message.ToolResult{
ToolCallID: tc.ID,
@@ -508,6 +549,8 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
currentAssistant.AddFinish(message.FinishReasonCanceled, "User canceled request", "")
} else if isPermissionErr {
currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "User denied permission", "")
+ } else if isHookDenied {
+ currentAssistant.AddFinish(message.FinishReasonPermissionDenied, "Hook denied execution", "")
} else if errors.As(err, &providerErr) {
currentAssistant.AddFinish(message.FinishReasonError, cmp.Or(stringext.Capitalize(providerErr.Title), defaultTitle), providerErr.Message)
} else if errors.As(err, &fantasyErr) {
@@ -525,6 +568,9 @@ func (a *sessionAgent) Run(ctx context.Context, call SessionAgentCall) (*fantasy
}
wg.Wait()
+ // Set completion reason for stop hook
+ stopReason = "completed"
+
if shouldSummarize {
a.activeRequests.Del(call.SessionID)
if summarizeErr := a.Summarize(genCtx, call.SessionID, call.ProviderOptions); summarizeErr != nil {
@@ -967,3 +1013,149 @@ func (a *sessionAgent) executePromptSubmitHook(ctx context.Context, msg *message
}
return nil
}
+
+// executePreToolUseHook executes the pre-tool-use hook and applies modifications.
+// Only runs for main agent (not sub-agents).
+func (a *sessionAgent) executePreToolUseHook(ctx context.Context, sessionID string, toolCall fantasy.ToolCall, currentAssistant *message.Message) (context.Context, *fantasy.ToolCall, error) {
+ // Skip if sub-agent or no hooks manager.
+ if a.isSubAgent || a.hooksManager == nil {
+ return ctx, nil, nil
+ }
+
+ // Parse tool input to map
+ var toolInput map[string]any
+ if err := json.Unmarshal([]byte(toolCall.Input), &toolInput); err != nil {
+ // If we can't parse the input, skip the hook
+ return ctx, nil, nil
+ }
+
+ hookResult, err := a.hooksManager.ExecutePreToolUse(ctx, sessionID, a.workingDir, hooks.PreToolUseData{
+ ToolName: toolCall.Name,
+ ToolCallID: toolCall.ID,
+ ToolInput: toolInput,
+ })
+ if err != nil {
+ return ctx, nil, fmt.Errorf("pre-tool-use hook execution failed: %w", err)
+ }
+
+ // Store hook result in the current assistant's tool call
+ for _, tc := range currentAssistant.ToolCalls() {
+ if tc.ID == toolCall.ID {
+ tc.HookResult = &hookResult
+ currentAssistant.AddToolCall(tc)
+ if updateErr := a.messages.Update(ctx, *currentAssistant); updateErr != nil {
+ slog.Error("failed to update assistant message with pre-hook result", "error", updateErr)
+ }
+ break
+ }
+ }
+
+ // If hook returned Continue: false, deny execution.
+ if !hookResult.Continue {
+ return ctx, nil, ErrHookDenied
+ }
+
+ // Set permission in context for tools to use
+ if hookResult.Permission != "" {
+ ctx = tools.SetHookPermissionInContext(ctx, hookResult.Permission)
+ }
+
+ // Apply modified input if present.
+ if len(hookResult.ModifiedInput) > 0 {
+ // Merge modified input with original
+ for k, v := range hookResult.ModifiedInput {
+ toolInput[k] = v
+ }
+
+ modifiedInputJSON, err := json.Marshal(toolInput)
+ if err != nil {
+ return ctx, nil, fmt.Errorf("failed to marshal modified input: %w", err)
+ }
+
+ modifiedCall := toolCall
+ modifiedCall.Input = string(modifiedInputJSON)
+ return ctx, &modifiedCall, nil
+ }
+
+ return ctx, nil, nil
+}
+
+// executePostToolUseHook executes the post-tool-use hook and applies modifications.
+// Only runs for main agent (not sub-agents).
+func (a *sessionAgent) executePostToolUseHook(ctx context.Context, sessionID string, toolCall fantasy.ToolCall, response fantasy.ToolResponse, executionTimeMs int64) (*fantasy.ToolResponse, *hooks.HookResult, error) {
+ // Skip if sub-agent or no hooks manager.
+ if a.isSubAgent || a.hooksManager == nil {
+ return nil, nil, nil
+ }
+
+ // Parse tool input to map
+ var toolInput map[string]any
+ if err := json.Unmarshal([]byte(toolCall.Input), &toolInput); err != nil {
+ return nil, nil, nil
+ }
+
+ // Parse tool output to map
+ toolOutput := map[string]any{
+ "success": !response.IsError,
+ "content": response.Content,
+ }
+ if response.Metadata != "" {
+ toolOutput["metadata"] = response.Metadata
+ }
+
+ hookResult, err := a.hooksManager.ExecutePostToolUse(ctx, sessionID, a.workingDir, hooks.PostToolUseData{
+ ToolName: toolCall.Name,
+ ToolCallID: toolCall.ID,
+ ToolInput: toolInput,
+ ToolOutput: toolOutput,
+ ExecutionTimeMs: executionTimeMs,
+ })
+ if err != nil {
+ return nil, nil, fmt.Errorf("post-tool-use hook execution failed: %w", err)
+ }
+
+ // If hook returned Continue: false, return error to stop execution.
+ if !hookResult.Continue {
+ return nil, &hookResult, ErrHookDenied
+ }
+
+ // Apply modified output if present.
+ if len(hookResult.ModifiedOutput) > 0 {
+ modifiedResponse := response
+
+ // Apply modifications
+ if content, ok := hookResult.ModifiedOutput["content"].(string); ok {
+ modifiedResponse.Content = content
+ }
+ if success, ok := hookResult.ModifiedOutput["success"].(bool); ok {
+ modifiedResponse.IsError = !success
+ }
+ if metadata, ok := hookResult.ModifiedOutput["metadata"].(string); ok {
+ modifiedResponse.Metadata = metadata
+ }
+
+ return &modifiedResponse, &hookResult, nil
+ }
+
+ return nil, &hookResult, nil
+}
+
+// executeStopHook executes the stop hook when agent loop ends.
+// Only runs for main agent (not sub-agents). Errors are logged but don't fail.
+func (a *sessionAgent) executeStopHook(ctx context.Context, sessionID, reason string) {
+ // Skip if sub-agent or no hooks manager.
+ if a.isSubAgent || a.hooksManager == nil {
+ return
+ }
+
+ // Use a fresh context with timeout to ensure hook runs even if parent is cancelled
+ hookCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ _, err := a.hooksManager.ExecuteStop(hookCtx, sessionID, a.workingDir, hooks.StopData{
+ Reason: reason,
+ })
+ if err != nil {
+ slog.Error("stop hook execution failed", "session_id", sessionID, "reason", reason, "error", err)
+ }
+}
@@ -11,6 +11,7 @@ var (
ErrEmptyPrompt = errors.New("prompt is empty")
ErrSessionMissing = errors.New("session id is missing")
ErrHookExecutionStop = errors.New("hook stopped execution")
+ ErrHookDenied = errors.New("hook denied execution")
)
func isCancelledErr(err error) bool {
@@ -215,18 +215,19 @@ func NewBashTool(permissions permission.Service, workingDir string, attribution
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for executing shell command")
}
if !isSafeReadOnly {
- p := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: execWorkingDir,
- ToolCallID: call.ID,
- ToolName: BashToolName,
- Action: "execute",
- Description: fmt.Sprintf("Execute command: %s", params.Command),
- Params: BashPermissionsParams(params),
- },
- )
- if !p {
+ granted, err := CheckHookPermission(ctx, permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: execWorkingDir,
+ ToolCallID: call.ID,
+ ToolName: BashToolName,
+ Action: "execute",
+ Description: fmt.Sprintf("Execute command: %s", params.Command),
+ Params: BashPermissionsParams(params),
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
}
@@ -70,18 +70,18 @@ func NewDownloadTool(permissions permission.Service, workingDir string, client *
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for downloading files")
}
- p := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: filePath,
- ToolName: DownloadToolName,
- Action: "download",
- Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
- Params: DownloadPermissionsParams(params),
- },
- )
-
- if !p {
+ granted, err := CheckHookPermission(ctx, permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: filePath,
+ ToolName: DownloadToolName,
+ Action: "download",
+ Description: fmt.Sprintf("Download file from URL: %s to %s", params.URL, filePath),
+ Params: DownloadPermissionsParams(params),
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -128,22 +128,23 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
content,
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, edit.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Create file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: "",
- NewContent: content,
- },
+ granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
+ ToolCallID: call.ID,
+ ToolName: EditToolName,
+ Action: "write",
+ Description: fmt.Sprintf("Create file %s", filePath),
+ Params: EditPermissionsParams{
+ FilePath: filePath,
+ OldContent: "",
+ NewContent: content,
},
- )
- if !p {
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -176,8 +177,7 @@ func createNewFile(edit editContext, filePath, content string, call fantasy.Tool
NewContent: content,
Additions: additions,
Removals: removals,
- },
- ), nil
+ }), nil
}
func deleteContent(edit editContext, filePath, oldString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
@@ -249,22 +249,23 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, edit.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Delete content from file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: oldContent,
- NewContent: newContent,
- },
+ granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
+ ToolCallID: call.ID,
+ ToolName: EditToolName,
+ Action: "write",
+ Description: fmt.Sprintf("Delete content from file %s", filePath),
+ Params: EditPermissionsParams{
+ FilePath: filePath,
+ OldContent: oldContent,
+ NewContent: newContent,
},
- )
- if !p {
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -309,8 +310,7 @@ func deleteContent(edit editContext, filePath, oldString string, replaceAll bool
NewContent: newContent,
Additions: additions,
Removals: removals,
- },
- ), nil
+ }), nil
}
func replaceContent(edit editContext, filePath, oldString, newString string, replaceAll bool, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
@@ -384,22 +384,23 @@ func replaceContent(edit editContext, filePath, oldString, newString string, rep
strings.TrimPrefix(filePath, edit.workingDir),
)
- p := edit.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, edit.workingDir),
- ToolCallID: call.ID,
- ToolName: EditToolName,
- Action: "write",
- Description: fmt.Sprintf("Replace content in file %s", filePath),
- Params: EditPermissionsParams{
- FilePath: filePath,
- OldContent: oldContent,
- NewContent: newContent,
- },
+ granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: fsext.PathOrPrefix(filePath, edit.workingDir),
+ ToolCallID: call.ID,
+ ToolName: EditToolName,
+ Action: "write",
+ Description: fmt.Sprintf("Replace content in file %s", filePath),
+ Params: EditPermissionsParams{
+ FilePath: filePath,
+ OldContent: oldContent,
+ NewContent: newContent,
},
- )
- if !p {
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -55,19 +55,19 @@ func NewFetchTool(permissions permission.Service, workingDir string, client *htt
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
}
- p := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: workingDir,
- ToolCallID: call.ID,
- ToolName: FetchToolName,
- Action: "fetch",
- Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
- Params: FetchPermissionsParams(params),
- },
- )
-
- if !p {
+ granted, err := CheckHookPermission(ctx, permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: workingDir,
+ ToolCallID: call.ID,
+ ToolName: FetchToolName,
+ Action: "fetch",
+ Description: fmt.Sprintf("Fetch content from URL: %s", params.URL),
+ Params: FetchPermissionsParams(params),
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -79,7 +79,7 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing directories outside working directory")
}
- granted := permissions.Request(
+ granted, err := CheckHookPermission(ctx, permissions,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: absSearchPath,
@@ -88,8 +88,10 @@ func NewLsTool(permissions permission.Service, workingDir string, lsConfig confi
Action: "list",
Description: fmt.Sprintf("List directory outside working directory: %s", absSearchPath),
Params: LSPermissionsParams(params),
- },
- )
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
@@ -89,18 +89,19 @@ func (m *Tool) Run(ctx context.Context, params fantasy.ToolCall) (fantasy.ToolRe
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for creating a new file")
}
permissionDescription := fmt.Sprintf("execute %s with the following parameters:", m.Info().Name)
- p := m.permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- ToolCallID: params.ID,
- Path: m.workingDir,
- ToolName: m.Info().Name,
- Action: "execute",
- Description: permissionDescription,
- Params: params.Input,
- },
- )
- if !p {
+ granted, err := CheckHookPermission(ctx, m.permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ ToolCallID: params.ID,
+ Path: m.workingDir,
+ ToolName: m.Info().Name,
+ Action: "execute",
+ Description: permissionDescription,
+ Params: params.Input,
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -165,7 +165,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
// Check permissions
_, additions, removals := diff.GenerateDiff("", currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
- p := edit.permissions.Request(permission.CreatePermissionRequest{
+ granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
ToolCallID: call.ID,
@@ -178,12 +178,12 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
NewContent: currentContent,
},
})
- if !p {
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
// Write the file
- err := os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
+ err = os.WriteFile(params.FilePath, []byte(currentContent), 0o644)
if err != nil {
return fantasy.ToolResponse{}, fmt.Errorf("failed to write file: %w", err)
}
@@ -219,8 +219,7 @@ func processMultiEditWithCreation(edit editContext, params MultiEditParams, call
Removals: removals,
EditsApplied: editsApplied,
EditsFailed: failedEdits,
- },
- ), nil
+ }), nil
}
func processMultiEditExistingFile(edit editContext, params MultiEditParams, call fantasy.ToolCall) (fantasy.ToolResponse, error) {
@@ -299,7 +298,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
// Generate diff and check permissions
_, additions, removals := diff.GenerateDiff(oldContent, currentContent, strings.TrimPrefix(params.FilePath, edit.workingDir))
- p := edit.permissions.Request(permission.CreatePermissionRequest{
+ granted, err := CheckHookPermission(edit.ctx, edit.permissions, permission.CreatePermissionRequest{
SessionID: sessionID,
Path: fsext.PathOrPrefix(params.FilePath, edit.workingDir),
ToolCallID: call.ID,
@@ -312,7 +311,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
NewContent: currentContent,
},
})
- if !p {
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -368,8 +367,7 @@ func processMultiEditExistingFile(edit editContext, params MultiEditParams, call
Removals: removals,
EditsApplied: editsApplied,
EditsFailed: failedEdits,
- },
- ), nil
+ }), nil
}
func applyEditToContent(content string, edit MultiEditOperation) (string, error) {
@@ -2,16 +2,20 @@ package tools
import (
"context"
+
+ "github.com/charmbracelet/crush/internal/permission"
)
type (
- sessionIDContextKey string
- messageIDContextKey string
+ sessionIDContextKey string
+ messageIDContextKey string
+ hookPermissionContextKey string
)
const (
- SessionIDContextKey sessionIDContextKey = "session_id"
- MessageIDContextKey messageIDContextKey = "message_id"
+ SessionIDContextKey sessionIDContextKey = "session_id"
+ MessageIDContextKey messageIDContextKey = "message_id"
+ HookPermissionContextKey hookPermissionContextKey = "hook_permission"
)
func GetSessionFromContext(ctx context.Context) string {
@@ -37,3 +41,49 @@ func GetMessageFromContext(ctx context.Context) string {
}
return s
}
+
+// GetHookPermissionFromContext gets the hook permission decision from context.
+// Returns: permission string ("approve" or "deny"), found bool
+func GetHookPermissionFromContext(ctx context.Context) (string, bool) {
+ permission := ctx.Value(HookPermissionContextKey)
+ if permission == nil {
+ return "", false
+ }
+ s, ok := permission.(string)
+ if !ok {
+ return "", false
+ }
+ return s, true
+}
+
+// SetHookPermissionInContext sets the hook permission decision in context.
+func SetHookPermissionInContext(ctx context.Context, permission string) context.Context {
+ return context.WithValue(ctx, HookPermissionContextKey, permission)
+}
+
+// CheckHookPermission checks if a hook has already made a permission decision.
+// Returns true if execution should proceed, false if denied.
+// If hook approved, skips the permission service call.
+// If hook denied, returns ErrorPermissionDenied.
+// If hook said "ask" or no decision, calls the permission service.
+func CheckHookPermission(ctx context.Context, permissionService permission.Service, req permission.CreatePermissionRequest) (bool, error) {
+ hookPerm, hasHookPerm := GetHookPermissionFromContext(ctx)
+
+ if hasHookPerm {
+ switch hookPerm {
+ case "approve":
+ // Hook auto-approved, skip permission check
+ return true, nil
+ case "deny":
+ // Hook denied, return error
+ return false, permission.ErrorPermissionDenied
+ }
+ }
+
+ // No hook decision or hook said "ask", use normal permission flow
+ granted := permissionService.Request(req)
+ if !granted {
+ return false, permission.ErrorPermissionDenied
+ }
+ return true, nil
+}
@@ -82,7 +82,7 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
return fantasy.ToolResponse{}, fmt.Errorf("session ID is required for accessing files outside working directory")
}
- granted := permissions.Request(
+ granted, err := CheckHookPermission(ctx, permissions,
permission.CreatePermissionRequest{
SessionID: sessionID,
Path: absFilePath,
@@ -91,8 +91,10 @@ func NewViewTool(lspClients *csync.Map[string, *lsp.Client], permissions permiss
Action: "read",
Description: fmt.Sprintf("Read file outside working directory: %s", absFilePath),
Params: ViewPermissionsParams(params),
- },
- )
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
@@ -110,22 +110,23 @@ func NewWriteTool(lspClients *csync.Map[string, *lsp.Client], permissions permis
strings.TrimPrefix(filePath, workingDir),
)
- p := permissions.Request(
- permission.CreatePermissionRequest{
- SessionID: sessionID,
- Path: fsext.PathOrPrefix(filePath, workingDir),
- ToolCallID: call.ID,
- ToolName: WriteToolName,
- Action: "write",
- Description: fmt.Sprintf("Create file %s", filePath),
- Params: WritePermissionsParams{
- FilePath: filePath,
- OldContent: oldContent,
- NewContent: params.Content,
- },
+ granted, err := CheckHookPermission(ctx, permissions, permission.CreatePermissionRequest{
+ SessionID: sessionID,
+ Path: fsext.PathOrPrefix(filePath, workingDir),
+ ToolCallID: call.ID,
+ ToolName: WriteToolName,
+ Action: "write",
+ Description: fmt.Sprintf("Create file %s", filePath),
+ Params: WritePermissionsParams{
+ FilePath: filePath,
+ OldContent: oldContent,
+ NewContent: params.Content,
},
- )
- if !p {
+ })
+ if err != nil {
+ return fantasy.ToolResponse{}, err
+ }
+ if !granted {
return fantasy.ToolResponse{}, permission.ErrorPermissionDenied
}
@@ -222,13 +222,6 @@ crush_deny "Blocked dangerous operation"
# Script exits immediately with code 2
```
-#### `crush_ask [message]`
-Ask user for permission (default behavior).
-
-```bash
-crush_ask "This command modifies files, please review"
-```
-
### Context Helpers
#### `crush_add_context "content"`
@@ -373,7 +366,7 @@ export CRUSH_CONTEXT_FILES="/path/to/file1.md:/path/to/file2.md"
```
**Available variables**:
-- `CRUSH_PERMISSION` - `approve`, `ask`, or `deny`
+- `CRUSH_PERMISSION` - `approve` or `deny`
- `CRUSH_MESSAGE` - User-facing message
- `CRUSH_CONTINUE` - `true` or `false` (stop execution)
- `CRUSH_MODIFIED_PROMPT` - New prompt text
@@ -401,7 +394,7 @@ echo '{
**JSON fields**:
- `continue` (bool) - Continue execution
-- `permission` (string) - `approve`, `ask`, `deny`
+- `permission` (string) - `approve` or `deny`
- `message` (string) - User-facing message
- `modified_prompt` (string) - New prompt
- `modified_input` (object) - Modified tool parameters
@@ -444,8 +437,10 @@ Hooks execute **sequentially** in alphabetical order. Use numeric prefixes to co
When multiple hooks execute, their results are merged:
### Permission (Most Restrictive Wins)
-- `deny` > `ask` > `approve`
+- `deny` > `approve`
- If any hook denies, the final result is deny
+- If any hook approves and no denials, the result is approve
+- If no hooks set permission, normal permission flow applies
### Continue (AND Logic)
- All hooks must set `Continue=true` (or not set it)
@@ -21,13 +21,6 @@ crush_deny() {
exit 2
}
-# Ask user for permission (default behavior).
-# Usage: crush_ask ["message"]
-crush_ask() {
- export CRUSH_PERMISSION=ask
- [ -n "$1" ] && export CRUSH_MESSAGE="$1"
-}
-
# Context helpers
# Add raw text content to LLM context.
@@ -241,8 +241,6 @@ func (m *manager) mergeResults(accumulated *HookResult, new *HookResult) {
if new.Permission != "" {
if new.Permission == "deny" {
accumulated.Permission = "deny"
- } else if new.Permission == "ask" && accumulated.Permission != "deny" {
- accumulated.Permission = "ask"
} else if new.Permission == "approve" && accumulated.Permission == "" {
accumulated.Permission = "approve"
}
@@ -97,6 +97,15 @@ type Manager interface {
// ExecuteUserPromptSubmit executes the UserPromptSubmit event
ExecuteUserPromptSubmit(ctx context.Context, sessionID, workingDir string, data UserPromptSubmitData) (HookResult, error)
+
+ // ExecutePreToolUse executes the PreToolUse event
+ ExecutePreToolUse(ctx context.Context, sessionID, workingDir string, data PreToolUseData) (HookResult, error)
+
+ // ExecutePostToolUse executes the PostToolUse event
+ ExecutePostToolUse(ctx context.Context, sessionID, workingDir string, data PostToolUseData) (HookResult, error)
+
+ // ExecuteStop executes the Stop event
+ ExecuteStop(ctx context.Context, sessionID, workingDir string, data StopData) (HookResult, error)
}
type UserPromptSubmitData struct {
@@ -96,23 +96,25 @@ func (bc BinaryContent) String(p catwalk.InferenceProvider) string {
func (BinaryContent) isPart() {}
type ToolCall struct {
- ID string `json:"id"`
- Name string `json:"name"`
- Input string `json:"input"`
- ProviderExecuted bool `json:"provider_executed"`
- Finished bool `json:"finished"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Input string `json:"input"`
+ ProviderExecuted bool `json:"provider_executed"`
+ Finished bool `json:"finished"`
+ HookResult *hooks.HookResult `json:"hook_result,omitempty"`
}
func (ToolCall) isPart() {}
type ToolResult struct {
- ToolCallID string `json:"tool_call_id"`
- Name string `json:"name"`
- Content string `json:"content"`
- Data string `json:"data"`
- MIMEType string `json:"mime_type"`
- Metadata string `json:"metadata"`
- IsError bool `json:"is_error"`
+ ToolCallID string `json:"tool_call_id"`
+ Name string `json:"name"`
+ Content string `json:"content"`
+ Data string `json:"data"`
+ MIMEType string `json:"mime_type"`
+ Metadata string `json:"metadata"`
+ IsError bool `json:"is_error"`
+ HookResult *hooks.HookResult `json:"hook_result,omitempty"`
}
func (ToolResult) isPart() {}