Skip to content

Commit 82c9a59

Browse files
committed
Add ifc label for search_issues tool
Emits an IFC SecurityLabel on the search_issues tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in #2432, list_issues in #2453, and get_file_contents in #2454. Search results may span multiple repositories, so the label is the IFC join of the per-repository labels: - Integrity is always untrusted (issues are user-authored). - If any matched repository is public, the joined readers are ["public"] (the public side dominates the lub). - Otherwise the joined readers are the intersection of the collaborator sets across all matched private repositories. - Empty result sets are labelled public-untrusted (no data leaked). The shared searchHandler in search_utils.go gains an additive variadic 'searchOption' hook so SearchIssues can attach _meta.ifc without duplicating the search call. SearchPullRequests is unaffected; it does not pass any options. If any per-repository visibility or collaborators lookup fails the label is omitted entirely, consistent with get_file_contents, to avoid misclassifying the result. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389. Note: this PR is chained on #2454 (gokhanarkan/fides-get-file-contents) because it depends on the FetchRepoIsPrivate and FetchRepoCollaborators helpers introduced there. GitHub will retarget the base to main once #2454 merges.
1 parent 0cdcd4a commit 82c9a59

5 files changed

Lines changed: 476 additions & 5 deletions

File tree

pkg/github/issues.go

Lines changed: 100 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -543,7 +543,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string,
543543
}
544544

545545
return utils.NewToolResultText(string(out)), nil
546-
547546
}
548547

549548
// ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues.
@@ -838,7 +837,6 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo
838837
}
839838

840839
return utils.NewToolResultText(string(r)), nil
841-
842840
}
843841

844842
func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) {
@@ -978,11 +976,110 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool {
978976
},
979977
[]scopes.Scope{scopes.Repo},
980978
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
981-
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues")
979+
var options []searchOption
980+
if deps.GetFlags(ctx).InsidersMode {
981+
options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps)))
982+
}
983+
result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...)
982984
return result, nil, err
983985
})
984986
}
985987

988+
// searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the
989+
// IFC label for a search_issues result. It looks up the visibility (and, for
990+
// private repos, collaborators) of every repository represented in the search
991+
// payload and joins the labels via ifc.LabelSearchIssues. If any per-repo
992+
// lookup fails the label is omitted to avoid misclassifying the result.
993+
func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn {
994+
return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) {
995+
if callResult == nil || callResult.IsError || result == nil {
996+
return
997+
}
998+
999+
client, err := deps.GetClient(ctx)
1000+
if err != nil {
1001+
return
1002+
}
1003+
1004+
uniqueRepos := uniqueSearchIssuesRepos(result)
1005+
visibilities := make([]bool, 0, len(uniqueRepos))
1006+
readerSets := make([][]string, 0, len(uniqueRepos))
1007+
for _, r := range uniqueRepos {
1008+
isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo)
1009+
if err != nil {
1010+
return
1011+
}
1012+
visibilities = append(visibilities, isPrivate)
1013+
if !isPrivate {
1014+
readerSets = append(readerSets, nil)
1015+
continue
1016+
}
1017+
collaborators, err := FetchRepoCollaborators(ctx, client, r.owner, r.repo)
1018+
if err != nil {
1019+
return
1020+
}
1021+
if len(collaborators) == 0 {
1022+
collaborators = []string{r.owner}
1023+
}
1024+
readerSets = append(readerSets, collaborators)
1025+
}
1026+
1027+
if callResult.Meta == nil {
1028+
callResult.Meta = mcp.Meta{}
1029+
}
1030+
callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities, readerSets)
1031+
}
1032+
}
1033+
1034+
type searchIssuesRepoRef struct {
1035+
owner string
1036+
repo string
1037+
}
1038+
1039+
// uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the
1040+
// search result, preserving order of first appearance and deduplicating.
1041+
func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef {
1042+
if result == nil {
1043+
return nil
1044+
}
1045+
seen := make(map[string]struct{})
1046+
var out []searchIssuesRepoRef
1047+
for _, issue := range result.Issues {
1048+
if issue == nil {
1049+
continue
1050+
}
1051+
owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL())
1052+
if !ok {
1053+
continue
1054+
}
1055+
key := owner + "/" + repo
1056+
if _, dup := seen[key]; dup {
1057+
continue
1058+
}
1059+
seen[key] = struct{}{}
1060+
out = append(out, searchIssuesRepoRef{owner: owner, repo: repo})
1061+
}
1062+
return out
1063+
}
1064+
1065+
// parseRepositoryURL extracts the owner and repo from a GitHub API repository
1066+
// URL of the form https://api.github.com/repos/{owner}/{repo}.
1067+
func parseRepositoryURL(repoURL string) (string, string, bool) {
1068+
if repoURL == "" {
1069+
return "", "", false
1070+
}
1071+
const marker = "/repos/"
1072+
idx := strings.LastIndex(repoURL, marker)
1073+
if idx < 0 {
1074+
return "", "", false
1075+
}
1076+
parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/")
1077+
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
1078+
return "", "", false
1079+
}
1080+
return parts[0], parts[1], true
1081+
}
1082+
9861083
// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository.
9871084
// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource.
9881085
const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"

pkg/github/issues_test.go

Lines changed: 211 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -381,7 +381,6 @@ func Test_AddIssueComment(t *testing.T) {
381381
require.NoError(t, err)
382382
assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID)
383383
assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL)
384-
385384
})
386385
}
387386
}
@@ -693,6 +692,217 @@ func Test_SearchIssues(t *testing.T) {
693692
}
694693
}
695694

695+
func Test_SearchIssues_IFC_InsidersMode(t *testing.T) {
696+
t.Parallel()
697+
698+
serverTool := SearchIssues(translations.NullTranslationHelper)
699+
700+
makeIssue := func(owner, repo string, number int) *github.Issue {
701+
return &github.Issue{
702+
Number: github.Ptr(number),
703+
Title: github.Ptr("issue"),
704+
State: github.Ptr("open"),
705+
RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo),
706+
User: &github.User{Login: github.Ptr("u")},
707+
}
708+
}
709+
710+
type repoFixture struct {
711+
owner string
712+
repo string
713+
isPrivate bool
714+
collaborators []string
715+
repoStatus int
716+
}
717+
718+
repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc {
719+
repoByPath := map[string]repoFixture{}
720+
for _, r := range repos {
721+
repoByPath["/repos/"+r.owner+"/"+r.repo] = r
722+
}
723+
collaboratorsByPath := map[string]repoFixture{}
724+
for _, r := range repos {
725+
collaboratorsByPath["/repos/"+r.owner+"/"+r.repo+"/collaborators"] = r
726+
}
727+
return map[string]http.HandlerFunc{
728+
GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
729+
r, ok := repoByPath[req.URL.Path]
730+
if !ok {
731+
w.WriteHeader(http.StatusNotFound)
732+
return
733+
}
734+
if r.repoStatus != 0 && r.repoStatus != http.StatusOK {
735+
w.WriteHeader(r.repoStatus)
736+
return
737+
}
738+
body, _ := json.Marshal(map[string]any{
739+
"name": r.repo,
740+
"private": r.isPrivate,
741+
})
742+
w.WriteHeader(http.StatusOK)
743+
_, _ = w.Write(body)
744+
},
745+
GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
746+
r, ok := collaboratorsByPath[req.URL.Path]
747+
if !ok {
748+
w.WriteHeader(http.StatusOK)
749+
_, _ = w.Write([]byte("[]"))
750+
return
751+
}
752+
users := make([]*github.User, len(r.collaborators))
753+
for i, login := range r.collaborators {
754+
users[i] = &github.User{Login: github.Ptr(login)}
755+
}
756+
body, _ := json.Marshal(users)
757+
w.WriteHeader(http.StatusOK)
758+
_, _ = w.Write(body)
759+
},
760+
}
761+
}
762+
763+
makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client {
764+
handlers := repoHandlers(repos)
765+
handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult)
766+
return MockHTTPClientWithHandlers(handlers)
767+
}
768+
769+
reqParams := map[string]any{"query": "bug"}
770+
771+
t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
772+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
773+
deps := BaseDeps{
774+
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
775+
Flags: FeatureFlags{InsidersMode: false},
776+
}
777+
handler := serverTool.Handler(deps)
778+
779+
request := createMCPRequest(reqParams)
780+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
781+
require.NoError(t, err)
782+
require.False(t, result.IsError)
783+
assert.Nil(t, result.Meta)
784+
})
785+
786+
t.Run("insiders mode enabled with single public repo emits public untrusted", func(t *testing.T) {
787+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
788+
deps := BaseDeps{
789+
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
790+
Flags: FeatureFlags{InsidersMode: true},
791+
}
792+
handler := serverTool.Handler(deps)
793+
794+
request := createMCPRequest(reqParams)
795+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
796+
require.NoError(t, err)
797+
require.False(t, result.IsError)
798+
799+
require.NotNil(t, result.Meta)
800+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
801+
assert.Equal(t, "untrusted", ifcMap["integrity"])
802+
assert.Equal(t, []any{"public"}, ifcMap["confidentiality"])
803+
})
804+
805+
t.Run("insiders mode mixed public and private collapses to public", func(t *testing.T) {
806+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{
807+
makeIssue("octocat", "private-repo", 1),
808+
makeIssue("octocat", "public-repo", 2),
809+
}}
810+
deps := BaseDeps{
811+
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
812+
{owner: "octocat", repo: "private-repo", isPrivate: true, collaborators: []string{"alice"}},
813+
{owner: "octocat", repo: "public-repo"},
814+
})),
815+
Flags: FeatureFlags{InsidersMode: true},
816+
}
817+
handler := serverTool.Handler(deps)
818+
819+
request := createMCPRequest(reqParams)
820+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
821+
require.NoError(t, err)
822+
require.False(t, result.IsError)
823+
824+
require.NotNil(t, result.Meta)
825+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
826+
assert.Equal(t, "untrusted", ifcMap["integrity"])
827+
assert.Equal(t, []any{"public"}, ifcMap["confidentiality"])
828+
})
829+
830+
t.Run("insiders mode two private repos intersect collaborators", func(t *testing.T) {
831+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{
832+
makeIssue("octocat", "repo-a", 1),
833+
makeIssue("octocat", "repo-b", 2),
834+
}}
835+
deps := BaseDeps{
836+
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
837+
{owner: "octocat", repo: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}},
838+
{owner: "octocat", repo: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}},
839+
})),
840+
Flags: FeatureFlags{InsidersMode: true},
841+
}
842+
handler := serverTool.Handler(deps)
843+
844+
request := createMCPRequest(reqParams)
845+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
846+
require.NoError(t, err)
847+
require.False(t, result.IsError)
848+
849+
require.NotNil(t, result.Meta)
850+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
851+
assert.Equal(t, "untrusted", ifcMap["integrity"])
852+
assert.Equal(t, []any{"bob", "carol"}, ifcMap["confidentiality"])
853+
})
854+
855+
t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) {
856+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}}
857+
deps := BaseDeps{
858+
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
859+
{owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError},
860+
})),
861+
Flags: FeatureFlags{InsidersMode: true},
862+
}
863+
handler := serverTool.Handler(deps)
864+
865+
request := createMCPRequest(reqParams)
866+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
867+
require.NoError(t, err)
868+
require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails")
869+
870+
if result.Meta != nil {
871+
_, hasIFC := result.Meta["ifc"]
872+
assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails")
873+
}
874+
})
875+
876+
t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) {
877+
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}}
878+
deps := BaseDeps{
879+
Client: github.NewClient(makeMockClient(searchResult, nil)),
880+
Flags: FeatureFlags{InsidersMode: true},
881+
}
882+
handler := serverTool.Handler(deps)
883+
884+
request := createMCPRequest(reqParams)
885+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
886+
require.NoError(t, err)
887+
require.False(t, result.IsError)
888+
889+
require.NotNil(t, result.Meta)
890+
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
891+
assert.Equal(t, "untrusted", ifcMap["integrity"])
892+
assert.Equal(t, []any{"public"}, ifcMap["confidentiality"])
893+
})
894+
}
895+
896+
func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
897+
t.Helper()
898+
require.NotNil(t, ifcLabel, "ifc label should be present")
899+
ifcJSON, err := json.Marshal(ifcLabel)
900+
require.NoError(t, err)
901+
var ifcMap map[string]any
902+
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
903+
return ifcMap
904+
}
905+
696906
func Test_CreateIssue(t *testing.T) {
697907
// Verify tool definition once
698908
serverTool := IssueWrite(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)