Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
gokhanarkan committed May 12, 2026
commit 82c9a59217c8da43f714375f1a19520c2598d324
103 changes: 100 additions & 3 deletions pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -543,7 +543,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string,
}

return utils.NewToolResultText(string(out)), nil

}

// 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.
Expand Down Expand Up @@ -838,7 +837,6 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo
}

return utils.NewToolResultText(string(r)), nil

}

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

// searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the
// IFC label for a search_issues result. It looks up the visibility (and, for
// private repos, collaborators) of every repository represented in the search
// payload and joins the labels via ifc.LabelSearchIssues. If any per-repo
// lookup fails the label is omitted to avoid misclassifying the result.
func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn {
return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) {
if callResult == nil || callResult.IsError || result == nil {
return
}

client, err := deps.GetClient(ctx)
if err != nil {
return
}

uniqueRepos := uniqueSearchIssuesRepos(result)
visibilities := make([]bool, 0, len(uniqueRepos))
readerSets := make([][]string, 0, len(uniqueRepos))
for _, r := range uniqueRepos {
isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo)
if err != nil {
return
}
visibilities = append(visibilities, isPrivate)
if !isPrivate {
readerSets = append(readerSets, nil)
continue
}
collaborators, err := FetchRepoCollaborators(ctx, client, r.owner, r.repo)
if err != nil {
return
}
if len(collaborators) == 0 {
collaborators = []string{r.owner}
}
readerSets = append(readerSets, collaborators)
}

if callResult.Meta == nil {
callResult.Meta = mcp.Meta{}
}
callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities, readerSets)
}
}

type searchIssuesRepoRef struct {
owner string
repo string
}

// uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the
// search result, preserving order of first appearance and deduplicating.
func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef {
if result == nil {
return nil
}
seen := make(map[string]struct{})
var out []searchIssuesRepoRef
for _, issue := range result.Issues {
if issue == nil {
continue
}
owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL())
if !ok {
continue
}
key := owner + "/" + repo
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
out = append(out, searchIssuesRepoRef{owner: owner, repo: repo})
}
return out
}

// parseRepositoryURL extracts the owner and repo from a GitHub API repository
// URL of the form https://api.github.com/repos/{owner}/{repo}.
func parseRepositoryURL(repoURL string) (string, string, bool) {
if repoURL == "" {
return "", "", false
}
const marker = "/repos/"
idx := strings.LastIndex(repoURL, marker)
if idx < 0 {
return "", "", false
}
parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/")
if len(parts) < 2 || parts[0] == "" || parts[1] == "" {
return "", "", false
}
return parts[0], parts[1], true
}

// IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository.
// IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource.
const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write"
Expand Down
212 changes: 211 additions & 1 deletion pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,6 @@ func Test_AddIssueComment(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID)
assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL)

})
}
}
Expand Down Expand Up @@ -693,6 +692,217 @@ func Test_SearchIssues(t *testing.T) {
}
}

func Test_SearchIssues_IFC_InsidersMode(t *testing.T) {
t.Parallel()

serverTool := SearchIssues(translations.NullTranslationHelper)

makeIssue := func(owner, repo string, number int) *github.Issue {
return &github.Issue{
Number: github.Ptr(number),
Title: github.Ptr("issue"),
State: github.Ptr("open"),
RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo),
User: &github.User{Login: github.Ptr("u")},
}
}

type repoFixture struct {
owner string
repo string
isPrivate bool
collaborators []string
repoStatus int
}

repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc {
repoByPath := map[string]repoFixture{}
for _, r := range repos {
repoByPath["/repos/"+r.owner+"/"+r.repo] = r
}
collaboratorsByPath := map[string]repoFixture{}
for _, r := range repos {
collaboratorsByPath["/repos/"+r.owner+"/"+r.repo+"/collaborators"] = r
}
return map[string]http.HandlerFunc{
GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
r, ok := repoByPath[req.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
if r.repoStatus != 0 && r.repoStatus != http.StatusOK {
w.WriteHeader(r.repoStatus)
return
}
body, _ := json.Marshal(map[string]any{
"name": r.repo,
"private": r.isPrivate,
})
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
},
GetReposCollaboratorsByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) {
r, ok := collaboratorsByPath[req.URL.Path]
if !ok {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("[]"))
return
}
users := make([]*github.User, len(r.collaborators))
for i, login := range r.collaborators {
users[i] = &github.User{Login: github.Ptr(login)}
}
body, _ := json.Marshal(users)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
},
}
}

makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client {
handlers := repoHandlers(repos)
handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult)
return MockHTTPClientWithHandlers(handlers)
}

reqParams := map[string]any{"query": "bug"}

t.Run("insiders mode disabled omits ifc label", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
Flags: FeatureFlags{InsidersMode: false},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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 with single public repo emits public untrusted", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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 mixed public and private collapses to public", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{
makeIssue("octocat", "private-repo", 1),
makeIssue("octocat", "public-repo", 2),
}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
{owner: "octocat", repo: "private-repo", isPrivate: true, collaborators: []string{"alice"}},
{owner: "octocat", repo: "public-repo"},
})),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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 two private repos intersect collaborators", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{
makeIssue("octocat", "repo-a", 1),
makeIssue("octocat", "repo-b", 2),
}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
{owner: "octocat", repo: "repo-a", isPrivate: true, collaborators: []string{"alice", "bob", "carol"}},
{owner: "octocat", repo: "repo-b", isPrivate: true, collaborators: []string{"bob", "carol", "dan"}},
})),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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{"bob", "carol"}, ifcMap["confidentiality"])
})

t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, []repoFixture{
{owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError},
})),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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")
}
})

t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) {
searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}}
deps := BaseDeps{
Client: github.NewClient(makeMockClient(searchResult, nil)),
Flags: FeatureFlags{InsidersMode: true},
}
handler := serverTool.Handler(deps)

request := createMCPRequest(reqParams)
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"])
})
}

func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any {
t.Helper()
require.NotNil(t, ifcLabel, "ifc label should be present")
ifcJSON, err := json.Marshal(ifcLabel)
require.NoError(t, err)
var ifcMap map[string]any
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
return ifcMap
}

func Test_CreateIssue(t *testing.T) {
// Verify tool definition once
serverTool := IssueWrite(translations.NullTranslationHelper)
Expand Down
Loading