From bd08e428a413a4ac305dce52394cf7a18ee197b3 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 22 Mar 2026 22:16:35 -0700 Subject: [PATCH 1/2] feat(proxy): inject author{user{login}} into commit GraphQL queries Extend GraphQL field injection to support commit queries. The Commit type uses author{user{login}} (different from Issue/PR author{login}) and has no authorAssociation field. This enables the guard's trusted-bot detection for commit objects. Also adds a GraphQL pattern for commit history queries (list_commits) and refactors the injection to use tool-specific field sets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- internal/proxy/graphql.go | 3 ++ internal/proxy/graphql_rewrite.go | 61 +++++++++++++++++--------- internal/proxy/graphql_rewrite_test.go | 44 +++++++++++++++++++ 3 files changed, 88 insertions(+), 20 deletions(-) diff --git a/internal/proxy/graphql.go b/internal/proxy/graphql.go index cbaccd53..3fa15149 100644 --- a/internal/proxy/graphql.go +++ b/internal/proxy/graphql.go @@ -47,6 +47,9 @@ var graphqlPatterns = []graphqlPattern{ {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bpullRequest\s*\(`), toolName: "pull_request_read"}, {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bpullRequests\s*[\({]`), toolName: "list_pull_requests"}, + // Commit history operations + {queryPattern: regexp.MustCompile(`(?i)\bhistory\s*[\({]`), toolName: "list_commits"}, + // Discussion operations {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bdiscussion\s*\(`), toolName: "list_discussions"}, {queryPattern: regexp.MustCompile(`(?i)repository\s*\([^)]*\)\s*\{[^}]*\bdiscussions\s*[\({]`), toolName: "list_discussions"}, diff --git a/internal/proxy/graphql_rewrite.go b/internal/proxy/graphql_rewrite.go index 3c1359ac..188311ee 100644 --- a/internal/proxy/graphql_rewrite.go +++ b/internal/proxy/graphql_rewrite.go @@ -6,22 +6,46 @@ import ( "strings" ) -// guardRequiredFields lists the GraphQL selection fields the DIFC guard needs -// for accurate integrity labeling. author{login} enables trusted-bot detection; -// authorAssociation provides the integrity level directly (MEMBER, CONTRIBUTOR, -// etc.) so the guard doesn't need extra enrichment REST round-trips. -var guardRequiredFields = []struct { +// guardFieldSet defines the GraphQL fields the DIFC guard needs for a +// specific class of GitHub objects. +type guardFieldSet struct { field string // field text to inject present *regexp.Regexp // pattern that indicates the field is already selected -}{ +} + +// issueAndPRFields are required for Issue and PullRequest types. +// author{login} enables trusted-bot detection; authorAssociation provides the +// integrity level directly so the guard doesn't need enrichment REST round-trips. +var issueAndPRFields = []guardFieldSet{ {"author{login}", regexp.MustCompile(`\bauthor\s*\{[^}]*\blogin\b`)}, {"authorAssociation", regexp.MustCompile(`\bauthorAssociation\b`)}, } -// allGuardFieldsPresent returns true if the query already contains every -// required guard field. -func allGuardFieldsPresent(query string) bool { - for _, f := range guardRequiredFields { +// commitFields are required for Commit types. +// author{user{login}} enables trusted-bot detection. Commits don't have an +// authorAssociation field in the GraphQL schema. +var commitFields = []guardFieldSet{ + {"author{user{login}}", regexp.MustCompile(`\bauthor\s*\{[^}]*\buser\s*\{[^}]*\blogin\b`)}, +} + +// fieldsForTool returns the guard fields applicable to the given tool name, +// or nil if no injection is needed. +func fieldsForTool(toolName string) []guardFieldSet { + switch toolName { + case "list_issues", "list_pull_requests", "issue_read", "pull_request_read", + "search_issues": + return issueAndPRFields + case "list_commits": + return commitFields + default: + return nil + } +} + +// allFieldsPresent returns true if the query already contains every +// required guard field from the given set. +func allFieldsPresent(query string, fields []guardFieldSet) bool { + for _, f := range fields { if !f.present.MatchString(query) { return false } @@ -29,10 +53,10 @@ func allGuardFieldsPresent(query string) bool { return true } -// missingGuardFields returns the field strings not yet present in the query. -func missingGuardFields(query string) []string { +// missingFields returns the field strings from the set not yet present in the query. +func missingFields(query string, fields []guardFieldSet) []string { var missing []string - for _, f := range guardRequiredFields { + for _, f := range fields { if !f.present.MatchString(query) { missing = append(missing, f.field) } @@ -45,11 +69,8 @@ func missingGuardFields(query string) []string { // Returns the (possibly modified) body. If injection is not needed or fails, // the original body is returned unchanged. func InjectGuardFields(body []byte, toolName string) []byte { - // Only rewrite for tools that need author info - switch toolName { - case "list_issues", "list_pull_requests", "issue_read", "pull_request_read", - "search_issues": - default: + fields := fieldsForTool(toolName) + if fields == nil { return body } @@ -58,11 +79,11 @@ func InjectGuardFields(body []byte, toolName string) []byte { return body } - if gql.Query == "" || allGuardFieldsPresent(gql.Query) { + if gql.Query == "" || allFieldsPresent(gql.Query, fields) { return body } - missing := missingGuardFields(gql.Query) + missing := missingFields(gql.Query, fields) modified := injectFieldsIntoQuery(gql.Query, missing) if modified == gql.Query { return body diff --git a/internal/proxy/graphql_rewrite_test.go b/internal/proxy/graphql_rewrite_test.go index 9b265169..354929a7 100644 --- a/internal/proxy/graphql_rewrite_test.go +++ b/internal/proxy/graphql_rewrite_test.go @@ -21,6 +21,13 @@ func TestInjectGuardFields_SkipsWhenFieldsPresent(t *testing.T) { assert.Equal(t, body, result) } +func TestInjectGuardFields_SkipsWhenCommitFieldsPresent(t *testing.T) { + query := `query { repository(owner:"o", name:"r") { defaultBranchRef { target { ... on Commit { history(first:10) { nodes { oid author{user{login}} } } } } } } }` + body, _ := json.Marshal(GraphQLRequest{Query: query}) + result := InjectGuardFields(body, "list_commits") + assert.Equal(t, body, result) +} + func TestInjectGuardFields_InjectsIntoNodes(t *testing.T) { query := `query { repository(owner:"o", name:"r") { pullRequests(first:10) { nodes { number title } } } }` body, _ := json.Marshal(GraphQLRequest{Query: query}) @@ -147,6 +154,43 @@ query { repository(owner:"o",name:"r") { pullRequests(first:1) { nodes { ...pr } assert.Contains(t, result, "author{login},authorAssociation}") } +func TestInjectGuardFields_CommitInjectsAuthorUser(t *testing.T) { + query := `query { repository(owner:"o", name:"r") { defaultBranchRef { target { ... on Commit { history(first:10) { nodes { oid messageHeadline } } } } } } }` + body, _ := json.Marshal(GraphQLRequest{Query: query}) + + result := InjectGuardFields(body, "list_commits") + + var gql GraphQLRequest + require.NoError(t, json.Unmarshal(result, &gql)) + assert.Contains(t, gql.Query, "author{user{login}}") + // Should NOT inject issue/PR fields + assert.NotContains(t, gql.Query, "authorAssociation") + // Original fields still present + assert.Contains(t, gql.Query, "oid") + assert.Contains(t, gql.Query, "messageHeadline") +} + +func TestInjectGuardFields_CommitFragment(t *testing.T) { + query := `fragment c on Commit{oid,messageHeadline} +query { repository(owner:"o", name:"r") { defaultBranchRef { target { ... on Commit { history(first:10) { nodes { ...c } } } } } } }` + body, _ := json.Marshal(GraphQLRequest{Query: query}) + + result := InjectGuardFields(body, "list_commits") + + var gql GraphQLRequest + require.NoError(t, json.Unmarshal(result, &gql)) + assert.Contains(t, gql.Query, "author{user{login}}") + assert.Contains(t, gql.Query, "fragment c on Commit{oid,messageHeadline,author{user{login}}}") +} + +func TestInjectGuardFields_CommitSkipsWhenAuthorUserPresent(t *testing.T) { + // Has author{user{login}} already — should be a no-op + query := `query { repository(owner:"o", name:"r") { defaultBranchRef { target { ... on Commit { history(first:10) { nodes { oid author { user { login } } } } } } } } }` + body, _ := json.Marshal(GraphQLRequest{Query: query}) + result := InjectGuardFields(body, "list_commits") + assert.Equal(t, body, result) +} + func countOccurrences(s, substr string) int { count := 0 for i := 0; i+len(substr) <= len(s); i++ { From a9ad06b73853dac5073dd9f0e85ee7c59017c01c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Mon, 23 Mar 2026 06:53:15 -0700 Subject: [PATCH 2/2] fix(guard): add copilot-swe-agent to trusted first-party bots The Copilot SWE Agent (app/copilot-swe-agent) was not recognized as a trusted first-party bot, causing its PRs to receive none integrity and be filtered by DIFC. Add all login variants: - copilot-swe-agent[bot] (REST API bot user) - copilot-swe-agent (without [bot] suffix) - app/copilot-swe-agent (gh CLI app/ prefix) Update tests to use a non-builtin bot name for configured-only trusted bot scenarios since copilot-swe-agent is now built-in. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../github-guard/rust-guard/src/labels/helpers.rs | 8 +++++++- guards/github-guard/rust-guard/src/labels/mod.rs | 14 +++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/guards/github-guard/rust-guard/src/labels/helpers.rs b/guards/github-guard/rust-guard/src/labels/helpers.rs index 6dc9b353..dfc1d595 100644 --- a/guards/github-guard/rust-guard/src/labels/helpers.rs +++ b/guards/github-guard/rust-guard/src/labels/helpers.rs @@ -1229,7 +1229,10 @@ pub fn commit_integrity( /// - github-actions: GitHub Actions workflow actor (without [bot] suffix, as returned by some APIs) /// - app/github-actions: GitHub Actions workflow actor (with app/ prefix, as returned by gh CLI) /// - github-merge-queue[bot]: GitHub merge queue automation -/// - copilot: GitHub Copilot AI assistant +/// - copilot: GitHub Copilot coding agent (app login) +/// - copilot-swe-agent[bot]: GitHub Copilot SWE agent (bot user login from REST API) +/// - copilot-swe-agent: GitHub Copilot SWE agent (without [bot] suffix) +/// - app/copilot-swe-agent: GitHub Copilot SWE agent (with app/ prefix, as returned by gh CLI) pub fn is_trusted_first_party_bot(username: &str) -> bool { let lower = username.to_lowercase(); lower == "dependabot[bot]" @@ -1238,6 +1241,9 @@ pub fn is_trusted_first_party_bot(username: &str) -> bool { || lower == "app/github-actions" || lower == "github-merge-queue[bot]" || lower == "copilot" + || lower == "copilot-swe-agent[bot]" + || lower == "copilot-swe-agent" + || lower == "app/copilot-swe-agent" } /// Check if a user is in the gateway-configured trusted bot list. diff --git a/guards/github-guard/rust-guard/src/labels/mod.rs b/guards/github-guard/rust-guard/src/labels/mod.rs index ddadb130..c608e3e0 100644 --- a/guards/github-guard/rust-guard/src/labels/mod.rs +++ b/guards/github-guard/rust-guard/src/labels/mod.rs @@ -833,11 +833,15 @@ mod tests { assert!(is_trusted_first_party_bot("github-actions[bot]")); assert!(is_trusted_first_party_bot("github-merge-queue[bot]")); assert!(is_trusted_first_party_bot("copilot")); + assert!(is_trusted_first_party_bot("copilot-swe-agent[bot]")); + assert!(is_trusted_first_party_bot("copilot-swe-agent")); + assert!(is_trusted_first_party_bot("app/copilot-swe-agent")); // Case-insensitive assert!(is_trusted_first_party_bot("Dependabot[bot]")); assert!(is_trusted_first_party_bot("GitHub-Actions[bot]")); assert!(is_trusted_first_party_bot("Copilot")); + assert!(is_trusted_first_party_bot("Copilot-Swe-Agent[bot]")); // Not trusted (third-party bots) assert!(!is_trusted_first_party_bot("renovate[bot]")); @@ -1020,13 +1024,13 @@ mod tests { let repo = "github/copilot"; let ctx_with_bots = PolicyContext { - trusted_bots: vec!["copilot-swe-agent[bot]".to_string()], + trusted_bots: vec!["my-deploy-bot[bot]".to_string()], ..Default::default() }; // A configured trusted bot issue gets approved (writer) integrity even with NONE association let configured_bot_issue = json!({ - "user": {"login": "copilot-swe-agent[bot]"}, + "user": {"login": "my-deploy-bot[bot]"}, "author_association": "NONE" }); assert_eq!( @@ -1036,7 +1040,7 @@ mod tests { // Case-insensitive match let upper_bot_issue = json!({ - "user": {"login": "COPILOT-SWE-AGENT[BOT]"}, + "user": {"login": "MY-DEPLOY-BOT[BOT]"}, "author_association": "NONE" }); assert_eq!( @@ -1057,13 +1061,13 @@ mod tests { let repo = "github/copilot"; let ctx_with_bots = PolicyContext { - trusted_bots: vec!["copilot-swe-agent[bot]".to_string()], + trusted_bots: vec!["my-deploy-bot[bot]".to_string()], ..Default::default() }; // A configured trusted bot PR gets approved (writer) integrity even with NONE association let configured_bot_pr = json!({ - "user": {"login": "copilot-swe-agent[bot]"}, + "user": {"login": "my-deploy-bot[bot]"}, "author_association": "NONE" }); assert_eq!(