diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 86641aa70c..5760cd50a8 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -1241,7 +1241,7 @@ func buildAIBridgeThread( thread.Model = rootIntc.Model thread.Provider = rootIntc.Provider thread.CredentialKind = string(rootIntc.CredentialKind) - thread.CredentialHint = rootIntc.CredentialHint + thread.CredentialHint = sanitizeCredentialHint(rootIntc.CredentialHint) // Get first user prompt from root interception. // A thread can only have one prompt, by definition, since we currently // only store the last prompt observed in an interception. @@ -1407,6 +1407,25 @@ func InvalidatedPresets(invalidatedPresets []database.UpdatePresetsLastInvalidat return presets } +// sanitizeCredentialHint ensures the hint looks masked before exposing +// it in the API. The aibridge library uses "..." as the masking +// delimiter (e.g. "sk-a...efgh"), so we check for its presence. If +// the hint doesn't contain "..." or exceeds the max length, it's +// replaced with "..." to prevent leaking raw secrets. +func sanitizeCredentialHint(hint string) string { + // Matches the VARCHAR(15) DB constraint. + const maxCredentialHintLength = 15 + + if hint == "" { + return "" + } + + if len(hint) > maxCredentialHintLength || !strings.Contains(hint, "...") { + return "..." + } + return hint +} + func jsonOrEmptyMap(rawMessage pqtype.NullRawMessage) map[string]any { var m map[string]any if !rawMessage.Valid { diff --git a/coderd/database/db2sdk/db2sdk_internal_test.go b/coderd/database/db2sdk/db2sdk_internal_test.go index 2222238c90..e7492eaa6a 100644 --- a/coderd/database/db2sdk/db2sdk_internal_test.go +++ b/coderd/database/db2sdk/db2sdk_internal_test.go @@ -306,3 +306,29 @@ func TestAggregateTokenUsage(t *testing.T) { require.Empty(t, result.Metadata) }) } + +func TestSanitizeCredentialHint(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + expected string + }{ + {"valid_short", "s...t", "s...t"}, + {"valid_long", "sk-a...efgh", "sk-a...efgh"}, + {"valid_only_dots", "...", "..."}, + {"empty", "", ""}, + {"short_unmasked_secret", "abc12", "..."}, + {"missing_dots", "sk-abcdefgh", "..."}, + {"too_long", "sk-a...efghijklmn", "..."}, + {"raw_secret", "sk-proj-abc123xyz789", "..."}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.expected, sanitizeCredentialHint(tc.input)) + }) + } +} diff --git a/enterprise/coderd/aibridge_test.go b/enterprise/coderd/aibridge_test.go index 718dd4695c..dea7030899 100644 --- a/enterprise/coderd/aibridge_test.go +++ b/enterprise/coderd/aibridge_test.go @@ -1836,10 +1836,12 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { now := dbtime.Now() endedAt := now.Add(time.Minute) i1 := dbgen.AIBridgeInterception(t, db, database.InsertAIBridgeInterceptionParams{ - InitiatorID: firstUser.UserID, - Provider: "openai", - Model: "gpt-4", - StartedAt: now, + InitiatorID: firstUser.UserID, + Provider: "openai", + Model: "gpt-4", + StartedAt: now, + CredentialKind: database.CredentialKindByok, + CredentialHint: "sk-a...efgh", }, &endedAt) // When no client session ID is set, the interception ID becomes the session identifier. @@ -1847,6 +1849,8 @@ func TestAIBridgeGetSessionThreads(t *testing.T) { require.NoError(t, err) require.Equal(t, i1.ID.String(), res.ID) require.Len(t, res.Threads, 1) + require.Equal(t, "byok", res.Threads[0].CredentialKind) + require.Equal(t, "sk-a...efgh", res.Threads[0].CredentialHint) }) t.Run("ThreadsWithAgenticActions", func(t *testing.T) { diff --git a/go.mod b/go.mod index 1c8c295c29..fc4b00ac7b 100644 --- a/go.mod +++ b/go.mod @@ -496,7 +496,7 @@ require ( github.com/anthropics/anthropic-sdk-go v1.19.0 github.com/brianvoe/gofakeit/v7 v7.14.0 github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 - github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77 + github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947 github.com/coder/aisdk-go v0.0.9 github.com/coder/boundary v0.8.4-0.20260304164748-566aeea939ab github.com/coder/preview v1.0.8 diff --git a/go.sum b/go.sum index 7c2099bf3f..dbf0bd62d3 100644 --- a/go.sum +++ b/go.sum @@ -312,8 +312,8 @@ github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225 h1:tRIViZ5JRmzdOEo5wUWngaGEFBG8OaE1o2GIHN5ujJ8= github.com/coder/agentapi-sdk-go v0.0.0-20250505131810-560d1d88d225/go.mod h1:rNLVpYgEVeu1Zk29K64z6Od8RBP9DwqCu9OfCzh8MR4= -github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77 h1:VNN+JBL4/IiC/sb9eTQmAkRv4ALwSnmMvW9iI2o+G6o= -github.com/coder/aibridge v1.1.1-0.20260408143328-f72a795f1e77/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4= +github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947 h1:5asZktST8evXjs9vaS9ujSew/LNyp/lBLfJk8Rd8+pk= +github.com/coder/aibridge v1.1.1-0.20260410135158-9ac7525ed947/go.mod h1:u6WvGLMQQbk3ByeOw+LBdVgDNc/v/ujAtUc6MfvzQb4= github.com/coder/aisdk-go v0.0.9 h1:Vzo/k2qwVGLTR10ESDeP2Ecek1SdPfZlEjtTfMveiVo= github.com/coder/aisdk-go v0.0.9/go.mod h1:KF6/Vkono0FJJOtWtveh5j7yfNrSctVTpwgweYWSp5M= github.com/coder/anthropic-sdk-go v0.0.0-20260409105508-5711db120546 h1:dYAA1uo93n9r/B4Gvx76pjaeS0kBTX1lO0WX0p0uqo8=