Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion guards/github-guard/docs/AGENTIC_WORKFLOW_POLICY_FRONTMATTER.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ Provide a small, explicit policy surface in workflow frontmatter that can expres

- **Scope filtering** (what repository visibility/scope is allowed)
- **Integrity floor** (minimum trust level for input content)
- **Blocked-user enforcement** (unconditionally block content from specific authors)
- **Label-based promotion** (trust labels applied by reviewers to promote content integrity)

Both controls are used to filter inputs before the agent consumes content.
All controls are used to filter inputs before the agent consumes content.

---

Expand All @@ -24,6 +26,19 @@ Both controls are used to filter inputs before the agent consumes content.
}
```

With optional integrity-level management fields:

```json
{
"AllowOnly": {
"Repos": "Public",
"min-integrity": "approved",
"blocked-users": ["external-bot", "untrusted-fork"],
"approval-labels": ["approved", "human-reviewed"]
}
}
```

### Field semantics

- `AllowOnly.Repos` (optional)
Expand All @@ -37,6 +52,36 @@ Both controls are used to filter inputs before the agent consumes content.
- `Approved`
- `Merged`

- `AllowOnly.blocked-users` (optional, array of strings)
- GitHub usernames whose content items are **unconditionally blocked**, regardless of labels or min-integrity.
- Items from blocked users receive an effective integrity of `blocked` (below `none`), which is always denied by the DIFC filter.
- `approval-labels` cannot override a blocked-user exclusion.
- An empty array is equivalent to omitting the field.

- `AllowOnly.approval-labels` (optional, array of strings)
- GitHub label names that **promote a content item's effective integrity to `approved`** when present on the item.
- If the item's computed integrity is already higher than `approved` (e.g. `merged`), it remains unchanged.
- Does not override `blocked-users`: blocked authors remain blocked even if they have an approval label.
- An empty array is equivalent to omitting the field.

### Integrity Level Hierarchy

```
blocked (below none) < none < unapproved < approved < merged
```

### Effective Integrity Computation

```
1. Start with the item's base integrity level (from GitHub metadata).
2. IF the item's author is in blocked-users:
effective_integrity ← blocked (item is always denied)
3. ELSE IF any label on the item is in approval-labels:
effective_integrity ← max(base_integrity, approved)
4. ELSE:
effective_integrity ← base_integrity
```

Rules:
- `Integrity` is required for all policies.

Expand Down
31 changes: 29 additions & 2 deletions guards/github-guard/docs/GATEWAY_ALLOWONLY_INTEGRATION_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,10 @@ This document defines the gateway changes required to support:

- Policy-driven DIFC input filtering via `AllowOnly`
- New required policy field `Integrity`
- New integrity hierarchy with baseline `none`
- New integrity hierarchy with baseline `none` and `blocked` level below `none`
- Guard-side policy initialization via a new `label_agent` interface
- Blocked-user enforcement via `blocked-users`
- Label-based integrity promotion via `approval-labels`
Comment on lines 8 to +12

Copilot AI Mar 21, 2026

Copy link

Choose a reason for hiding this comment

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

This section still states the required policy field is Integrity, but the examples and implementation use min-integrity (and the PR adds fields under that same allow-only shape). Please update the doc to consistently use the correct field/key names throughout (including later label_agent payload examples) to avoid integrators wiring the wrong JSON keys.

Copilot uses AI. Check for mistakes.

It is written as an implementation checklist for gateway and guard integration.

Expand All @@ -28,6 +30,19 @@ Gateway must accept and pass this policy shape:
}
```

With optional integrity-level management fields:

```json
{
"AllowOnly": {
"Repos": "Public",
"min-integrity": "approved",
"blocked-users": ["external-bot"],
"approval-labels": ["approved", "human-reviewed"]
}
}
```

`AllowOnly.Repos` supports:
- `"Public"`
- `{ "owner": "<owner>" }`
Expand All @@ -39,9 +54,21 @@ Gateway must accept and pass this policy shape:
- `Approved`
- `Merged`

`AllowOnly.blocked-users` (optional, array of strings):
- GitHub usernames unconditionally blocked regardless of labels or min-integrity.
- Items from blocked users have effective integrity `blocked` (below `none`) and are always denied.
- Cannot be overridden by `approval-labels`.

`AllowOnly.approval-labels` (optional, array of strings):
- GitHub label names that promote an item's effective integrity to at least `approved`.
- Promotion uses `max(base_integrity, approved)` — never lowers integrity.
- Does not override `blocked-users`.

### Integrity order

`Merged > Approveduted > Unapproveduted > none`
```
blocked (below none) < none < unapproved < approved < merged
```

For resource/response labels, `none` is always the baseline:
- public scope: `none`
Expand Down
17 changes: 17 additions & 0 deletions guards/github-guard/docs/agentic-workflow-policy.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,25 @@
]
},
"min-integrity": {
"description": "Minimum integrity level required for a content item to be accessible. Items with effective integrity below this level are blocked.",
"type": "string",
"enum": ["none", "unapproved", "approved", "merged"]
},
"blocked-users": {
"description": "GitHub usernames whose content items are unconditionally blocked (effective integrity = blocked, below none). Approval labels cannot override this.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
},
"approval-labels": {
"description": "GitHub label names that promote a content item's effective integrity to 'approved' when present. Does not override blocked-users.",
"type": "array",
"items": {
"type": "string",
"minLength": 1
}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions guards/github-guard/rust-guard/src/labels/constants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ pub mod label_constants {
pub const WRITER_PREFIX: &str = "approved:";
pub const MERGED_PREFIX: &str = "merged:";
pub const NONE_PREFIX: &str = "none:";
pub const BLOCKED_PREFIX: &str = "blocked:";
pub const BLOCKED_BASE: &str = "blocked";
pub const READER_BASE: &str = "unapproved";
pub const WRITER_BASE: &str = "approved";
pub const MERGED_BASE: &str = "merged";
Expand Down
142 changes: 140 additions & 2 deletions guards/github-guard/rust-guard/src/labels/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ pub struct PolicyContext {
/// of their author_association, just like the built-in trusted first-party bots.
/// This list is additive and cannot override the built-in trusted bot list.
pub trusted_bots: Vec<String>,
/// Usernames whose content items are always blocked (effective integrity = blocked).
/// Blocked items are unconditionally denied regardless of approval labels or min-integrity.
pub blocked_users: Vec<String>,
/// GitHub label names that promote a content item's effective integrity to "approved"
/// when present on the item. Does not override blocked_users.
pub approval_labels: Vec<String>,
}

fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String {
Expand Down Expand Up @@ -167,6 +173,62 @@ pub fn none_integrity(scope: &str, ctx: &PolicyContext) -> Vec<String> {
)]
}

/// Generate blocked-level integrity tags for a scope.
///
/// Items with blocked integrity are unconditionally denied by the DIFC filter
/// because no agent is ever assigned a "blocked:" tag. This represents the
/// integrity level for items authored by users in the `blocked-users` list.
pub fn blocked_integrity(scope: &str, ctx: &PolicyContext) -> Vec<String> {
let normalized_scope = normalize_scope(scope, ctx);
vec![format_integrity_label(
label_constants::BLOCKED_PREFIX,
&normalized_scope,
label_constants::BLOCKED_BASE,
)]
}

/// Check if a username appears in the configured blocked-users list (case-insensitive).
pub fn is_blocked_user(username: &str, ctx: &PolicyContext) -> bool {
if ctx.blocked_users.is_empty() {
return false;
}
let lower = username.to_lowercase();
ctx.blocked_users.iter().any(|u| u.to_lowercase() == lower)
}

/// Extract GitHub label names from a content item's `labels` array.
///
/// Returns the `name` field from each element of the item's `labels` array.
fn extract_github_label_names<'a>(item: &'a Value) -> Vec<&'a str> {
item.get("labels")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|label| label.get("name").and_then(|v| v.as_str()))
.collect()
})
.unwrap_or_default()
}

/// Check whether a content item carries at least one label from the configured
/// `approval-labels` list (case-insensitive comparison).
pub fn has_approval_label(item: &Value, ctx: &PolicyContext) -> bool {
first_matching_approval_label(item, ctx).is_some()
}

/// Return the first matching approval label name from an item, if any.
fn first_matching_approval_label<'a>(item: &'a Value, ctx: &PolicyContext) -> Option<&'a str> {
if ctx.approval_labels.is_empty() {
return None;
}
let label_names = extract_github_label_names(item);
label_names.into_iter().find(|name| {
ctx.approval_labels
.iter()
.any(|al| al.eq_ignore_ascii_case(name))
})
}

pub fn ensure_integrity_baseline(
scope: &str,
integrity: Vec<String>,
Expand Down Expand Up @@ -771,17 +833,30 @@ pub fn is_default_branch_commit_context(tool_name: &str, sha_or_ref: &str) -> bo

/// Determine integrity level for a pull request
/// Rules:
/// - PR authored by a blocked user => blocked-level (unconditional denial)
/// - merged PR => merged-level
/// - private repo PR => approved
/// - public forked PR => unapproved
/// - public direct PR => approved
/// - PR with an approval label => at least approved
pub fn pr_integrity(
item: &Value,
repo_full_name: &str,
repo_private: bool,
is_forked: Option<bool>,
ctx: &PolicyContext,
) -> Vec<String> {
// Step 1: Check if author is in blocked_users — takes precedence over all other rules.
let author_login = extract_author_login(item);
if !author_login.is_empty() && is_blocked_user(author_login, ctx) {
let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
crate::log_info(&format!(
"[integrity] pr:{}#{} → blocked (author '{}' in blocked-users)",
repo_full_name, number, author_login
));
return blocked_integrity(repo_full_name, ctx);
}

let mut integrity = author_association_floor(item, repo_full_name, ctx);

// Check if PR is merged (either merged_at field exists or merged boolean is true)
Expand Down Expand Up @@ -825,19 +900,49 @@ pub fn pr_integrity(
);
}

ensure_integrity_baseline(repo_full_name, integrity, ctx)
let integrity = ensure_integrity_baseline(repo_full_name, integrity, ctx);

// Step 2: Apply approval-labels promotion — raise to at least approved.
if let Some(label) = first_matching_approval_label(item, ctx) {
let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
crate::log_info(&format!(
"[integrity] pr:{}#{} promoted to approved (label '{}' in approval-labels)",
repo_full_name, number, label
));
max_integrity(
repo_full_name,
integrity,
writer_integrity(repo_full_name, ctx),
ctx,
)
} else {
integrity
}
}

/// Determine integrity level for an issue
/// Rules:
/// - Issue authored by a blocked user => blocked-level (unconditional denial)
/// - private repo issues => approved
/// - public repo issues => no integrity
/// - Issue with an approval label => at least approved
pub fn issue_integrity(
item: &Value,
repo_full_name: &str,
repo_private: bool,
ctx: &PolicyContext,
) -> Vec<String> {
// Step 1: Check if author is in blocked_users — takes precedence over all other rules.
let author_login = extract_author_login(item);
if !author_login.is_empty() && is_blocked_user(author_login, ctx) {
let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
crate::log_info(&format!(
"[integrity] issue:{}#{} → blocked (author '{}' in blocked-users)",
repo_full_name, number, author_login
));
return blocked_integrity(repo_full_name, ctx);
}

let mut integrity = author_association_floor(item, repo_full_name, ctx);
if repo_private {
integrity = max_integrity(
Expand All @@ -847,22 +952,55 @@ pub fn issue_integrity(
ctx,
);
}
ensure_integrity_baseline(repo_full_name, integrity, ctx)
let integrity = ensure_integrity_baseline(repo_full_name, integrity, ctx);

// Step 2: Apply approval-labels promotion — raise to at least approved.
if let Some(label) = first_matching_approval_label(item, ctx) {
let number = item.get("number").and_then(|v| v.as_u64()).unwrap_or(0);
crate::log_info(&format!(
"[integrity] issue:{}#{} promoted to approved (label '{}' in approval-labels)",
repo_full_name, number, label
));
max_integrity(
repo_full_name,
integrity,
writer_integrity(repo_full_name, ctx),
ctx,
)
} else {
integrity
}
}

/// Determine integrity level for a commit.
///
/// Rules:
/// - Commit authored by a blocked user => blocked-level (unconditional denial)
/// - Start from author_association floor
/// - Private repo commits elevate to approved
/// - Default-branch reachable commits elevate to merged
///
/// Note: approval-labels promotion does not apply to commits because GitHub
/// commits do not carry issue/PR-style labels.
pub fn commit_integrity(
item: &Value,
repo_full_name: &str,
repo_private: bool,
is_default_branch: bool,
ctx: &PolicyContext,
) -> Vec<String> {
// Step 1: Check if author is in blocked_users — takes precedence over all other rules.
let author_login = extract_author_login(item);
if !author_login.is_empty() && is_blocked_user(author_login, ctx) {
let sha = item.get("sha").and_then(|v| v.as_str()).unwrap_or("unknown");
let short_sha = if sha.len() > 8 { &sha[..8] } else { sha };
crate::log_info(&format!(
"[integrity] commit:{}@{} → blocked (author '{}' in blocked-users)",
repo_full_name, short_sha, author_login
));
return blocked_integrity(repo_full_name, ctx);
}

let mut integrity = author_association_floor(item, repo_full_name, ctx);

if repo_private {
Expand Down
Loading
Loading