Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion pkg/github/issues.go
Original file line number Diff line number Diff line change
Expand Up @@ -745,7 +745,7 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies,
issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title))
}
if issue.Body != nil {
issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body))
issue.Body = github.Ptr(sanitize.FilterBody(*issue.Body))
}
}

Expand Down Expand Up @@ -946,6 +946,15 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
subIssues = filteredSubIssues
}

for _, subIssue := range subIssues {
if subIssue.Title != nil {
subIssue.Title = github.Ptr(sanitize.Sanitize(*subIssue.Title))
}
if subIssue.Body != nil {
subIssue.Body = github.Ptr(sanitize.FilterBody(*subIssue.Body))
}
}

r, err := json.Marshal(subIssues)
if err != nil {
return nil, fmt.Errorf("failed to marshal response: %w", err)
Expand Down
50 changes: 50 additions & 0 deletions pkg/github/issues_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4738,6 +4738,56 @@ func Test_GetSubIssues(t *testing.T) {
}
}

func Test_GetSubIssues_Sanitization(t *testing.T) {
serverTool := IssueRead(translations.NullTranslationHelper)

hiddenPayload := "Sub-issue\U000E0001\U000E0049\U000E0067\U000E006E\U000E006F\U000E0072\U000E0065"
bodyWithCode := "Repro:\n```go\nif a<b { fmt.Println(\"x\") }\n```"

mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{
{
Number: github.Ptr(123),
Title: github.Ptr(hiddenPayload),
Body: github.Ptr(hiddenPayload),
State: github.Ptr("open"),
},
{
Number: github.Ptr(124),
Title: github.Ptr("Sub-issue 2"),
Body: github.Ptr(bodyWithCode),
State: github.Ptr("open"),
},
}),
})

deps := BaseDeps{
Client: mustNewGHClient(t, mockedClient),
GQLClient: githubv4.NewClient(nil),
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
}
handler := serverTool.Handler(deps)

request := createMCPRequest(map[string]any{
"method": "get_sub_issues",
"owner": "owner",
"repo": "repo",
"issue_number": float64(42),
})

result, err := handler(ContextWithDeps(context.Background(), deps), &request)
require.NoError(t, err)

var returnedSubIssues []*github.Issue
require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedSubIssues))
require.Len(t, returnedSubIssues, 2)

assert.Equal(t, "Sub-issue", returnedSubIssues[0].GetTitle(), "hidden characters must be stripped from titles")
assert.Equal(t, "Sub-issue", returnedSubIssues[0].GetBody(), "hidden characters must be stripped from bodies")
assert.Equal(t, bodyWithCode, returnedSubIssues[1].GetBody(), "code content must survive sanitization")
}

func TestAddIssueComment(t *testing.T) {
t.Parallel()

Expand Down
12 changes: 7 additions & 5 deletions pkg/github/minimal_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ func convertToMinimalPullRequestReview(review *github.PullRequestReview) Minimal
m := MinimalPullRequestReview{
ID: review.GetID(),
State: review.GetState(),
Body: review.GetBody(),
Body: sanitize.FilterBody(review.GetBody()),
HTMLURL: review.GetHTMLURL(),
User: convertToMinimalUser(review.GetUser()),
CommitID: review.GetCommitID(),
Expand Down Expand Up @@ -858,7 +858,7 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue {
m := MinimalIssue{
Number: int(fragment.Number),
Title: sanitize.Sanitize(string(fragment.Title)),
Body: sanitize.Sanitize(string(fragment.Body)),
Body: sanitize.FilterBody(string(fragment.Body)),
State: string(fragment.State),
Comments: int(fragment.Comments.TotalCount),
CreatedAt: fragment.CreatedAt.Format(time.RFC3339),
Expand Down Expand Up @@ -929,8 +929,10 @@ func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesRe

func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment {
m := MinimalIssueComment{
ID: comment.GetID(),
Body: comment.GetBody(),
ID: comment.GetID(),
// Comment bodies carry the same invisible-glyph injection surface as
// issue and PR bodies, which the read paths already sanitize.
Body: sanitize.FilterBody(comment.GetBody()),
HTMLURL: comment.GetHTMLURL(),
User: convertToMinimalUser(comment.GetUser()),
AuthorAssociation: comment.GetAuthorAssociation(),
Expand Down Expand Up @@ -2192,7 +2194,7 @@ func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread {

func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment {
m := MinimalReviewComment{
Body: string(c.Body),
Body: sanitize.FilterBody(string(c.Body)),
Path: string(c.Path),
Author: string(c.Author.Login),
HTMLURL: c.URL.String(),
Expand Down
87 changes: 87 additions & 0 deletions pkg/github/minimal_types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package github

import (
"net/url"
"testing"

"github.com/google/go-github/v89/github"
"github.com/shurcooL/githubv4"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// sanitizedBodyWithHiddenChars embeds Unicode tag characters, which are invisible to a
// human reviewer but legible to a model.
const sanitizedBodyWithHiddenChars = "Looks good\U000E0001\U000E0049\U000E0067\U000E006E\U000E006F\U000E0072\U000E0065"

const sanitizedBodyWithCode = "Compare with:\n```go\nif a<b { fmt.Println(\"x\") }\n```\nand <Foo/> in JSX."

func TestConvertToMinimalIssueCommentSanitizesBody(t *testing.T) {
t.Run("strips hidden characters", func(t *testing.T) {
m := convertToMinimalIssueComment(&github.IssueComment{
ID: github.Ptr(int64(1)),
Body: github.Ptr(sanitizedBodyWithHiddenChars),
})
assert.Equal(t, "Looks good", m.Body)
})

t.Run("preserves code content", func(t *testing.T) {
m := convertToMinimalIssueComment(&github.IssueComment{
ID: github.Ptr(int64(1)),
Body: github.Ptr(sanitizedBodyWithCode),
})
assert.Equal(t, sanitizedBodyWithCode, m.Body)
})
}

func TestConvertToMinimalPullRequestReviewSanitizesBody(t *testing.T) {
t.Run("strips hidden characters", func(t *testing.T) {
m := convertToMinimalPullRequestReview(&github.PullRequestReview{
ID: github.Ptr(int64(1)),
Body: github.Ptr(sanitizedBodyWithHiddenChars),
})
assert.Equal(t, "Looks good", m.Body)
})

t.Run("preserves code content", func(t *testing.T) {
m := convertToMinimalPullRequestReview(&github.PullRequestReview{
ID: github.Ptr(int64(1)),
Body: github.Ptr(sanitizedBodyWithCode),
})
assert.Equal(t, sanitizedBodyWithCode, m.Body)
})
}

func TestConvertToMinimalReviewCommentSanitizesBody(t *testing.T) {
commentURL, err := url.Parse("https://github.com/owner/repo/pull/1#discussion_r1")
require.NoError(t, err)

t.Run("strips hidden characters", func(t *testing.T) {
m := convertToMinimalReviewComment(reviewCommentNode{
Body: githubv4.String(sanitizedBodyWithHiddenChars),
Path: githubv4.String("main.go"),
URL: githubv4.URI{URL: commentURL},
})
assert.Equal(t, "Looks good", m.Body)
})

t.Run("preserves code content", func(t *testing.T) {
m := convertToMinimalReviewComment(reviewCommentNode{
Body: githubv4.String(sanitizedBodyWithCode),
Path: githubv4.String("main.go"),
URL: githubv4.URI{URL: commentURL},
})
assert.Equal(t, sanitizedBodyWithCode, m.Body)
})
}

func TestFragmentToMinimalIssueSanitization(t *testing.T) {
m := fragmentToMinimalIssue(IssueFragment{
Number: 1,
Title: githubv4.String(sanitizedBodyWithHiddenChars),
Body: githubv4.String(sanitizedBodyWithCode),
})

assert.Equal(t, "Looks good", m.Title, "hidden characters must be stripped from titles")
assert.Equal(t, sanitizedBodyWithCode, m.Body, "code content must survive sanitization")
}
4 changes: 2 additions & 2 deletions pkg/github/pullrequests.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende
pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title))
}
if pr.Body != nil {
pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body))
pr.Body = github.Ptr(sanitize.FilterBody(*pr.Body))
}
}

Expand Down Expand Up @@ -1463,7 +1463,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool
pr.Title = github.Ptr(sanitize.Sanitize(*pr.Title))
}
if pr.Body != nil {
pr.Body = github.Ptr(sanitize.Sanitize(*pr.Body))
pr.Body = github.Ptr(sanitize.FilterBody(*pr.Body))
}
}

Expand Down
9 changes: 9 additions & 0 deletions pkg/sanitize/sanitize.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ func Sanitize(input string) string {
return FilterHTMLTags(FilterCodeFenceMetadata(FilterInvisibleCharacters(input)))
}

// FilterBody strips the injection surface that matters for markdown bodies —
// invisible glyphs and hidden code-fence info strings — without running the
// HTML filter. Bodies routinely contain code (generics, JSX, shell redirects),
// and HTML filtering silently truncates a fenced block at the first '<', which
// would corrupt the content delivered to the model.
func FilterBody(input string) string {
return FilterCodeFenceMetadata(FilterInvisibleCharacters(input))
}

// FilterInvisibleCharacters removes invisible or control characters that should not appear
// in user-facing titles or bodies. This includes:
// - Unicode tag characters: U+E0001, U+E0020–U+E007F
Expand Down
50 changes: 50 additions & 0 deletions pkg/sanitize/sanitize_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,3 +300,53 @@ func TestSanitizeRemovesInvisibleCodeFenceMetadata(t *testing.T) {
result := Sanitize(input)
assert.Equal(t, expected, result)
}

func TestFilterBody(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "removes unicode tag characters",
input: "hello\U000E0001\U000E0068\U000E0069world",
expected: "helloworld",
},
{
name: "removes bidi overrides",
input: "safe\u202Ereversed\u202C",
expected: "safereversed",
},
{
name: "strips hidden code fence metadata",
input: "```steal secrets\nfmt.Println(42)\n```",
expected: "```\nfmt.Println(42)\n```",
},
{
name: "preserves angle brackets in prose",
input: "a < b && c > d",
expected: "a < b && c > d",
},
{
name: "preserves code fences containing angle brackets",
input: "```go\nif a<b { fmt.Println(\"x\") }\n```",
expected: "```go\nif a<b { fmt.Println(\"x\") }\n```",
},
{
name: "preserves html-like markup",
input: "use <Foo/> component",
expected: "use <Foo/> component",
},
{
name: "empty string",
input: "",
expected: "",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, FilterBody(tt.input))
})
}
}