Skip to content

Commit 662a784

Browse files
authored
Add built-in promotion and demotion labels to GitHub guard (#4899)
Adds two optional, opt-in policy fields that let operators promote or demote content item integrity via GitHub labels — a more auditable alternative to reactions since only triage+ users can add labels. ## Policy fields | Field | JSON key | Default | Effect | |---|---|---|---| | `promotion_label` | `"promotion-label"` | `""` (disabled) | Raises integrity to `approved` when label is present | | `demotion_label` | `"demotion-label"` | `""` (disabled) | Caps integrity at `none` when label is present | Example policy JSON: ```json { "promotion-label": "agent-approved", "demotion-label": "agent-blocked" } ``` ## Evaluation order Updated `apply_post_integrity_adjustments` chain: 1. `approval-labels` promotion (existing) 2. **Built-in promotion label** ← new 3. Endorsement reactions (existing) 4. **Built-in demotion label** ← new (overrides steps 1–3) 5. Disapproval reactions (existing; lower result wins against step 4) 6. `blocked-users` (unconditional, unchanged) ## Changes - **`helpers.rs`**: New `promotion_label`/`demotion_label` fields on `PolicyContext`; `has_promotion_label`, `has_demotion_label` public helpers; `apply_promotion_label_promotion`, `apply_demotion_label_demotion` internal functions wired into the adjustment pipeline - **`lib.rs`**: `AllowOnlyPolicy` gains `promotion-label` and `demotion-label` serde fields, threaded into `PolicyContext` construction in `label_agent` - **`mod.rs`**: Re-exports new helpers; ~20 unit tests covering detection, case-insensitivity, precedence (demotion overrides promotion and `approval-labels`; `blocked-users` still wins unconditionally) > [!WARNING] > > <details> > <summary>Firewall rules blocked me from connecting to one or more addresses (expand for details)</summary> > > #### I tried to connect to the following addresses, but was blocked by firewall rules: > > - `example.com` > - Triggering command: `/tmp/go-build533781863/b513/launcher.test /tmp/go-build533781863/b513/launcher.test -test.testlogfile=/tmp/go-build533781863/b513/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build533781863/b413/vet.cfg 1.80.0/internal/resolver/delegatingresolver/delegatingresolver.go -I x_amd64/vet --gdwarf-5 nal/encoding/def-atomic -o x_amd64/vet -I g_.a 64/src/runtime/t-ifaceassert x_amd64/vet --gdwarf-5 ernal/middleware-atomic -o x_amd64/vet` (dns block) > - `invalid-host-that-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build533781863/b495/config.test /tmp/go-build533781863/b495/config.test -test.testlogfile=/tmp/go-build533781863/b495/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build533781863/b368/vet.cfg 1.80.0/internal/resolver/dns/dns_resolver.go aw-mcpg/internal/sys/docker.go x_amd64/vet --gdwarf-5 --64 -o x_amd64/vet -I g_.a -I x_amd64/vet --gdwarf-5 --64 -o x_amd64/vet` (dns block) > - `nonexistent.local` > - Triggering command: `/tmp/go-build533781863/b513/launcher.test /tmp/go-build533781863/b513/launcher.test -test.testlogfile=/tmp/go-build533781863/b513/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build533781863/b413/vet.cfg 1.80.0/internal/resolver/delegatingresolver/delegatingresolver.go -I x_amd64/vet --gdwarf-5 nal/encoding/def-atomic -o x_amd64/vet -I g_.a 64/src/runtime/t-ifaceassert x_amd64/vet --gdwarf-5 ernal/middleware-atomic -o x_amd64/vet` (dns block) > - `slow.example.com` > - Triggering command: `/tmp/go-build533781863/b513/launcher.test /tmp/go-build533781863/b513/launcher.test -test.testlogfile=/tmp/go-build533781863/b513/testlog.txt -test.paniconexit0 -test.timeout=10m0s /tmp/go-build533781863/b413/vet.cfg 1.80.0/internal/resolver/delegatingresolver/delegatingresolver.go -I x_amd64/vet --gdwarf-5 nal/encoding/def-atomic -o x_amd64/vet -I g_.a 64/src/runtime/t-ifaceassert x_amd64/vet --gdwarf-5 ernal/middleware-atomic -o x_amd64/vet` (dns block) > - `this-host-does-not-exist-12345.com` > - Triggering command: `/tmp/go-build533781863/b522/mcp.test /tmp/go-build533781863/b522/mcp.test -test.testlogfile=/tmp/go-build533781863/b522/testlog.txt -test.paniconexit0 -test.timeout=10m0s -W .cfg /tmp/go-build141-ifaceassert x_amd64/vet . --gdwarf2 --64 x_amd64/vet .cfg�� 1358588/b392/_pkg_.a -I x_amd64/vet --gdwarf-5 g/protobuf/inter--version -o x_amd64/vet` (dns block) > > If you need me to access, download, or install something from one of these locations, you can either: > > - Configure [Actions setup steps](https://gh.io/copilot/actions-setup-steps) to set up my environment, which run before the firewall is enabled > - Add the appropriate URLs or hosts to the custom allowlist in this repository's [Copilot coding agent settings](https://github.com/github/gh-aw-mcpg/settings/copilot/coding_agent) (admins only) > > </details>
2 parents 5216c6c + 944c935 commit 662a784

3 files changed

Lines changed: 368 additions & 3 deletions

File tree

guards/github-guard/rust-guard/src/labels/helpers.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,15 @@ pub struct PolicyContext {
145145
/// endorsement or disapproval. Defaults to "approved" when empty. Options:
146146
/// "none", "unapproved", "approved", "merged".
147147
pub endorser_min_integrity: String,
148+
/// A single GitHub label name that promotes a content item's effective integrity to
149+
/// "approved" when present. Disabled when empty. Case-insensitive. Composes with
150+
/// `approval-labels`; both can promote to approved.
151+
pub promotion_label: String,
152+
/// A single GitHub label name that demotes a content item's effective integrity to
153+
/// "none" when present. Disabled when empty. Case-insensitive. Overrides promotion
154+
/// label, approval-labels, trusted-users, and endorsement reactions. Only
155+
/// `blocked-users` takes precedence over demotion label.
156+
pub demotion_label: String,
148157
}
149158

150159
fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String {
@@ -352,6 +361,77 @@ fn apply_approval_label_promotion(
352361
}
353362
}
354363

364+
// ============================================================================
365+
// Built-in promotion/demotion label helpers
366+
// ============================================================================
367+
368+
/// Check whether a content item carries the configured built-in promotion label
369+
/// (case-insensitive). Returns `false` when `promotion_label` is empty (feature disabled).
370+
pub fn has_promotion_label(item: &Value, ctx: &PolicyContext) -> bool {
371+
if ctx.promotion_label.is_empty() {
372+
return false;
373+
}
374+
let label_names = extract_github_label_names(item);
375+
label_names
376+
.iter()
377+
.any(|name| ctx.promotion_label.eq_ignore_ascii_case(name))
378+
}
379+
380+
/// Check whether a content item carries the configured built-in demotion label
381+
/// (case-insensitive). Returns `false` when `demotion_label` is empty (feature disabled).
382+
pub fn has_demotion_label(item: &Value, ctx: &PolicyContext) -> bool {
383+
if ctx.demotion_label.is_empty() {
384+
return false;
385+
}
386+
let label_names = extract_github_label_names(item);
387+
label_names
388+
.iter()
389+
.any(|name| ctx.demotion_label.eq_ignore_ascii_case(name))
390+
}
391+
392+
/// Apply built-in promotion label: if the item carries the configured promotion label,
393+
/// raise integrity to at least writer (approved) level.
394+
fn apply_promotion_label_promotion(
395+
item: &Value,
396+
resource_type: &str,
397+
repo_full_name: &str,
398+
integrity: Vec<String>,
399+
ctx: &PolicyContext,
400+
) -> Vec<String> {
401+
if has_promotion_label(item, ctx) {
402+
let number = item.get(field_names::NUMBER).and_then(|v| v.as_u64()).unwrap_or(0);
403+
crate::log_info(&format!(
404+
"[integrity] {}:{}#{} promoted to approved (built-in promotion-label '{}')",
405+
resource_type, repo_full_name, number, ctx.promotion_label
406+
));
407+
max_integrity(repo_full_name, integrity, writer_integrity(repo_full_name, ctx), ctx)
408+
} else {
409+
integrity
410+
}
411+
}
412+
413+
/// Apply built-in demotion label: if the item carries the configured demotion label,
414+
/// cap integrity at none. Overrides promotion label, approval-labels, trusted-users,
415+
/// and endorsement reactions. Only `blocked-users` takes absolute precedence.
416+
fn apply_demotion_label_demotion(
417+
item: &Value,
418+
resource_type: &str,
419+
repo_full_name: &str,
420+
integrity: Vec<String>,
421+
ctx: &PolicyContext,
422+
) -> Vec<String> {
423+
if has_demotion_label(item, ctx) {
424+
let number = item.get(field_names::NUMBER).and_then(|v| v.as_u64()).unwrap_or(0);
425+
crate::log_info(&format!(
426+
"[integrity] {}:{}#{} demoted to none (built-in demotion-label '{}')",
427+
resource_type, repo_full_name, number, ctx.demotion_label
428+
));
429+
cap_integrity(repo_full_name, integrity, none_integrity(repo_full_name, ctx), ctx)
430+
} else {
431+
integrity
432+
}
433+
}
434+
355435
// ============================================================================
356436
// Reaction-based endorsement and disapproval helpers
357437
// ============================================================================
@@ -1431,9 +1511,11 @@ pub fn is_default_branch_commit_context(tool_name: &str, sha_or_ref: &str) -> bo
14311511

14321512
/// Apply the standard post-integrity adjustment pipeline to a content item after
14331513
/// baseline integrity calculation:
1434-
/// 1. Approval-label promotion → raise to at least approved
1435-
/// 2. Endorsement promotion → raise to at least approved on maintainer reaction
1436-
/// 3. Disapproval demotion → cap at configured level on maintainer reaction (wins last)
1514+
/// 1. Approval-label promotion → raise to at least approved
1515+
/// 2. Built-in promotion label → raise to at least approved (new)
1516+
/// 3. Endorsement promotion → raise to at least approved on maintainer reaction
1517+
/// 4. Built-in demotion label → cap at none (new; overrides steps 1–3)
1518+
/// 5. Disapproval demotion → cap at configured level on maintainer reaction (wins last)
14371519
fn apply_post_integrity_adjustments(
14381520
item: &Value,
14391521
resource_type: &str,
@@ -1443,8 +1525,12 @@ fn apply_post_integrity_adjustments(
14431525
) -> Vec<String> {
14441526
let integrity =
14451527
apply_approval_label_promotion(item, resource_type, repo_full_name, integrity, ctx);
1528+
let integrity =
1529+
apply_promotion_label_promotion(item, resource_type, repo_full_name, integrity, ctx);
14461530
let integrity =
14471531
apply_endorsement_promotion(item, resource_type, repo_full_name, integrity, ctx);
1532+
let integrity =
1533+
apply_demotion_label_demotion(item, resource_type, repo_full_name, integrity, ctx);
14481534
apply_disapproval_demotion(item, resource_type, repo_full_name, integrity, ctx)
14491535
}
14501536

0 commit comments

Comments
 (0)