From 5271c51aae7cbe69a4264f56534ab50034599986 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=B6khan=20Arkan?= Date: Tue, 12 May 2026 14:11:28 +0300 Subject: [PATCH] Add ifc label for issue_read tool Emits an IFC SecurityLabel on the issue_read tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432, list_issues in #2453, get_file_contents in #2454, and search_issues in #2456. issue_read operates on a single issue in a single repository so the label has the same per-repo semantics as list_issues; the helper ifc.LabelListIssues is reused directly. Integrity is always untrusted (issue contents, comments, and label descriptions are user-authored). Public repos are labelled PublicUntrusted; private repos are labelled PrivateUntrusted with the repository's collaborator logins, falling back to [owner] when the collaborators lookup fails. The IssueRead handler dispatches to four sub-functions (GetIssue, GetIssueComments, GetSubIssues, GetIssueLabels). The IFC label is attached at the dispatch site via a single attachIFC closure, so all four method branches emit the label without changes to the underlying helpers. Visibility-lookup failures cause the label to be omitted entirely (consistent with get_file_contents and search_issues). A future cleanup PR can extract attachIFC into a shared helper now that get_file_contents, search_issues, and issue_read use near-identical closures; intentionally not bundled here to keep the diff minimal. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. Note: chained on #2456 (gokhanarkan/fides-search-issues), which is in turn chained on #2454. GitHub will retarget the base to main once those merge. --- pkg/github/issues.go | 50 ++++++++++++++-- pkg/github/issues_test.go | 121 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 4 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index c89c38d13..26339e6af 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -296,19 +296,61 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // InsidersMode is enabled. The visibility and (for private + // repositories) collaborators lookups are performed lazily on + // first use. If the visibility lookup fails the label is omitted + // rather than misclassifying the result; the failure is not + // cached so a subsequent dispatch branch could retry. If only + // the collaborators lookup fails for a private repo we fall back + // to the owner so the reader set is never empty. The label + // matches list_issues semantics: per-repo visibility, integrity + // always untrusted. + var ( + ifcLabelKnown bool + ifcIsPrivate bool + ifcReaders []string + ) + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + return r + } + if !ifcLabelKnown { + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + ifcIsPrivate = isPrivate + if ifcIsPrivate { + if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil { + ifcReaders = collaborators + } + if len(ifcReaders) == 0 { + ifcReaders = []string{owner} + } + } + ifcLabelKnown = true + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelListIssues(ifcIsPrivate, ifcReaders) + return r + } + switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 06d2e33ea..90cbb56cf 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -275,6 +275,127 @@ func Test_GetIssue(t *testing.T) { } } +func Test_IssueRead_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/octocat/repo/issues/1"), + User: &github.User{Login: github.Ptr("u")}, + } + + mockComments := []*github.IssueComment{ + {Body: github.Ptr("hello"), User: &github.User{Login: github.Ptr("u")}}, + } + + makeMockClient := func(isPrivate bool, repoStatus int) *http.Client { + handlers := map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{ + {Login: github.Ptr("octocat")}, + {Login: github.Ptr("alice")}, + }), + } + if repoStatus != 0 && repoStatus != http.StatusOK { + handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom") + } else { + handlers[GetReposByOwnerByRepo] = mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }) + } + return MockHTTPClientWithHandlers(handlers) + } + + getReq := map[string]any{ + "method": "get", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + commentsReq := map[string]any{ + "method": "get_comments", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"public"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(true, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(commentsReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, []any{"octocat", "alice"}, ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: github.NewClient(makeMockClient(false, http.StatusInternalServerError)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper)