Fix IFC private repository labels (#2695)

This commit is contained in:
Ross Tarrant
2026-06-17 09:22:31 +01:00
committed by GitHub
parent 4e8eb81dac
commit 667bd3e803
14 changed files with 681 additions and 210 deletions
+3 -3
View File
@@ -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
},
)
+2 -10
View File
@@ -101,13 +101,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool {
}
result := utils.NewToolResultText(string(r))
// Gist contents are user-authored (untrusted); confidentiality is
// the IFC join of each gist's own public/secret flag.
visibilities := make([]bool, 0, len(gists))
for _, g := range gists {
visibilities = append(visibilities, g.GetPublic())
}
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList())
return result, nil, nil
},
)
@@ -167,9 +161,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool {
}
result := utils.NewToolResultText(string(r))
// Gist contents are user-authored (untrusted); confidentiality
// derives from the gist's own public/secret flag.
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic()))
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist())
return result, nil, nil
},
)
+1
View File
@@ -22,6 +22,7 @@ import (
const (
// User endpoints
GetUser = "GET /user"
GetUsersByUsername = "GET /users/{username}"
GetUserStarred = "GET /user/starred"
GetUsersGistsByUsername = "GET /users/{username}/gists"
GetUsersStarredByUsername = "GET /users/{username}/starred"
+27 -10
View File
@@ -17,6 +17,10 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) {
r.Meta["ifc"] = label
}
func shouldAttachIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult) bool {
return r != nil && !r.IsError && deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels)
}
// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result
// when IFC labels are enabled. It is used by tools whose label does not depend
// on any repository visibility lookup (e.g. security alerts, global
@@ -25,7 +29,7 @@ func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) {
// Error results are left untouched, and the label is omitted entirely when the
// IFC feature flag is disabled.
func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
setIFCLabel(r, label)
@@ -49,7 +53,7 @@ func attachRepoVisibilityIFCLabel(
r *mcp.CallToolResult,
labelFn func(isPrivate bool) ifc.SecurityLabel,
) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo)
@@ -86,7 +90,7 @@ func attachRepoVisibilityIFCLabelLazy(
r *mcp.CallToolResult,
labelFn func(isPrivate bool) ifc.SecurityLabel,
) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
client, err := deps.GetClient(ctx)
@@ -97,12 +101,11 @@ func attachRepoVisibilityIFCLabelLazy(
}
// attachJoinedIFCLabel attaches an IFC label computed by joining a set of
// per-item visibilities (true == private for repositories, true == public for
// gists) when IFC labels are enabled. joinFn is the lattice join for the
// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The
// visibility slice is cheap to build from an already-fetched response, so
// callers may construct it unconditionally and let this helper own the
// feature-flag gate.
// per-item visibilities (true == private) when IFC labels are enabled. joinFn
// is the lattice join for the relevant item kind (e.g. ifc.LabelSearchIssues or
// ifc.LabelProjectList). The visibility slice is cheap to build from an
// already-fetched response, so callers may construct it unconditionally and let
// this helper own the feature-flag gate.
func attachJoinedIFCLabel(
ctx context.Context,
deps ToolDependencies,
@@ -110,13 +113,27 @@ func attachJoinedIFCLabel(
visibilities []bool,
joinFn func([]bool) ifc.SecurityLabel,
) *mcp.CallToolResult {
if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
setIFCLabel(r, joinFn(visibilities))
return r
}
func attachProjectVisibilityIFCLabel(
ctx context.Context,
deps ToolDependencies,
r *mcp.CallToolResult,
isPrivate bool,
labelFn func(isPrivate bool) ifc.SecurityLabel,
) *mcp.CallToolResult {
if !shouldAttachIFCLabel(ctx, deps, r) {
return r
}
setIFCLabel(r, labelFn(isPrivate))
return r
}
// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility
// IFC label to a tool result, for handlers that have several return paths and
// want to label each one. The returned function owns the feature-flag gate (so
+1 -1
View File
@@ -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":
+4 -4
View File
@@ -356,7 +356,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) {
assert.Equal(t, "public", ifcMap["confidentiality"])
})
t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) {
t.Run("insiders mode enabled on private repo with get_comments emits private trusted", func(t *testing.T) {
deps := BaseDeps{
Client: mustNewGHClient(t, makeMockClient(true, 0)),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
@@ -370,7 +370,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) {
require.NotNil(t, result.Meta)
ifcMap := unmarshalIFC(t, result.Meta["ifc"])
assert.Equal(t, "untrusted", ifcMap["integrity"])
assert.Equal(t, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
@@ -2729,7 +2729,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{
@@ -2752,7 +2752,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"])
})
}
+157 -88
View File
@@ -65,33 +65,43 @@ type statusUpdateNode struct {
}
}
type projectVisibility struct {
Public githubv4.Boolean
}
type statusUpdateNodeWithProject struct {
statusUpdateNode
Project projectVisibility
}
type statusUpdateConnection struct {
Nodes []statusUpdateNode
PageInfo PageInfoFragment
}
type statusUpdatesProject struct {
Public githubv4.Boolean
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
}
// statusUpdatesUserQuery is the GraphQL query for listing status updates on a user-owned project.
type statusUpdatesUserQuery struct {
User struct {
ProjectV2 struct {
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
} `graphql:"projectV2(number: $projectNumber)"`
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"user(login: $owner)"`
}
// statusUpdatesOrgQuery is the GraphQL query for listing status updates on an org-owned project.
type statusUpdatesOrgQuery struct {
Organization struct {
ProjectV2 struct {
StatusUpdates statusUpdateConnection `graphql:"statusUpdates(first: $first, after: $after, orderBy: {field: CREATED_AT, direction: DESC})"`
} `graphql:"projectV2(number: $projectNumber)"`
ProjectV2 statusUpdatesProject `graphql:"projectV2(number: $projectNumber)"`
} `graphql:"organization(login: $owner)"`
}
// statusUpdateNodeQuery is the GraphQL query for fetching a single status update by node ID.
type statusUpdateNodeQuery struct {
Node struct {
StatusUpdate statusUpdateNode `graphql:"... on ProjectV2StatusUpdate"`
StatusUpdate statusUpdateNodeWithProject `graphql:"... on ProjectV2StatusUpdate"`
} `graphql:"node(id: $id)"`
}
@@ -228,26 +238,18 @@ Use this tool to list projects for a user or organization, or list project field
return utils.NewToolResultError(err.Error()), nil, nil
}
// attachIFC adds the IFC label to a successful result when IFC
// labels are enabled. Project titles, item content, field
// definitions, and status updates are user-authored free text
// (untrusted); confidentiality is conservatively private since the
// project's public flag is not available across every sub-result.
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
}
switch method {
case projectsMethodListProjects:
result, payload, err := listProjects(ctx, client, args, owner, ownerType)
return attachIFC(result), payload, err
default:
result, visibilities, payload, err := listProjects(ctx, client, args, owner, ownerType)
result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelProjectList)
return result, payload, err
case projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates:
// All other methods require project_number and ownerType detection
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
if ownerType == "" {
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
ownerType, err = detectOwnerType(ctx, client, owner, projectNumber)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
@@ -257,20 +259,35 @@ Use this tool to list projects for a user or organization, or list project field
switch method {
case projectsMethodListProjectFields:
result, payload, err := listProjectFields(ctx, client, args, owner, ownerType)
return attachIFC(result), payload, err
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
}
}
return result, payload, err
case projectsMethodListProjectItems:
result, payload, err := listProjectItems(ctx, client, args, owner, ownerType)
return attachIFC(result), payload, err
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
}
}
return result, payload, err
case projectsMethodListProjectStatusUpdates:
gqlClient, err := deps.GetGQLClient(ctx)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
return attachIFC(result), payload, err
result, isPrivate, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
},
)
@@ -346,14 +363,6 @@ Use this tool to get details about individual projects, project fields, and proj
return utils.NewToolResultError(err.Error()), nil, nil
}
// attachIFC adds the IFC label to a successful result when IFC
// labels are enabled. Project data is user-authored free text
// (untrusted); confidentiality is conservatively private since the
// project's public flag is not available across every sub-result.
attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult {
return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false))
}
// Handle get_project_status_update early — it only needs status_update_id
if method == projectsMethodGetProjectStatusUpdate {
statusUpdateID, err := RequiredParam[string](args, "status_update_id")
@@ -364,8 +373,9 @@ Use this tool to get details about individual projects, project fields, and proj
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
return attachIFC(result), payload, err
result, isPrivate, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProjectContent(isPrivate))
return result, payload, err
}
owner, err := RequiredParam[string](args, "owner")
@@ -398,15 +408,22 @@ Use this tool to get details about individual projects, project fields, and proj
switch method {
case projectsMethodGetProject:
result, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
return attachIFC(result), payload, err
result, isPrivate, payload, err := getProject(ctx, client, owner, ownerType, projectNumber)
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelProject(isPrivate))
return result, payload, err
case projectsMethodGetProjectField:
fieldID, err := RequiredBigInt(args, "field_id")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
}
result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID)
return attachIFC(result), payload, err
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProject)
}
}
return result, payload, err
case projectsMethodGetProjectItem:
itemID, err := RequiredBigInt(args, "item_id")
if err != nil {
@@ -417,7 +434,13 @@ Use this tool to get details about individual projects, project fields, and proj
return utils.NewToolResultError(err.Error()), nil, nil
}
result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields)
return attachIFC(result), payload, err
if shouldAttachIFCLabel(ctx, deps, result) {
isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber)
if visibilityErr == nil {
result = attachProjectVisibilityIFCLabel(ctx, deps, result, isPrivate, ifc.LabelProjectContent)
}
}
return result, payload, err
default:
return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil
}
@@ -678,15 +701,15 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool {
// Helper functions for consolidated projects tools
func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, []bool, any, error) {
queryStr, err := OptionalParam[string](args, "query")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
return utils.NewToolResultError(err.Error()), nil, nil, nil
}
pagination, err := extractPaginationOptionsFromArgs(args)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
return utils.NewToolResultError(err.Error()), nil, nil, nil
}
var resp *github.Response
@@ -709,7 +732,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
"failed to list projects",
resp,
err,
), nil, nil
), nil, nil, nil
}
default:
projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts)
@@ -718,7 +741,7 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
"failed to list projects",
resp,
err,
), nil, nil
), nil, nil, nil
}
}
@@ -739,18 +762,18 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
}
return nil, nil, fmt.Errorf("unexpected state in listProjects")
return nil, nil, nil, fmt.Errorf("unexpected state in listProjects")
}
// listProjectsFromBothOwnerTypes fetches projects from both user and org endpoints
// when owner_type is not specified, combining the results with owner_type labels.
func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, any, error) {
func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client, owner string, opts *github.ListProjectsOptions) (*mcp.CallToolResult, []bool, any, error) {
var minimalProjects []MinimalProject
var resp *github.Response
@@ -781,7 +804,7 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client,
// If both failed, return error
if (userErr != nil || userResp == nil || userResp.StatusCode != http.StatusOK) &&
(orgErr != nil || orgResp == nil || orgResp.StatusCode != http.StatusOK) {
return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("failed to list projects for owner '%s': not found as user or organization", owner)), nil, nil, nil
}
response := map[string]any{
@@ -795,9 +818,21 @@ func listProjectsFromBothOwnerTypes(ctx context.Context, client *github.Client,
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, nil, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
return utils.NewToolResultText(string(r)), projectVisibilities(minimalProjects), nil, nil
}
func projectVisibilities(projects []MinimalProject) []bool {
visibilities := make([]bool, 0, len(projects))
for _, project := range projects {
isPrivate := true
if project.Public != nil {
isPrivate = !*project.Public
}
visibilities = append(visibilities, isPrivate)
}
return visibilities
}
func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
@@ -911,40 +946,54 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin
return utils.NewToolResultText(string(r)), nil, nil
}
func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) {
var resp *github.Response
var project *github.ProjectV2
var err error
func fetchProjectV2(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*github.ProjectV2, *github.Response, error) {
if ownerType == "org" {
project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
} else {
project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
return client.Projects.GetOrganizationProject(ctx, owner, projectNumber)
}
return client.Projects.GetUserProject(ctx, owner, projectNumber)
}
// FetchProjectIsPrivate returns whether a GitHub Project is private.
func FetchProjectIsPrivate(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (bool, error) {
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
if resp != nil && resp.Body != nil {
defer func() { _ = resp.Body.Close() }()
}
if err != nil {
return false, err
}
if resp == nil || resp.StatusCode != http.StatusOK {
return false, fmt.Errorf("failed to fetch project visibility")
}
return !project.GetPublic(), nil
}
func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, bool, any, error) {
project, resp, err := fetchProjectV2(ctx, client, owner, ownerType, projectNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
"failed to get project",
resp,
err,
), nil, nil
), false, nil, nil
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, fmt.Errorf("failed to read response body: %w", err)
return nil, false, nil, fmt.Errorf("failed to read response body: %w", err)
}
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), nil, nil
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get project", resp, body), false, nil, nil
}
minimalProject := convertToMinimalProject(project)
r, err := json.Marshal(minimalProject)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
return utils.NewToolResultText(string(r)), !project.GetPublic(), nil, nil
}
func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) {
@@ -1274,19 +1323,19 @@ func createProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client,
}
// listProjectStatusUpdates lists status updates for a project via GraphQL.
func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) {
func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, bool, any, error) {
if ownerType != "user" && ownerType != "org" {
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), false, nil, nil
}
projectNumber, err := RequiredInt(args, "project_number")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
return utils.NewToolResultError(err.Error()), false, nil, nil
}
perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage)
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
return utils.NewToolResultError(err.Error()), false, nil, nil
}
if perPage > MaxProjectsPerPage {
perPage = MaxProjectsPerPage
@@ -1297,7 +1346,7 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
afterCursor, err := OptionalParam[string](args, "after")
if err != nil {
return utils.NewToolResultError(err.Error()), nil, nil
return utils.NewToolResultError(err.Error()), false, nil, nil
}
vars := map[string]any{
@@ -1313,21 +1362,26 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
var nodes []statusUpdateNode
var pi PageInfoFragment
var isPrivate bool
if ownerType == "org" {
var q statusUpdatesOrgQuery
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
}
nodes = q.Organization.ProjectV2.StatusUpdates.Nodes
pi = q.Organization.ProjectV2.StatusUpdates.PageInfo
project := q.Organization.ProjectV2
nodes = project.StatusUpdates.Nodes
pi = project.StatusUpdates.PageInfo
isPrivate = !bool(project.Public)
} else {
var q statusUpdatesUserQuery
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateListFailedError, err)), false, nil, nil
}
nodes = q.User.ProjectV2.StatusUpdates.Nodes
pi = q.User.ProjectV2.StatusUpdates.PageInfo
project := q.User.ProjectV2
nodes = project.StatusUpdates.Nodes
pi = project.StatusUpdates.PageInfo
isPrivate = !bool(project.Public)
}
updates := make([]MinimalProjectStatusUpdate, 0, len(nodes))
@@ -1347,33 +1401,34 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a
r, err := json.Marshal(response)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
}
// getProjectStatusUpdate fetches a single status update by its node ID via GraphQL.
func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, any, error) {
func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, statusUpdateID string) (*mcp.CallToolResult, bool, any, error) {
var q statusUpdateNodeQuery
vars := map[string]any{
"id": githubv4.ID(statusUpdateID),
}
if err := gqlClient.Query(ctx, &q, vars); err != nil {
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("%s: %v", ProjectStatusUpdateGetFailedError, err)), false, nil, nil
}
if q.Node.StatusUpdate.ID == nil || q.Node.StatusUpdate.ID == "" {
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), nil, nil
return utils.NewToolResultError(fmt.Sprintf("%s: node is not a ProjectV2StatusUpdate or was not found", ProjectStatusUpdateGetFailedError)), false, nil, nil
}
update := convertToMinimalStatusUpdate(q.Node.StatusUpdate)
update := convertToMinimalStatusUpdate(q.Node.StatusUpdate.statusUpdateNode)
isPrivate := !bool(q.Node.StatusUpdate.Project.Public)
r, err := json.Marshal(update)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
return nil, false, nil, fmt.Errorf("failed to marshal response: %w", err)
}
return utils.NewToolResultText(string(r)), nil, nil
return utils.NewToolResultText(string(r)), isPrivate, nil, nil
}
// validateAndConvertToInt64 ensures the value is a number and converts it to int64.
@@ -1761,11 +1816,25 @@ type ProjectV2IterationFieldIterationInput struct {
Title githubv4.String `json:"title"`
}
// detectOwnerType attempts to detect the owner type by trying both user and org
// Returns the detected type ("user" or "org") and any error encountered
// detectOwnerType attempts to detect whether the project owner is a user or org.
// It first asks GitHub for the account type, then falls back to project probes
// for older or mocked clients where the account type is unavailable.
func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) {
user, resp, err := client.Users.Get(ctx, owner)
if resp != nil && resp.Body != nil {
_ = resp.Body.Close()
}
if err == nil && resp != nil && resp.StatusCode == http.StatusOK {
switch user.GetType() {
case "User":
return "user", nil
case "Organization":
return "org", nil
}
}
// Try user first (more common for personal projects)
_, resp, err := client.Projects.GetUserProject(ctx, owner, projectNumber)
_, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber)
if err == nil && resp.StatusCode == http.StatusOK {
_ = resp.Body.Close()
return "user", nil
+265
View File
@@ -366,6 +366,196 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) {
})
}
func Test_detectOwnerType(t *testing.T) {
t.Run("uses organization account type", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
"login": "github",
"type": "Organization",
}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "github", 1)
require.NoError(t, err)
assert.Equal(t, "org", ownerType)
})
t.Run("uses user account type", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersByUsername: mockResponse(t, http.StatusOK, map[string]any{
"login": "octocat",
"type": "User",
}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "octocat", 1)
require.NoError(t, err)
assert.Equal(t, "user", ownerType)
})
t.Run("falls back to project probes", func(t *testing.T) {
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{"id": 1}),
})
client := mustNewGHClient(t, mockedClient)
ownerType, err := detectOwnerType(context.Background(), client, "octo-org", 1)
require.NoError(t, err)
assert.Equal(t, "org", ownerType)
})
}
func Test_ProjectsList_IFC_InsidersMode(t *testing.T) {
toolDef := ProjectsList(translations.NullTranslationHelper)
t.Run("list_projects joins returned project visibilities", func(t *testing.T) {
projects := []map[string]any{
{"id": 1, "node_id": "NODE1", "title": "Public Project", "public": true},
{"id": 2, "node_id": "NODE2", "title": "Private Project", "public": false},
}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2: mockResponse(t, http.StatusOK, projects),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_projects",
"owner": "octo-org",
"owner_type": "org",
})
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, "private", ifcMap["confidentiality"])
})
t.Run("list_project_fields uses project metadata label", func(t *testing.T) {
fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}}
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_fields",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
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, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("list_project_items uses project content label", func(t *testing.T) {
items := []map[string]any{verbosePullRequestProjectItemFixture()}
project := map[string]any{"id": 1, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items),
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_items",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
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, "private", ifcMap["confidentiality"])
})
t.Run("list_project_status_updates uses GraphQL project visibility", func(t *testing.T) {
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
statusUpdatesOrgQuery{},
map[string]any{
"owner": githubv4.String("octo-org"),
"projectNumber": githubv4.Int(1),
"first": githubv4.Int(50),
"after": (*githubv4.String)(nil),
},
githubv4mock.DataResponse(map[string]any{
"organization": map[string]any{
"projectV2": map[string]any{
"public": true,
"statusUpdates": map[string]any{
"nodes": []map[string]any{},
"pageInfo": map[string]any{
"hasNextPage": false,
"hasPreviousPage": false,
"startCursor": "",
"endCursor": "",
},
},
},
},
}),
),
)
deps := BaseDeps{
Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})),
GQLClient: githubv4.NewClient(gqlMockedClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "list_project_status_updates",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
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, "public", ifcMap["confidentiality"])
})
}
func Test_ProjectsGet(t *testing.T) {
// Verify tool definition once
toolDef := ProjectsGet(translations.NullTranslationHelper)
@@ -438,6 +628,79 @@ func Test_ProjectsGet_GetProject(t *testing.T) {
})
}
func Test_ProjectsGet_IFC_InsidersMode(t *testing.T) {
toolDef := ProjectsGet(translations.NullTranslationHelper)
t.Run("get_project uses project metadata label", func(t *testing.T) {
project := map[string]any{"id": 123, "node_id": "NODE1", "title": "Private Project", "public": false}
mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project),
})
client := mustNewGHClient(t, mockedClient)
deps := BaseDeps{
Client: client,
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project",
"owner": "octo-org",
"owner_type": "org",
"project_number": float64(1),
})
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, "trusted", ifcMap["integrity"])
assert.Equal(t, "private", ifcMap["confidentiality"])
})
t.Run("get_project_status_update uses GraphQL project visibility", func(t *testing.T) {
gqlMockedClient := githubv4mock.NewMockedHTTPClient(
githubv4mock.NewQueryMatcher(
statusUpdateNodeQuery{},
map[string]any{
"id": githubv4.ID("SU_abc123"),
},
githubv4mock.DataResponse(map[string]any{
"node": map[string]any{
"id": "SU_abc123",
"body": "On track",
"status": "ON_TRACK",
"createdAt": "2026-01-15T10:00:00Z",
"startDate": "2026-01-01",
"targetDate": "2026-03-01",
"creator": map[string]any{"login": "octocat"},
"project": map[string]any{"public": true},
},
}),
),
)
deps := BaseDeps{
GQLClient: githubv4.NewClient(gqlMockedClient),
featureChecker: featureCheckerFor(FeatureFlagIFCLabels),
}
handler := toolDef.Handler(deps)
request := createMCPRequest(map[string]any{
"method": "get_project_status_update",
"status_update_id": "SU_abc123",
})
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, "public", ifcMap["confidentiality"])
})
}
func Test_ProjectsGet_GetProjectField(t *testing.T) {
toolDef := ProjectsGet(translations.NullTranslationHelper)
@@ -1091,6 +1354,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) {
githubv4mock.DataResponse(map[string]any{
"user": map[string]any{
"projectV2": map[string]any{
"public": false,
"statusUpdates": map[string]any{
"nodes": []map[string]any{
{
@@ -1161,6 +1425,7 @@ func Test_ProjectsGet_GetProjectStatusUpdate(t *testing.T) {
"startDate": "2026-01-01",
"targetDate": "2026-03-01",
"creator": map[string]any{"login": "octocat"},
"project": map[string]any{"public": false},
},
}),
),
+2 -2
View File
@@ -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 {
@@ -1436,7 +1436,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
})
}
+4 -3
View File
@@ -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)
+9 -8
View File
@@ -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 {
+1 -1
View File
@@ -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},
+87 -35
View File
@@ -76,10 +76,23 @@ 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 PrivateUntrusted()
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. Public repository content is untrusted because it may be authored by
// outside contributors. Private repository content is trusted because users who
// can read it are trusted collaborators.
func LabelRepoUserContent(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateTrusted()
}
return PublicUntrusted()
}
@@ -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
@@ -261,47 +285,75 @@ func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) Security
// LabelGist returns the IFC label for gist content.
//
// Integrity is untrusted: gist contents are arbitrary user-authored text.
// Confidentiality derives from the gist's own visibility rather than any
// repository — public gists are universally readable, while secret gists are
// restricted to those who hold the gist URL (modeled with the opaque "private"
// marker).
func LabelGist(isPublic bool) SecurityLabel {
if isPublic {
return PublicUntrusted()
}
return PrivateUntrusted()
// Confidentiality is public because secret gists are URL-accessible and cannot
// be modeled as private to a GitHub reader set.
func LabelGist() SecurityLabel {
return PublicUntrusted()
}
// LabelGistList returns the IFC label for a list of gists belonging to a user,
// joining the per-gist confidentiality across the result set.
//
// Integrity is untrusted (user-authored content). Confidentiality follows the
// IFC meet: if any gist in the result is secret the joined label is private;
// otherwise public. An empty result is treated as public-untrusted.
// Integrity is untrusted (user-authored content). Confidentiality is public
// because even secret gists are URL-accessible.
//
// See LabelSearchIssues for why list results carry a single joined label
// rather than one label per item.
func LabelGistList(gistVisibilities []bool) SecurityLabel {
for _, isPublic := range gistVisibilities {
if !isPublic {
return PrivateUntrusted()
}
func LabelGistList() SecurityLabel {
return PublicUntrusted()
}
// LabelProject returns the IFC label for GitHub Project metadata (Projects v2),
// such as get_project results and project field definitions.
//
// Public project metadata can contain public user-authored text, so it is
// untrusted. Private project metadata is treated as trusted
// collaborator-controlled data.
//
// Confidentiality derives from the project's own privacy — private projects
// restrict the reader set, while public projects are universally readable.
func LabelProject(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateTrusted()
}
return PublicUntrusted()
}
// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its
// items, status updates, and field definitions.
// LabelProjectList returns the IFC label for a list_projects result, joining
// the per-project labels across every returned project.
//
// Integrity is untrusted: project titles, item content, and status update
// bodies are user-authored free text. Confidentiality derives from the
// project's own public flag — public projects are universally readable, while
// private projects restrict the reader set.
func LabelProject(isPublic bool) SecurityLabel {
if isPublic {
// Public-only results are untrusted and public. All-private results are trusted
// and private. Mixed public/private results are untrusted and private: public
// items keep the joined payload's integrity untrusted, while private items keep
// the joined payload's confidentiality private.
func LabelProjectList(projectVisibilities []bool) SecurityLabel {
var anyPrivate, anyPublic bool
for _, isPrivate := range projectVisibilities {
if isPrivate {
anyPrivate = true
} else {
anyPublic = true
}
}
switch {
case anyPrivate && anyPublic:
return PrivateUntrusted()
case anyPrivate:
return PrivateTrusted()
default:
return PublicUntrusted()
}
return PrivateUntrusted()
}
// LabelProjectContent returns the IFC label for project results that can include
// item content, field values, or status update bodies. These can aggregate
// content from a variety of sources, so integrity remains untrusted even when
// the project is private.
func LabelProjectContent(isPrivate bool) SecurityLabel {
if isPrivate {
return PrivateUntrusted()
}
return PublicUntrusted()
}
// LabelTeam returns the IFC label for organization team membership data
+118 -45
View File
@@ -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 trusted and private", func(t *testing.T) {
t.Parallel()
label := LabelRepoUserContent(true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})
}
func TestLabelSearchIssues(t *testing.T) {
t.Parallel()
tests := []struct {
name string
visibilities []bool
visibilities []bool // true == private
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)
})
}
@@ -208,56 +250,25 @@ func TestLabelGist(t *testing.T) {
t.Run("public gist is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelGist(true)
label := LabelGist()
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("secret gist is untrusted and private", func(t *testing.T) {
t.Run("secret gist is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelGist(false)
label := LabelGist()
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
}
func TestLabelGistList(t *testing.T) {
t.Parallel()
tests := []struct {
name string
visibilities []bool // true == public
wantConfidential Confidentiality
}{
{
name: "empty result is treated as public",
wantConfidential: ConfidentialityPublic,
},
{
name: "all public gists stay public",
visibilities: []bool{true, true},
wantConfidential: ConfidentialityPublic,
},
{
name: "any secret gist flips to private",
visibilities: []bool{true, false, true},
wantConfidential: ConfidentialityPrivate,
},
{
name: "all secret gists stay private",
visibilities: []bool{false, false},
wantConfidential: ConfidentialityPrivate,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
label := LabelGistList(tc.visibilities)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, tc.wantConfidential, label.Confidentiality)
})
}
label := LabelGistList()
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
}
func TestLabelProject(t *testing.T) {
@@ -265,14 +276,76 @@ func TestLabelProject(t *testing.T) {
t.Run("public project is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelProject(true)
label := LabelProject(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("private project is untrusted and private", func(t *testing.T) {
t.Run("private project metadata is trusted and private", func(t *testing.T) {
t.Parallel()
label := LabelProject(false)
label := LabelProject(true)
assert.Equal(t, IntegrityTrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})
}
func TestLabelProjectList(t *testing.T) {
t.Parallel()
tests := []struct {
name string
visibilities []bool // true == private
wantIntegrity Integrity
wantConfidential Confidentiality
}{
{
name: "empty result is treated as public",
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,
},
{
name: "all public projects stay public",
visibilities: []bool{false, false},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPublic,
},
{
name: "mixed public and private projects become untrusted private",
visibilities: []bool{false, true},
wantIntegrity: IntegrityUntrusted,
wantConfidential: ConfidentialityPrivate,
},
{
name: "all private projects stay trusted private",
visibilities: []bool{true, true},
wantIntegrity: IntegrityTrusted,
wantConfidential: ConfidentialityPrivate,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
label := LabelProjectList(tc.visibilities)
assert.Equal(t, tc.wantIntegrity, label.Integrity)
assert.Equal(t, tc.wantConfidential, label.Confidentiality)
})
}
}
func TestLabelProjectContent(t *testing.T) {
t.Parallel()
t.Run("public project content is untrusted and public", func(t *testing.T) {
t.Parallel()
label := LabelProjectContent(false)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPublic, label.Confidentiality)
})
t.Run("private project content is untrusted and private", func(t *testing.T) {
t.Parallel()
label := LabelProjectContent(true)
assert.Equal(t, IntegrityUntrusted, label.Integrity)
assert.Equal(t, ConfidentialityPrivate, label.Confidentiality)
})