Skip to content

Commit 3d74c4f

Browse files
zwickCopilot
andcommitted
Sanitize and lockdown-gate issue_read get_parent title
GetIssueParent returned the parent issue title raw and ungated by lockdown mode, so an agent could read an unverified, possibly cross-repo parent title even with lockdown enabled. Always sanitize the parent title and, under lockdown mode, only return the parent when its author has push access to the parent repository, failing closed otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 29634da commit 3d74c4f

2 files changed

Lines changed: 103 additions & 28 deletions

File tree

pkg/github/issues.go

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -701,7 +701,7 @@ Options are:
701701
result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination)
702702
return attachIFC(result), nil, err
703703
case "get_parent":
704-
result, err := GetIssueParent(ctx, gqlClient, owner, repo, issueNumber)
704+
result, err := GetIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber)
705705
return attachIFC(result), nil, err
706706
case "get_labels":
707707
result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber)
@@ -903,15 +903,29 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc
903903
// GetIssueParent returns the parent issue of the given issue, or a null parent
904904
// when the issue is not a sub-issue of any other issue. It reads the GraphQL
905905
// Issue.parent field, the upward counterpart to the downward get_sub_issues read.
906-
func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
906+
//
907+
// The parent may live in a different repository, so its title is always
908+
// sanitized before being returned. When lockdown mode is enabled the parent is
909+
// only returned when its author has push access to the parent repository
910+
// (mirroring GetIssue); otherwise it is omitted (fail closed).
911+
func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) {
912+
cache, err := deps.GetRepoAccessCache(ctx)
913+
if err != nil {
914+
return nil, fmt.Errorf("failed to get repo access cache: %w", err)
915+
}
916+
flags := deps.GetFlags(ctx)
917+
907918
var query struct {
908919
Repository struct {
909920
Issue struct {
910921
Parent *struct {
911-
Number githubv4.Int
912-
Title githubv4.String
913-
State githubv4.String
914-
URL githubv4.String
922+
Number githubv4.Int
923+
Title githubv4.String
924+
State githubv4.String
925+
URL githubv4.String
926+
Author struct {
927+
Login githubv4.String
928+
}
915929
Repository struct {
916930
NameWithOwner githubv4.String
917931
}
@@ -935,10 +949,28 @@ func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string,
935949
return MarshalledTextResult(map[string]any{"parent": nil}), nil
936950
}
937951

952+
if flags.LockdownMode {
953+
if cache == nil {
954+
return nil, fmt.Errorf("lockdown cache is not configured")
955+
}
956+
// The lockdown safe-content check keys off the parent author's push
957+
// access to the parent repository. Fail closed by omitting the parent
958+
// if anything required for that check is missing or unverifiable.
959+
parentAuthorLogin := string(parent.Author.Login)
960+
parentOwner, parentRepo, ok := strings.Cut(string(parent.Repository.NameWithOwner), "/")
961+
if parentAuthorLogin == "" || !ok || parentOwner == "" || parentRepo == "" {
962+
return MarshalledTextResult(map[string]any{"parent": nil}), nil
963+
}
964+
isSafeContent, err := cache.IsSafeContent(ctx, parentAuthorLogin, parentOwner, parentRepo)
965+
if err != nil || !isSafeContent {
966+
return MarshalledTextResult(map[string]any{"parent": nil}), nil
967+
}
968+
}
969+
938970
return MarshalledTextResult(map[string]any{
939971
"parent": map[string]any{
940972
"number": int(parent.Number),
941-
"title": string(parent.Title),
973+
"title": sanitize.Sanitize(string(parent.Title)),
942974
"state": string(parent.State),
943975
"url": string(parent.URL),
944976
"repository": string(parent.Repository.NameWithOwner),

pkg/github/issues_test.go

Lines changed: 64 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3638,10 +3638,13 @@ func Test_GetIssueParent(t *testing.T) {
36383638
Repository struct {
36393639
Issue struct {
36403640
Parent *struct {
3641-
Number githubv4.Int
3642-
Title githubv4.String
3643-
State githubv4.String
3644-
URL githubv4.String
3641+
Number githubv4.Int
3642+
Title githubv4.String
3643+
State githubv4.String
3644+
URL githubv4.String
3645+
Author struct {
3646+
Login githubv4.String
3647+
}
36453648
Repository struct {
36463649
NameWithOwner githubv4.String
36473650
}
@@ -3656,9 +3659,32 @@ func Test_GetIssueParent(t *testing.T) {
36563659
"issueNumber": githubv4.Int(123),
36573660
}
36583661

3662+
parentResponse := func(authorLogin string) githubv4mock.GQLResponse {
3663+
return githubv4mock.DataResponse(map[string]any{
3664+
"repository": map[string]any{
3665+
"issue": map[string]any{
3666+
"parent": map[string]any{
3667+
"number": githubv4.Int(42),
3668+
"title": githubv4.String("Parent issue"),
3669+
"state": githubv4.String("OPEN"),
3670+
"url": githubv4.String("https://github.com/owner/repo/issues/42"),
3671+
"author": map[string]any{
3672+
"login": githubv4.String(authorLogin),
3673+
},
3674+
"repository": map[string]any{
3675+
"nameWithOwner": githubv4.String("owner/repo"),
3676+
},
3677+
},
3678+
},
3679+
},
3680+
})
3681+
}
3682+
36593683
tests := []struct {
36603684
name string
36613685
mockedClient *http.Client
3686+
lockdownEnabled bool
3687+
restOverrides map[string]string
36623688
expectToolError bool
36633689
expectedText string
36643690
}{
@@ -3668,21 +3694,7 @@ func Test_GetIssueParent(t *testing.T) {
36683694
githubv4mock.NewQueryMatcher(
36693695
parentMatcherStruct,
36703696
vars,
3671-
githubv4mock.DataResponse(map[string]any{
3672-
"repository": map[string]any{
3673-
"issue": map[string]any{
3674-
"parent": map[string]any{
3675-
"number": githubv4.Int(42),
3676-
"title": githubv4.String("Parent issue"),
3677-
"state": githubv4.String("OPEN"),
3678-
"url": githubv4.String("https://github.com/owner/repo/issues/42"),
3679-
"repository": map[string]any{
3680-
"nameWithOwner": githubv4.String("owner/repo"),
3681-
},
3682-
},
3683-
},
3684-
},
3685-
}),
3697+
parentResponse("author"),
36863698
),
36873699
),
36883700
expectedText: `"number":42`,
@@ -3715,17 +3727,48 @@ func Test_GetIssueParent(t *testing.T) {
37153727
),
37163728
expectToolError: true,
37173729
},
3730+
{
3731+
name: "lockdown enabled - parent author has push access",
3732+
mockedClient: githubv4mock.NewMockedHTTPClient(
3733+
githubv4mock.NewQueryMatcher(
3734+
parentMatcherStruct,
3735+
vars,
3736+
parentResponse("maintainer"),
3737+
),
3738+
),
3739+
lockdownEnabled: true,
3740+
restOverrides: map[string]string{"maintainer": "write"},
3741+
expectedText: `"title":"Parent issue"`,
3742+
},
3743+
{
3744+
name: "lockdown enabled - parent author lacks push access",
3745+
mockedClient: githubv4mock.NewMockedHTTPClient(
3746+
githubv4mock.NewQueryMatcher(
3747+
parentMatcherStruct,
3748+
vars,
3749+
parentResponse("externaluser"),
3750+
),
3751+
),
3752+
lockdownEnabled: true,
3753+
restOverrides: map[string]string{"externaluser": "read"},
3754+
expectedText: `"parent":null`,
3755+
},
37183756
}
37193757

37203758
for _, tc := range tests {
37213759
t.Run(tc.name, func(t *testing.T) {
37223760
gqlClient := githubv4.NewClient(tc.mockedClient)
37233761
client := mustNewGHClient(t, nil)
3762+
3763+
var restClient *github.Client
3764+
if tc.lockdownEnabled {
3765+
restClient = mockRESTPermissionServer(t, "read", tc.restOverrides)
3766+
}
37243767
deps := BaseDeps{
37253768
Client: client,
37263769
GQLClient: gqlClient,
3727-
RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute),
3728-
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}),
3770+
RepoAccessCache: stubRepoAccessCache(restClient, 15*time.Minute),
3771+
Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}),
37293772
}
37303773
handler := serverTool.Handler(deps)
37313774

0 commit comments

Comments
 (0)