diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 1f9459773..68ed014b2 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -276,7 +276,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(out)) // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) @@ -384,7 +384,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(out)) // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) @@ -592,7 +592,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve result := utils.NewToolResultText(string(out)) // Discussion comments are user-authored (untrusted); confidentiality // follows repo visibility. - result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) return result, nil, nil }, ) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 79b8b23ad..3c03c8f7d 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -804,7 +804,7 @@ Options are: // attachIFC adds the IFC label to a successful tool result when // IFC labels are enabled. If the visibility lookup fails the // label is omitted rather than misclassifying the result. - attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelListIssues) + attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelRepoUserContent) switch method { case "get": diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5378ff62b..9473224ed 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2852,7 +2852,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ @@ -2875,7 +2875,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { var ifcMap map[string]any require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) - assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "trusted", ifcMap["integrity"]) assert.Equal(t, "private", ifcMap["confidentiality"]) }) } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 985d8cc93..032e1406f 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -115,7 +115,7 @@ Possible options: // visibility lookup fails the label is omitted rather than // misclassifying the result. attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues) + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelRepoUserContent) } switch method { @@ -1339,7 +1339,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(r)) // Pull request titles/bodies are user-authored (untrusted); // confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) return result, nil, nil }) } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 21cbf7e64..949a18008 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2121,9 +2121,10 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser result := utils.NewToolResultText(string(r)) // A starred-repository listing exposes repository data across many // repos; reuse the multi-repo join shared with search_repositories - // (untrusted integrity; confidentiality private if any matched repo - // is private). Visibility is read directly from the response, so no - // extra API call is needed. + // (public-only results stay public-untrusted, mixed-visibility + // results become private-untrusted, all-private results become + // private-trusted). Visibility is read directly from the response, + // so no extra API call is needed. visibilities := make([]bool, 0, len(minimalRepos)) for _, mr := range minimalRepos { visibilities = append(visibilities, mr.Private) diff --git a/pkg/github/search.go b/pkg/github/search.go index 42ba2896f..23ccbd838 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -173,8 +173,9 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo // every matched repository and attaches the result to callResult when IFC // labels are enabled. Visibility is read directly from the search response — // no extra API call. The join math is shared with search_issues via -// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is -// private if any matched repository is private, otherwise public. The +// ifc.LabelSearchIssues: public-only results stay public-untrusted, +// mixed-visibility results become private-untrusted, and all-private results +// become private-trusted. The // feature-flag check is centralized here (mirroring the attach* helpers in // ifc_labels.go) so the handler can call this unconditionally. func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) { @@ -302,9 +303,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } callResult := utils.NewToolResultText(string(r)) - // Code search spans repositories and exposes file contents - // (untrusted). Confidentiality is the IFC join across every matched - // repository's visibility, read directly from the search response. + // Code search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. visibilities := make([]bool, 0, len(result.CodeResults)) for _, code := range result.CodeResults { if code.Repository != nil { @@ -593,9 +594,9 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } callResult := utils.NewToolResultText(string(r)) - // Commit search spans repositories and exposes commit content - // (untrusted). Confidentiality is the IFC join across every matched - // repository's visibility, read directly from the search response. + // Commit search spans repositories; the IFC label is the conservative + // join across every matched repository's visibility, read directly + // from the search response. visibilities := make([]bool, 0, len(result.Commits)) for _, commit := range result.Commits { if commit.Repository != nil { diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index fa48bf19a..5ebf60842 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -238,7 +238,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { assert.Equal(t, "public", ifcMap["confidentiality"]) }) - t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { + t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "private-repo", isPrivate: true}, diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index fefe542e3..365ecbdea 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -76,8 +76,21 @@ func LabelGetMe() SecurityLabel { // LabelListIssues returns the IFC label for a list_issues result. // Public repositories are universally readable; private repositories are // restricted to their collaborators (resolved client-side from the marker). -// Issue contents are attacker-controllable, so integrity is always untrusted. +// Public repository issue contents are attacker-controllable, while private +// repository issues are treated as trusted collaborator-authored data. func LabelListIssues(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelRepoUserContent returns the IFC label for user-authored content scoped +// to a repository when that tool has not opted into a more specific integrity +// policy. Confidentiality follows repository visibility, while integrity stays +// untrusted because the payload can contain free-form issue, pull request, +// discussion, review, or comment text. +func LabelRepoUserContent(isPrivate bool) SecurityLabel { if isPrivate { return PrivateUntrusted() } @@ -99,11 +112,12 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel { // result, joining per-repository labels across all matched repositories. // Used by both search_issues and search_repositories. // -// Integrity is always untrusted because results expose user-authored content. -// -// Confidentiality follows the IFC meet (greatest lower bound): if any matched -// repository is private the joined label is private; otherwise public. The -// reader set is opaque (the "private" marker); the client engine resolves +// Public-only results are untrusted and public. All-private results are trusted +// and private because private repository content is treated as trusted +// collaborator-authored data. Mixed public/private results are untrusted and +// private: the public items keep the joined payload's integrity untrusted, +// while the private items keep the joined payload's confidentiality private. +// The reader set is opaque (the "private" marker); the client engine resolves // concrete readers on demand at egress decision time. // // An empty result set is treated as public-untrusted (no repository data is @@ -119,12 +133,22 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel { // until then they would invite unsafe declassification of a "public" item that // actually arrived alongside private data. func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + var anyPrivate, anyPublic bool for _, isPrivate := range repoVisibilities { if isPrivate { - return PrivateUntrusted() + anyPrivate = true + } else { + anyPublic = true } } - return PublicUntrusted() + switch { + case anyPrivate && anyPublic: + return PrivateUntrusted() + case anyPrivate: + return PrivateTrusted() + default: + return PublicUntrusted() + } } // LabelRepoMetadata returns the IFC label for structural repository metadata diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index 90788a8cb..98bdd0c01 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -6,36 +6,78 @@ import ( "github.com/stretchr/testify/assert" ) +func TestLabelListIssues(t *testing.T) { + t.Parallel() + + t.Run("public repo issues are untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo issues are trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelListIssues(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelRepoUserContent(t *testing.T) { + t.Parallel() + + t.Run("public repo user content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo user content is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepoUserContent(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + func TestLabelSearchIssues(t *testing.T) { t.Parallel() tests := []struct { name string visibilities []bool + wantIntegrity Integrity wantConfidential Confidentiality }{ { name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { name: "single public repo", visibilities: []bool{false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { name: "all public repos stay public", visibilities: []bool{false, false, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "any private match flips to private", + name: "mixed public and private repos become untrusted private", visibilities: []bool{false, true, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPrivate, }, { - name: "all private repos stay private", + name: "all private repos stay trusted private", visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, wantConfidential: ConfidentialityPrivate, }, } @@ -44,7 +86,7 @@ func TestLabelSearchIssues(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() label := LabelSearchIssues(tc.visibilities) - assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantIntegrity, label.Integrity) assert.Equal(t, tc.wantConfidential, label.Confidentiality) }) }