Skip to content

Commit 8e78920

Browse files
fix(governance): recurse into rule-parameter array elements
Address the final Copilot review comment: droppedKeyPath previously treated array elements as opaque, so an unrecognized key inside an array element (e.g. required_status_checks[].integration_ids, a typo for integration_id) was silently dropped by go-github's JSON unmarshal without being caught -- the resulting rule would then accept a status check from any integration instead of only the one requested. This round-trip is entirely local (our own marshal/unmarshal of a go-github struct, not a remote API response), so slice order and length are deterministic and safe to compare by index. droppedKeyPath now delegates to a new droppedValuePath helper that recurses into both nested objects and array elements, reporting paths like "required_status_checks[0].integration_ids" when a caller-supplied key disappears in either a map or an array position. Added a regression test for the array-nested typo, and a companion test confirming valid array-nested parameters still round-trip without being misflagged. Note: committed unsigned -- the local 1Password SSH-signing agent is still unavailable (session locked). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0c25c92 commit 8e78920

2 files changed

Lines changed: 135 additions & 13 deletions

File tree

pkg/github/rulesets.go

Lines changed: 47 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -869,13 +869,15 @@ func buildRepositoryRulesetFromArgs(args map[string]any) (github.RepositoryRules
869869
}
870870

871871
// droppedKeyPath recursively compares a caller-supplied object against its
872-
// round-tripped counterpart and returns the dotted path of the first key that
873-
// did not survive, or "" if every key survived. A caller-supplied key whose
874-
// value is a JSON zero value (false, 0, "", or an empty array/object) is
875-
// exempt, since it is indistinguishable from a field omitted by a
876-
// `json:",omitempty"` struct tag on the far side of the round-trip. Only
877-
// nested objects are recursed into; array elements are treated as opaque so
878-
// that reordering by the API cannot produce a false positive.
872+
// round-tripped counterpart and returns the path of the first key or array
873+
// element that did not survive (e.g. "required_status_checks[0].integration_id"),
874+
// or "" if everything survived. A caller-supplied value that is a JSON zero
875+
// value (false, 0, "", or an empty array/object) is exempt, since it is
876+
// indistinguishable from a field omitted by a `json:",omitempty"` struct tag
877+
// on the far side of the round-trip. This round-trip is entirely local (our
878+
// own JSON marshal/unmarshal of a go-github struct, not a remote API
879+
// response), so slice order and length are preserved deterministically and
880+
// array elements are safe to compare by index.
879881
func droppedKeyPath(requested, applied map[string]any) string {
880882
for key, requestedValue := range requested {
881883
appliedValue, ok := applied[key]
@@ -885,17 +887,49 @@ func droppedKeyPath(requested, applied map[string]any) string {
885887
}
886888
return key
887889
}
888-
requestedChild, requestedIsMap := requestedValue.(map[string]any)
889-
appliedChild, appliedIsMap := appliedValue.(map[string]any)
890-
if requestedIsMap && appliedIsMap {
891-
if nested := droppedKeyPath(requestedChild, appliedChild); nested != "" {
892-
return key + "." + nested
893-
}
890+
if nested := droppedValuePath(requestedValue, appliedValue); nested != "" {
891+
return key + nested
894892
}
895893
}
896894
return ""
897895
}
898896

897+
// droppedValuePath recurses into map and array values on behalf of
898+
// droppedKeyPath. It returns a path suffix beginning with "." (object key) or
899+
// "[i]" (array index), or "" when requested and applied agree closely enough.
900+
func droppedValuePath(requested, applied any) string {
901+
switch requestedTyped := requested.(type) {
902+
case map[string]any:
903+
appliedMap, ok := applied.(map[string]any)
904+
if !ok {
905+
return ""
906+
}
907+
if nested := droppedKeyPath(requestedTyped, appliedMap); nested != "" {
908+
return "." + nested
909+
}
910+
return ""
911+
case []any:
912+
appliedArr, ok := applied.([]any)
913+
if !ok {
914+
return ""
915+
}
916+
for i, requestedElem := range requestedTyped {
917+
if i >= len(appliedArr) {
918+
if isZeroJSONValue(requestedElem) {
919+
continue
920+
}
921+
return fmt.Sprintf("[%d]", i)
922+
}
923+
if nested := droppedValuePath(requestedElem, appliedArr[i]); nested != "" {
924+
return fmt.Sprintf("[%d]%s", i, nested)
925+
}
926+
}
927+
return ""
928+
default:
929+
return ""
930+
}
931+
}
932+
899933
// isZeroJSONValue reports whether v is the JSON zero value for its type
900934
// (false, 0, "", nil, or an empty array/object). Such values are
901935
// indistinguishable from an omitted field once round-tripped through a Go

pkg/github/rulesets_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -667,6 +667,94 @@ func Test_CreateRepositoryRuleset(t *testing.T) {
667667
assert.NotEmpty(t, capturedBody)
668668
})
669669

670+
t.Run("unrecognized key inside a rule parameter array element is rejected", func(t *testing.T) {
671+
// "integration_ids" is a plausible typo for the real per-check field
672+
// "integration_id" on required_status_checks[]. Unlike the top-level
673+
// rule/condition round-trip, this array is produced by our own local
674+
// JSON marshal/unmarshal of the go-github struct (not a remote API
675+
// response), so element order is guaranteed stable and comparing by
676+
// index is safe. Without this check, the typo would silently vanish and
677+
// the resulting rule would accept a status check from any integration
678+
// instead of only the one requested.
679+
called := false
680+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
681+
"POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) {
682+
called = true
683+
w.WriteHeader(http.StatusCreated)
684+
},
685+
}))
686+
deps := BaseDeps{Client: client}
687+
handler := toolDef.Handler(deps)
688+
request := createMCPRequest(map[string]any{
689+
"level": "repository",
690+
"owner": "owner",
691+
"repo": "repo",
692+
"name": "x",
693+
"enforcement": "active",
694+
"rules": []any{
695+
map[string]any{
696+
"type": "required_status_checks",
697+
"parameters": map[string]any{
698+
"required_status_checks": []any{
699+
map[string]any{
700+
"context": "ci",
701+
"integration_ids": float64(42), // typo: should be integration_id
702+
},
703+
},
704+
"strict_required_status_checks_policy": true,
705+
},
706+
},
707+
},
708+
})
709+
710+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
711+
require.NoError(t, err)
712+
require.True(t, result.IsError)
713+
assert.Contains(t, getErrorResult(t, result).Text, "required_status_checks[0].integration_ids")
714+
assert.False(t, called, "request must not be sent when a nested array element key is unrecognized")
715+
})
716+
717+
t.Run("valid rule parameter array elements round-trip and are not misflagged", func(t *testing.T) {
718+
var capturedBody []byte
719+
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
720+
"POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) {
721+
capturedBody, _ = io.ReadAll(r.Body)
722+
w.WriteHeader(http.StatusCreated)
723+
_, _ = w.Write(capturedBody)
724+
},
725+
}))
726+
deps := BaseDeps{Client: client}
727+
handler := toolDef.Handler(deps)
728+
request := createMCPRequest(map[string]any{
729+
"level": "repository",
730+
"owner": "owner",
731+
"repo": "repo",
732+
"name": "x",
733+
"enforcement": "active",
734+
"rules": []any{
735+
map[string]any{
736+
"type": "required_status_checks",
737+
"parameters": map[string]any{
738+
"required_status_checks": []any{
739+
map[string]any{
740+
"context": "ci",
741+
"integration_id": float64(42),
742+
},
743+
},
744+
"strict_required_status_checks_policy": true,
745+
},
746+
},
747+
},
748+
})
749+
750+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
751+
require.NoError(t, err)
752+
if result.IsError {
753+
t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text)
754+
}
755+
assert.NotEmpty(t, capturedBody)
756+
})
757+
670758
t.Run("bypass_actors accepts exempt bypass mode and enterprise actor types", func(t *testing.T) {
671759
var capturedBody github.RepositoryRuleset
672760
client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{

0 commit comments

Comments
 (0)