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
25 changes: 23 additions & 2 deletions guards/github-guard/rust-guard/src/labels/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ pub struct PolicyContext {
/// 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>,
/// GitHub usernames that are elevated to approved (writer) integrity regardless of
/// their author_association. Analogous to trusted_bots but for regular human users.
/// blocked_users takes precedence over trusted_users.
pub trusted_users: Vec<String>,
}

fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String {
Expand Down Expand Up @@ -986,11 +990,13 @@ pub fn has_author_association(item: &Value) -> bool {
/// Extract author_association from an item and return initial integrity floor.
/// Trusted first-party GitHub bots and any gateway-configured trusted bots are
/// elevated to approved (writer) integrity regardless of their author_association value.
/// Users in the trusted_users list are also elevated to approved integrity.
pub fn author_association_floor(item: &Value, scope: &str, ctx: &PolicyContext) -> Vec<String> {
let author_login = extract_author_login(item);
if !author_login.is_empty()
&& (is_trusted_first_party_bot(author_login)
|| is_configured_trusted_bot(author_login, ctx))
|| is_configured_trusted_bot(author_login, ctx)
|| is_trusted_user(author_login, ctx))
{
return writer_integrity(scope, ctx);
}
Expand Down Expand Up @@ -1092,10 +1098,11 @@ pub fn pr_integrity(
facts.author_association.as_deref(),
ctx,
);
// Elevate trusted bots
// Elevate trusted bots and trusted users
let enriched_floor = if let Some(ref login) = facts.author_login {
if is_trusted_first_party_bot(login)
|| is_configured_trusted_bot(login, ctx)
|| is_trusted_user(login, ctx)
{
max_integrity(
repo_full_name,
Expand Down Expand Up @@ -1339,6 +1346,20 @@ pub fn is_configured_trusted_bot(username: &str, ctx: &PolicyContext) -> bool {
ctx.trusted_bots.iter().any(|b| b.to_lowercase() == lower)
}

/// Check if a user is in the gateway-configured trusted users list.
///
/// This checks the `trusted_users` list in `PolicyContext`, which is populated from
/// the allow-only policy's `trusted-users` field. Users in this list receive approved
/// (writer) integrity regardless of their `author_association`. Comparison is
/// case-insensitive. `blocked_users` takes precedence over `trusted_users`.
pub fn is_trusted_user(username: &str, ctx: &PolicyContext) -> bool {
if ctx.trusted_users.is_empty() {
return false;
}
let lower = username.to_lowercase();
ctx.trusted_users.iter().any(|u| u.to_lowercase() == lower)
}

/// Check if a user appears to be a bot (broad detection).
///
/// This is a broader check that includes third-party bots.
Expand Down
165 changes: 165 additions & 0 deletions guards/github-guard/rust-guard/src/labels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3889,4 +3889,169 @@ mod tests {
None
);
}

#[test]
fn test_trusted_user_detection() {
use super::helpers::is_trusted_user;

let ctx_with_users = PolicyContext {
trusted_users: vec!["contractor-1".to_string(), "partner-dev".to_string()],
..Default::default()
};

// Configured trusted users are detected
assert!(is_trusted_user("contractor-1", &ctx_with_users));
assert!(is_trusted_user("partner-dev", &ctx_with_users));

// Case-insensitive
assert!(is_trusted_user("Contractor-1", &ctx_with_users));
assert!(is_trusted_user("PARTNER-DEV", &ctx_with_users));

// Users not in the list are not detected
assert!(!is_trusted_user("other-user", &ctx_with_users));
assert!(!is_trusted_user("dependabot[bot]", &ctx_with_users));

// Empty context has no trusted users
let empty_ctx = default_ctx();
assert!(!is_trusted_user("contractor-1", &empty_ctx));
assert!(!is_trusted_user("", &empty_ctx));
}

#[test]
fn test_trusted_user_issue_integrity_elevation() {
let repo = "github/copilot";

let ctx_with_users = PolicyContext {
trusted_users: vec!["contractor-1".to_string()],
..Default::default()
};

// A trusted user issue gets approved (writer) integrity even with NONE association
let trusted_user_issue = json!({
"user": {"login": "contractor-1"},
"author_association": "NONE"
});
assert_eq!(
issue_integrity(&trusted_user_issue, repo, false, &ctx_with_users),
writer_integrity(repo, &ctx_with_users)
);

// Case-insensitive match
let upper_user_issue = json!({
"user": {"login": "CONTRACTOR-1"},
"author_association": "NONE"
});
assert_eq!(
issue_integrity(&upper_user_issue, repo, false, &ctx_with_users),
writer_integrity(repo, &ctx_with_users)
);

// Without trusted_users in context, the same user gets none integrity
let ctx_without_users = default_ctx();
assert_eq!(
issue_integrity(&trusted_user_issue, repo, false, &ctx_without_users),
none_integrity(repo, &ctx_without_users)
);
}

#[test]
fn test_trusted_user_pr_integrity_elevation() {
let repo = "github/copilot";

let ctx_with_users = PolicyContext {
trusted_users: vec!["partner-dev".to_string()],
..Default::default()
};

// A trusted user PR gets approved (writer) integrity even with NONE association
let trusted_user_pr = json!({
"user": {"login": "partner-dev"},
"author_association": "NONE"
});
assert_eq!(
pr_integrity(&trusted_user_pr, repo, false, None, &ctx_with_users),
writer_integrity(repo, &ctx_with_users)
);

// Without trusted_users, the same user gets none integrity
let ctx_without_users = default_ctx();
assert_eq!(
pr_integrity(&trusted_user_pr, repo, false, None, &ctx_without_users),
none_integrity(repo, &ctx_without_users)
);
}

#[test]
fn test_trusted_user_case_insensitive() {
let repo = "github/copilot";

let ctx = PolicyContext {
trusted_users: vec!["MyUser".to_string()],
..Default::default()
};

// Matching regardless of case
let issue_lower = json!({
"user": {"login": "myuser"},
"author_association": "NONE"
});
assert_eq!(
issue_integrity(&issue_lower, repo, false, &ctx),
writer_integrity(repo, &ctx)
);

let issue_upper = json!({
"user": {"login": "MYUSER"},
"author_association": "NONE"
});
assert_eq!(
issue_integrity(&issue_upper, repo, false, &ctx),
writer_integrity(repo, &ctx)
);
}

#[test]
fn test_blocked_user_overrides_trusted_user() {
let repo = "github/copilot";

// User appears in both blocked-users and trusted-users
let ctx = PolicyContext {
trusted_users: vec!["dual-listed".to_string()],
blocked_users: vec!["dual-listed".to_string()],
..Default::default()
};

let issue = json!({
"user": {"login": "dual-listed"},
"author_association": "NONE"
});

// blocked-users takes precedence — integrity should be blocked
let result = issue_integrity(&issue, repo, false, &ctx);
assert!(
result.iter().any(|t| t.contains("blocked")),
"Expected blocked integrity when user is in both blocked-users and trusted-users, got: {:?}",
result
);
}

#[test]
fn test_trusted_user_with_first_timer_association() {
let repo = "github/copilot";

let ctx = PolicyContext {
trusted_users: vec!["known-contractor".to_string()],
..Default::default()
};

// A trusted user with FIRST_TIMER association gets elevated to approved
let first_timer_issue = json!({
"user": {"login": "known-contractor"},
"author_association": "FIRST_TIMER"
});
assert_eq!(
issue_integrity(&first_timer_issue, repo, false, &ctx),
writer_integrity(repo, &ctx)
);
}
}
12 changes: 7 additions & 5 deletions guards/github-guard/rust-guard/src/labels/tool_rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ use super::helpers::{
author_association_floor_from_str, ensure_integrity_baseline, extract_number_as_string,
extract_repo_info, extract_repo_info_from_search_query, format_repo_id,
is_configured_trusted_bot, is_default_branch_commit_context, is_default_branch_ref,
is_trusted_first_party_bot, max_integrity, merged_integrity, policy_private_scope_label,
private_user_label, project_github_label, reader_integrity, secret_label, writer_integrity,
PolicyContext,
is_trusted_first_party_bot, is_trusted_user, max_integrity, merged_integrity,
policy_private_scope_label, private_user_label, project_github_label, reader_integrity,
secret_label, writer_integrity, PolicyContext,
};

fn apply_repo_visibility_secrecy(
Expand Down Expand Up @@ -126,10 +126,11 @@ pub fn apply_tool_labels(
info.author_association.as_deref(),
ctx,
);
// Elevate trusted first-party bots to approved
// Elevate trusted first-party bots and trusted users to approved
if let Some(ref login) = info.author_login {
if is_trusted_first_party_bot(login)
|| is_configured_trusted_bot(login, ctx)
|| is_trusted_user(login, ctx)
{
Comment on lines +129 to 134

Copilot AI Mar 26, 2026

Copy link

Choose a reason for hiding this comment

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

Blocked-users precedence is not enforced here: a login that is in blocked_users but also matches is_trusted_user/is_trusted_* will be elevated to approved integrity in apply_tool_labels. This conflicts with the documented rule that blocked_users overrides trusted_users/bots. Consider importing is_blocked_user (and/or blocked_integrity) and short-circuiting to blocked integrity before applying any trusted elevation, or at least gating the elevation on !is_blocked_user(login, ctx).

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

Copilot uses AI. Check for mistakes.
floor = max_integrity(
repo_id,
Expand Down Expand Up @@ -186,10 +187,11 @@ pub fn apply_tool_labels(
ctx,
);

// Elevate trusted first-party bots to approved
// Elevate trusted first-party bots and trusted users to approved
if let Some(ref login) = facts.author_login {
if is_trusted_first_party_bot(login)
|| is_configured_trusted_bot(login, ctx)
|| is_trusted_user(login, ctx)
{
integrity = max_integrity(
repo_id,
Expand Down
3 changes: 3 additions & 0 deletions guards/github-guard/rust-guard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,8 @@ struct AllowOnlyPolicy {
blocked_users: Vec<String>,
#[serde(rename = "approval-labels", default)]
approval_labels: Vec<String>,
#[serde(rename = "trusted-users", default)]
trusted_users: Vec<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -512,6 +514,7 @@ pub extern "C" fn label_agent(
trusted_bots,
blocked_users: policy.blocked_users,
approval_labels: policy.approval_labels,
trusted_users: policy.trusted_users,
};
set_runtime_policy_context(ctx.clone());

Expand Down
23 changes: 13 additions & 10 deletions internal/cmd/proxy.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ import (

// Proxy subcommand flag variables
var (
proxyGuardWasm string
proxyPolicy string
proxyToken string
proxyListen string
proxyLogDir string
proxyDIFCMode string
proxyAPIURL string
proxyTLS bool
proxyTLSDir string
proxyTrustedBots []string
proxyGuardWasm string
proxyPolicy string
proxyToken string
proxyListen string
proxyLogDir string
proxyDIFCMode string
proxyAPIURL string
proxyTLS bool
proxyTLSDir string
proxyTrustedBots []string
proxyTrustedUsers []string
)

func init() {
Expand Down Expand Up @@ -96,6 +97,7 @@ Local usage:
cmd.Flags().BoolVar(&proxyTLS, "tls", false, "Enable HTTPS with auto-generated self-signed certificates")
cmd.Flags().StringVar(&proxyTLSDir, "tls-dir", "", "Directory for TLS certificates (default: <log-dir>/proxy-tls)")
cmd.Flags().StringSliceVar(&proxyTrustedBots, "trusted-bots", nil, "Additional trusted bot usernames (comma-separated, extends built-in list)")
cmd.Flags().StringSliceVar(&proxyTrustedUsers, "trusted-users", nil, "User logins that receive approved integrity (comma-separated)")

// Only require --guard-wasm when no baked-in guard is available
if defaultGuard == "" {
Expand Down Expand Up @@ -162,6 +164,7 @@ func runProxy(cmd *cobra.Command, args []string) error {
GitHubAPIURL: apiURL,
DIFCMode: proxyDIFCMode,
TrustedBots: proxyTrustedBots,
TrustedUsers: proxyTrustedUsers,
})
if err != nil {
return fmt.Errorf("failed to create proxy server: %w", err)
Expand Down
24 changes: 24 additions & 0 deletions internal/config/guard_policy.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ type AllowOnlyPolicy struct {
MinIntegrity string `toml:"MinIntegrity" json:"min-integrity"`
BlockedUsers []string `toml:"BlockedUsers" json:"blocked-users,omitempty"`
ApprovalLabels []string `toml:"ApprovalLabels" json:"approval-labels,omitempty"`
TrustedUsers []string `toml:"TrustedUsers" json:"trusted-users,omitempty"`
}

// NormalizedGuardPolicy is a canonical policy representation for caching and observability.
Expand All @@ -52,6 +53,7 @@ type NormalizedGuardPolicy struct {
MinIntegrity string `json:"min-integrity"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
}

func (p *GuardPolicy) UnmarshalJSON(data []byte) error {
Expand Down Expand Up @@ -132,6 +134,10 @@ func (p *AllowOnlyPolicy) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(value, &p.ApprovalLabels); err != nil {
return fmt.Errorf("invalid allow-only.approval-labels: %w", err)
}
case "trusted-users":
if err := json.Unmarshal(value, &p.TrustedUsers); err != nil {
return fmt.Errorf("invalid allow-only.trusted-users: %w", err)
}
default:
return fmt.Errorf("allow-only contains unsupported field %q", key)
}
Expand All @@ -153,6 +159,7 @@ func (p AllowOnlyPolicy) MarshalJSON() ([]byte, error) {
MinIntegrity string `json:"min-integrity"`
BlockedUsers []string `json:"blocked-users,omitempty"`
ApprovalLabels []string `json:"approval-labels,omitempty"`
TrustedUsers []string `json:"trusted-users,omitempty"`
}

return json.Marshal(serializedAllowOnly(p))
Expand Down Expand Up @@ -331,6 +338,23 @@ func NormalizeGuardPolicy(policy *GuardPolicy) (*NormalizedGuardPolicy, error) {
}
}

// Validate and normalize trusted-users.
// Dedup uses lowercased keys to match Rust guard's case-insensitive comparison.
if len(policy.AllowOnly.TrustedUsers) > 0 {
seen := make(map[string]struct{}, len(policy.AllowOnly.TrustedUsers))
for _, u := range policy.AllowOnly.TrustedUsers {
u = strings.TrimSpace(u)
if u == "" {
return nil, fmt.Errorf("allow-only.trusted-users entries must not be empty")
}
key := strings.ToLower(u)
if _, exists := seen[key]; !exists {
seen[key] = struct{}{}
normalized.TrustedUsers = append(normalized.TrustedUsers, u)
}
}
}

switch scope := policy.AllowOnly.Repos.(type) {
case string:
scopeValue := strings.ToLower(strings.TrimSpace(scope))
Expand Down
Loading
Loading