fb7cbc8b85
* Annotate read tools with ifc labels * Dont automatically enable IFCLabels in insiders mode * ifc: don't label unpublished repo advisories as public Repository security advisory listings can include draft/triage/closed advisories (via the state filter), which are not world-readable even on a public repository. Deriving confidentiality from repo visibility alone under-classified those results as public. LabelRepositorySecurityAdvisory now takes an allPublished flag and only returns a public label when the repo is public AND every returned advisory is published; otherwise it is private. list_repository_security_advisories computes allPublished from the response state; the org-wide listing stays private-untrusted. Adds unit + handler regression tests covering the draft-advisory-on-public-repo case. Addresses PR review feedback. * ifc: fix confidentiality under-classification in releases, collaborators, get_me Audit for the same bug class as the repo-advisory fix (confidentiality derived from a coarse signal that misses access-restricted items) found three more under-classifications: - Releases (list_releases, get_latest_release, get_release_by_tag): draft releases are visible only to push-access users and are not world-readable even on a public repo. New LabelRelease(isPrivate, hasDraft) returns public only for a non-draft release on a public repo; handlers compute hasDraft from the response (Draft flag / per-item scan). - list_repository_collaborators: a collaborator roster requires push access to list, so it is never world-readable, not even on a public repo. New LabelCollaboratorRoster() is always PrivateTrusted (mirrors LabelTeam), replacing the repo-visibility-derived label. - get_me: the result includes private_gists / total_private_repos / owned_private_repos, which are not part of the public profile. LabelGetMe is now PrivateTrusted instead of PublicTrusted. Verified the remaining public-capable labels are sound: Actions logs are world-readable on public repos; branches/tags are public metadata; gist, project, search, and starred-repo labels read per-item visibility and join. Adds ifc unit tests for the new/changed labels and a get_release_by_tag handler regression test (draft on public repo -> private); updates the get_me handler test to assert private. * ifc: document why list results use one joined label, not per-item Explain on LabelSearchIssues (and cross-ref from LabelGistList) that a tool result is delivered as one opaque payload and the IFC engine makes one allow/deny decision per flow at egress, so the only sound bound for a list is the meet of every item's label. Per-item labels would only be load-bearing if the engine could partition a result and route items to different sinks; until then they would invite unsafe declassification of a public item that arrived alongside private data. Doc-only change.
276 lines
10 KiB
Go
276 lines
10 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
ghcontext "github.com/github/github-mcp-server/pkg/context"
|
|
ghErrors "github.com/github/github-mcp-server/pkg/errors"
|
|
"github.com/github/github-mcp-server/pkg/ifc"
|
|
"github.com/github/github-mcp-server/pkg/inventory"
|
|
"github.com/github/github-mcp-server/pkg/scopes"
|
|
"github.com/github/github-mcp-server/pkg/translations"
|
|
"github.com/github/github-mcp-server/pkg/utils"
|
|
"github.com/google/jsonschema-go/jsonschema"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
"github.com/shurcooL/githubv4"
|
|
)
|
|
|
|
// IssueField represents a repository issue field definition.
|
|
type IssueField struct {
|
|
ID string `json:"id"`
|
|
DatabaseID int64 `json:"full_database_id,omitempty"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
DataType string `json:"data_type"`
|
|
Visibility string `json:"visibility"`
|
|
Options []IssueSingleSelectFieldOption `json:"options,omitempty"`
|
|
}
|
|
|
|
// IssueSingleSelectFieldOption represents an option for a single_select issue field.
|
|
type IssueSingleSelectFieldOption struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description,omitempty"`
|
|
Color string `json:"color"`
|
|
Priority *int `json:"priority,omitempty"`
|
|
}
|
|
|
|
// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union.
|
|
// Only the fragment matching __typename is populated; read from the matching fragment.
|
|
// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because
|
|
// shurcooL/githubv4 does not support interface fragments at the top level of a union.
|
|
type issueFieldNode struct {
|
|
TypeName githubv4.String `graphql:"__typename"`
|
|
IssueFieldText struct {
|
|
ID githubv4.ID
|
|
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
|
|
Name githubv4.String
|
|
Description githubv4.String
|
|
DataType githubv4.String
|
|
Visibility githubv4.String
|
|
} `graphql:"... on IssueFieldText"`
|
|
IssueFieldNumber struct {
|
|
ID githubv4.ID
|
|
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
|
|
Name githubv4.String
|
|
Description githubv4.String
|
|
DataType githubv4.String
|
|
Visibility githubv4.String
|
|
} `graphql:"... on IssueFieldNumber"`
|
|
IssueFieldDate struct {
|
|
ID githubv4.ID
|
|
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
|
|
Name githubv4.String
|
|
Description githubv4.String
|
|
DataType githubv4.String
|
|
Visibility githubv4.String
|
|
} `graphql:"... on IssueFieldDate"`
|
|
IssueFieldSingleSelect struct {
|
|
ID githubv4.ID
|
|
FullDatabaseID githubv4.String `graphql:"fullDatabaseId"`
|
|
Name githubv4.String
|
|
Description githubv4.String
|
|
DataType githubv4.String
|
|
Visibility githubv4.String
|
|
Options []struct {
|
|
ID githubv4.ID
|
|
Name githubv4.String
|
|
Description githubv4.String
|
|
Color githubv4.String
|
|
Priority *int
|
|
}
|
|
} `graphql:"... on IssueFieldSingleSelect"`
|
|
}
|
|
|
|
// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository.
|
|
type issueFieldsRepoQuery struct {
|
|
Repository struct {
|
|
IssueFields struct {
|
|
Nodes []issueFieldNode
|
|
} `graphql:"issueFields(first: 100)"`
|
|
} `graphql:"repository(owner: $owner, name: $name)"`
|
|
}
|
|
|
|
// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization.
|
|
type issueFieldsOrgQuery struct {
|
|
Organization struct {
|
|
IssueFields struct {
|
|
Nodes []issueFieldNode
|
|
} `graphql:"issueFields(first: 100)"`
|
|
} `graphql:"organization(login: $login)"`
|
|
}
|
|
|
|
// ListIssueFields creates a tool to list issue field definitions for a repository or organization.
|
|
// Gated by FeatureFlagIssueFields: the tool is only registered when the flag is on.
|
|
func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool {
|
|
st := NewTool(
|
|
ToolsetMetadataIssues,
|
|
mcp.Tool{
|
|
Name: "list_issue_fields",
|
|
Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."),
|
|
Annotations: &mcp.ToolAnnotations{
|
|
Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"),
|
|
ReadOnlyHint: true,
|
|
},
|
|
InputSchema: &jsonschema.Schema{
|
|
Type: "object",
|
|
Properties: map[string]*jsonschema.Schema{
|
|
"owner": {
|
|
Type: "string",
|
|
Description: "The account owner of the repository or organization. The name is not case sensitive.",
|
|
},
|
|
"repo": {
|
|
Type: "string",
|
|
Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.",
|
|
},
|
|
},
|
|
Required: []string{"owner"},
|
|
},
|
|
},
|
|
[]scopes.Scope{scopes.Repo, scopes.ReadOrg},
|
|
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
|
|
owner, err := RequiredParam[string](args, "owner")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
repo, err := OptionalParam[string](args, "repo")
|
|
if err != nil {
|
|
return utils.NewToolResultError(err.Error()), nil, nil
|
|
}
|
|
|
|
gqlClient, err := deps.GetGQLClient(ctx)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil
|
|
}
|
|
|
|
fields, err := fetchIssueFields(ctx, gqlClient, owner, repo)
|
|
if err != nil {
|
|
return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil
|
|
}
|
|
|
|
r, err := json.Marshal(fields)
|
|
if err != nil {
|
|
return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil
|
|
}
|
|
|
|
result := utils.NewToolResultText(string(r))
|
|
// Issue field definitions are repo/org structural metadata
|
|
// (trusted). When scoped to a specific repo, confidentiality
|
|
// follows that repo's visibility; for an org-level lookup (no
|
|
// repo) it is conservatively treated as private.
|
|
if repo == "" {
|
|
result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true))
|
|
} else {
|
|
result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata)
|
|
}
|
|
return result, nil, nil
|
|
})
|
|
st.FeatureFlagEnable = FeatureFlagIssueFields
|
|
return st
|
|
}
|
|
|
|
// fetchIssueFields returns the issue field definitions for the given owner.
|
|
// If repo is provided, fields are scoped to that repository (inherited from its
|
|
// organization); otherwise fields are returned directly from the organization.
|
|
func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) {
|
|
ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields")
|
|
if repo != "" {
|
|
var query issueFieldsRepoQuery
|
|
vars := map[string]any{
|
|
"owner": githubv4.String(owner),
|
|
"name": githubv4.String(repo),
|
|
}
|
|
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
|
|
return nil, err
|
|
}
|
|
return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil
|
|
}
|
|
|
|
var query issueFieldsOrgQuery
|
|
vars := map[string]any{
|
|
"login": githubv4.String(owner),
|
|
}
|
|
if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil {
|
|
return nil, err
|
|
}
|
|
return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil
|
|
}
|
|
|
|
// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values.
|
|
// Read from the fragment matching __typename; the other fragments are zero-valued.
|
|
func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField {
|
|
fields := make([]IssueField, 0, len(nodes))
|
|
for _, node := range nodes {
|
|
var f IssueField
|
|
switch string(node.TypeName) {
|
|
case "IssueFieldSingleSelect":
|
|
opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options))
|
|
for _, o := range node.IssueFieldSingleSelect.Options {
|
|
opts = append(opts, IssueSingleSelectFieldOption{
|
|
ID: fmt.Sprintf("%v", o.ID),
|
|
Name: string(o.Name),
|
|
Description: string(o.Description),
|
|
Color: string(o.Color),
|
|
Priority: o.Priority,
|
|
})
|
|
}
|
|
f = IssueField{
|
|
ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID),
|
|
DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)),
|
|
Name: string(node.IssueFieldSingleSelect.Name),
|
|
Description: string(node.IssueFieldSingleSelect.Description),
|
|
DataType: string(node.IssueFieldSingleSelect.DataType),
|
|
Visibility: string(node.IssueFieldSingleSelect.Visibility),
|
|
Options: opts,
|
|
}
|
|
case "IssueFieldText":
|
|
f = IssueField{
|
|
ID: fmt.Sprintf("%v", node.IssueFieldText.ID),
|
|
DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)),
|
|
Name: string(node.IssueFieldText.Name),
|
|
Description: string(node.IssueFieldText.Description),
|
|
DataType: string(node.IssueFieldText.DataType),
|
|
Visibility: string(node.IssueFieldText.Visibility),
|
|
}
|
|
case "IssueFieldNumber":
|
|
f = IssueField{
|
|
ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID),
|
|
DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)),
|
|
Name: string(node.IssueFieldNumber.Name),
|
|
Description: string(node.IssueFieldNumber.Description),
|
|
DataType: string(node.IssueFieldNumber.DataType),
|
|
Visibility: string(node.IssueFieldNumber.Visibility),
|
|
}
|
|
case "IssueFieldDate":
|
|
f = IssueField{
|
|
ID: fmt.Sprintf("%v", node.IssueFieldDate.ID),
|
|
DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)),
|
|
Name: string(node.IssueFieldDate.Name),
|
|
Description: string(node.IssueFieldDate.Description),
|
|
DataType: string(node.IssueFieldDate.DataType),
|
|
Visibility: string(node.IssueFieldDate.Visibility),
|
|
}
|
|
default:
|
|
continue
|
|
}
|
|
fields = append(fields, f)
|
|
}
|
|
return fields
|
|
}
|
|
|
|
// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64.
|
|
// Returns 0 if the string is empty or cannot be parsed.
|
|
func parseFullDatabaseID(s string) int64 {
|
|
if s == "" {
|
|
return 0
|
|
}
|
|
n, err := strconv.ParseInt(s, 10, 64)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
return n
|
|
}
|