From 47479c8abd7cf45a8d03c052c3573f4c42750046 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:30:42 +0000 Subject: [PATCH 1/2] fix: classify lifecycle hook denials --- coderd/apidoc/docs.go | 6 ++++-- coderd/apidoc/swagger.json | 6 ++++-- coderd/exp_chats.go | 5 ++++- coderd/exp_chats_hooks_test.go | 12 ++++++++---- codersdk/chats.go | 10 ++++++++++ docs/reference/api/chats.md | 12 ++++++------ docs/reference/api/schemas.md | 6 +++--- site/src/api/typesGenerated.ts | 12 ++++++++++++ 8 files changed, 51 insertions(+), 18 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index be2196ed0910e..73a55b51fcbcb 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -17341,7 +17341,8 @@ const docTemplate = `{ "missing_key", "provider_disabled", "content_filter", - "hook_dispatch_failed" + "hook_dispatch_failed", + "hook_denied" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -17355,7 +17356,8 @@ const docTemplate = `{ "ChatErrorKindMissingKey", "ChatErrorKindProviderDisabled", "ChatErrorKindContentFilter", - "ChatErrorKindHookDispatchFailed" + "ChatErrorKindHookDispatchFailed", + "ChatErrorKindHookDenied" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 2088758ceb69e..56a22722260a5 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -15599,7 +15599,8 @@ "missing_key", "provider_disabled", "content_filter", - "hook_dispatch_failed" + "hook_dispatch_failed", + "hook_denied" ], "x-enum-varnames": [ "ChatErrorKindGeneric", @@ -15613,7 +15614,8 @@ "ChatErrorKindMissingKey", "ChatErrorKindProviderDisabled", "ChatErrorKindContentFilter", - "ChatErrorKindHookDispatchFailed" + "ChatErrorKindHookDispatchFailed", + "ChatErrorKindHookDenied" ] }, "codersdk.ChatFileMetadata": { diff --git a/coderd/exp_chats.go b/coderd/exp_chats.go index 763c52279e6a7..6807ab61c8dea 100644 --- a/coderd/exp_chats.go +++ b/coderd/exp_chats.go @@ -132,7 +132,10 @@ func writeChatHookErr(ctx context.Context, rw http.ResponseWriter, err error, de if message == "" { message = deniedFallback } - httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{Message: message}) + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.ChatHookDeniedResponse{ + Response: codersdk.Response{Message: message}, + Kind: codersdk.ChatErrorKindHookDenied, + }) return true } if hookErr, ok := errors.AsType[*dispatch.Error](err); ok { diff --git a/coderd/exp_chats_hooks_test.go b/coderd/exp_chats_hooks_test.go index 1fd479a9dbecc..295a3306d3b9d 100644 --- a/coderd/exp_chats_hooks_test.go +++ b/coderd/exp_chats_hooks_test.go @@ -75,7 +75,7 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { model := createAdditionalChatModelConfig(t, client, "openai", "gpt-4.1") ctx := testutil.Context(t, testutil.WaitLong) - _, err := client.CreateChat(ctx, codersdk.CreateChatRequest{ + res, err := client.Request(ctx, http.MethodPost, "/api/experimental/chats", codersdk.CreateChatRequest{ OrganizationID: user.OrganizationID, ModelConfigID: &model.ID, Content: []codersdk.ChatInputPart{{ @@ -83,10 +83,14 @@ func TestPostChatsInitialPromptHookErrors(t *testing.T) { Text: "blocked prompt", }}, }) - sdkErr := coderdtest.SDKError(t, err) - require.Equal(t, test.wantStatus, sdkErr.StatusCode()) + require.NoError(t, err) + defer res.Body.Close() + require.Equal(t, test.wantStatus, res.StatusCode) + var response codersdk.ChatHookDeniedResponse + require.NoError(t, json.NewDecoder(res.Body).Decode(&response)) if test.wantMessage != "" { - require.Equal(t, test.wantMessage, sdkErr.Message) + require.Equal(t, test.wantMessage, response.Message) + require.Equal(t, codersdk.ChatErrorKindHookDenied, response.Kind) } request := testutil.RequireReceive(ctx, t, requests) require.Equal(t, agenthooks.EventUserPromptSubmit, request.Type) diff --git a/codersdk/chats.go b/codersdk/chats.go index e76784b6148d4..36c47d7e2c798 100644 --- a/codersdk/chats.go +++ b/codersdk/chats.go @@ -1740,6 +1740,7 @@ const ( ChatErrorKindProviderDisabled ChatErrorKind = "provider_disabled" ChatErrorKindContentFilter ChatErrorKind = "content_filter" ChatErrorKindHookDispatchFailed ChatErrorKind = "hook_dispatch_failed" + ChatErrorKindHookDenied ChatErrorKind = "hook_denied" ) // AllChatErrorKinds contains every ChatErrorKind value. @@ -1757,6 +1758,7 @@ var AllChatErrorKinds = []ChatErrorKind{ ChatErrorKindProviderDisabled, ChatErrorKindContentFilter, ChatErrorKindHookDispatchFailed, + ChatErrorKindHookDenied, } // ChatError represents a terminal chat error in persisted chat state or the @@ -2025,6 +2027,14 @@ type ChatHookDispatchFailedResponse struct { Kind ChatErrorKind `json:"kind"` } +// ChatHookDeniedResponse is the error body returned when a lifecycle hook +// denies a synchronous chat operation. Kind lets clients classify the denial +// without parsing message text. +type ChatHookDeniedResponse struct { + Response + Kind ChatErrorKind `json:"kind"` +} + type chatUsageLimitExceededError struct { err *Error response ChatUsageLimitExceededResponse diff --git a/docs/reference/api/chats.md b/docs/reference/api/chats.md index 7f53292eae743..9e0221f94871e 100644 --- a/docs/reference/api/chats.md +++ b/docs/reference/api/chats.md @@ -225,12 +225,12 @@ Status Code **200** #### Enumerated Values -| Property | Value(s) | -|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `client_type` | `api`, `ui` | -| `kind` | `auth`, `config`, `content_filter`, `generic`, `hook_dispatch_failed`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | -| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` | -| `plan_mode` | `plan` | +| Property | Value(s) | +|---------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `client_type` | `api`, `ui` | +| `kind` | `auth`, `config`, `content_filter`, `generic`, `hook_denied`, `hook_dispatch_failed`, `instruction_file`, `mcp_config`, `mcp_server`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `skill`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| `status` | `error`, `excluded`, `interrupting`, `invalid`, `ok`, `oversize`, `requires_action`, `running`, `unreadable`, `waiting` | +| `plan_mode` | `plan` | To perform this operation, you must be authenticated. [Learn more](authentication.md). diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index cf13255dfce66..17f6aeaa2ec7d 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2658,9 +2658,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value(s) | -|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `auth`, `config`, `content_filter`, `generic`, `hook_dispatch_failed`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | +| Value(s) | +|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `auth`, `config`, `content_filter`, `generic`, `hook_denied`, `hook_dispatch_failed`, `missing_key`, `overloaded`, `provider_disabled`, `rate_limit`, `stream_silence_timeout`, `timeout`, `usage_limit` | ## codersdk.ChatFileMetadata diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index eada14adadc8b..e9b596b172777 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2460,6 +2460,7 @@ export type ChatErrorKind = | "config" | "content_filter" | "generic" + | "hook_denied" | "hook_dispatch_failed" | "missing_key" | "overloaded" @@ -2474,6 +2475,7 @@ export const ChatErrorKinds: ChatErrorKind[] = [ "config", "content_filter", "generic", + "hook_denied", "hook_dispatch_failed", "missing_key", "overloaded", @@ -2591,6 +2593,16 @@ export interface ChatGroup extends Group { readonly role: ChatRole; } +// From codersdk/chats.go +/** + * ChatHookDeniedResponse is the error body returned when a lifecycle hook + * denies a synchronous chat operation. Kind lets clients classify the denial + * without parsing message text. + */ +export interface ChatHookDeniedResponse extends Response { + readonly kind: ChatErrorKind; +} + // From codersdk/chats.go /** * ChatHookDispatchFailedResponse is the error body returned when a From 37a65f2d116a9be31df1e9e74cca1ae43d6f28be Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:52:41 +0000 Subject: [PATCH 2/2] fix(site/src/pages/AgentsPage): render hook denials as policy decisions Map the structured 403 denial body to the hook_denied kind so an existing chat titles it 'Blocked by policy' instead of 'Request failed', and give the create form dedicated branches for both hook outcomes so a policy decision no longer renders as a developer error with a stack trace. --- site/src/pages/AgentsPage/AgentChatPage.tsx | 10 ++- .../components/AgentCreateForm.stories.tsx | 78 +++++++++++++++++++ .../AgentsPage/components/AgentCreateForm.tsx | 29 ++++++- .../ChatConversation/chatStatusHelpers.ts | 2 + .../AgentsPage/utils/usageLimitMessage.ts | 14 ++++ 5 files changed, 129 insertions(+), 4 deletions(-) diff --git a/site/src/pages/AgentsPage/AgentChatPage.tsx b/site/src/pages/AgentsPage/AgentChatPage.tsx index 838a1e34506e8..99123f81defb6 100644 --- a/site/src/pages/AgentsPage/AgentChatPage.tsx +++ b/site/src/pages/AgentsPage/AgentChatPage.tsx @@ -117,6 +117,7 @@ import { import { type ChatDetailError, formatUsageLimitMessage, + isChatHookDeniedResponse, isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "./utils/usageLimitMessage"; @@ -1368,10 +1369,13 @@ const AgentChatPage: FC = () => { setChatErrorReason(agentId, reason); } else if (isApiError(error)) { const detail = error.response?.data?.detail?.trim() || undefined; - const reason: ChatDetailError = { - kind: isChatHookDispatchFailedResponse(error.response?.data) + const kind = isChatHookDeniedResponse(error.response?.data) + ? "hook_denied" + : isChatHookDispatchFailedResponse(error.response?.data) ? "hook_dispatch_failed" - : "generic", + : "generic"; + const reason: ChatDetailError = { + kind, message: getErrorMessage(error, "An unexpected error occurred."), ...(detail ? { detail } : {}), }; diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx index 5e72778eb5000..56524349131a2 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.stories.tsx @@ -816,6 +816,84 @@ export const UsageLimitExceeded: Story = { }, }; +export const HookDispatchFailed: Story = { + args: { + ...defaultArgs, + createError: Object.assign( + new Error("Request failed with status code 502"), + { + isAxiosError: true, + response: { + status: 502, + statusText: "Bad Gateway", + data: { + kind: "hook_dispatch_failed", + message: "Chat lifecycle hook dispatch failed.", + detail: + "Lifecycle hook dispatch 00000000-0000-0000-0000-000000000001 failed (http_error).", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect(canvas.getByText("Lifecycle hook failed")).toBeVisible(); + await expect( + canvas.getByText("Chat lifecycle hook dispatch failed."), + ).toBeVisible(); + await expect( + canvas.getByText( + "Lifecycle hook dispatch 00000000-0000-0000-0000-000000000001 failed (http_error).", + ), + ).toBeVisible(); + await expect(canvas.queryByText("Stack Trace")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Response data")).not.toBeInTheDocument(); + }, +}; + +export const HookDenied: Story = { + args: { + ...defaultArgs, + createError: Object.assign( + new Error("Request failed with status code 403"), + { + isAxiosError: true, + response: { + status: 403, + statusText: "Forbidden", + data: { + kind: "hook_denied", + message: "This prompt is blocked by policy.", + }, + headers: {}, + config: {}, + }, + config: {}, + toJSON: () => ({}), + }, + ), + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await expect( + canvas.getByText("This prompt is blocked by policy."), + ).toBeVisible(); + await expect( + canvas.queryByText("Blocked by policy"), + ).not.toBeInTheDocument(); + await expect( + canvas.queryByText("Go to workspaces"), + ).not.toBeInTheDocument(); + await expect(canvas.queryByText("Stack Trace")).not.toBeInTheDocument(); + await expect(canvas.queryByText("Response data")).not.toBeInTheDocument(); + }, +}; + export const ForbiddenErrorWithRole: Story = { args: { ...defaultArgs, diff --git a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx index 358a1d70b3c1a..7ce7a50971f0e 100644 --- a/site/src/pages/AgentsPage/components/AgentCreateForm.tsx +++ b/site/src/pages/AgentsPage/components/AgentCreateForm.tsx @@ -6,7 +6,7 @@ import { isApiError } from "#/api/errors"; import { permittedOrganizations } from "#/api/queries/organizations"; import type * as TypesGen from "#/api/typesGenerated"; import type { AgentChatSendShortcut } from "#/api/typesGenerated"; -import { Alert, AlertDescription } from "#/components/Alert/Alert"; +import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert"; import { ErrorAlert } from "#/components/Alert/ErrorAlert"; import { Button } from "#/components/Button/Button"; import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog"; @@ -27,10 +27,13 @@ import { } from "../utils/reasoningEffort"; import { formatUsageLimitMessage, + isChatHookDeniedResponse, + isChatHookDispatchFailedResponse, isChatUsageLimitExceededResponse, } from "../utils/usageLimitMessage"; import { AgentChatInput } from "./AgentChatInput"; import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert"; +import { getErrorTitle } from "./ChatConversation/chatStatusHelpers"; import type { ModelSelectorOption } from "./ChatElements"; import { CompactOrgSelector } from "./ChatElements"; import { @@ -527,6 +530,30 @@ export const AgentCreateForm: FC = ({ {formatUsageLimitMessage(createError.response.data)} + ) : isApiError(createError) && + createError.response.status === 502 && + isChatHookDispatchFailedResponse(createError.response.data) ? ( + + + {getErrorTitle("hook_dispatch_failed", "error")} + + + {createError.response.data.message} + {createError.response.data.detail && ( + + {createError.response.data.detail} + + )} + + + ) : isApiError(createError) && + createError.response.status === 403 && + isChatHookDeniedResponse(createError.response.data) ? ( + + + {createError.response.data.message} + + ) : ( ) diff --git a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts index 137dc5cb263c6..951eb81c6a9b9 100644 --- a/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts +++ b/site/src/pages/AgentsPage/components/ChatConversation/chatStatusHelpers.ts @@ -50,6 +50,8 @@ export const getErrorTitle = ( return "Response blocked"; case "hook_dispatch_failed": return "Lifecycle hook failed"; + case "hook_denied": + return "Blocked by policy"; default: return mode === "retry" ? "Retrying request" : "Request failed"; } diff --git a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts index c15d7c22bfe31..4cea8134f761c 100644 --- a/site/src/pages/AgentsPage/utils/usageLimitMessage.ts +++ b/site/src/pages/AgentsPage/utils/usageLimitMessage.ts @@ -95,6 +95,20 @@ export function isChatHookDispatchFailedResponse( ); } +/** + * Runtime guard for the structured 403 hook-denial response. + */ +export function isChatHookDeniedResponse( + value: unknown, +): value is TypesGen.ChatHookDeniedResponse { + return ( + typeof value === "object" && + value !== null && + "kind" in value && + value.kind === "hook_denied" + ); +} + /** * Build a user-friendly usage-limit message from structured 409 * response data. Falls back to a generic message if structured