Skip to content

feat: use collaborator permission level for integrity filtering#2863

Merged
lpcox merged 5 commits intomainfrom
fix/collaborator-permission-integrity
Mar 30, 2026
Merged

feat: use collaborator permission level for integrity filtering#2863
lpcox merged 5 commits intomainfrom
fix/collaborator-permission-integrity

Conversation

@lpcox
Copy link
Copy Markdown
Collaborator

@lpcox lpcox commented Mar 30, 2026

Summary

Supplements author_association with GitHub's collaborator permission API (GET /repos/{owner}/{repo}/collaborators/{username}/permission) to get the user's effective permission level, which correctly includes inherited org permissions.

Problem

GitHub's author_association field reflects a user's direct relationship with a repository, not their effective permissions. Org admins who haven't been explicitly added as collaborators get CONTRIBUTOR or NONE, causing the integrity filter to silently block their issues and PRs.

Solution

When author_association gives less than writer/approved integrity, the guard now also checks the user's collaborator permission level via a new synthetic get_collaborator_permission tool. The gateway intercepts this tool name and makes a direct REST API call to GitHub.

Permission-to-integrity mapping

Permission Integrity
admin, maintain, write approved (writer)
triage, read unapproved (reader)
none none

Graceful degradation

If the permission API call fails (no token, 403, 404, network error), the system falls back to the existing author_association logic. No behavior change when the API is unavailable.

Changes

Rust Guard

  • backend.rs: New CollaboratorPermission struct + get_collaborator_permission_with_callback() function with extensive logging at every step (entry, empty-field validation, callback failure, JSON parse errors, and success with resolved permission/login). 12 unit tests.
  • helpers.rs: New collaborator_permission_floor() mapping function (12 unit tests)
  • tool_rules.rs: issue_read and pull_request_read paths now check collaborator permission when author_association gives less than writer integrity; each path logs when the check is initiated (with current integrity floor and login), the returned permission, the merged integrity level, and when the lookup returns None.

Go Gateway

  • unified.go: guardBackendCaller.CallTool() intercepts get_collaborator_permission and makes direct REST call to GitHub API using env token. Extensive logging covers unexpected args type, missing required fields, GET path with owner/repo/username, REST call failures, and success with the extracted permission field.
  • proxy.go: restBackendCaller.CallTool() handles new tool via existing enrichment path. Logs missing args with field values and the resolved permission extracted from the successful JSON response.

Tests

  • Rust: 23 new tests (12 for backend, 11 for helpers)
  • Go unified: 18 new tests covering all permission levels, error cases, token lookup, API URL resolution, auth headers, MCP response format
  • Go proxy: 9 new tests covering permission levels, error cases, token precedence

Total: 50 new tests

Supplement author_association with GitHub's collaborator permission API
(GET /repos/{owner}/{repo}/collaborators/{username}/permission) to get
the user's effective permission level, which correctly includes inherited
org permissions.

This fixes a bug where org admins with CONTRIBUTOR or NONE
author_association had their content silently filtered by the integrity
filter, even though they have admin/write access to the repository.

Changes:
- Rust guard: add get_collaborator_permission backend callback that
  queries the synthetic get_collaborator_permission tool
- Rust guard: add collaborator_permission_floor mapping function
  (admin/maintain/write -> approved, triage/read -> unapproved)
- Rust guard: tool_rules.rs calls collaborator permission when
  author_association gives less than writer integrity
- Gateway unified mode: guardBackendCaller intercepts synthetic tool
  and makes direct REST call to GitHub API
- Gateway proxy mode: restBackendCaller handles new tool case

Graceful degradation: if the permission API call fails (no token,
403, 404), falls back to the existing author_association logic.

Fixes #2862

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 30, 2026 18:26
@lpcox
Copy link
Copy Markdown
Collaborator Author

lpcox commented Mar 30, 2026

@copilot make sure that interactions with get_collaborator_permission are extensively logged in the rust and go code.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a collaborator-permission-based integrity signal to the GitHub guard by introducing a synthetic get_collaborator_permission tool that the gateway translates into GitHub’s REST collaborator permission endpoint, improving integrity filtering for users with inherited org permissions (fixes #2862).

Changes:

  • Gateway: intercepts get_collaborator_permission in unified mode and routes it to GitHub’s REST API; proxy mode supports the same tool via existing REST enrichment plumbing.
  • Rust guard: maps collaborator permission → integrity floor and uses it as a supplement when author_association is below writer/approved.
  • Tests: adds unit/integration tests covering permission levels, error paths, token selection, and MCP wrapper formatting.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
internal/server/unified.go Intercepts get_collaborator_permission and performs a direct GitHub REST call, returning an MCP-wrapped response.
internal/server/collaborator_permission_test.go Tests unified-mode collaborator permission interception, token/env lookup, error handling, and MCP response format.
internal/proxy/proxy.go Adds get_collaborator_permission support to the proxy’s REST enrichment tool switch.
internal/proxy/collaborator_permission_test.go Tests proxy-mode behavior for collaborator permission calls and token precedence.
guards/github-guard/rust-guard/src/labels/tool_rules.rs Uses collaborator permission as a fallback integrity signal when author_association is insufficient.
guards/github-guard/rust-guard/src/labels/helpers.rs Introduces collaborator_permission_floor() mapping permission → integrity (with tests).
guards/github-guard/rust-guard/src/labels/backend.rs Adds backend callback plumbing to fetch collaborator permission via the synthetic tool (with tests).
Comments suppressed due to low confidence (1)

guards/github-guard/rust-guard/src/labels/tool_rules.rs:258

  • Using integrity.len() < 3 to decide whether to fetch collaborator permissions couples this logic to the current label vector length. This can become incorrect if integrity tags gain additional entries; consider basing the decision on integrity rank/label presence instead of vector length.
                        // Supplement with collaborator permission when author_association
                        // gives less than writer integrity (e.g., CONTRIBUTOR for org admins)
                        if integrity.len() < 3 {
                            if let Some(ref login) = facts.author_login {
                                if let Some(collab) = super::backend::get_collaborator_permission(&owner, &repo, login) {

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +291 to +295
req.Header.Set("Authorization", "token "+token)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This REST call uses http.DefaultClient, which has no request timeout configured. A hung connection to GitHub can block guard labeling indefinitely; use a dedicated http.Client with a reasonable Timeout (and ideally a hardened Transport) instead of the default client.

Copilot uses AI. Check for mistakes.
Comment on lines +472 to +499
/// Fetch collaborator permission level for a user in a repository.
/// Uses the synthetic `get_collaborator_permission` tool which the gateway translates
/// to GET /repos/{owner}/{repo}/collaborators/{username}/permission.
/// Returns the user's effective permission (including inherited org permissions),
/// which is more accurate than author_association for org admins.
pub fn get_collaborator_permission_with_callback(
callback: GithubMcpCallback,
owner: &str,
repo: &str,
username: &str,
) -> Option<CollaboratorPermission> {
if owner.is_empty() || repo.is_empty() || username.is_empty() {
return None;
}

let args = serde_json::json!({
"owner": owner,
"repo": repo,
"username": username,
});

let args_str = args.to_string();
let mut result_buffer = vec![0u8; SMALL_BUFFER_SIZE];

let len = match callback("get_collaborator_permission", &args_str, &mut result_buffer) {
Ok(len) if len > 0 => len,
_ => return None,
};
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This collaborator permission lookup is invoked in labeling paths but has no caching, unlike the repo visibility lookup earlier in this module. Consider adding a small in-memory cache keyed by (owner/repo, username) to reduce rate-limit risk and latency when multiple items trigger permission checks in a single session/run.

Copilot uses AI. Check for mistakes.
}
// Supplement with collaborator permission when author_association
// gives less than writer integrity (e.g., CONTRIBUTOR for org admins)
if floor.len() < 3 {
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using floor.len() < 3 as a proxy for “less than writer integrity” is brittle because it relies on the current label vector shape rather than the integrity rank semantics. Prefer an explicit rank check (e.g. via a helper that mirrors integrity_rank) or checking for the writer label for the normalized scope.

This issue also appears on line 254 of the same file.

Suggested change
if floor.len() < 3 {
let writer_floor = writer_integrity(repo_id, ctx);
let elevated_floor = max_integrity(
repo_id,
floor.clone(),
writer_floor,
ctx,
);
if elevated_floor != floor {

Copilot uses AI. Check for mistakes.
lpcox and others added 2 commits March 30, 2026 11:36
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
- Rust backend.rs: log start/empty-args/callback-failure/JSON-parse/success
  with owner/repo/username and resolved permission at log_info level
- Rust tool_rules.rs: log when collaborator permission check is initiated
  (with current integrity floor/len), result (permission string), and
  merged integrity len for both issue_read and pull_request_read paths
- Go unified.go: log unexpected args type, missing args with values,
  per-call GET path with owner/repo/username, REST call failures,
  and success with extracted permission level from JSON response
- Go proxy.go: log missing args with field values for
  get_collaborator_permission; after success log the resolved permission
  level extracted from the JSON response body

Agent-Logs-Url: https://github.com/github/gh-aw-mcpg/sessions/988ecd15-f8a9-4a34-a740-c55a2eef9eb3

Co-authored-by: lpcox <15877973+lpcox@users.noreply.github.com>
@lpcox
Copy link
Copy Markdown
Collaborator Author

lpcox commented Mar 30, 2026

@copilot apply changes based on the comments in this thread

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 30, 2026

@copilot make sure that interactions with get_collaborator_permission are extensively logged in the rust and go code.

Added extensive logging across all four files in commit c87afe9 (Rust) / f53ce55 (Go refactor):

Rust backend.rs (get_collaborator_permission_with_callback): log_warn on empty-field validation, log_debug on entry with owner/repo/username, log_warn on callback failure/empty response/UTF-8/JSON parse errors, log_info on success with resolved permission and login.

Rust tool_rules.rs (both issue_read and pull_request_read): log_info when the collaborator permission check is initiated (showing current integrity floor len and the login being looked up), log_info with the returned permission string and merged integrity len after elevation, and log_info when the lookup returns None (keeping the author_association floor).

Go unified.go (callCollaboratorPermission): logs unexpected args type, missing required args with field values, the GET path including owner/repo/username, REST call failures, and on success the extracted permission field from the JSON response.

Go proxy.go (restBackendCaller): logs missing args with field values, and after a successful response extracts and logs the permission field from the JSON body.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • example.com
    • Triggering command: /tmp/go-build3601291359/b334/launcher.test /tmp/go-build3601291359/b334/launcher.test -test.testlogfile=/tmp/go-build3601291359/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a lang.org/x/net/http2/hpack/encod--gdwarf2 ache/go/1.25.8/x64/pkg/tool/linu--64 (dns block)
    • Triggering command: /tmp/go-build2955893734/b338/launcher.test /tmp/go-build2955893734/b338/launcher.test -test.testlogfile=/tmp/go-build2955893734/b338/testlog.txt -test.paniconexit0 -test.timeout=10m0s /home/REDACTED/.cache/go-build/20/20b97b104a04a95de5ddb14fc83f0f29501057a0c540fb50fd99506abab87f34-d lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libminiz_oxide-92023c1cb0992e10.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libadler2-5db259ced01f20df.rlib 1291359/b352=&gt; d30fe3.rlib credential.usern/tmp/go-build4028916734/b229/vet.cfg ache/Python/3.12.13/x64/bin/git grep -qE ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/rustceLVldV/symbols.o ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398--version ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398b92b2aace.1astod-stringintconv z/symbols.o pt_build-10e2da2-atomic known-linux-gnu/-bool known-linux-gnu/-buildtags (dns block)
  • invalid-host-that-does-not-exist-12345.com
    • Triggering command: /tmp/go-build3601291359/b316/config.test /tmp/go-build3601291359/b316/config.test -test.testlogfile=/tmp/go-build3601291359/b316/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true go 7luPlZJ6G x_amd64/vet pull.rebase (dns block)
    • Triggering command: /tmp/go-build3543185180/b316/config.test /tmp/go-build3543185180/b316/config.test -test.testlogfile=/tmp/go-build3543185180/b316/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true ache/go/1.25.8/x64/src/runtime/c-p ache/go/1.25.8/x64/src/internal/github.com/tetratelabs/wazero/internal/engine/wazevo/backend/reg-o x_amd64/compile -p 1291359/b156/sym--version -lang=go1.25 x_amd64/compile go_.�� 64/src/net -trimpath x_amd64/link -p internal/syscalldocker-cli-plugin-metadata -lang=go1.25 x_amd64/link (dns block)
    • Triggering command: /tmp/go-build2955893734/b320/config.test /tmp/go-build2955893734/b320/config.test -test.testlogfile=/tmp/go-build2955893734/b320/testlog.txt -test.paniconexit0 -test.timeout=10m0s lib/�� lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libmemchr-18191ac39533322d.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libaddr2line-b71e0b8cc6121ae3.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libgimli-73b3201bc7b0c6bf.rlib lib/rustlib/x86_/opt/hostedtoolcache/go/1.25.8/x64/pkg/tool/linux_amd64/vet lib/rustlib/x86_-unsafeptr=false lib/rustlib/x86_/tmp/go-build4028916734/b065/vet.cfg lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libhashbrown-12fc529a8c82ceee.rlib lib/�� lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libminiz_oxide-92023c1cb0992e10.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libadler2-5db259ced01f20df.rlib /usr/sbin/bash d30fe3.rlib .0rhlut5.rcgu.o .0rhlut5.rcgu.o .0rhlut5.rcgu.o (dns block)
  • nonexistent.local
    • Triggering command: /tmp/go-build3601291359/b334/launcher.test /tmp/go-build3601291359/b334/launcher.test -test.testlogfile=/tmp/go-build3601291359/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a lang.org/x/net/http2/hpack/encod--gdwarf2 ache/go/1.25.8/x64/pkg/tool/linu--64 (dns block)
    • Triggering command: /tmp/go-build2955893734/b338/launcher.test /tmp/go-build2955893734/b338/launcher.test -test.testlogfile=/tmp/go-build2955893734/b338/testlog.txt -test.paniconexit0 -test.timeout=10m0s /home/REDACTED/.cache/go-build/20/20b97b104a04a95de5ddb14fc83f0f29501057a0c540fb50fd99506abab87f34-d lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libminiz_oxide-92023c1cb0992e10.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libadler2-5db259ced01f20df.rlib 1291359/b352=&gt; d30fe3.rlib credential.usern/tmp/go-build4028916734/b229/vet.cfg ache/Python/3.12.13/x64/bin/git grep -qE ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/rustceLVldV/symbols.o ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398--version ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398b92b2aace.1astod-stringintconv z/symbols.o pt_build-10e2da2-atomic known-linux-gnu/-bool known-linux-gnu/-buildtags (dns block)
  • slow.example.com
    • Triggering command: /tmp/go-build3601291359/b334/launcher.test /tmp/go-build3601291359/b334/launcher.test -test.testlogfile=/tmp/go-build3601291359/b334/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true g_.a lang.org/x/net/http2/hpack/encod--gdwarf2 ache/go/1.25.8/x64/pkg/tool/linu--64 (dns block)
    • Triggering command: /tmp/go-build2955893734/b338/launcher.test /tmp/go-build2955893734/b338/launcher.test -test.testlogfile=/tmp/go-build2955893734/b338/testlog.txt -test.paniconexit0 -test.timeout=10m0s /home/REDACTED/.cache/go-build/20/20b97b104a04a95de5ddb14fc83f0f29501057a0c540fb50fd99506abab87f34-d lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libminiz_oxide-92023c1cb0992e10.rlib lib/rustlib/x86_64-REDACTED-linux-gnu/lib/libadler2-5db259ced01f20df.rlib 1291359/b352=&gt; d30fe3.rlib credential.usern/tmp/go-build4028916734/b229/vet.cfg ache/Python/3.12.13/x64/bin/git grep -qE ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/rustceLVldV/symbols.o ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398--version ithub-guard/rust-guard/target/debug/build/serde_json-bfdb398b92b2aace/build_script_build-bfdb398b92b2aace.1astod-stringintconv z/symbols.o pt_build-10e2da2-atomic known-linux-gnu/-bool known-linux-gnu/-buildtags (dns block)
  • this-host-does-not-exist-12345.com
    • Triggering command: /tmp/go-build3601291359/b343/mcp.test /tmp/go-build3601291359/b343/mcp.test -test.testlogfile=/tmp/go-build3601291359/b343/testlog.txt -test.paniconexit0 -test.timeout=10m0s -test.v=true 64/src/runtime/cgo (dns block)
    • Triggering command: /tmp/go-build2955893734/b347/mcp.test /tmp/go-build2955893734/b347/mcp.test -test.testlogfile=/tmp/go-build2955893734/b347/testlog.txt -test.paniconexit0 -test.timeout=10m0s -gua�� -guard/target/debug/deps/github_guard-d6a03f414dc506a0.2qy5imqpflz26hi4smzxan76o.1t6xda3.rcgu.o -guard/target/debug/deps/github_guard-d6a03f414dc506a0.3b5uze745g1xcu3osb0kcla3j.1t6xda3.rcgu.o ker/cli-plugins/docker-buildx d30fe3.rlib 64/src/unicode/u-V=full x_amd64/vet ker/cli-plugins/docker-buildx n-me�� ithub-guard/rust-guard/target/debug/deps/rustcZqfk1h/symbols.o ithub-guard/rust-guard/target/debug/deps/github_guard-d6a03f414dc506a0.0bxgpkkxlbr5ndvint8fbouk8inspect ithub-guard/rust-guard/target/debug/deps/github_guard-d6a03f414dc506a0.0jw880073obtnc1lrfavrjkde--format ithub-guard/rust/usr/bin/runc.original ithub-guard/rust--version ithub-guard/rust-guard/target/de/tmp/go-build4028916734/b048/vet.cfg ithub-guard/rust-guard/target/debug/deps/github_guard-d6a03f414dc506a0.1bcwnvbtt1rjnfaf9npfbpy14.1t6xda3.rcgu.o (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Add owner type detection that piggybacks on existing search_repositories
calls to determine if a repo belongs to an Organization or User account.
The collaborator permission API call is now gated on org ownership since
personal repos don't have inherited org permissions that cause
author_association to be inaccurate.

Changes:
- backend.rs: Add repo_owner_type_cache, is_repo_org_owned(),
  extract_owner_is_org(), owner_is_org_from_items(), and
  owner_type_from_repo_object() functions
- backend.rs: Piggyback owner type extraction in
  is_repo_private_with_callback success path
- tool_rules.rs: Gate get_collaborator_permission calls on
  is_repo_org_owned() returning Some(true) or unknown (defaults to
  calling for safety)
- backend.rs: Add 10 new tests for owner type detection

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants