Skip to content

Commit 0803f48

Browse files
committed
Add ifc label for get_file_contents tool
Emits an IFC SecurityLabel on the get_file_contents tool result when the InsidersMode flag is enabled, mirroring the pattern landed for get_me in Public repositories are labelled PublicUntrusted (anyone can author file content via pull requests). Private repositories are labelled PrivateTrusted with the repository owner as a placeholder reader, since only collaborators can land changes there. Full collaborator enumeration is intentionally deferred to a follow-up shared helper. A new exported FetchRepoIsPrivate helper wraps Repositories.Get for visibility lookups; it is invoked lazily and only when InsidersMode is on, so non-insiders pay no extra round trip. Visibility lookup failures skip the label rather than fail the user-facing call. Refs github/copilot-mcp-core#1623, github/copilot-mcp-core#1389.
1 parent 6d9f188 commit 0803f48

3 files changed

Lines changed: 182 additions & 7 deletions

File tree

pkg/github/repositories.go

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"strings"
1111

1212
ghErrors "github.com/github/github-mcp-server/pkg/errors"
13+
"github.com/github/github-mcp-server/pkg/ifc"
1314
"github.com/github/github-mcp-server/pkg/inventory"
1415
"github.com/github/github-mcp-server/pkg/octicons"
1516
"github.com/github/github-mcp-server/pkg/scopes"
@@ -681,6 +682,17 @@ func FetchRepoCollaborators(ctx context.Context, client *github.Client, owner, r
681682
return logins, nil
682683
}
683684

685+
// FetchRepoIsPrivate returns the visibility of a repository. It is a thin
686+
// wrapper around the GitHub Repositories.Get endpoint provided as a shared
687+
// helper for IFC label computation across tools.
688+
func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) {
689+
r, _, err := client.Repositories.Get(ctx, owner, repo)
690+
if err != nil {
691+
return false, err
692+
}
693+
return r.GetPrivate(), nil
694+
}
695+
684696
// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository.
685697
func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool {
686698
return NewTool(
@@ -753,6 +765,42 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
753765
return utils.NewToolResultError("failed to get GitHub client"), nil, nil
754766
}
755767

768+
// attachIFC adds the IFC label to a successful tool result when
769+
// InsidersMode is enabled. The visibility and (for private
770+
// repositories) collaborators lookups are performed lazily on
771+
// first use and are best-effort: if they fail we still attach
772+
// the label, falling back to the repository owner so the reader
773+
// set is never empty for a private repo.
774+
var (
775+
ifcLabelLoaded bool
776+
ifcIsPrivate bool
777+
ifcReaders []string
778+
)
779+
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
780+
if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode {
781+
return r
782+
}
783+
if !ifcLabelLoaded {
784+
if isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo); err == nil {
785+
ifcIsPrivate = isPrivate
786+
}
787+
if ifcIsPrivate {
788+
if collaborators, err := FetchRepoCollaborators(ctx, client, owner, repo); err == nil {
789+
ifcReaders = collaborators
790+
}
791+
if len(ifcReaders) == 0 {
792+
ifcReaders = []string{owner}
793+
}
794+
}
795+
ifcLabelLoaded = true
796+
}
797+
if r.Meta == nil {
798+
r.Meta = mcp.Meta{}
799+
}
800+
r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate, ifcReaders)
801+
return r
802+
}
803+
756804
rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha)
757805
if err != nil {
758806
return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil
@@ -774,7 +822,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
774822
// The path does not point to a file or directory.
775823
// Instead let's try to find it in the Git Tree by matching the end of the path.
776824
if err != nil || (fileContent == nil && dirContent == nil) {
777-
return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0)
825+
res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0)
826+
return attachIFC(res), data, err
778827
}
779828

780829
if fileContent != nil && fileContent.SHA != nil {
@@ -804,7 +853,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
804853
Text: "",
805854
MIMEType: "text/plain",
806855
}
807-
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
856+
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
808857
}
809858

810859
// For files >= 1MB, return a ResourceLink instead of content
@@ -817,10 +866,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
817866
Title: fmt.Sprintf("File: %s", path),
818867
Size: &size,
819868
}
820-
return utils.NewToolResultResourceLink(
869+
return attachIFC(utils.NewToolResultResourceLink(
821870
fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s",
822871
path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote),
823-
resourceLink), nil, nil
872+
resourceLink)), nil, nil
824873
}
825874

826875
// For files < 1MB, get content directly from Contents API
@@ -848,7 +897,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
848897
Text: content,
849898
MIMEType: contentType,
850899
}
851-
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
900+
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
852901
}
853902

854903
// Binary content - encode as base64 blob
@@ -858,14 +907,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool
858907
Blob: []byte(blobContent),
859908
MIMEType: contentType,
860909
}
861-
return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil
910+
return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil
862911
} else if dirContent != nil {
863912
// file content or file SHA is nil which means it's a directory
864913
r, err := json.Marshal(dirContent)
865914
if err != nil {
866915
return utils.NewToolResultError("failed to marshal response"), nil, nil
867916
}
868-
return utils.NewToolResultText(string(r)), nil, nil
917+
return attachIFC(utils.NewToolResultText(string(r))), nil, nil
869918
}
870919

871920
return utils.NewToolResultError("failed to get file contents"), nil, nil

pkg/github/repositories_test.go

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,121 @@ func Test_GetFileContents(t *testing.T) {
477477
}
478478
}
479479

480+
func Test_GetFileContents_IFC_InsidersMode(t *testing.T) {
481+
t.Parallel()
482+
483+
serverTool := GetFileContents(translations.NullTranslationHelper)
484+
485+
mockRawContent := []byte("hello")
486+
487+
makeMockClient := func(isPrivate bool) *http.Client {
488+
return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
489+
GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"),
490+
GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{
491+
"name": "repo",
492+
"default_branch": "main",
493+
"private": isPrivate,
494+
}),
495+
GetReposCollaboratorsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.User{
496+
{Login: github.Ptr("octocat")},
497+
{Login: github.Ptr("alice")},
498+
}),
499+
GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) {
500+
w.WriteHeader(http.StatusOK)
501+
encodedContent := base64.StdEncoding.EncodeToString(mockRawContent)
502+
fileContent := &github.RepositoryContent{
503+
Name: github.Ptr("README.md"),
504+
Path: github.Ptr("README.md"),
505+
SHA: github.Ptr("abc123"),
506+
Type: github.Ptr("file"),
507+
Content: github.Ptr(encodedContent),
508+
Size: github.Ptr(len(mockRawContent)),
509+
Encoding: github.Ptr("base64"),
510+
}
511+
contentBytes, _ := json.Marshal(fileContent)
512+
_, _ = w.Write(contentBytes)
513+
},
514+
})
515+
}
516+
517+
reqParams := map[string]any{
518+
"owner": "octocat",
519+
"repo": "repo",
520+
"path": "README.md",
521+
"ref": "refs/heads/main",
522+
}
523+
524+
t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) {
525+
deps := BaseDeps{
526+
Client: github.NewClient(makeMockClient(false)),
527+
Flags: FeatureFlags{InsidersMode: false},
528+
}
529+
handler := serverTool.Handler(deps)
530+
531+
request := createMCPRequest(reqParams)
532+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
533+
require.NoError(t, err)
534+
require.False(t, result.IsError)
535+
536+
assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled")
537+
})
538+
539+
t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) {
540+
deps := BaseDeps{
541+
Client: github.NewClient(makeMockClient(false)),
542+
Flags: FeatureFlags{InsidersMode: true},
543+
}
544+
handler := serverTool.Handler(deps)
545+
546+
request := createMCPRequest(reqParams)
547+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
548+
require.NoError(t, err)
549+
require.False(t, result.IsError)
550+
551+
require.NotNil(t, result.Meta)
552+
ifcLabel, ok := result.Meta["ifc"]
553+
require.True(t, ok, "result meta should contain ifc key")
554+
555+
ifcJSON, err := json.Marshal(ifcLabel)
556+
require.NoError(t, err)
557+
var ifcMap map[string]any
558+
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
559+
560+
assert.Equal(t, "untrusted", ifcMap["integrity"])
561+
confList, ok := ifcMap["confidentiality"].([]any)
562+
require.True(t, ok, "confidentiality should be a list")
563+
require.Len(t, confList, 1)
564+
assert.Equal(t, "public", confList[0])
565+
})
566+
567+
t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) {
568+
deps := BaseDeps{
569+
Client: github.NewClient(makeMockClient(true)),
570+
Flags: FeatureFlags{InsidersMode: true},
571+
}
572+
handler := serverTool.Handler(deps)
573+
574+
request := createMCPRequest(reqParams)
575+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
576+
require.NoError(t, err)
577+
require.False(t, result.IsError)
578+
579+
require.NotNil(t, result.Meta)
580+
ifcLabel, ok := result.Meta["ifc"]
581+
require.True(t, ok, "result meta should contain ifc key")
582+
583+
ifcJSON, err := json.Marshal(ifcLabel)
584+
require.NoError(t, err)
585+
var ifcMap map[string]any
586+
require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap))
587+
588+
assert.Equal(t, "trusted", ifcMap["integrity"])
589+
confList, ok := ifcMap["confidentiality"].([]any)
590+
require.True(t, ok, "confidentiality should be a list")
591+
assert.Equal(t, []any{"octocat", "alice"}, confList)
592+
})
593+
}
594+
480595
func Test_ForkRepository(t *testing.T) {
481596
// Verify tool definition once
482597
serverTool := ForkRepository(translations.NullTranslationHelper)

pkg/ifc/ifc.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,14 @@ func LabelListIssues(isPrivate bool, readers []string) SecurityLabel {
7575
}
7676
return PublicUntrusted()
7777
}
78+
79+
// LabelGetFileContents returns the IFC label for a get_file_contents result.
80+
// Public repository file contents may be authored by anyone via pull requests
81+
// and are therefore untrusted. In private repositories only collaborators can
82+
// land changes, so contents are treated as trusted.
83+
func LabelGetFileContents(isPrivate bool, readers []string) SecurityLabel {
84+
if isPrivate {
85+
return PrivateTrusted(readers)
86+
}
87+
return PublicUntrusted()
88+
}

0 commit comments

Comments
 (0)