Skip to content

Commit 60ebbe1

Browse files
owenniblockCopilot
andcommitted
Fix runtime description leak on list_issue_fields legacy variant
The pkg/translations TranslationHelper is first-write-wins on cache hits: if two callers pass the same key with different fallback strings, only the first fallback is remembered. Both ListIssueFields (MS-aware) and ListIssueFieldsLegacy shared the translation key TOOL_LIST_ISSUE_FIELDS_DESCRIPTION, so at runtime the second-registered variant (legacy) inherited the first's description — leaking the MS-flavoured 'single_select, multi_select' text into the FF-off surface. Only list_issue_fields was affected: issue_write and list_issues use identical top-level descriptions across variants (their MS-vs-legacy differentiation is in InputSchema property descriptions, which are hardcoded strings not routed through the translation helper). Fix: give the legacy variant its own translation key (TOOL_LIST_ISSUE_FIELDS_LEGACY_DESCRIPTION). Simplify buildListIssueFields to accept the already-resolved description string, so the two variants resolve their translation before calling the shared builder. Live-verified against a local binary: - FF off: description no longer contains 'multi_select' - FF on: description contains 'multi_select' Regression test: Test_ListIssueFields_LegacyDoesNotLeakMSDescription UnderRealTranslationHelper uses an in-test cache that mimics the real helper's first-write-wins behaviour. Confirmed the test fails if both variants share a key and passes with them split. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 352a2bc commit 60ebbe1

2 files changed

Lines changed: 52 additions & 8 deletions

File tree

pkg/github/issue_fields.go

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -129,29 +129,38 @@ type issueFieldsOrgQuery struct {
129129
// multi_select arm, so the OUTPUT will still surface multi-select fields if
130130
// the org has them (matching what the dotcom UI shows). Only the description
131131
// is gated.
132+
//
133+
// The two variants use DIFFERENT translation keys because the shared
134+
// TranslationHelper is first-write-wins on cache hits — if both variants used
135+
// the same key, the second one registered would inherit the first's cached
136+
// value at runtime, and the FF-off variant would leak the MS description.
132137
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
133-
st := buildListIssueFields(t, true)
138+
description := t(
139+
"TOOL_LIST_ISSUE_FIELDS_DESCRIPTION",
140+
"List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select, multi_select), and for single_select and multi_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.",
141+
)
142+
st := buildListIssueFields(t, description)
134143
st.FeatureFlagEnable = FeatureFlagIssueFieldsMultiSelect
135144
return st
136145
}
137146

138147
// ListIssueFieldsLegacy is the FF-off variant of list_issue_fields.
139148
func ListIssueFieldsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool {
140-
st := buildListIssueFields(t, false)
149+
description := t(
150+
"TOOL_LIST_ISSUE_FIELDS_LEGACY_DESCRIPTION",
151+
"List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.",
152+
)
153+
st := buildListIssueFields(t, description)
141154
st.FeatureFlagDisable = []string{FeatureFlagIssueFieldsMultiSelect}
142155
return st
143156
}
144157

145-
func buildListIssueFields(t translations.TranslationHelperFunc, multiSelectEnabled bool) inventory.ServerTool {
146-
description := "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."
147-
if multiSelectEnabled {
148-
description = "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select, multi_select), and for single_select and multi_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."
149-
}
158+
func buildListIssueFields(t translations.TranslationHelperFunc, description string) inventory.ServerTool {
150159
return NewTool(
151160
ToolsetMetadataIssues,
152161
mcp.Tool{
153162
Name: "list_issue_fields",
154-
Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", description),
163+
Description: description,
155164
Annotations: &mcp.ToolAnnotations{
156165
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
157166
ReadOnlyHint: true,

pkg/github/issue_fields_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,38 @@ func Test_ListIssueFields(t *testing.T) {
316316
})
317317
}
318318
}
319+
320+
// Test_ListIssueFields_LegacyDoesNotLeakMSDescriptionUnderRealTranslationHelper
321+
// pins the fix for the translation-key collision bug. The real translation
322+
// helper is first-write-wins on cache hits: if both the MS and legacy variants
323+
// use the same translation key, the second registration inherits the first
324+
// registration's cached description regardless of the fallback passed in.
325+
//
326+
// AllTools() calls the MS variant first, so under the bug the legacy variant
327+
// silently gets the MS description at runtime — leaking "multi_select" into
328+
// what should be a legacy-only surface. This test simulates the real helper's
329+
// cache behaviour and asserts the two descriptions actually differ.
330+
func Test_ListIssueFields_LegacyDoesNotLeakMSDescriptionUnderRealTranslationHelper(t *testing.T) {
331+
// Mimic the caching behaviour of translations.TranslationHelper (see
332+
// pkg/translations/translations.go): first-write-wins keyed by key name.
333+
cache := map[string]string{}
334+
firstWriteWins := func(key, defaultValue string) string {
335+
if v, ok := cache[key]; ok {
336+
return v
337+
}
338+
cache[key] = defaultValue
339+
return defaultValue
340+
}
341+
342+
// Call in registration order: MS variant first, legacy second. This is
343+
// what AllTools() does — see tools.go.
344+
msDesc := ListIssueFields(firstWriteWins).Tool.Description
345+
legacyDesc := ListIssueFieldsLegacy(firstWriteWins).Tool.Description
346+
347+
require.NotEqual(t, msDesc, legacyDesc,
348+
"the MS and legacy variants MUST use different translation keys so the real TranslationHelper's first-write-wins cache doesn't leak the MS description into the legacy variant")
349+
assert.Contains(t, msDesc, "multi_select",
350+
"MS variant description must advertise multi_select")
351+
assert.NotContains(t, legacyDesc, "multi_select",
352+
"legacy variant description must not advertise multi_select — that would leak the gated feature")
353+
}

0 commit comments

Comments
 (0)