|
5 | 5 | "encoding/json" |
6 | 6 | "fmt" |
7 | 7 | "net/http" |
| 8 | + pathpkg "path" |
8 | 9 | "strings" |
9 | 10 |
|
10 | 11 | ghErrors "github.com/github/github-mcp-server/pkg/errors" |
@@ -90,6 +91,113 @@ func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client |
90 | 91 | return createdRef, nil |
91 | 92 | } |
92 | 93 |
|
| 94 | +const gitSymlinkMode = "120000" |
| 95 | + |
| 96 | +type symlinkWriteBlockedError struct { |
| 97 | + Error string `json:"error"` |
| 98 | + Path string `json:"path"` |
| 99 | + SymlinkTarget string `json:"symlink_target"` |
| 100 | + ResolvedTargetPath string `json:"resolved_target_path,omitempty"` |
| 101 | + Message string `json:"message"` |
| 102 | +} |
| 103 | + |
| 104 | +func newSymlinkWriteBlockedResult(path, target string) *mcp.CallToolResult { |
| 105 | + resolvedTargetPath := resolveRepositorySymlinkTarget(path, target) |
| 106 | + message := "The exact Git path is a symbolic link. get_file_contents may have returned the linked file's content and SHA, " + |
| 107 | + "but create_or_update_file would write that content into the symlink itself. " |
| 108 | + if resolvedTargetPath != "" { |
| 109 | + message += fmt.Sprintf("Write to %q instead, or set allow_symlink_write to true only to intentionally change the link target.", resolvedTargetPath) |
| 110 | + } else { |
| 111 | + message += "The link target resolves outside this repository. Set allow_symlink_write to true only to intentionally change the link target." |
| 112 | + } |
| 113 | + |
| 114 | + payload, _ := json.Marshal(symlinkWriteBlockedError{ |
| 115 | + Error: "symlink_write_requires_explicit_opt_in", |
| 116 | + Path: path, |
| 117 | + SymlinkTarget: target, |
| 118 | + ResolvedTargetPath: resolvedTargetPath, |
| 119 | + Message: message, |
| 120 | + }) |
| 121 | + return utils.NewToolResultError(string(payload)) |
| 122 | +} |
| 123 | + |
| 124 | +func symlinkTargetAtPath(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (string, bool, *github.Response, error) { |
| 125 | + ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+treeish) |
| 126 | + if err != nil { |
| 127 | + return "", false, resp, err |
| 128 | + } |
| 129 | + if resp != nil && resp.Body != nil { |
| 130 | + _ = resp.Body.Close() |
| 131 | + } |
| 132 | + headSHA := ref.GetObject().GetSHA() |
| 133 | + if headSHA == "" { |
| 134 | + return "", false, nil, fmt.Errorf("branch %q has no commit SHA", treeish) |
| 135 | + } |
| 136 | + |
| 137 | + entry, resp, err := getTreeEntry(ctx, client, owner, repo, headSHA, path) |
| 138 | + if err != nil { |
| 139 | + return "", false, resp, err |
| 140 | + } |
| 141 | + if entry == nil { |
| 142 | + return "", false, nil, fmt.Errorf("path %q exists according to the Contents API but was not found in the Git tree", path) |
| 143 | + } |
| 144 | + if entry.GetMode() != gitSymlinkMode { |
| 145 | + return "", false, nil, nil |
| 146 | + } |
| 147 | + |
| 148 | + target, resp, err := client.Git.GetBlobRaw(ctx, owner, repo, entry.GetSHA()) |
| 149 | + if err != nil { |
| 150 | + return "", false, resp, err |
| 151 | + } |
| 152 | + if resp != nil && resp.Body != nil { |
| 153 | + _ = resp.Body.Close() |
| 154 | + } |
| 155 | + return string(target), true, nil, nil |
| 156 | +} |
| 157 | + |
| 158 | +func getTreeEntry(ctx context.Context, client *github.Client, owner, repo, treeish, path string) (*github.TreeEntry, *github.Response, error) { |
| 159 | + segments := strings.Split(pathpkg.Clean(strings.TrimPrefix(path, "/")), "/") |
| 160 | + for i, segment := range segments { |
| 161 | + tree, resp, err := client.Git.GetTree(ctx, owner, repo, treeish, false) |
| 162 | + if err != nil { |
| 163 | + return nil, resp, err |
| 164 | + } |
| 165 | + if resp != nil && resp.Body != nil { |
| 166 | + _ = resp.Body.Close() |
| 167 | + } |
| 168 | + |
| 169 | + var matched *github.TreeEntry |
| 170 | + for _, entry := range tree.Entries { |
| 171 | + if entry.GetPath() == segment { |
| 172 | + matched = entry |
| 173 | + break |
| 174 | + } |
| 175 | + } |
| 176 | + if matched == nil { |
| 177 | + return nil, nil, nil |
| 178 | + } |
| 179 | + if i == len(segments)-1 { |
| 180 | + return matched, nil, nil |
| 181 | + } |
| 182 | + if matched.GetType() != "tree" { |
| 183 | + return nil, nil, nil |
| 184 | + } |
| 185 | + treeish = matched.GetSHA() |
| 186 | + } |
| 187 | + return nil, nil, nil |
| 188 | +} |
| 189 | + |
| 190 | +func resolveRepositorySymlinkTarget(linkPath, target string) string { |
| 191 | + if pathpkg.IsAbs(target) { |
| 192 | + return "" |
| 193 | + } |
| 194 | + resolved := pathpkg.Clean(pathpkg.Join(pathpkg.Dir(linkPath), target)) |
| 195 | + if resolved == ".." || strings.HasPrefix(resolved, "../") { |
| 196 | + return "" |
| 197 | + } |
| 198 | + return resolved |
| 199 | +} |
| 200 | + |
93 | 201 | // matchFiles searches for files in the Git tree that match the given path. |
94 | 202 | // It's used when GetContents fails or returns unexpected results. |
95 | 203 | func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { |
|
0 commit comments