diff --git a/pkg/github/issues.go b/pkg/github/issues.go index bbccdc35d8..0f12804a59 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -734,18 +734,8 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, } if flags.LockdownMode { - if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") - } - login := issue.GetUser().GetLogin() - if login != "" { - isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil - } - if !isSafeContent { - return utils.NewToolResultError("access to issue details is restricted by lockdown mode"), nil - } + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, issue.GetUser().GetLogin(), lockdownIssueRestrictedMessage); restricted != nil || err != nil { + return restricted, err } } diff --git a/pkg/github/lockdown.go b/pkg/github/lockdown.go new file mode 100644 index 0000000000..1d3a687028 --- /dev/null +++ b/pkg/github/lockdown.go @@ -0,0 +1,38 @@ +package github + +import ( + "context" + "fmt" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/github/github-mcp-server/pkg/lockdown" + "github.com/github/github-mcp-server/pkg/utils" +) + +// Restriction messages returned when lockdown mode withholds content from a read tool. +const ( + lockdownPullRequestRestrictedMessage = "access to pull request is restricted by lockdown mode" + lockdownIssueRestrictedMessage = "access to issue details is restricted by lockdown mode" +) + +// authorLockdownResult returns a restricted tool result when content authored by +// authorLogin cannot be surfaced for owner/repo under lockdown mode, and (nil, nil) +// when access is permitted. It should only be called when lockdown mode is enabled. +// It fails closed: a missing cache, an empty author, or a lookup error denies access. +func authorLockdownResult(ctx context.Context, cache *lockdown.RepoAccessCache, owner, repo, authorLogin, restrictedMessage string) (*mcp.CallToolResult, error) { + if cache == nil { + return nil, fmt.Errorf("lockdown cache is not configured") + } + if authorLogin == "" { + return utils.NewToolResultError(restrictedMessage), nil + } + isSafeContent, err := cache.IsSafeContent(ctx, authorLogin, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + } + if !isSafeContent { + return utils.NewToolResultError(restrictedMessage), nil + } + return nil, nil +} diff --git a/pkg/github/lockdown_test.go b/pkg/github/lockdown_test.go new file mode 100644 index 0000000000..efba381147 --- /dev/null +++ b/pkg/github/lockdown_test.go @@ -0,0 +1,38 @@ +package github + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_authorLockdownResult(t *testing.T) { + t.Parallel() + + t.Run("missing cache returns error", func(t *testing.T) { + result, err := authorLockdownResult(context.Background(), nil, "owner", "repo", "author", lockdownIssueRestrictedMessage) + require.Error(t, err) + assert.Nil(t, result) + }) + + t.Run("empty author fails closed", func(t *testing.T) { + cache := stubRepoAccessCache(nil, time.Minute) + result, err := authorLockdownResult(context.Background(), cache, "owner", "repo", "", lockdownIssueRestrictedMessage) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, lockdownIssueRestrictedMessage) + }) + + t.Run("lookup failure returns tool-result error", func(t *testing.T) { + cache := stubRepoAccessCache(nil, time.Minute) + result, err := authorLockdownResult(context.Background(), cache, "owner", "repo", "author", lockdownIssueRestrictedMessage) + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to check lockdown mode") + }) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 942cfd3a91..92cf156b80 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -196,19 +196,8 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende } if ff.LockdownMode { - if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") - } - login := pr.GetUser().GetLogin() - if login != "" { - isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) - if err != nil { - return nil, fmt.Errorf("failed to check content removal: %w", err) - } - - if !isSafeContent { - return utils.NewToolResultError("access to pull request is restricted by lockdown mode"), nil - } + if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { + return restricted, err } } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index c032502c97..1076befd58 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -53,12 +53,14 @@ func Test_GetPullRequest(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedPR *github.PullRequest - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedPR *github.PullRequest + expectedErrMsg string + lockdownEnabled bool + restPermission string }{ { name: "successful PR fetch", @@ -91,6 +93,38 @@ func Test_GetPullRequest(t *testing.T) { expectError: true, expectedErrMsg: "failed to get pull request", }, + { + name: "lockdown enabled - user lacks push access", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), + requestArgs: map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: true, + expectedErrMsg: "access to pull request is restricted by lockdown mode", + lockdownEnabled: true, + restPermission: "read", + }, + { + name: "lockdown enabled - private repository", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), + requestArgs: map[string]any{ + "method": "get", + "owner": "owner2", + "repo": "repo2", + "pullNumber": float64(42), + }, + expectError: false, + expectedPR: mockPR, + lockdownEnabled: true, + restPermission: "none", + }, } for _, tc := range tests { @@ -98,11 +132,17 @@ func Test_GetPullRequest(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + + var restClient *github.Client + if tc.restPermission != "" { + restClient = mockRESTPermissionServer(t, tc.restPermission, nil) + } + deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps)