diff --git a/guards/github-guard/rust-guard/src/labels/helpers.rs b/guards/github-guard/rust-guard/src/labels/helpers.rs index 854081a7e..ff35ca05e 100644 --- a/guards/github-guard/rust-guard/src/labels/helpers.rs +++ b/guards/github-guard/rust-guard/src/labels/helpers.rs @@ -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, + /// 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, } fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String { @@ -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 { 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); } @@ -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, @@ -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. diff --git a/guards/github-guard/rust-guard/src/labels/mod.rs b/guards/github-guard/rust-guard/src/labels/mod.rs index afee5e96b..3089e5f49 100644 --- a/guards/github-guard/rust-guard/src/labels/mod.rs +++ b/guards/github-guard/rust-guard/src/labels/mod.rs @@ -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) + ); + } } diff --git a/guards/github-guard/rust-guard/src/labels/tool_rules.rs b/guards/github-guard/rust-guard/src/labels/tool_rules.rs index 34ab5fe2e..0ec3e2def 100644 --- a/guards/github-guard/rust-guard/src/labels/tool_rules.rs +++ b/guards/github-guard/rust-guard/src/labels/tool_rules.rs @@ -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( @@ -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) { floor = max_integrity( repo_id, @@ -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, diff --git a/guards/github-guard/rust-guard/src/lib.rs b/guards/github-guard/rust-guard/src/lib.rs index a97efd118..ad56bd030 100644 --- a/guards/github-guard/rust-guard/src/lib.rs +++ b/guards/github-guard/rust-guard/src/lib.rs @@ -281,6 +281,8 @@ struct AllowOnlyPolicy { blocked_users: Vec, #[serde(rename = "approval-labels", default)] approval_labels: Vec, + #[serde(rename = "trusted-users", default)] + trusted_users: Vec, } #[derive(Debug, Deserialize)] @@ -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()); diff --git a/internal/cmd/proxy.go b/internal/cmd/proxy.go index c92a8687f..ad1d5ccbd 100644 --- a/internal/cmd/proxy.go +++ b/internal/cmd/proxy.go @@ -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() { @@ -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: /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 == "" { @@ -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) diff --git a/internal/config/guard_policy.go b/internal/config/guard_policy.go index 5b7287d83..002103bfe 100644 --- a/internal/config/guard_policy.go +++ b/internal/config/guard_policy.go @@ -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. @@ -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 { @@ -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) } @@ -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)) @@ -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)) diff --git a/internal/config/guard_policy_test.go b/internal/config/guard_policy_test.go index 7dfeff2c5..711409939 100644 --- a/internal/config/guard_policy_test.go +++ b/internal/config/guard_policy_test.go @@ -282,6 +282,84 @@ func TestNormalizeGuardPolicyBlockedAndApproval(t *testing.T) { }) } +// TestNormalizeGuardPolicyTrustedUsers tests NormalizeGuardPolicy with trusted-users. +func TestNormalizeGuardPolicyTrustedUsers(t *testing.T) { + t.Run("trusted-users propagated to normalized policy", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{"contractor-1", "partner-dev"}, + }} + + got, err := NormalizeGuardPolicy(policy) + require.NoError(t, err) + assert.Equal(t, []string{"contractor-1", "partner-dev"}, got.TrustedUsers) + }) + + t.Run("trusted-users deduplication", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{"contractor-1", "contractor-1", "partner-dev"}, + }} + + got, err := NormalizeGuardPolicy(policy) + require.NoError(t, err) + assert.Len(t, got.TrustedUsers, 2) + assert.Contains(t, got.TrustedUsers, "contractor-1") + assert.Contains(t, got.TrustedUsers, "partner-dev") + }) + + t.Run("trusted-users case-insensitive deduplication", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{"Contractor-1", "contractor-1", "CONTRACTOR-1"}, + }} + + got, err := NormalizeGuardPolicy(policy) + require.NoError(t, err) + assert.Len(t, got.TrustedUsers, 1) + assert.Equal(t, "Contractor-1", got.TrustedUsers[0]) // keeps first occurrence + }) + + t.Run("empty trusted-users string entry returns error", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{"valid-user", ""}, + }} + + _, err := NormalizeGuardPolicy(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "trusted-users entries must not be empty") + }) + + t.Run("empty trusted-users slice results in nil normalized list", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{}, + }} + + got, err := NormalizeGuardPolicy(policy) + require.NoError(t, err) + assert.Empty(t, got.TrustedUsers) + }) + + t.Run("whitespace-only entry rejected", func(t *testing.T) { + policy := &GuardPolicy{AllowOnly: &AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{" "}, + }} + + _, err := NormalizeGuardPolicy(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "trusted-users entries must not be empty") + }) +} + func TestIsValidRepoScope(t *testing.T) { tests := []struct { name string @@ -632,6 +710,30 @@ func TestAllowOnlyPolicyUnmarshalJSON(t *testing.T) { assert.Equal(t, []string{"ok"}, p.ApprovalLabels) }, }, + { + name: "trusted-users parsed correctly", + json: `{"repos":"public","min-integrity":"none","trusted-users":["contractor-1","partner-dev"]}`, + check: func(t *testing.T, p *AllowOnlyPolicy) { + assert.Equal(t, []string{"contractor-1", "partner-dev"}, p.TrustedUsers) + }, + }, + { + name: "empty trusted-users array is valid", + json: `{"repos":"public","min-integrity":"none","trusted-users":[]}`, + check: func(t *testing.T, p *AllowOnlyPolicy) { + assert.Empty(t, p.TrustedUsers) + }, + }, + { + name: "all fields including trusted-users parse correctly", + json: `{"repos":"public","min-integrity":"approved","blocked-users":["bad"],"approval-labels":["ok"],"trusted-users":["contractor-1"]}`, + check: func(t *testing.T, p *AllowOnlyPolicy) { + assert.Equal(t, "approved", p.MinIntegrity) + assert.Equal(t, []string{"bad"}, p.BlockedUsers) + assert.Equal(t, []string{"ok"}, p.ApprovalLabels) + assert.Equal(t, []string{"contractor-1"}, p.TrustedUsers) + }, + }, } for _, tt := range tests { @@ -739,6 +841,36 @@ func TestAllowOnlyPolicyMarshalJSON(t *testing.T) { jsonStr := string(data) assert.NotContains(t, jsonStr, `"blocked-users"`) assert.NotContains(t, jsonStr, `"approval-labels"`) + assert.NotContains(t, jsonStr, `"trusted-users"`) + }) + + t.Run("trusted-users is included when set", func(t *testing.T) { + policy := AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + TrustedUsers: []string{"contractor-1", "partner-dev"}, + } + + data, err := json.Marshal(policy) + require.NoError(t, err) + + jsonStr := string(data) + assert.Contains(t, jsonStr, `"trusted-users"`) + assert.Contains(t, jsonStr, `"contractor-1"`) + assert.Contains(t, jsonStr, `"partner-dev"`) + }) + + t.Run("nil trusted-users is omitted", func(t *testing.T) { + policy := AllowOnlyPolicy{ + Repos: "public", + MinIntegrity: "none", + } + + data, err := json.Marshal(policy) + require.NoError(t, err) + + jsonStr := string(data) + assert.NotContains(t, jsonStr, `"trusted-users"`) }) } diff --git a/internal/guard/wasm.go b/internal/guard/wasm.go index 7446dac38..20f594eee 100644 --- a/internal/guard/wasm.go +++ b/internal/guard/wasm.go @@ -384,7 +384,7 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e // Validate that the allow-only object contains only known keys. for k := range allowOnly { switch k { - case "repos", "min-integrity", "integrity", "blocked-users", "approval-labels": + case "repos", "min-integrity", "integrity", "blocked-users", "approval-labels", "trusted-users": // valid allow-only keys default: return nil, fmt.Errorf("invalid guard policy transport shape: unexpected allow-only key %q", k) @@ -449,20 +449,35 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e } } + // Validate trusted-users if present inside allow-only. + // Must be a non-empty array of non-empty strings when present. + if trustedUsersRaw, ok := allowOnly["trusted-users"]; ok { + arr, ok := trustedUsersRaw.([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid trusted-users value: expected array of strings") + } + for _, entry := range arr { + if s, ok := entry.(string); !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("invalid trusted-users value: each entry must be a non-empty string") + } + } + } + return payload, nil } // BuildLabelAgentPayload constructs the label_agent input payload from the given guard policy -// and an optional list of additional trusted bot usernames. The trusted bots are merged with -// the guard's built-in list and cannot remove any built-in entries. If trustedBots is nil or -// empty, the returned payload contains only the allow-only policy. -func BuildLabelAgentPayload(policy interface{}, trustedBots []string) interface{} { - if len(trustedBots) == 0 { +// and optional lists of additional trusted bot usernames and trusted user logins. The trusted +// bots are merged with the guard's built-in list and cannot remove any built-in entries. If +// both trustedBots and trustedUsers are nil or empty, the returned payload contains only the +// allow-only policy. +func BuildLabelAgentPayload(policy interface{}, trustedBots []string, trustedUsers []string) interface{} { + if len(trustedBots) == 0 && len(trustedUsers) == 0 { return policy } - // Marshal the policy to a generic map so we can inject the trusted-bots key - // alongside the allow-only policy without altering the policy itself. + // Marshal the policy to a generic map so we can inject the trusted-bots and trusted-users + // keys alongside the allow-only policy without altering the policy itself. policyJSON, err := json.Marshal(policy) if err != nil { // If we can't marshal the policy, return it as-is; buildStrictLabelAgentPayload @@ -475,12 +490,31 @@ func BuildLabelAgentPayload(policy interface{}, trustedBots []string) interface{ return policy } - // Convert []string to []interface{} for JSON compatibility - bots := make([]interface{}, len(trustedBots)) - for i, b := range trustedBots { - bots[i] = b + if len(trustedBots) > 0 { + // trusted-bots is a top-level key in the label_agent payload. + // Convert []string to []interface{} for JSON compatibility. + bots := make([]interface{}, len(trustedBots)) + for i, b := range trustedBots { + bots[i] = b + } + payload["trusted-bots"] = bots } - payload["trusted-bots"] = bots + + if len(trustedUsers) > 0 { + // trusted-users is injected inside the allow-only object. + // Convert []string to []interface{} for JSON compatibility. + // If allow-only is absent, the injection is skipped and buildStrictLabelAgentPayload + // will reject the payload when called with the missing allow-only key. + users := make([]interface{}, len(trustedUsers)) + for i, u := range trustedUsers { + users[i] = u + } + // Inject into allow-only object if present + if allowOnly, ok := payload["allow-only"].(map[string]interface{}); ok { + allowOnly["trusted-users"] = users + } + } + return payload } diff --git a/internal/guard/wasm_test.go b/internal/guard/wasm_test.go index 4a853a88b..afba182a4 100644 --- a/internal/guard/wasm_test.go +++ b/internal/guard/wasm_test.go @@ -553,6 +553,72 @@ func TestBuildStrictLabelAgentPayloadExtended(t *testing.T) { assert.Contains(t, result, "trusted-bots") }) + t.Run("valid trusted-users in allow-only succeeds", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "approved", + "trusted-users": []interface{}{"contractor-1", "partner-dev"}, + }, + } + + result, err := buildStrictLabelAgentPayload(policy) + require.NoError(t, err) + require.NotNil(t, result) + allowOnly := result["allow-only"].(map[string]interface{}) + assert.Contains(t, allowOnly, "trusted-users") + }) + + t.Run("trusted-users with all fields succeeds", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "approved", + "blocked-users": []interface{}{"bad-actor"}, + "approval-labels": []interface{}{"approved"}, + "trusted-users": []interface{}{"contractor-1"}, + }, + "trusted-bots": []interface{}{"my-org-bot"}, + } + + result, err := buildStrictLabelAgentPayload(policy) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result, "allow-only") + assert.Contains(t, result, "trusted-bots") + allowOnly := result["allow-only"].(map[string]interface{}) + assert.Contains(t, allowOnly, "trusted-users") + }) + + t.Run("trusted-users with non-string entry returns error", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + "trusted-users": []interface{}{"valid", 42}, + }, + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "trusted-users") + assert.Contains(t, err.Error(), "non-empty string") + }) + + t.Run("trusted-users with empty string entry returns error", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + "trusted-users": []interface{}{"valid", ""}, + }, + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "trusted-users") + }) + t.Run("blocked-users with non-string entry returns error", func(t *testing.T) { policy := map[string]interface{}{ "allow-only": map[string]interface{}{ @@ -613,25 +679,25 @@ func TestBuildStrictLabelAgentPayloadExtended(t *testing.T) { } func TestBuildLabelAgentPayload(t *testing.T) { - t.Run("nil trusted bots returns policy unchanged", func(t *testing.T) { + t.Run("nil trusted bots and users returns policy unchanged", func(t *testing.T) { policy := map[string]interface{}{ "allow-only": map[string]interface{}{ "repos": "public", "min-integrity": "none", }, } - result := BuildLabelAgentPayload(policy, nil) + result := BuildLabelAgentPayload(policy, nil, nil) assert.Equal(t, policy, result) }) - t.Run("empty trusted bots returns policy unchanged", func(t *testing.T) { + t.Run("empty trusted bots and users returns policy unchanged", func(t *testing.T) { policy := map[string]interface{}{ "allow-only": map[string]interface{}{ "repos": "public", "min-integrity": "none", }, } - result := BuildLabelAgentPayload(policy, []string{}) + result := BuildLabelAgentPayload(policy, []string{}, []string{}) assert.Equal(t, policy, result) }) @@ -643,7 +709,7 @@ func TestBuildLabelAgentPayload(t *testing.T) { }, } bots := []string{"copilot-swe-agent[bot]", "my-org-bot[bot]"} - result := BuildLabelAgentPayload(policy, bots) + result := BuildLabelAgentPayload(policy, bots, nil) resultMap, ok := result.(map[string]interface{}) require.True(t, ok) @@ -665,7 +731,7 @@ func TestBuildLabelAgentPayload(t *testing.T) { }, } bots := []string{"copilot-swe-agent[bot]"} - payload := BuildLabelAgentPayload(policy, bots) + payload := BuildLabelAgentPayload(policy, bots, nil) _, err := buildStrictLabelAgentPayload(payload) assert.NoError(t, err) @@ -679,7 +745,7 @@ func TestBuildLabelAgentPayload(t *testing.T) { }, } bots := []string{"my-bot[bot]"} - _ = BuildLabelAgentPayload(policy, bots) + _ = BuildLabelAgentPayload(policy, bots, nil) // Original policy should NOT contain trusted-bots _, hasTrustedBots := policy["trusted-bots"] @@ -694,7 +760,7 @@ func TestBuildLabelAgentPayload(t *testing.T) { }, } bots := []string{"bot-a[bot]", "bot-b[bot]", "bot-c"} - result := BuildLabelAgentPayload(policy, bots) + result := BuildLabelAgentPayload(policy, bots, nil) resultMap, ok := result.(map[string]interface{}) require.True(t, ok) @@ -706,6 +772,66 @@ func TestBuildLabelAgentPayload(t *testing.T) { assert.Equal(t, "bot-b[bot]", trustedBots[1]) assert.Equal(t, "bot-c", trustedBots[2]) }) + + t.Run("non-empty trusted users injects into allow-only", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + }, + } + users := []string{"contractor-1", "partner-dev"} + result := BuildLabelAgentPayload(policy, nil, users) + + resultMap, ok := result.(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, resultMap, "allow-only") + assert.NotContains(t, resultMap, "trusted-users") // top-level key should not be added + + allowOnly, ok := resultMap["allow-only"].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, allowOnly, "trusted-users") + + trustedUsers, ok := allowOnly["trusted-users"].([]interface{}) + require.True(t, ok) + assert.Len(t, trustedUsers, 2) + assert.Equal(t, "contractor-1", trustedUsers[0]) + assert.Equal(t, "partner-dev", trustedUsers[1]) + }) + + t.Run("trusted users payload accepted by buildStrictLabelAgentPayload", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "approved", + }, + } + users := []string{"contractor-1"} + payload := BuildLabelAgentPayload(policy, nil, users) + + _, err := buildStrictLabelAgentPayload(payload) + assert.NoError(t, err) + }) + + t.Run("both trusted bots and users injected together", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "approved", + }, + } + bots := []string{"my-bot[bot]"} + users := []string{"contractor-1"} + result := BuildLabelAgentPayload(policy, bots, users) + + resultMap, ok := result.(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, resultMap, "trusted-bots") + + allowOnly, ok := resultMap["allow-only"].(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, allowOnly, "trusted-users") + }) } func TestWasmGuardClose(t *testing.T) { diff --git a/internal/proxy/proxy.go b/internal/proxy/proxy.go index a473b53da..86af9cf6b 100644 --- a/internal/proxy/proxy.go +++ b/internal/proxy/proxy.go @@ -127,6 +127,11 @@ type Config struct { // initialization, extending the guard's built-in trusted bot list // (e.g. dependabot[bot], github-actions[bot]). TrustedBots []string + + // TrustedUsers is an optional list of GitHub usernames to elevate to approved + // (writer) integrity, regardless of their author_association. These are injected + // into the allow-only policy's trusted-users field during LabelAgent initialization. + TrustedUsers []string } // New creates a new proxy Server from the given Config. @@ -182,7 +187,7 @@ func New(ctx context.Context, cfg Config) (*Server, error) { // Initialize guard policy (LabelAgent) if cfg.Policy != "" { logProxy.Printf("Initializing guard policy from config") - if err := s.initGuardPolicy(ctx, cfg.Policy, cfg.TrustedBots); err != nil { + if err := s.initGuardPolicy(ctx, cfg.Policy, cfg.TrustedBots, cfg.TrustedUsers); err != nil { return nil, fmt.Errorf("failed to initialize guard policy: %w", err) } } else { @@ -193,9 +198,9 @@ func New(ctx context.Context, cfg Config) (*Server, error) { return s, nil } -// initGuardPolicy calls LabelAgent with the provided policy JSON and optional trusted bots. -func (s *Server) initGuardPolicy(ctx context.Context, policyJSON string, trustedBots []string) error { - logProxy.Printf("Initializing guard policy: policyJSON_len=%d, trustedBots=%d", len(policyJSON), len(trustedBots)) +// initGuardPolicy calls LabelAgent with the provided policy JSON, optional trusted bots, and optional trusted users. +func (s *Server) initGuardPolicy(ctx context.Context, policyJSON string, trustedBots []string, trustedUsers []string) error { + logProxy.Printf("Initializing guard policy: policyJSON_len=%d, trustedBots=%d, trustedUsers=%d", len(policyJSON), len(trustedBots), len(trustedUsers)) var policy interface{} if err := json.Unmarshal([]byte(policyJSON), &policy); err != nil { @@ -221,8 +226,8 @@ func (s *Server) initGuardPolicy(ctx context.Context, policyJSON string, trusted return fmt.Errorf("policy validation failed: %w", err) } - // Build payload with optional trusted bots - payload := guard.BuildLabelAgentPayload(policy, trustedBots) + // Build payload with optional trusted bots and trusted users + payload := guard.BuildLabelAgentPayload(policy, trustedBots, trustedUsers) logProxy.Printf("Calling LabelAgent to initialize agent labels from guard") backend := &restBackendCaller{server: s} diff --git a/internal/server/guard_init.go b/internal/server/guard_init.go index 68343e005..495fd932a 100644 --- a/internal/server/guard_init.go +++ b/internal/server/guard_init.go @@ -326,10 +326,13 @@ func (us *UnifiedServer) ensureGuardInitialized( } // Build the label_agent payload, merging in any configured trusted bots. + // trusted-users is not injected here as a separate list because in gateway mode + // it is specified directly inside the allow-only policy JSON (not as a standalone + // gateway config field). The policy object already carries trusted-users when set. // The policyHash covers both the policy and trusted bots so that any change // to either field invalidates the cached guard session state. trustedBots := us.getTrustedBots() - labelAgentPayload := guard.BuildLabelAgentPayload(policy, trustedBots) + labelAgentPayload := guard.BuildLabelAgentPayload(policy, trustedBots, nil) payloadJSON, err := json.Marshal(labelAgentPayload) if err != nil { return defaultMode, fmt.Errorf("failed to serialize label_agent payload: %w", err)