Thanks to visit codestin.com
Credit goes to github.com

Skip to content

fix: require a configured audience for agent hook consumers - #27497

Closed
ibetitsmike wants to merge 86 commits into
mike/chat-hooks/apifrom
mike/chat-hooks/audience
Closed

fix: require a configured audience for agent hook consumers#27497
ibetitsmike wants to merge 86 commits into
mike/chat-hooks/apifrom
mike/chat-hooks/audience

Conversation

@ibetitsmike

Copy link
Copy Markdown
Collaborator

Problem

agenthooks.NewHTTPHandler derived the expected audience from the incoming request (URL, Host, optionally X-Forwarded-*). Every input to that derivation is caller-controlled, so a party holding a hook token minted for a different listener could satisfy the audience check by sending an absolute-form request URI. The audience claim then constrained nothing.

The reference consumer also had a dedup race: the remembered-decision lookup, the policy decision, and the store each took the mutex separately, so concurrent duplicate deliveries of the same tool_use_id could both miss the cache and overwrite each other.

Change

  • NewHTTPHandler(secret, expectedAudience, hooks, opts...) compares the aud claim against a configured value. A handler built with an empty audience rejects every request, so an unset value cannot become a bypass.
  • Removed WithTrustForwardedHeaders, requestAudience, and the forwarded-header parsing that only existed to support the derivation.
  • scripts/agenthooks-server gains a required --audience / CODER_AGENTHOOKS_AUDIENCE. The bind address is not a usable audience: it may be a wildcard or ephemeral port, and behind a proxy the signed audience is the proxy URL.
  • decidePreToolUse resolves lookup, decision, and store under one lock.

codersdk/x/ is experimental and has no non-test callers of NewHTTPHandler, so the signature break is contained.

Tests

  • TestHTTPHandlerRejectsRequestDerivedAudience drives the handler with an absolute-form request that poisons URL.Scheme, URL.Host, and Host at once. Verified red against the old derivation (200) and green with the fix (400).
  • TestHTTPHandlerWithoutAudienceRejectsEveryRequest pins the fail-closed empty case.
  • Deleted the three forwarded-header tests; their subject no longer exists.
  • SDK unit tests now sign for a fixed testAudience while still posting to server.URL, which is exactly the decoupling this change introduces. The four end-to-end tests in coderd/exp_chats_hooks_test.go use httptest.NewUnstartedServer so the handler can be configured with the real listener address that Coder signs.

go test ./codersdk/x/agenthooks/... ./coderd/x/agenthooks/... -race, the coderd hook suite, make lint, and pnpm run lint-docs pass.

Mux opened this PR on Mike's behalf.

…ate support

Add generic chat state and query capabilities used by lifecycle hook
integration:

- chatstate: EditMessage accepts caller-provided suffix messages that
  insert after the replacement in the same transaction, transitions
  can carry a typed error kind, and FailIdle moves an idle chat to the
  error state.
- database: UpdateChatMessageContentByID rewrites a message's content
  while preserving the search_tsv backfill marker, and InsertChat
  accepts an optional caller-provided ID.
Allow FinishError from a waiting chat instead of adding a separate
transition. Callers encode the error payload, matching the existing
FinishError contract.
…hine

Add Tx.UpdateMessageContent so content rewrites happen inside a
ChatMachine.Update transaction, where the revision trigger stamps the
allocated snapshot version, and document the constraint on the query.
UpdateChatMessageContentByID now filters by chat_id and deleted, and
returns the affected row count so Tx.UpdateMessageContent fails on
stale, deleted, or cross-chat message IDs instead of silently
rewriting another chat's history.
Dispatch lifecycle events from chatd to a configured consumer and
apply the responses, gated by the agent-lifecycle-hooks experiment:

- Events: session_start, user_prompt_submit, pre_tool_use,
  post_tool_use, pre_compact, post_compact, and stop.
- user_prompt_submit dispatches once per submission at admission and
  folds its effects into the stored prompt as typed message parts:
  user parts, then model-only hook context, then a user-visible hook
  notice. Hook context is stripped from client-facing conversions and
  hook notices are excluded from model prompts.
- pre_tool_use allow can override tool input; deny produces a
  synthetic denied tool result carrying returned model context.
- Dispatch is fail-closed: failures reject the triggering request or
  move the chat to the error state in the same transaction as the
  affected step.
- Successful pre_tool_use decisions are cached in process memory so
  same-process recovery can reuse them; correctness never depends on
  the cache.
- Add chat-hook-url, chat-hook-secret, chat-hook-timeout, and
  chat-hook-enabled deployment options with startup validation.
Hook dispatch is disabled without a URL, so a deployment that sets
CODER_CHAT_HOOK_TIMEOUT without using hooks must not fail startup
validation.
…ures

Follows the chatstate fold of FailIdle into FinishError. The handler
gates on the waiting status so a dispatch failure for a queued send
never parks a running chat.
…alls

Hook effects such as pre_tool_use model context can persist rows
between an assistant tool call and its result rows. Hoist matching
result rows found before the next assistant message back next to the
call so the prompt does not inject a synthetic interrupted result
ahead of the real one.
The agenthooks SDK dropped Request.Decode; tests now unmarshal the
typed payload with a small generic helper. Also corrects the hook
secret comment: the SDK, not go-jose, enforces the 32-byte minimum.
Remove the process-local hook decision cache and the streamed-step
preflight. Every non-provider-executed tool call now dispatches
pre_tool_use at execution time and is validated from that response;
Coder never reuses an earlier decision on the consumer's behalf.
acceptServerChatStatus armed a resync that applied the currently cached
chatRecord.status immediately, so a failed send or edit could replace a
live websocket "running" with a stale REST "waiting" and make
shouldApplyMessagePart drop assistant parts.

The resync now waits for the chat query's dataUpdatedAt to advance past
the value captured when it was armed. Object identity does not work here
because TanStack Query structural sharing preserves the chatRecord
reference when a refetch returns value-equal data, which would leave the
resync armed forever and never apply an unchanged status.

Also covers the cross-chat send guard with an interaction story and drops
a store test that restated the guard instead of exercising it.
A status or error event arriving after acceptServerChatStatus armed the
resync but before the invalidated chat query resolved was overwritten by
the older REST status, which could strand the turn and make
shouldApplyMessagePart drop the retry's assistant deltas.

The resync now captures the server status version when armed and skips
the overwrite when the websocket advanced it in the meantime.
The send and edit failure paths armed a resync from the render that
started the request, so a rejection arriving after the user navigated
away captured the previous chat's dataUpdatedAt. The newly active
chat's higher cached timestamp then satisfied the freshness check at
once, overwriting a websocket-delivered status and clearing the
websocket-authoritative guard.

acceptServerChatStatus now ignores calls whose chat is no longer the
active one, which covers both failure paths at their single shared
entry point.
@github-actions

Copy link
Copy Markdown
Contributor

Docs preview

Check off each page once it's been reviewed. If a page changes in a later push, its checkbox clears automatically so it gets a fresh look. Pages not yet wired into the docs navigation aren't listed here.

The SDK handler derived the expected audience from the incoming request,
so a caller controlling the request URI, the Host header, or forwarding
headers could satisfy the audience check with a token minted for a
different listener. NewHTTPHandler now takes the audience Coder is
configured to dispatch to and compares the aud claim against it, and a
handler built without an audience rejects every request.

WithTrustForwardedHeaders and the request-derived audience path are
removed with it.
…one lock

The remembered-decision lookup, the policy decision, and the store each
took the mutex separately, so concurrent duplicate deliveries of the same
tool_use_id could both miss the cache, both decide, and overwrite each
other's entry. decidePreToolUse now performs all three under one lock.
Consumers now compare the aud claim against a configured audience instead
of one derived from the request, so the forwarded-header guidance no
longer applies and the reference consumer needs --audience.
@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: c4820d9ba5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@linear-code

linear-code Bot commented Jul 27, 2026

Copy link
Copy Markdown

CODAGT-868

@ibetitsmike

Copy link
Copy Markdown
Collaborator Author

Closing: this change is now folded into the base PRs of the stack, so it no longer needs a separate PR.

The three consumer SDK and reference server files moved into #27401, and the coderd/exp_chats_hooks_test.go change moved into #27429. The request-derived audience and WithTrustForwardedHeaders path never appear in the rewritten history; #27401 introduces the configured audience directly.

Closed by Mux, an AI coding agent, on Mike's behalf.

@github-actions github-actions Bot locked and limited conversation to collaborators Jul 27, 2026
@ibetitsmike
ibetitsmike deleted the mike/chat-hooks/audience branch July 27, 2026 11:12
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant