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/gists.go b/pkg/github/gists.go index 2eacabe4b..9c319176b 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -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 }, ) diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 7f86c8b98..319f5f0e4 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -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" diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go index a1c6fea36..f10b517c5 100644 --- a/pkg/github/ifc_labels.go +++ b/pkg/github/ifc_labels.go @@ -97,12 +97,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, 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..680468ef1 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -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"]) }) @@ -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/projects.go b/pkg/github/projects.go index 85774490d..a5f4b39d4 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -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,25 @@ 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 + result = attachProjectVisibilityIFCLabel(ctx, deps, client, owner, ownerType, projectNumber, result, ifc.LabelProject) + return result, payload, err case projectsMethodListProjectItems: result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) - return attachIFC(result), payload, err + result = attachProjectVisibilityIFCLabel(ctx, deps, client, owner, ownerType, projectNumber, result, 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 +353,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 +363,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 +398,17 @@ 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 + result = attachProjectVisibilityIFCLabel(ctx, deps, client, owner, ownerType, projectNumber, result, ifc.LabelProject) + return result, payload, err case projectsMethodGetProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -417,7 +419,8 @@ 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 + result = attachProjectVisibilityIFCLabel(ctx, deps, client, owner, ownerType, projectNumber, result, ifc.LabelProjectContent) + return result, payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -678,15 +681,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 +712,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 +721,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 +742,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 +784,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 +798,41 @@ 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 attachProjectVisibilityIFCLabel( + ctx context.Context, + deps ToolDependencies, + client *github.Client, + owner, ownerType string, + projectNumber int, + r *mcp.CallToolResult, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + isPrivate, err := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) + if err != nil { + return r + } + setIFCLabel(r, labelFn(isPrivate)) + return r } 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 diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index ad5ce6db8..05914975a 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -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}, }, }), ), 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..f23383ce7 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -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 diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index 90788a8cb..f4b25c187 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 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,44 +250,75 @@ 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() + label := LabelGistList() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) +} + +func TestLabelProject(t *testing.T) { + t.Parallel() + + t.Run("public project is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelProject(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private project metadata is trusted and private", func(t *testing.T) { + t.Parallel() + 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 == public + visibilities []bool // true == private + wantIntegrity Integrity wantConfidential Confidentiality }{ { name: "empty result is treated as public", + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "all public gists stay public", - visibilities: []bool{true, true}, + name: "all public projects stay public", + visibilities: []bool{false, false}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPublic, }, { - name: "any secret gist flips to private", - visibilities: []bool{true, false, true}, + name: "mixed public and private projects become untrusted private", + visibilities: []bool{false, true}, + wantIntegrity: IntegrityUntrusted, wantConfidential: ConfidentialityPrivate, }, { - name: "all secret gists stay private", - visibilities: []bool{false, false}, + name: "all private projects stay trusted private", + visibilities: []bool{true, true}, + wantIntegrity: IntegrityTrusted, wantConfidential: ConfidentialityPrivate, }, } @@ -253,26 +326,26 @@ func TestLabelGistList(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { t.Parallel() - label := LabelGistList(tc.visibilities) - assert.Equal(t, IntegrityUntrusted, label.Integrity) + label := LabelProjectList(tc.visibilities) + assert.Equal(t, tc.wantIntegrity, label.Integrity) assert.Equal(t, tc.wantConfidential, label.Confidentiality) }) } } -func TestLabelProject(t *testing.T) { +func TestLabelProjectContent(t *testing.T) { t.Parallel() - t.Run("public project is untrusted and public", func(t *testing.T) { + t.Run("public project content is untrusted and public", func(t *testing.T) { t.Parallel() - label := LabelProject(true) + label := LabelProjectContent(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 content is untrusted and private", func(t *testing.T) { t.Parallel() - label := LabelProject(false) + label := LabelProjectContent(true) assert.Equal(t, IntegrityUntrusted, label.Integrity) assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) })