-
Notifications
You must be signed in to change notification settings - Fork 3.7k
Fix SHA validation in create_or_update_file #2134
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,7 +7,6 @@ import ( | |
| "fmt" | ||
| "io" | ||
| "net/http" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| ghErrors "github.com/github/github-mcp-server/pkg/errors" | ||
|
|
@@ -323,9 +322,9 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTo | |
| If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations. | ||
|
|
||
| In order to obtain the SHA of original file version before updating, use the following git command: | ||
| git ls-tree HEAD <path to file> | ||
| git rev-parse <branch>:<path to file> | ||
|
|
||
| If the SHA is not provided, the tool will attempt to acquire it by fetching the current file contents from the repository, which may lead to rewriting latest committed changes if the file has changed since last retrieval. | ||
| SHA MUST be provided for existing file updates. | ||
| `), | ||
| Annotations: &mcp.ToolAnnotations{ | ||
| Title: t("TOOL_CREATE_OR_UPDATE_FILE_USER_TITLE", "Create or update file"), | ||
|
|
@@ -360,7 +359,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the | |
| }, | ||
| "sha": { | ||
| Type: "string", | ||
| Description: "The blob SHA of the file being replaced.", | ||
| Description: "The blob SHA of the file being replaced. Required if the file already exists.", | ||
| }, | ||
| }, | ||
| Required: []string{"owner", "repo", "path", "content", "message", "branch"}, | ||
|
|
@@ -420,55 +419,68 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the | |
|
|
||
| path = strings.TrimPrefix(path, "/") | ||
|
|
||
| // SHA validation using conditional HEAD request (efficient - no body transfer) | ||
| var previousSHA string | ||
| contentURL := fmt.Sprintf("repos/%s/%s/contents/%s", owner, repo, url.PathEscape(path)) | ||
| if branch != "" { | ||
| contentURL += "?ref=" + url.QueryEscape(branch) | ||
| } | ||
| // SHA validation using Contents API to fetch current file metadata (blob SHA) | ||
| getOpts := &github.RepositoryContentGetOptions{Ref: branch} | ||
|
|
||
| if sha != "" { | ||
| // User provided SHA - validate it's still current | ||
| req, err := client.NewRequest("HEAD", contentURL, nil) | ||
| if err == nil { | ||
| req.Header.Set("If-None-Match", fmt.Sprintf(`"%s"`, sha)) | ||
| resp, _ := client.Do(ctx, req, nil) | ||
| if resp != nil { | ||
| defer resp.Body.Close() | ||
|
|
||
| switch resp.StatusCode { | ||
| case http.StatusNotModified: | ||
| // SHA matches current - proceed | ||
| opts.SHA = github.Ptr(sha) | ||
| case http.StatusOK: | ||
| // SHA is stale - reject with current SHA so user can check diff | ||
| currentSHA := strings.Trim(resp.Header.Get("ETag"), `"`) | ||
| return utils.NewToolResultError(fmt.Sprintf( | ||
| "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ | ||
| "Use get_file_contents or compare commits to review changes before updating.", | ||
| sha, currentSHA)), nil, nil | ||
| case http.StatusNotFound: | ||
| // File doesn't exist - this is a create, ignore provided SHA | ||
| } | ||
| existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) | ||
| if respCheck != nil { | ||
| _ = respCheck.Body.Close() | ||
| } | ||
| switch { | ||
| case getErr != nil: | ||
| // 404 means file doesn't exist - proceed (new file creation) | ||
| // Any other error (403, 500, network) should be surfaced | ||
| if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { | ||
| return ghErrors.NewGitHubAPIErrorResponse(ctx, | ||
| "failed to verify file SHA", | ||
| respCheck, | ||
| getErr, | ||
| ), nil, nil | ||
| } | ||
| case dirContent != nil: | ||
| return utils.NewToolResultError(fmt.Sprintf( | ||
| "Path %s is a directory, not a file. This tool only works with files.", | ||
| path)), nil, nil | ||
| case existingFile != nil: | ||
| currentSHA := existingFile.GetSHA() | ||
| if currentSHA != sha { | ||
| return utils.NewToolResultError(fmt.Sprintf( | ||
| "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ | ||
| "Pull the latest changes and use git rev-parse %s:%s to get the current SHA.", | ||
| sha, currentSHA, branch, path)), nil, nil | ||
| } | ||
| } | ||
| } else { | ||
| // No SHA provided - check if file exists to warn about blind update | ||
| req, err := client.NewRequest("HEAD", contentURL, nil) | ||
| if err == nil { | ||
| resp, _ := client.Do(ctx, req, nil) | ||
| if resp != nil { | ||
| defer resp.Body.Close() | ||
| if resp.StatusCode == http.StatusOK { | ||
| previousSHA = strings.Trim(resp.Header.Get("ETag"), `"`) | ||
| } | ||
| // 404 = new file, no previous SHA needed | ||
| // No SHA provided - check if file already exists | ||
| existingFile, dirContent, respCheck, getErr := client.Repositories.GetContents(ctx, owner, repo, path, getOpts) | ||
| if respCheck != nil { | ||
| _ = respCheck.Body.Close() | ||
| } | ||
| switch { | ||
| case getErr != nil: | ||
| // 404 means file doesn't exist - proceed with creation | ||
| // Any other error (403, 500, network) should be surfaced | ||
| if respCheck == nil || respCheck.StatusCode != http.StatusNotFound { | ||
| return ghErrors.NewGitHubAPIErrorResponse(ctx, | ||
| "failed to check if file exists", | ||
| respCheck, | ||
| getErr, | ||
| ), nil, nil | ||
| } | ||
| case dirContent != nil: | ||
| return utils.NewToolResultError(fmt.Sprintf( | ||
| "Path %s is a directory, not a file. This tool only works with files.", | ||
| path)), nil, nil | ||
| case existingFile != nil: | ||
| // File exists but no SHA was provided - reject to prevent blind overwrites | ||
| return utils.NewToolResultError(fmt.Sprintf( | ||
| "File already exists at %s. You must provide the current file's SHA when updating. "+ | ||
| "Use git rev-parse %s:%s to get the blob SHA, then retry with the sha parameter.", | ||
| path, branch, path)), nil, nil | ||
| } | ||
| } | ||
|
|
||
| if previousSHA != "" { | ||
| opts.SHA = github.Ptr(previousSHA) | ||
| // If file not found, no previous SHA needed (new file creation) | ||
| } | ||
|
|
||
| fileContent, resp, err := client.Repositories.CreateFile(ctx, owner, repo, path, opts) | ||
|
|
@@ -491,23 +503,6 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the | |
|
|
||
| minimalResponse := convertToMinimalFileContentResponse(fileContent) | ||
|
|
||
| // Warn if file was updated without SHA validation (blind update) | ||
| if sha == "" && previousSHA != "" { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. removing blind update completely
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's fair to accept the errors, thought this is probably the most controversial change.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I decided if GitHub API requires sha for updates who are we to make it optional :D
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Well, I think it's fine to make agent fail, but I don't think it's wrong either way, just different trade-offs. You still retain a git record of what you changed, so it might be surprising to update a file that has changed since you last checked, but you can at least still work out what happened. The concern for me was always concurrent updates from different agents and then thinking independently that two things were done, but actually only one was because the other overwrites it. That's not great, and I think it's ok for us to not allow that by accident. |
||
| warning, err := json.Marshal(minimalResponse) | ||
| if err != nil { | ||
| return nil, nil, fmt.Errorf("failed to marshal response: %w", err) | ||
| } | ||
| return utils.NewToolResultText(fmt.Sprintf( | ||
| "Warning: File updated without SHA validation. Previous file SHA was %s. "+ | ||
| `Verify no unintended changes were overwritten: | ||
| 1. Extract the SHA of the local version using git ls-tree HEAD %s. | ||
| 2. Compare with the previous SHA above. | ||
| 3. Revert changes if shas do not match. | ||
|
|
||
| %s`, | ||
| previousSHA, path, string(warning))), nil, nil | ||
| } | ||
|
|
||
| return MarshalledTextResult(minimalResponse), nil, nil | ||
| }, | ||
| ) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.