Problem
When min-integrity is set to approved or higher, content authored by external contributors (author_association = NONE or CONTRIBUTOR) is filtered out by the guard. Today the only mechanisms to promote such content to approved integrity are:
approval_labels — a repo maintainer manually adds a configured GitHub label (e.g., human-reviewed) to the issue/PR.
trusted_users — the author is pre-configured in the AllowOnly policy.
Both require either config changes or label management. Neither is lightweight enough for a maintainer to quickly endorse a specific issue, comment, or PR inline during a conversation.
The gap
GitHub reactions (👍, ❤️, etc.) are a natural, low-friction way for maintainers to signal endorsement or disapproval. However:
- The MCP server only returns reaction counts, not who reacted. An agent sees
{"thumbs_up": 3} but has no idea if any of those 3 are maintainers.
- The guard has no reaction-awareness today. There are no GraphQL patterns, REST routes, field injections, or enrichment calls related to reactions anywhere in the codebase.
When MCPG runs in proxy mode, it already intercepts all GitHub API traffic and makes enrichment calls (e.g., get_collaborator_permission). This means the proxy could resolve reaction authors and check their roles — the agent and MCP server never need to know.
Proposed solution: Reaction-based integrity promotion and demotion
Add two new integrity mechanisms, analogous to approval_labels:
- Endorsement (promotion): A positive reaction from a maintainer promotes an item's integrity to
approved.
- Disapproval (demotion): A negative reaction from a maintainer lowers an item's integrity, acting as an explicit repudiation. This allows maintainers to flag content that has been compromised, is misleading, or should not be trusted — even if it would otherwise qualify as
approved or unapproved based on authorship alone.
Configuration
Add new fields to the AllowOnly policy:
{
"allow-only": {
"min-integrity": "approved",
"endorsement-reactions": ["THUMBS_UP", "HEART"],
"disapproval-reactions": ["THUMBS_DOWN", "CONFUSED"],
"disapproval-integrity": "none",
"endorser-min-integrity": "approved"
}
}
endorsement-reactions: List of GitHub ReactionContent values (e.g., THUMBS_UP, HEART, HOORAY, ROCKET) that count as endorsements. Empty = feature disabled (default).
disapproval-reactions: List of GitHub ReactionContent values (e.g., THUMBS_DOWN, CONFUSED) that count as repudiations. Empty = feature disabled (default).
disapproval-integrity: The integrity level to force when a maintainer disapproval is detected. Default: none. This caps the item's effective integrity regardless of authorship or other promotion rules. Can be set to none (filtered when min-integrity > none) or even mapped to a blocked-equivalent for hard denial.
endorser-min-integrity: Minimum integrity level that the reactor must have (based on their own author_association in the repo) for their reaction to count as an endorsement or disapproval. Default: approved (i.e., the reactor must be an OWNER or COLLABORATOR). Options: approved, unapproved, merged. This reuses the same integrity hierarchy as min-integrity, keeping the mental model consistent.
Semantics: disapproval overrides endorsement
Disapproval takes precedence over endorsement. If the same item has both a maintainer 👍 and a maintainer 👎, the disapproval wins. This is the safe default — it's better to suppress trusted content than to surface untrusted content.
Precedence: blocked_users > disapproval reactions > all promotion rules
Gateway mode: graceful degradation
When MCPG runs as an MCP gateway (not proxy mode), it sits between the agent and backend MCP servers but does not intercept GitHub API traffic. In this mode:
- The guard sees tool responses from the MCP server, which include reaction counts only (e.g.,
{"thumbs_up": 3, "heart": 1}) — no user.login per reaction.
- Without knowing who reacted, the guard cannot evaluate endorsement or disapproval.
Required behavior: When endorsement-reactions or disapproval-reactions are configured and the guard encounters an item with reactions but no per-reaction user data:
- Log a warning explaining that reaction-based integrity cannot be evaluated because reactor identity is unavailable in gateway mode.
[integrity] issue:owner/repo#42: endorsement-reactions configured but reactor identity unavailable (gateway mode) — ignoring reactions for integrity evaluation
- Skip reaction evaluation entirely — do not promote or demote. Fall through to the remaining integrity rules (approval_labels, author_association, etc.) as if no reactions exist.
- Log once per session (not per item) to avoid log spam when processing collections.
This ensures the feature degrades safely — gateway mode never guesses about reactor identity, and operators get clear feedback about why reactions aren't being used.
Proxy mode: observability logging
When MCPG runs in proxy mode and reaction-based integrity evaluation is active, every promotion or demotion must be logged so operators can audit why an item's integrity changed.
Endorsement promotion:
[integrity] issue:owner/repo#42 promoted to approved (endorsement reaction THUMBS_UP from @alice, integrity=approved)
Disapproval demotion:
[integrity] issue:owner/repo#42 demoted to none (disapproval reaction THUMBS_DOWN from @bob, integrity=approved)
Reactor below threshold (no effect):
[integrity] issue:owner/repo#42: reactor @eve has integrity=none, below endorser-min-integrity=approved — ignoring THUMBS_UP
These follow the existing [integrity] log format used by apply_approval_label_promotion() and blocked_users checks, keeping log grep patterns consistent. All reaction integrity logs should go through logger.LogInfo (file logger) so they appear in mcp-gateway.log and the per-server log files.
Implementation plan
Phase 1: Proxy — Reaction enrichment infrastructure (Go)
1a. Add get_reactions enrichment tool to restBackendCaller (internal/proxy/proxy.go)
Add a new case in CallTool() following the existing get_collaborator_permission pattern:
case "get_reactions":
owner, _ := argsMap["owner"].(string)
repo, _ := argsMap["repo"].(string)
number, _ := argsMap["number"].(string)
contentType, _ := argsMap["content_type"].(string) // "issues" or "pulls"
apiPath = fmt.Sprintf("/repos/%s/%s/%s/%s/reactions", owner, repo, contentType, number)
This makes a REST call to GET /repos/{owner}/{repo}/issues/{number}/reactions using the enrichment token (server token with org visibility, falling back to client auth).
The response is an array of reaction objects, each containing user.login and content (the reaction type).
1b. Add get_reactions to GraphQL field injection (internal/proxy/graphql_rewrite.go)
When enabled, inject reactions(first:100){nodes{user{login},content}} into issue/PR queries so the guard gets reaction data inline without a separate enrichment call. This is an optimization — the enrichment call in 1a is the fallback.
Phase 2: Guard — Reaction evaluation (Rust)
2a. Extend PolicyContext (guards/github-guard/rust-guard/src/labels/helpers.rs)
pub struct PolicyContext {
// ... existing fields ...
pub endorsement_reactions: Vec<String>, // e.g., ["THUMBS_UP", "HEART"]
pub disapproval_reactions: Vec<String>, // e.g., ["THUMBS_DOWN", "CONFUSED"]
pub disapproval_integrity: String, // e.g., "none"
pub endorser_min_integrity: String, // e.g., "approved"
}
2b. Add has_maintainer_disapproval() function (guards/github-guard/rust-guard/src/labels/helpers.rs)
pub fn has_maintainer_disapproval(item: &Value, ctx: &PolicyContext) -> bool {
if ctx.disapproval_reactions.is_empty() {
return false;
}
// Extract reactions array from item (reactions.nodes[])
// For each reaction:
// 1. Check if reaction.content is in disapproval_reactions
// 2. If yes, call get_collaborator_permission for reaction.user.login
// 3. Resolve reactor's integrity via author_association (or enrichment)
// 4. If reactor's integrity >= endorser_min_integrity, return true
false
}
2c. Add has_maintainer_endorsement() function
Analogous to has_approval_label():
pub fn has_maintainer_endorsement(item: &Value, ctx: &PolicyContext) -> bool {
if ctx.endorsement_reactions.is_empty() {
return false;
}
// Same pattern as disapproval but checking endorsement_reactions
// Reactor's integrity resolved via author_association_floor
false
}
2d. Add apply_disapproval_demotion() and apply_endorsement_promotion()
Following the apply_approval_label_promotion() pattern:
/// Disapproval: cap integrity at the configured disapproval level.
fn apply_disapproval_demotion(
item: &Value,
resource_type: &str,
repo_full_name: &str,
integrity: Vec<String>,
ctx: &PolicyContext,
) -> Vec<String> {
if has_maintainer_disapproval(item, ctx) {
let number = item.get(field_names::NUMBER)
.and_then(|v| v.as_u64()).unwrap_or(0);
log_info(&format!(
"[integrity] {}:{}#{} demoted to {} (maintainer disapproval reaction)",
resource_type, repo_full_name, number, ctx.disapproval_integrity
));
// Force integrity down to the disapproval level
vec![format!("{}:{}",
ctx.disapproval_integrity,
normalize_scope(repo_full_name, ctx))]
} else {
integrity
}
}
/// Endorsement: promote integrity to at least approved.
fn apply_endorsement_promotion(
item: &Value,
resource_type: &str,
repo_full_name: &str,
integrity: Vec<String>,
ctx: &PolicyContext,
) -> Vec<String> {
if has_maintainer_endorsement(item, ctx) {
log_info(&format!(
"[integrity] {}:{}#{} promoted to approved (maintainer endorsement reaction)",
resource_type, repo_full_name,
item.get(field_names::NUMBER)
.and_then(|v| v.as_u64()).unwrap_or(0)
));
max_integrity(
repo_full_name, integrity,
writer_integrity(repo_full_name, ctx), ctx)
} else {
integrity
}
}
2e. Wire into issue_integrity() and pr_integrity()
After the existing apply_approval_label_promotion() call, apply in order:
let integrity = apply_approval_label_promotion(item, "issue", repo_full_name, integrity, ctx);
let integrity = apply_endorsement_promotion(item, "issue", repo_full_name, integrity, ctx);
apply_disapproval_demotion(item, "issue", repo_full_name, integrity, ctx)
Disapproval runs last so it always wins over endorsement and approval labels.
Phase 3: Config parsing (Go + Rust)
3a. Go config — Parse endorsement-reactions, disapproval-reactions, disapproval-integrity, and endorser-min-integrity from the guard policy JSON and pass them through to the WASM guard.
3b. Rust config — Deserialize the new fields in the policy struct and populate PolicyContext.
Phase 4: Comment endorsement/disapproval (stretch)
Extend to issue/PR comments (not just the top-level object). A maintainer 👎 on a specific comment from an external user could repudiate just that comment without affecting the entire issue. This requires:
- Reactions on comment objects in GraphQL responses
- Per-comment integrity labeling (already exists in the collection labeling pipeline)
Security considerations
- Disapproval overrides endorsement — If a maintainer disapproves and another endorses, the item is treated as disapproved. Safe default.
- Endorsement is additive only — it can promote integrity upward but never above
approved. A merged item stays merged even without endorsement.
- Disapproval is capped, not blocked —
disapproval-integrity: "none" lowers to none (filtered by min-integrity: approved), but does not unconditionally block like blocked_users. To hard-block, use blocked_users.
blocked_users takes precedence over everything — endorsement and disapproval cannot override a block.
- Rate limiting — When
author_association is not available inline (e.g., reactions fetched via REST), each reactor may require a get_collaborator_permission enrichment call. Cap the number of reactions checked (e.g., first 20) to bound API calls.
- Caching — Reactor integrity results should be cached per
(owner, repo, username) for the duration of the request to avoid duplicate enrichment calls when multiple items share the same reactor.
- Reaction spoofing — Only reactions from users with sufficient permission count. A non-maintainer adding 👍 or 👎 has no effect.
Precedence order
The full integrity resolution chain with this feature:
blocked_users → unconditional deny (highest priority)
author_association floor → base integrity from authorship
trusted_users / trusted_bots → promote to approved
- Merge status → promote to merged
approval_labels → promote to approved
endorsement-reactions → promote to approved (NEW)
disapproval-reactions → demote to configured level (NEW, overrides 5+6)
ensure_integrity_baseline → floor enforcement
Problem
When
min-integrityis set toapprovedor higher, content authored by external contributors (author_association =NONEorCONTRIBUTOR) is filtered out by the guard. Today the only mechanisms to promote such content toapprovedintegrity are:approval_labels— a repo maintainer manually adds a configured GitHub label (e.g.,human-reviewed) to the issue/PR.trusted_users— the author is pre-configured in the AllowOnly policy.Both require either config changes or label management. Neither is lightweight enough for a maintainer to quickly endorse a specific issue, comment, or PR inline during a conversation.
The gap
GitHub reactions (👍, ❤️, etc.) are a natural, low-friction way for maintainers to signal endorsement or disapproval. However:
{"thumbs_up": 3}but has no idea if any of those 3 are maintainers.When MCPG runs in proxy mode, it already intercepts all GitHub API traffic and makes enrichment calls (e.g.,
get_collaborator_permission). This means the proxy could resolve reaction authors and check their roles — the agent and MCP server never need to know.Proposed solution: Reaction-based integrity promotion and demotion
Add two new integrity mechanisms, analogous to
approval_labels:approved.approvedorunapprovedbased on authorship alone.Configuration
Add new fields to the AllowOnly policy:
{ "allow-only": { "min-integrity": "approved", "endorsement-reactions": ["THUMBS_UP", "HEART"], "disapproval-reactions": ["THUMBS_DOWN", "CONFUSED"], "disapproval-integrity": "none", "endorser-min-integrity": "approved" } }endorsement-reactions: List of GitHubReactionContentvalues (e.g.,THUMBS_UP,HEART,HOORAY,ROCKET) that count as endorsements. Empty = feature disabled (default).disapproval-reactions: List of GitHubReactionContentvalues (e.g.,THUMBS_DOWN,CONFUSED) that count as repudiations. Empty = feature disabled (default).disapproval-integrity: The integrity level to force when a maintainer disapproval is detected. Default:none. This caps the item's effective integrity regardless of authorship or other promotion rules. Can be set tonone(filtered when min-integrity > none) or even mapped to a blocked-equivalent for hard denial.endorser-min-integrity: Minimum integrity level that the reactor must have (based on their ownauthor_associationin the repo) for their reaction to count as an endorsement or disapproval. Default:approved(i.e., the reactor must be an OWNER or COLLABORATOR). Options:approved,unapproved,merged. This reuses the same integrity hierarchy asmin-integrity, keeping the mental model consistent.Semantics: disapproval overrides endorsement
Disapproval takes precedence over endorsement. If the same item has both a maintainer 👍 and a maintainer 👎, the disapproval wins. This is the safe default — it's better to suppress trusted content than to surface untrusted content.
Precedence:
blocked_users> disapproval reactions > all promotion rulesGateway mode: graceful degradation
When MCPG runs as an MCP gateway (not proxy mode), it sits between the agent and backend MCP servers but does not intercept GitHub API traffic. In this mode:
{"thumbs_up": 3, "heart": 1}) — nouser.loginper reaction.Required behavior: When
endorsement-reactionsordisapproval-reactionsare configured and the guard encounters an item with reactions but no per-reaction user data:This ensures the feature degrades safely — gateway mode never guesses about reactor identity, and operators get clear feedback about why reactions aren't being used.
Proxy mode: observability logging
When MCPG runs in proxy mode and reaction-based integrity evaluation is active, every promotion or demotion must be logged so operators can audit why an item's integrity changed.
Endorsement promotion:
Disapproval demotion:
Reactor below threshold (no effect):
These follow the existing
[integrity]log format used byapply_approval_label_promotion()andblocked_userschecks, keeping log grep patterns consistent. All reaction integrity logs should go throughlogger.LogInfo(file logger) so they appear inmcp-gateway.logand the per-server log files.Implementation plan
Phase 1: Proxy — Reaction enrichment infrastructure (Go)
1a. Add
get_reactionsenrichment tool torestBackendCaller(internal/proxy/proxy.go)Add a new case in
CallTool()following the existingget_collaborator_permissionpattern:This makes a REST call to
GET /repos/{owner}/{repo}/issues/{number}/reactionsusing the enrichment token (server token with org visibility, falling back to client auth).The response is an array of reaction objects, each containing
user.loginandcontent(the reaction type).1b. Add
get_reactionsto GraphQL field injection (internal/proxy/graphql_rewrite.go)When enabled, inject
reactions(first:100){nodes{user{login},content}}into issue/PR queries so the guard gets reaction data inline without a separate enrichment call. This is an optimization — the enrichment call in 1a is the fallback.Phase 2: Guard — Reaction evaluation (Rust)
2a. Extend
PolicyContext(guards/github-guard/rust-guard/src/labels/helpers.rs)2b. Add
has_maintainer_disapproval()function (guards/github-guard/rust-guard/src/labels/helpers.rs)2c. Add
has_maintainer_endorsement()functionAnalogous to
has_approval_label():2d. Add
apply_disapproval_demotion()andapply_endorsement_promotion()Following the
apply_approval_label_promotion()pattern:2e. Wire into
issue_integrity()andpr_integrity()After the existing
apply_approval_label_promotion()call, apply in order:Disapproval runs last so it always wins over endorsement and approval labels.
Phase 3: Config parsing (Go + Rust)
3a. Go config — Parse
endorsement-reactions,disapproval-reactions,disapproval-integrity, andendorser-min-integrityfrom the guard policy JSON and pass them through to the WASM guard.3b. Rust config — Deserialize the new fields in the policy struct and populate
PolicyContext.Phase 4: Comment endorsement/disapproval (stretch)
Extend to issue/PR comments (not just the top-level object). A maintainer 👎 on a specific comment from an external user could repudiate just that comment without affecting the entire issue. This requires:
Security considerations
approved. Amergeditem staysmergedeven without endorsement.disapproval-integrity: "none"lowers to none (filtered bymin-integrity: approved), but does not unconditionally block likeblocked_users. To hard-block, useblocked_users.blocked_userstakes precedence over everything — endorsement and disapproval cannot override a block.author_associationis not available inline (e.g., reactions fetched via REST), each reactor may require aget_collaborator_permissionenrichment call. Cap the number of reactions checked (e.g., first 20) to bound API calls.(owner, repo, username)for the duration of the request to avoid duplicate enrichment calls when multiple items share the same reactor.Precedence order
The full integrity resolution chain with this feature:
blocked_users→ unconditional deny (highest priority)author_associationfloor → base integrity from authorshiptrusted_users/trusted_bots→ promote to approvedapproval_labels→ promote to approvedendorsement-reactions→ promote to approved (NEW)disapproval-reactions→ demote to configured level (NEW, overrides 5+6)ensure_integrity_baseline→ floor enforcement