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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .speakeasy/out.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27733,8 +27733,14 @@ components:
continue:
type: boolean
description: Whether to continue (SessionStart only)
decision:
type: string
description: Top-level block decision for UserPromptSubmit / PostToolUse / Stop / SubagentStop. Use 'block' to halt processing.
hookSpecificOutput:
description: Hook-specific output as JSON object
reason:
type: string
description: Reason accompanying decision; shown to the user (UserPromptSubmit) or Claude (PostToolUse/Stop).
stopReason:
type: string
description: Reason if blocked (SessionStart only)
Expand Down
2 changes: 0 additions & 2 deletions .speakeasy/workflow.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions client/sdk/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions client/sdk/src/models/components/claudehookresult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,18 @@ export type ClaudeHookResult = {
* Whether to continue (SessionStart only)
*/
continue?: boolean | undefined;
/**
* Top-level block decision for UserPromptSubmit / PostToolUse / Stop / SubagentStop. Use 'block' to halt processing.
*/
decision?: string | undefined;
/**
* Hook-specific output as JSON object
*/
hookSpecificOutput?: any | undefined;
/**
* Reason accompanying decision; shown to the user (UserPromptSubmit) or Claude (PostToolUse/Stop).
*/
reason?: string | undefined;
/**
* Reason if blocked (SessionStart only)
*/
Expand All @@ -39,7 +47,9 @@ export const ClaudeHookResult$inboundSchema: z.ZodMiniType<
unknown
> = z.object({
continue: z.optional(z.boolean()),
decision: z.optional(z.string()),
hookSpecificOutput: z.optional(z.any()),
reason: z.optional(z.string()),
stopReason: z.optional(z.string()),
suppressOutput: z.optional(z.boolean()),
systemMessage: z.optional(z.string()),
Expand Down
5 changes: 5 additions & 0 deletions server/design/hooks/design.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ var ClaudeHookResult = Type("ClaudeHookResult", func() {
Attribute("suppressOutput", Boolean, "Whether to suppress the hook's output")
Attribute("systemMessage", String, "Warning message shown to the user in the terminal")
Attribute("hookSpecificOutput", Any, "Hook-specific output as JSON object")
// UserPromptSubmit, PostToolUse, Stop, and SubagentStop use a top-level
// `decision` field to block: "block" tells Claude to halt processing.
// PreToolUse uses hookSpecificOutput.permissionDecision instead.
Attribute("decision", String, "Top-level block decision for UserPromptSubmit / PostToolUse / Stop / SubagentStop. Use 'block' to halt processing.")
Attribute("reason", String, "Reason accompanying decision; shown to the user (UserPromptSubmit) or Claude (PostToolUse/Stop).")
})

// Cursor hook payload
Expand Down
6 changes: 6 additions & 0 deletions server/gen/hooks/service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions server/gen/http/hooks/client/types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions server/gen/http/hooks/server/types.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/gen/http/openapi3.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions server/gen/http/openapi3.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 12 additions & 5 deletions server/internal/background/activities/generate_chat_title.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,21 @@ type GenerateChatTitleArgs struct {
}

const (
defaultChatTitle = "New Chat"
DefaultClaudeChatTitle = "Claude Code Session"
DefaultCursorChatTitle = "Cursor Session"
DefaultCodexChatTitle = "Codex Session"
defaultChatTitle = "New Chat"
DefaultClaudeChatTitle = "Claude Code Session"
DefaultCoworkChatTitle = "Cowork Session"
DefaultClaudeAmbiguous = "Claude Session"
DefaultCursorChatTitle = "Cursor Session"
DefaultCodexChatTitle = "Codex Session"
)

func isDefaultChatTitle(title string) bool {
return title == defaultChatTitle || title == DefaultClaudeChatTitle || title == DefaultCursorChatTitle || title == DefaultCodexChatTitle
return title == defaultChatTitle ||
title == DefaultClaudeChatTitle ||
title == DefaultCoworkChatTitle ||
title == DefaultClaudeAmbiguous ||
title == DefaultCursorChatTitle ||
title == DefaultCodexChatTitle
}

func (g *GenerateChatTitle) Do(ctx context.Context, args GenerateChatTitleArgs) error {
Expand Down
19 changes: 19 additions & 0 deletions server/internal/hooks/cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ func sessionMCPListCacheKey(sessionID string) string {
return fmt.Sprintf("session:mcp-list:%s", sessionID)
}

// sessionAgentVariantCacheKey returns the Redis key for the agent variant
// of a session ("cowork" or "claude-code"). Stamped by SessionStart based
// on which mcp_inventory_* payload field is present; shares the MCP list
// TTL. Absence means SessionStart hasn't been processed for this session
// yet — callers should treat that as an ambiguous Claude session rather
// than assuming claude-code.
func sessionAgentVariantCacheKey(sessionID string) string {
return fmt.Sprintf("session:agent-variant:%s", sessionID)
}

const (
// agentVariantCowork marks a session that originated from a cowork
// (cmux-managed) Claude Code environment rather than the standard CLI.
agentVariantCowork = "cowork"
// agentVariantClaudeCode marks a session that originated from the
// standard Claude Code CLI (where `claude mcp list` was reachable).
agentVariantClaudeCode = "claude-code"
)

// sessionMCPListTTL is how long the parsed MCP list survives without any
// hook activity for its session id. Each hook received refreshes it.
const sessionMCPListTTL = 12 * time.Hour
74 changes: 35 additions & 39 deletions server/internal/hooks/claude_hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,27 +376,33 @@ func (s *Service) Claude(ctx context.Context, payload *gen.ClaudePayload) (*gen.
s.recordHook(ctx, payload)

// Route to appropriate handler based on hook type
var (
result *gen.ClaudeHookResult
err error
)
switch payload.HookEventName {
case "SessionStart":
return s.handleSessionStart(ctx, payload)
result, err = s.handleSessionStart(ctx, payload)
case "PreToolUse":
return s.handlePreToolUse(ctx, payload)
result, err = s.handlePreToolUse(ctx, payload)
case "PostToolUse":
return s.handlePostToolUse(ctx, payload)
result, err = s.handlePostToolUse(ctx, payload)
case "PostToolUseFailure":
return s.handlePostToolUseFailure(ctx, payload)
result, err = s.handlePostToolUseFailure(ctx, payload)
case "UserPromptSubmit":
return s.handleUserPromptSubmit(ctx, payload)
result, err = s.handleUserPromptSubmit(ctx, payload)
case "Stop":
return s.handleStop(ctx, payload)
result, err = s.handleStop(ctx, payload)
case "SessionEnd":
return s.handleSessionEnd(ctx, payload)
result, err = s.handleSessionEnd(ctx, payload)
case "Notification":
return s.handleNotification(ctx, payload)
result, err = s.handleNotification(ctx, payload)
default:
logger.ErrorContext(ctx, fmt.Sprintf("Unknown hook event: %s", payload.HookEventName))
return makeHookResult(payload.HookEventName), nil
result = makeHookResult(payload.HookEventName)
}

return result, err
}

func (s *Service) handleSessionStart(ctx context.Context, payload *gen.ClaudePayload) (*gen.ClaudeHookResult, error) {
Expand Down Expand Up @@ -428,15 +434,18 @@ func (s *Service) captureMCPListSnapshot(ctx context.Context, payload *gen.Claud
}

var entries []MCPServerEntry
var variant string
switch {
case payload.AdditionalData["mcp_inventory_claude_code"] != nil:
raw, ok := payload.AdditionalData["mcp_inventory_claude_code"].(string)
if !ok || raw == "" {
return
}
entries = ParseClaudeMCPList(raw)
variant = agentVariantClaudeCode
case payload.AdditionalData["mcp_inventory_cowork"] != nil:
entries = ParseCoworkMCPInventory(payload.AdditionalData["mcp_inventory_cowork"])
variant = agentVariantCowork
default:
return
}
Expand All @@ -449,6 +458,14 @@ func (s *Service) captureMCPListSnapshot(ctx context.Context, payload *gen.Claud
)
return
}

variantKey := sessionAgentVariantCacheKey(*payload.SessionID)
if err := s.cache.Set(ctx, variantKey, variant, sessionMCPListTTL); err != nil {
s.logger.WarnContext(ctx, "failed to cache session agent variant",
attr.SlogEvent("claude_hook_agent_variant_cache_set_failed"),
attr.SlogError(err),
)
}
}

// refreshMCPListTTL extends the MCP list cache TTL for the session if the
Expand All @@ -464,6 +481,12 @@ func (s *Service) refreshMCPListTTL(ctx context.Context, sessionID string) {
attr.SlogError(err),
)
}
if err := s.cache.Expire(ctx, sessionAgentVariantCacheKey(sessionID), sessionMCPListTTL); err != nil {
s.logger.WarnContext(ctx, "failed to refresh session agent variant TTL",
attr.SlogEvent("claude_hook_agent_variant_ttl_refresh_failed"),
attr.SlogError(err),
)
}
}

// hasOptionalPluginAuth returns true when the Claude request carries both
Expand Down Expand Up @@ -571,33 +594,19 @@ func (s *Service) getSessionMetadata(ctx context.Context, sessionID string) (Ses
func (s *Service) handlePreToolUse(ctx context.Context, payload *gen.ClaudePayload) (*gen.ClaudeHookResult, error) {
if s.riskScanner != nil && payload.SessionID != nil {
if scanResult := s.scanClaudeForEnforcement(ctx, payload); scanResult != nil {
result := makeHookResult(payload.HookEventName)
output, _ := result.HookSpecificOutput.(*HookSpecificOutput)
deny := "deny"
auditReason := fmt.Sprintf("Speakeasy blocked this tool call: matched policy %q (%s)", scanResult.PolicyName, scanResult.Description)
userReason := renderUserBlockReason(scanResult.UserMessage, auditReason)
// systemMessage renders as a warning in the user's terminal;
// permissionDecisionReason is what Claude itself sees and may quote
// back to the user. Send the same self-branded message in both so
// the user sees feedback regardless of how Claude chooses to render
// the deny — matches the shadow-MCP guard deny path below.
result.SystemMessage = &userReason
if output != nil {
output.PermissionDecision = &deny
output.PermissionDecisionReason = &userReason
}
// Surface the block reason on the trace summary so the dashboard
// shows why the call was denied. Always store the technical reason
// — the user_message override is for the agent-facing response only.
if metadata, err := s.getSessionMetadata(ctx, *payload.SessionID); err == nil {
s.writeClaudeBlockToClickHouse(ctx, payload, &metadata, auditReason)
}
return result, nil
return constructBlockResponse(payload.HookEventName, userReason), nil
}
}

allow := "allow"
deny := "deny"
result := makeHookResult(payload.HookEventName)
output, _ := result.HookSpecificOutput.(*HookSpecificOutput)

Expand Down Expand Up @@ -703,12 +712,7 @@ func (s *Service) handlePreToolUse(ctx context.Context, payload *gen.ClaudePaylo
attr.SlogRiskPolicyName(policy.Name),
)
s.writeClaudeBlockToClickHouse(ctx, payload, &metadata, auditReason)
result.SystemMessage = &userReason
if output != nil {
output.PermissionDecision = &deny
output.PermissionDecisionReason = &userReason
}
return result, nil
return constructBlockResponse(payload.HookEventName, userReason), nil
}

matched := matchCachedMCPEntry(entries, serverPrefix)
Expand Down Expand Up @@ -801,15 +805,7 @@ func (s *Service) handlePreToolUse(ctx context.Context, payload *gen.ClaudePaylo
// policy" actions against the URL itself.
s.recordShadowMCPBlockFinding(ctx, payload, &metadata, policy, matched, serverPrefix, detail)

// systemMessage renders as a warning in the user's terminal;
// permissionDecisionReason is what Claude itself sees and may quote
// back to the user, so we send the same self-branded message in both.
result.SystemMessage = &userReason
if output != nil {
output.PermissionDecision = &deny
output.PermissionDecisionReason = &userReason
}
return result, nil
return constructBlockResponse(payload.HookEventName, userReason), nil
}

if output != nil {
Expand Down
Loading
Loading