Skip to content

Commit 676a5b2

Browse files
fix(repos): expire deletion confirmations
Bind sealed repository deletion state to the immutable repository ID and a ten-minute expiry. Re-check identity before deletion so replay cannot affect a recreated repository. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4b04480c-c2e9-483e-9b0f-34830b76a2f8
1 parent 14aea52 commit 676a5b2

2 files changed

Lines changed: 140 additions & 4 deletions

File tree

pkg/github/repositories.go

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"slices"
1111
"strconv"
1212
"strings"
13+
"time"
1314

1415
ghErrors "github.com/github/github-mcp-server/pkg/errors"
1516
"github.com/github/github-mcp-server/pkg/ifc"
@@ -708,11 +709,14 @@ const (
708709
DeleteRepositoryToolName = "delete_repository"
709710
deleteRepositoryConfirmationID = "delete_repository_confirmation"
710711
deleteRepositoryConfirmationField = "repository_name"
712+
deleteRepositoryConfirmationTTL = 10 * time.Minute
711713
)
712714

713715
type deleteRepositoryState struct {
714-
Owner string `json:"owner"`
715-
Repo string `json:"repo"`
716+
Owner string `json:"owner"`
717+
Repo string `json:"repo"`
718+
RepositoryID int64 `json:"repository_id"`
719+
ExpiresAt int64 `json:"expires_at"`
716720
}
717721

718722
// DeleteRepository creates a tool that deletes a GitHub repository after the
@@ -756,6 +760,7 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
756760

757761
fullName := owner + "/" + repo
758762
sealer := requestStateSealerFromDeps(deps)
763+
var deletionState *deleteRepositoryState
759764
var responses mcp.InputResponseMap
760765
if req != nil && req.Params != nil {
761766
responses = req.Params.InputResponses
@@ -764,7 +769,20 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
764769
if !ok {
765770
var requestState string
766771
if sealer != nil {
767-
state, err := json.Marshal(deleteRepositoryState{Owner: owner, Repo: repo})
772+
client, err := deps.GetClient(ctx)
773+
if err != nil {
774+
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
775+
}
776+
repositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
777+
if result != nil {
778+
return result, nil, nil
779+
}
780+
state, err := json.Marshal(deleteRepositoryState{
781+
Owner: owner,
782+
Repo: repo,
783+
RepositoryID: repositoryID,
784+
ExpiresAt: time.Now().Add(deleteRepositoryConfirmationTTL).Unix(),
785+
})
768786
if err != nil {
769787
return nil, nil, fmt.Errorf("failed to marshal repository deletion state: %w", err)
770788
}
@@ -810,6 +828,13 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
810828
if state.Owner != owner || state.Repo != repo {
811829
return utils.NewToolResultError("Repository deletion target changed after confirmation was requested. The repository was not deleted."), nil, nil
812830
}
831+
if state.ExpiresAt <= time.Now().Unix() {
832+
return utils.NewToolResultError("Repository deletion confirmation expired. The repository was not deleted."), nil, nil
833+
}
834+
if state.RepositoryID == 0 {
835+
return utils.NewToolResultError("Repository deletion confirmation state was invalid. The repository was not deleted."), nil, nil
836+
}
837+
deletionState = &state
813838
}
814839

815840
confirmation, ok := response.(*mcp.ElicitResult)
@@ -828,6 +853,15 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
828853
if err != nil {
829854
return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err)
830855
}
856+
if deletionState != nil {
857+
currentRepositoryID, result := repositoryIDForDeletion(ctx, client, owner, repo)
858+
if result != nil {
859+
return result, nil, nil
860+
}
861+
if currentRepositoryID != deletionState.RepositoryID {
862+
return utils.NewToolResultError("Repository identity changed after confirmation was requested. The repository was not deleted."), nil, nil
863+
}
864+
}
831865
resp, err := client.Repositories.Delete(ctx, owner, repo)
832866
if err != nil {
833867
return ghErrors.NewGitHubAPIErrorResponse(ctx,
@@ -854,6 +888,21 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool
854888
return tool
855889
}
856890

891+
func repositoryIDForDeletion(ctx context.Context, client *github.Client, owner, repo string) (int64, *mcp.CallToolResult) {
892+
repository, resp, err := client.Repositories.Get(ctx, owner, repo)
893+
if err != nil {
894+
return 0, ghErrors.NewGitHubAPIErrorResponse(ctx,
895+
fmt.Sprintf("failed to get repository: %s/%s", owner, repo),
896+
resp,
897+
err,
898+
)
899+
}
900+
if resp != nil && resp.Body != nil {
901+
defer func() { _ = resp.Body.Close() }()
902+
}
903+
return repository.GetID(), nil
904+
}
905+
857906
// FetchRepoIsPrivate returns whether a repository is private. It is a thin
858907
// wrapper around the GitHub Repositories.Get endpoint provided as a shared
859908
// helper for IFC label computation across tools.

pkg/github/repositories_test.go

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3042,6 +3042,10 @@ func Test_DeleteRepository(t *testing.T) {
30423042
sealer, err := requeststate.New(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")))
30433043
require.NoError(t, err)
30443044
client := NewMockedHTTPClient(
3045+
WithRequestMatchHandler(
3046+
GetReposByOwnerByRepo,
3047+
mockResponse(t, http.StatusOK, map[string]any{"id": 123}),
3048+
),
30453049
WithRequestMatchHandler(
30463050
DeleteReposByOwnerByRepo,
30473051
mockResponse(t, http.StatusNoContent, nil),
@@ -3099,15 +3103,54 @@ func Test_DeleteRepository(t *testing.T) {
30993103
assert.Contains(t, getErrorResult(t, result).Text, "state was invalid")
31003104
})
31013105

3102-
t.Run("refuses a changed deletion target", func(t *testing.T) {
3106+
t.Run("refuses expired deletion state", func(t *testing.T) {
31033107
sealer, err := requeststate.New(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")))
31043108
require.NoError(t, err)
3109+
stateJSON, err := json.Marshal(deleteRepositoryState{
3110+
Owner: "owner",
3111+
Repo: "repo",
3112+
RepositoryID: 123,
3113+
ExpiresAt: time.Now().Add(-time.Minute).Unix(),
3114+
})
3115+
require.NoError(t, err)
3116+
state, err := sealer.Seal(context.Background(), stateJSON)
3117+
require.NoError(t, err)
31053118
deps := BaseDeps{
31063119
Client: mustNewGHClient(t, NewMockedHTTPClient()),
31073120
StateSealer: sealer,
31083121
}
31093122
handler := serverTool.Handler(deps)
31103123

3124+
request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})
3125+
request.Params.RequestState = state
3126+
request.Params.InputResponses = mcp.InputResponseMap{
3127+
deleteRepositoryConfirmationID: &mcp.ElicitResult{
3128+
Action: "accept",
3129+
Content: map[string]any{
3130+
deleteRepositoryConfirmationField: "owner/repo",
3131+
},
3132+
},
3133+
}
3134+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
3135+
require.NoError(t, err)
3136+
require.True(t, result.IsError)
3137+
assert.Contains(t, getErrorResult(t, result).Text, "confirmation expired")
3138+
})
3139+
3140+
t.Run("refuses a changed deletion target", func(t *testing.T) {
3141+
sealer, err := requeststate.New(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")))
3142+
require.NoError(t, err)
3143+
deps := BaseDeps{
3144+
Client: mustNewGHClient(t, NewMockedHTTPClient(
3145+
WithRequestMatchHandler(
3146+
GetReposByOwnerByRepo,
3147+
mockResponse(t, http.StatusOK, map[string]any{"id": 123}),
3148+
),
3149+
)),
3150+
StateSealer: sealer,
3151+
}
3152+
handler := serverTool.Handler(deps)
3153+
31113154
firstRequest := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})
31123155
firstResult, err := handler(ContextWithDeps(context.Background(), deps), &firstRequest)
31133156
require.NoError(t, err)
@@ -3128,6 +3171,50 @@ func Test_DeleteRepository(t *testing.T) {
31283171
assert.Contains(t, getErrorResult(t, result).Text, "target changed")
31293172
})
31303173

3174+
t.Run("refuses a recreated repository", func(t *testing.T) {
3175+
sealer, err := requeststate.New(base64.StdEncoding.EncodeToString([]byte("0123456789abcdef0123456789abcdef")))
3176+
require.NoError(t, err)
3177+
var repositoryLookups int
3178+
client := NewMockedHTTPClient(
3179+
WithRequestMatchHandler(
3180+
GetReposByOwnerByRepo,
3181+
http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
3182+
repositoryLookups++
3183+
id := 123
3184+
if repositoryLookups > 1 {
3185+
id = 456
3186+
}
3187+
w.WriteHeader(http.StatusOK)
3188+
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"id": id}))
3189+
}),
3190+
),
3191+
)
3192+
deps := BaseDeps{
3193+
Client: mustNewGHClient(t, client),
3194+
StateSealer: sealer,
3195+
}
3196+
handler := serverTool.Handler(deps)
3197+
3198+
firstRequest := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})
3199+
firstResult, err := handler(ContextWithDeps(context.Background(), deps), &firstRequest)
3200+
require.NoError(t, err)
3201+
3202+
retry := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"})
3203+
retry.Params.RequestState = firstResult.RequestState
3204+
retry.Params.InputResponses = mcp.InputResponseMap{
3205+
deleteRepositoryConfirmationID: &mcp.ElicitResult{
3206+
Action: "accept",
3207+
Content: map[string]any{
3208+
deleteRepositoryConfirmationField: "owner/repo",
3209+
},
3210+
},
3211+
}
3212+
result, err := handler(ContextWithDeps(context.Background(), deps), &retry)
3213+
require.NoError(t, err)
3214+
require.True(t, result.IsError)
3215+
assert.Contains(t, getErrorResult(t, result).Text, "identity changed")
3216+
})
3217+
31313218
t.Run("completes multi-round-trip elicitation before deleting", func(t *testing.T) {
31323219
httpClient := NewMockedHTTPClient(
31333220
WithRequestMatchHandler(

0 commit comments

Comments
 (0)