Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 3 additions & 3 deletions pkg/github/discussions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
)
Expand Down Expand Up @@ -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
},
)
Expand Down Expand Up @@ -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
},
)
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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"])
Comment thread
RossTarrant marked this conversation as resolved.
})
}
Expand Down
4 changes: 2 additions & 2 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
})
}
Expand Down
7 changes: 4 additions & 3 deletions pkg/github/repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 9 additions & 8 deletions pkg/github/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion pkg/github/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
40 changes: 32 additions & 8 deletions pkg/ifc/ifc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Comment thread
RossTarrant marked this conversation as resolved.
}
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()
}
Expand All @@ -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
Expand All @@ -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
}
Comment thread
RossTarrant marked this conversation as resolved.
}
return PublicUntrusted()
switch {
case anyPrivate && anyPublic:
return PrivateUntrusted()
case anyPrivate:
return PrivateTrusted()
default:
return PublicUntrusted()
}
}

// LabelRepoMetadata returns the IFC label for structural repository metadata
Expand Down
48 changes: 45 additions & 3 deletions pkg/ifc/ifc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
Expand All @@ -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)
})
}
Expand Down
Loading