mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: add --chat-hook-allow-insecure to allow plain HTTP chat hook URLs (#27896)
Adds a hidden `--chat-hook-allow-insecure` / `CODER_CHAT_HOOK_ALLOW_INSECURE` deployment option (default `false`) that allows the chat lifecycle hook URL to use plain HTTP for any host. The HTTPS requirement is enforced at two points, and the flag relaxes both: `DeploymentValues.Validate()` rejects `http` hook URLs at startup, and the hook dispatcher's `validateHookURL` allows `http` only for loopback hosts. With the flag set, any-host `http` is accepted; the host, fragment/userinfo, secret, and timeout checks are unchanged, and non-http(s) schemes still fail. This removes the need for an HTTPS reverse proxy when testing a hook consumer on a trusted network. Following security review feedback, the flag description and docs state that plain HTTP lets an on-path attacker forge hook responses (which control agent execution), and `coder server` logs a startup warning (with a redacted hook URL) when hooks run over plain HTTP. Docs, generated API types, and the server config golden are updated accordingly. > Mux acted on Mike's behalf to create this PR.
This commit is contained in:
+8
-2
@@ -803,8 +803,9 @@ chat:
|
||||
# opt-in settings.
|
||||
# (default: false, type: bool)
|
||||
debugLoggingEnabled: false
|
||||
# HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when
|
||||
# unset. Requires the agent-lifecycle-hooks experiment.
|
||||
# HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires
|
||||
# --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the
|
||||
# agent-lifecycle-hooks experiment.
|
||||
# (default: <unset>, type: url)
|
||||
hookURL:
|
||||
# Maximum time to wait for a chat agent lifecycle hook response.
|
||||
@@ -814,6 +815,11 @@ chat:
|
||||
# Requires the agent-lifecycle-hooks experiment.
|
||||
# (default: true, type: bool)
|
||||
hookEnabled: true
|
||||
# Allow the chat hook URL to use plain HTTP for any host. Plain HTTP exposes
|
||||
# sensitive chat data and lets an on-path attacker forge hook responses that
|
||||
# control agent execution, so only enable this on a network you fully trust.
|
||||
# (default: false, type: bool)
|
||||
hookAllowInsecure: false
|
||||
# Deprecated: AI Gateway routing is now the only routing path. Setting this value
|
||||
# has no effect. This option will be removed in a future release.
|
||||
# (default: true, type: bool)
|
||||
|
||||
Generated
+3
@@ -17407,6 +17407,9 @@ const docTemplate = `{
|
||||
"debug_logging_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hook_allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hook_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
Generated
+3
@@ -15644,6 +15644,9 @@
|
||||
"debug_logging_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hook_allow_insecure": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"hook_enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
|
||||
@@ -894,10 +894,16 @@ func New(options *Options) *API {
|
||||
)
|
||||
}
|
||||
if hooksConfigured && hooksExperimentEnabled {
|
||||
if chatConfig.HookAllowInsecure.Value() && chatConfig.HookURL.Value().Scheme == "http" {
|
||||
options.Logger.Warn(ctx, "chat hooks use a plain HTTP URL; hook traffic is unencrypted and hook responses controlling agent execution can be forged on the network",
|
||||
slog.F("hook_url", mcpclient.RedactURL(chatConfig.HookURL.String())),
|
||||
)
|
||||
}
|
||||
hookDispatcher = dispatch.New(
|
||||
options.Logger,
|
||||
nil,
|
||||
chatConfig.HookURL.String(),
|
||||
chatConfig.HookAllowInsecure.Value(),
|
||||
chatConfig.HookSecret.Value(),
|
||||
chatConfig.HookTimeout.Value(),
|
||||
api.DeploymentID,
|
||||
|
||||
@@ -107,9 +107,11 @@ type Dispatcher struct {
|
||||
}
|
||||
|
||||
// validateHookURL requires HTTPS because hook traffic carries sensitive data
|
||||
// and authorization tokens, and responses can control execution. Plain HTTP
|
||||
// is allowed only for loopback development consumers.
|
||||
func validateHookURL(raw string) error {
|
||||
// and authorization tokens, and responses can control execution. Loopback HTTP
|
||||
// is allowed by default; allowInsecure permits HTTP for any host.
|
||||
//
|
||||
//nolint:revive // allowInsecure is operator configuration, not caller control coupling.
|
||||
func validateHookURL(raw string, allowInsecure bool) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -137,6 +139,9 @@ func validateHookURL(raw string) error {
|
||||
if host == "" {
|
||||
return xerrors.New("chat hook URL must include a host")
|
||||
}
|
||||
if allowInsecure {
|
||||
return nil
|
||||
}
|
||||
if host == "localhost" {
|
||||
return nil
|
||||
}
|
||||
@@ -155,6 +160,7 @@ func New(
|
||||
logger slog.Logger,
|
||||
client *http.Client,
|
||||
hookURL string,
|
||||
allowInsecureURL bool,
|
||||
secret string,
|
||||
timeout time.Duration,
|
||||
deploymentID string,
|
||||
@@ -174,7 +180,7 @@ func New(
|
||||
logger: logger.Named("chat_hook_dispatcher"),
|
||||
client: client,
|
||||
hookURL: hookURL,
|
||||
hookURLErr: validateHookURL(hookURL),
|
||||
hookURLErr: validateHookURL(hookURL, allowInsecureURL),
|
||||
secret: []byte(secret),
|
||||
timeout: timeout,
|
||||
deploymentID: deploymentID,
|
||||
|
||||
@@ -78,18 +78,25 @@ func TestDispatcherRejectsCleartextURL(t *testing.T) {
|
||||
_, _, err := dispatcher.Dispatch(testutil.Context(t, testutil.WaitShort), event)
|
||||
require.ErrorContains(t, err, "must use HTTPS")
|
||||
|
||||
require.NoError(t, validateHookURL(""))
|
||||
require.NoError(t, validateHookURL("https://hooks.example.com/coder"))
|
||||
require.NoError(t, validateHookURL("http://localhost:8080/hooks"))
|
||||
require.NoError(t, validateHookURL("http://127.0.0.1:8080/hooks"))
|
||||
require.NoError(t, validateHookURL("http://[::1]:8080/hooks"))
|
||||
require.Error(t, validateHookURL("http://10.0.0.5/hooks"))
|
||||
require.Error(t, validateHookURL("ftp://hooks.example.com/coder"))
|
||||
require.ErrorContains(t, validateHookURL("https:///coder"), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("https:hooks.example.com"), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("http:///hooks"), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("https://hooks.example.com/coder#frag"), "must not contain a fragment")
|
||||
require.ErrorContains(t, validateHookURL("https://user:pass@hooks.example.com/coder"), "must not contain userinfo")
|
||||
require.NoError(t, validateHookURL("", false))
|
||||
require.NoError(t, validateHookURL("https://hooks.example.com/coder", false))
|
||||
require.NoError(t, validateHookURL("http://localhost:8080/hooks", false))
|
||||
require.NoError(t, validateHookURL("http://127.0.0.1:8080/hooks", false))
|
||||
require.NoError(t, validateHookURL("http://[::1]:8080/hooks", false))
|
||||
require.Error(t, validateHookURL("http://10.0.0.5/hooks", false))
|
||||
require.Error(t, validateHookURL("ftp://hooks.example.com/coder", false))
|
||||
require.ErrorContains(t, validateHookURL("https:///coder", false), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("https:hooks.example.com", false), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("http:///hooks", false), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("https://hooks.example.com/coder#frag", false), "must not contain a fragment")
|
||||
require.ErrorContains(t, validateHookURL("https://user:pass@hooks.example.com/coder", false), "must not contain userinfo")
|
||||
|
||||
require.NoError(t, validateHookURL("http://10.0.0.5/hooks", true))
|
||||
require.NoError(t, validateHookURL("http://hooks.example.com/coder", true))
|
||||
require.Error(t, validateHookURL("ftp://hooks.example.com/coder", true))
|
||||
require.ErrorContains(t, validateHookURL("http:///hooks", true), "must include a host")
|
||||
require.ErrorContains(t, validateHookURL("http://hooks.example.com/coder#frag", true), "must not contain a fragment")
|
||||
require.ErrorContains(t, validateHookURL("http://user:pass@hooks.example.com/coder", true), "must not contain userinfo")
|
||||
}
|
||||
|
||||
func TestDispatcherDeny(t *testing.T) {
|
||||
@@ -566,7 +573,7 @@ func TestDispatcherRejectedResponseIsNotObserved(t *testing.T) {
|
||||
|
||||
registry := prometheus.NewRegistry()
|
||||
dispatcher := New(
|
||||
testutil.Logger(t), server.Client(), server.URL, testSecret, time.Second,
|
||||
testutil.Logger(t), server.Client(), server.URL, false, testSecret, time.Second,
|
||||
testDeploymentID, testVersion, registry,
|
||||
)
|
||||
_, _, err := dispatcher.Dispatch(testutil.Context(t, testutil.WaitLong), event)
|
||||
@@ -655,6 +662,7 @@ func newTestDispatcher(
|
||||
testutil.Logger(t),
|
||||
client,
|
||||
hookURL,
|
||||
false,
|
||||
testSecret,
|
||||
timeout,
|
||||
testDeploymentID,
|
||||
@@ -738,7 +746,7 @@ func TestDispatcherAdmissionReserve(t *testing.T) {
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dispatcher := New(
|
||||
testutil.Logger(t), server.Client(), server.URL, testSecret, testutil.WaitShort,
|
||||
testutil.Logger(t), server.Client(), server.URL, false, testSecret, testutil.WaitShort,
|
||||
testDeploymentID, testVersion, prometheus.NewRegistry(),
|
||||
)
|
||||
event := newTestEvent(t, agenthooks.EventUserPromptSubmit, agenthooks.UserPromptSubmitData{Prompt: "hi"})
|
||||
@@ -797,7 +805,7 @@ func TestDispatcherAdmissionReserve(t *testing.T) {
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
dispatcher := New(
|
||||
testutil.Logger(t), server.Client(), server.URL, testSecret, testutil.WaitShort,
|
||||
testutil.Logger(t), server.Client(), server.URL, false, testSecret, testutil.WaitShort,
|
||||
testDeploymentID, testVersion, prometheus.NewRegistry(),
|
||||
)
|
||||
fill(t, dispatcher.admission, maxAdmissionDispatches)
|
||||
|
||||
@@ -51,6 +51,7 @@ func TestSessionStartDispatchSources(t *testing.T) {
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
consumer.Client(),
|
||||
consumer.URL,
|
||||
false,
|
||||
secret,
|
||||
time.Second,
|
||||
"test-deployment",
|
||||
@@ -102,6 +103,7 @@ func newTestTrigger(t *testing.T, handler http.Handler) *Trigger {
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
consumer.Client(),
|
||||
consumer.URL,
|
||||
false,
|
||||
"test-hook-secret-32-bytes-minimum!!",
|
||||
time.Second,
|
||||
"test-deployment",
|
||||
|
||||
@@ -59,6 +59,7 @@ func TestSessionStartDispatchFailureFinishesGeneration(t *testing.T) {
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
consumer.Client(),
|
||||
consumer.URL,
|
||||
false,
|
||||
"test-hook-secret-32-bytes-minimum!!",
|
||||
time.Second,
|
||||
"test-deployment",
|
||||
|
||||
@@ -121,6 +121,7 @@ func newHookDispatcher(t *testing.T, _ database.Store, consumer *httptest.Server
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
consumer.Client(),
|
||||
consumer.URL,
|
||||
false,
|
||||
"test-hook-secret-32-bytes-minimum!!",
|
||||
time.Second,
|
||||
"test-deployment",
|
||||
|
||||
@@ -298,6 +298,7 @@ func TestCreateChildSubagentChatDispatchesUserPromptSubmit(t *testing.T) {
|
||||
slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}),
|
||||
consumer.Client(),
|
||||
consumer.URL,
|
||||
false,
|
||||
"test-hook-secret-32-bytes-minimum!!",
|
||||
time.Second,
|
||||
"test-deployment",
|
||||
|
||||
+25
-6
@@ -4334,7 +4334,7 @@ Write out the current server config as YAML to stdout.`,
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook URL",
|
||||
Description: "HTTPS URL to receive chat agent lifecycle hook events. Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.",
|
||||
Description: "HTTPS URL to receive chat agent lifecycle hook events (plain HTTP requires --chat-hook-allow-insecure). Hooks are disabled when unset. Requires the agent-lifecycle-hooks experiment.",
|
||||
Flag: "chat-hook-url",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_URL",
|
||||
@@ -4377,6 +4377,17 @@ Write out the current server config as YAML to stdout.`,
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "hookEnabled",
|
||||
},
|
||||
{
|
||||
Name: "Chat: Hook Allow Insecure",
|
||||
Description: "Allow the chat hook URL to use plain HTTP for any host. Plain HTTP exposes sensitive chat data and lets an on-path attacker forge hook responses that control agent execution, so only enable this on a network you fully trust.",
|
||||
Flag: "chat-hook-allow-insecure",
|
||||
Hidden: true,
|
||||
Env: "CODER_CHAT_HOOK_ALLOW_INSECURE",
|
||||
Value: &c.AI.Chat.HookAllowInsecure,
|
||||
Default: "false",
|
||||
Group: &deploymentGroupChat,
|
||||
YAML: "hookAllowInsecure",
|
||||
},
|
||||
{
|
||||
Name: "Chat: AI Gateway Routing Enabled",
|
||||
Description: "Deprecated: AI Gateway routing is now the only routing path. Setting this value has no effect. This option will be removed in a future release.",
|
||||
@@ -5065,6 +5076,7 @@ type ChatConfig struct {
|
||||
HookSecret serpent.String `json:"hook_secret" typescript:",notnull"`
|
||||
HookTimeout serpent.Duration `json:"hook_timeout" typescript:",notnull"`
|
||||
HookEnabled serpent.Bool `json:"hook_enabled" typescript:",notnull"`
|
||||
HookAllowInsecure serpent.Bool `json:"hook_allow_insecure" typescript:",notnull"`
|
||||
// Deprecated: AI Gateway routing is now the only routing path. Setting this
|
||||
// value has no effect. This option will be removed in a future release.
|
||||
AIGatewayRoutingEnabled serpent.Bool `json:"ai_gateway_routing_enabled" typescript:",notnull" swaggerignore:"true"`
|
||||
@@ -5119,17 +5131,24 @@ func (c *DeploymentValues) Validate() error {
|
||||
if c.AI.Chat.HookEnabled.Value() {
|
||||
if c.AI.Chat.HookURL.String() != "" {
|
||||
hookURL := c.AI.Chat.HookURL.Value()
|
||||
if hookURL.Scheme != "https" {
|
||||
return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL")
|
||||
allowInsecure := c.AI.Chat.HookAllowInsecure.Value()
|
||||
switch {
|
||||
case hookURL.Scheme == "https":
|
||||
case hookURL.Scheme == "http" && allowInsecure:
|
||||
default:
|
||||
return xerrors.New("chat hook URL must use HTTPS; set --chat-hook-url to an HTTPS URL, or set --chat-hook-allow-insecure to allow plain HTTP")
|
||||
}
|
||||
if hookURL.Host == "" {
|
||||
return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete HTTPS URL")
|
||||
// Hostname() instead of Host: a URL like http://:8080/hooks has a
|
||||
// non-empty Host (":8080") but no hostname, and the dispatcher
|
||||
// rejects it on every dispatch.
|
||||
if hookURL.Hostname() == "" {
|
||||
return xerrors.New("chat hook URL must include a host; set --chat-hook-url to a complete URL")
|
||||
}
|
||||
// The configured string is signed verbatim as the JWT audience,
|
||||
// and neither component is ever transmitted, so a consumer
|
||||
// configured with the URL it actually serves would never match.
|
||||
if hookURL.Fragment != "" || hookURL.RawFragment != "" || hookURL.User != nil {
|
||||
return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a plain HTTPS URL")
|
||||
return xerrors.New("chat hook URL must not contain a fragment or userinfo; set --chat-hook-url to a URL without a fragment or userinfo")
|
||||
}
|
||||
if c.AI.Chat.HookSecret.Value() == "" {
|
||||
return xerrors.New("chat hook secret is required when chat hook URL is set; set --chat-hook-secret")
|
||||
|
||||
@@ -791,12 +791,13 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
disabled bool
|
||||
url string
|
||||
secret string
|
||||
timeout time.Duration
|
||||
wantErr string
|
||||
name string
|
||||
disabled bool
|
||||
url string
|
||||
secret string
|
||||
timeout time.Duration
|
||||
allowInsecure bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "NoURL",
|
||||
@@ -821,6 +822,28 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "chat hook URL must use HTTPS",
|
||||
},
|
||||
{
|
||||
name: "HTTPURLAllowInsecure",
|
||||
url: "http://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
},
|
||||
{
|
||||
name: "NonHTTPSchemeAllowInsecure",
|
||||
url: "ftp://hooks.example.com/agent",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
wantErr: "chat hook URL must use HTTPS",
|
||||
},
|
||||
{
|
||||
name: "AllowInsecureStillRequiresSecret",
|
||||
url: "http://hooks.example.com/agent",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
wantErr: "chat hook secret is required",
|
||||
},
|
||||
{
|
||||
name: "HostlessURL",
|
||||
url: "https:///hook",
|
||||
@@ -828,6 +851,29 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must include a host",
|
||||
},
|
||||
{
|
||||
name: "HostlessHTTPURLAllowInsecure",
|
||||
url: "http:///hook",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
wantErr: "set --chat-hook-url to a complete URL",
|
||||
},
|
||||
{
|
||||
name: "PortOnlyHTTPURLAllowInsecure",
|
||||
url: "http://:8080/hooks",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
wantErr: "must include a host",
|
||||
},
|
||||
{
|
||||
name: "PortOnlyHTTPSURL",
|
||||
url: "https://:8080/hooks",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must include a host",
|
||||
},
|
||||
{
|
||||
name: "FragmentURL",
|
||||
url: "https://hooks.example.com/agent#frag",
|
||||
@@ -835,6 +881,14 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
timeout: 1500 * time.Millisecond,
|
||||
wantErr: "must not contain a fragment or userinfo",
|
||||
},
|
||||
{
|
||||
name: "FragmentHTTPURLAllowInsecure",
|
||||
url: "http://hooks.example.com/agent#frag",
|
||||
secret: "0123456789abcdef0123456789abcdef",
|
||||
timeout: 1500 * time.Millisecond,
|
||||
allowInsecure: true,
|
||||
wantErr: "set --chat-hook-url to a URL without a fragment or userinfo",
|
||||
},
|
||||
{
|
||||
name: "UserinfoURL",
|
||||
url: "https://user:pass@hooks.example.com/agent",
|
||||
@@ -892,6 +946,7 @@ func TestDeploymentValues_Validate_ChatHooks(t *testing.T) {
|
||||
dv.AI.Chat.HookEnabled = serpent.Bool(!tt.disabled)
|
||||
dv.AI.Chat.HookSecret = serpent.String(tt.secret)
|
||||
dv.AI.Chat.HookTimeout = serpent.Duration(tt.timeout)
|
||||
dv.AI.Chat.HookAllowInsecure = serpent.Bool(tt.allowInsecure)
|
||||
if tt.url != "" {
|
||||
require.NoError(t, dv.AI.Chat.HookURL.Set(tt.url))
|
||||
}
|
||||
|
||||
@@ -29,12 +29,13 @@ The experiment list is read at startup, so enabling or disabling it requires a `
|
||||
|
||||
Set the following deployment options on `coder server`.
|
||||
|
||||
| Environment variable | CLI flag | Default | Requirement |
|
||||
|---------------------------|-----------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL. Hooks are inactive when this value is empty. |
|
||||
| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. |
|
||||
| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. |
|
||||
| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. |
|
||||
| Environment variable | CLI flag | Default | Requirement |
|
||||
|----------------------------------|------------------------------|---------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `CODER_CHAT_HOOK_URL` | `--chat-hook-url` | Empty | Use an `https` URL, or an `http` URL with `CODER_CHAT_HOOK_ALLOW_INSECURE`. Hooks are inactive when this value is empty. |
|
||||
| `CODER_CHAT_HOOK_SECRET` | `--chat-hook-secret` | Empty | Required when the hook URL is set, at least 32 bytes of cryptographically random data. Coder uses this shared secret to sign HS256 JWTs. |
|
||||
| `CODER_CHAT_HOOK_TIMEOUT` | `--chat-hook-timeout` | `1.5s` | Must be greater than `0` and no more than `5s`. The timeout applies to each request. |
|
||||
| `CODER_CHAT_HOOK_ENABLED` | `--chat-hook-enabled` | `true` | Set to `false` to stop dispatching without removing the URL or secret. |
|
||||
| `CODER_CHAT_HOOK_ALLOW_INSECURE` | `--chat-hook-allow-insecure` | `false` | Set to `true` to allow a plain `http` hook URL. Plain HTTP lets an attacker on the network forge hook responses, so only enable it on a network you fully trust. |
|
||||
|
||||
Treat `CODER_CHAT_HOOK_ENABLED=false` as the break-glass control.
|
||||
Changing deployment options requires the normal `coder server` configuration rollout for your installation.
|
||||
@@ -42,7 +43,8 @@ Changing deployment options requires the normal `coder server` configuration rol
|
||||
Use a dedicated secret and rotate it through your existing secret-management process.
|
||||
Rotation is a hard cutover: Coder signs with exactly one secret, so dispatches fail until the consumer accepts the new value.
|
||||
Rotate during a maintenance window, or temporarily set `CODER_CHAT_HOOK_ENABLED=false` for the cutover if blocked chats are worse than unreviewed ones for your deployment.
|
||||
Coder requires the configured URL to use HTTPS.
|
||||
Coder requires the configured URL to use HTTPS unless `CODER_CHAT_HOOK_ALLOW_INSECURE` is set.
|
||||
Plain HTTP removes more than transport privacy: hook responses are what allow or deny tool calls and can rewrite prompts and tool inputs, so anyone on the network path can forge them. Coder logs a warning at startup when hooks run over plain HTTP.
|
||||
A TLS terminator can forward the request to a consumer over plain HTTP on a trusted local network.
|
||||
Configure the consumer with the same `CODER_CHAT_HOOK_URL` value, because that URL is the audience Coder signs into every dispatch.
|
||||
The consumer compares the `aud` claim against its configured audience and rejects a mismatch.
|
||||
@@ -233,7 +235,7 @@ Agent hooks server listening on 127.0.0.1:8081 in log-only mode
|
||||
```
|
||||
|
||||
The reference server accepts optional TLS certificate and key paths.
|
||||
For local testing with plain HTTP, place an HTTPS reverse proxy in front of it because `CODER_CHAT_HOOK_URL` accepts only HTTPS URLs, and pass the proxy's URL as `--audience`.
|
||||
For local testing with plain HTTP, either set `CODER_CHAT_HOOK_ALLOW_INSECURE=true` and use the `http` URL directly, or place an HTTPS reverse proxy in front of the consumer and pass the proxy's URL as `--audience`.
|
||||
Run `go run ./scripts/agenthooks-server --help` for all flags and environment variable names.
|
||||
|
||||
## Audit dispatches
|
||||
|
||||
Generated
+1
@@ -233,6 +233,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
|
||||
"chat": {
|
||||
"acquire_batch_size": 0,
|
||||
"debug_logging_enabled": true,
|
||||
"hook_allow_insecure": true,
|
||||
"hook_enabled": true,
|
||||
"hook_secret": "string",
|
||||
"hook_timeout": 0,
|
||||
|
||||
Generated
+5
@@ -1082,6 +1082,7 @@
|
||||
"chat": {
|
||||
"acquire_batch_size": 0,
|
||||
"debug_logging_enabled": true,
|
||||
"hook_allow_insecure": true,
|
||||
"hook_enabled": true,
|
||||
"hook_secret": "string",
|
||||
"hook_timeout": 0,
|
||||
@@ -2455,6 +2456,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
{
|
||||
"acquire_batch_size": 0,
|
||||
"debug_logging_enabled": true,
|
||||
"hook_allow_insecure": true,
|
||||
"hook_enabled": true,
|
||||
"hook_secret": "string",
|
||||
"hook_timeout": 0,
|
||||
@@ -2480,6 +2482,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
||||
|-------------------------|----------------------------|----------|--------------|-------------|
|
||||
| `acquire_batch_size` | integer | false | | |
|
||||
| `debug_logging_enabled` | boolean | false | | |
|
||||
| `hook_allow_insecure` | boolean | false | | |
|
||||
| `hook_enabled` | boolean | false | | |
|
||||
| `hook_secret` | string | false | | |
|
||||
| `hook_timeout` | integer | false | | |
|
||||
@@ -5921,6 +5924,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"chat": {
|
||||
"acquire_batch_size": 0,
|
||||
"debug_logging_enabled": true,
|
||||
"hook_allow_insecure": true,
|
||||
"hook_enabled": true,
|
||||
"hook_secret": "string",
|
||||
"hook_timeout": 0,
|
||||
@@ -6547,6 +6551,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|
||||
"chat": {
|
||||
"acquire_batch_size": 0,
|
||||
"debug_logging_enabled": true,
|
||||
"hook_allow_insecure": true,
|
||||
"hook_enabled": true,
|
||||
"hook_secret": "string",
|
||||
"hook_timeout": 0,
|
||||
|
||||
Generated
+1
@@ -2037,6 +2037,7 @@ export interface ChatConfig {
|
||||
readonly hook_secret: string;
|
||||
readonly hook_timeout: number;
|
||||
readonly hook_enabled: boolean;
|
||||
readonly hook_allow_insecure: boolean;
|
||||
/**
|
||||
* @deprecated AI Gateway routing is now the only routing path. Setting this
|
||||
* value has no effect. This option will be removed in a future release.
|
||||
|
||||
Reference in New Issue
Block a user