diff --git a/guards/github-guard/rust-guard/src/labels/helpers.rs b/guards/github-guard/rust-guard/src/labels/helpers.rs index 7a924a60..d65f5325 100644 --- a/guards/github-guard/rust-guard/src/labels/helpers.rs +++ b/guards/github-guard/rust-guard/src/labels/helpers.rs @@ -35,6 +35,11 @@ pub enum MinIntegrity { #[derive(Debug, Clone, Default)] pub struct PolicyContext { pub scopes: Vec, + /// Additional trusted bot usernames configured at the gateway level. + /// Objects authored by these bots receive approved (writer) integrity regardless + /// 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, } fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String { @@ -706,11 +711,14 @@ fn extract_author_login(item: &Value) -> &str { } /// Extract author_association from an item and return initial integrity floor. -/// Trusted first-party GitHub bots are elevated to approved (writer) integrity -/// regardless of their author_association value. +/// Trusted first-party GitHub bots and any gateway-configured trusted bots are +/// elevated to approved (writer) integrity regardless of their author_association value. 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) { + if !author_login.is_empty() + && (is_trusted_first_party_bot(author_login) + || is_configured_trusted_bot(author_login, ctx)) + { return writer_integrity(scope, ctx); } @@ -882,6 +890,19 @@ pub fn is_trusted_first_party_bot(username: &str) -> bool { || lower == "copilot" } +/// Check if a user is in the gateway-configured trusted bot list. +/// +/// This checks the `trusted_bots` list in `PolicyContext`, which is populated from +/// the gateway configuration's `trustedBots` field. Comparison is case-insensitive. +/// This list is additive and cannot remove entries from the built-in trusted bot list. +pub fn is_configured_trusted_bot(username: &str, ctx: &PolicyContext) -> bool { + if ctx.trusted_bots.is_empty() { + return false; + } + let lower = username.to_lowercase(); + ctx.trusted_bots.iter().any(|b| b.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 c8094e08..8d636cfa 100644 --- a/guards/github-guard/rust-guard/src/labels/mod.rs +++ b/guards/github-guard/rust-guard/src/labels/mod.rs @@ -249,6 +249,7 @@ mod tests { scope_repo: Some("github-".to_string()), scope_label: "lpcox/github-*".to_string(), }], + ..Default::default() }; let in_scope = writer_integrity("lpcox/github-guard", &ctx); @@ -281,6 +282,7 @@ mod tests { scope_repo: Some("github-".to_string()), scope_label: "lpcox/github-*".to_string(), }], + ..Default::default() }; assert_eq!( @@ -316,6 +318,7 @@ mod tests { scope_label: "lpcox/git*".to_string(), }, ], + ..Default::default() }; assert_eq!( @@ -344,6 +347,7 @@ mod tests { scope_repo: None, scope_label: "public".to_string(), }], + ..Default::default() }; assert_eq!( @@ -969,6 +973,70 @@ mod tests { ); } + #[test] + fn test_configured_trusted_bot_detection() { + use super::helpers::is_configured_trusted_bot; + + let ctx_with_bots = PolicyContext { + trusted_bots: vec!["copilot-swe-agent[bot]".to_string(), "my-org-bot".to_string()], + ..Default::default() + }; + + // Configured bots are detected + assert!(is_configured_trusted_bot("copilot-swe-agent[bot]", &ctx_with_bots)); + assert!(is_configured_trusted_bot("my-org-bot", &ctx_with_bots)); + + // Case-insensitive + assert!(is_configured_trusted_bot("Copilot-SWE-Agent[bot]", &ctx_with_bots)); + assert!(is_configured_trusted_bot("MY-ORG-BOT", &ctx_with_bots)); + + // Bots not in the list are not detected + assert!(!is_configured_trusted_bot("other-bot[bot]", &ctx_with_bots)); + assert!(!is_configured_trusted_bot("dependabot[bot]", &ctx_with_bots)); + + // Empty context has no configured trusted bots + let empty_ctx = default_ctx(); + assert!(!is_configured_trusted_bot("copilot-swe-agent[bot]", &empty_ctx)); + assert!(!is_configured_trusted_bot("", &empty_ctx)); + } + + #[test] + fn test_configured_trusted_bot_issue_integrity() { + let repo = "github/copilot"; + + let ctx_with_bots = PolicyContext { + trusted_bots: vec!["copilot-swe-agent[bot]".to_string()], + ..Default::default() + }; + + // A configured trusted bot issue gets approved (writer) integrity even with NONE association + let configured_bot_issue = json!({ + "user": {"login": "copilot-swe-agent[bot]"}, + "author_association": "NONE" + }); + assert_eq!( + issue_integrity(&configured_bot_issue, repo, false, &ctx_with_bots), + writer_integrity(repo, &ctx_with_bots) + ); + + // Case-insensitive match + let upper_bot_issue = json!({ + "user": {"login": "COPILOT-SWE-AGENT[BOT]"}, + "author_association": "NONE" + }); + assert_eq!( + issue_integrity(&upper_bot_issue, repo, false, &ctx_with_bots), + writer_integrity(repo, &ctx_with_bots) + ); + + // Without trusted_bots in context, the same bot gets none integrity + let ctx_without_bots = default_ctx(); + assert_eq!( + issue_integrity(&configured_bot_issue, repo, false, &ctx_without_bots), + none_integrity(repo, &ctx_without_bots) + ); + } + #[test] fn test_get_str_or() { let value = json!({"name": "Alice", "count": 42}); @@ -1059,6 +1127,7 @@ mod tests { scope_repo: None, scope_label: owner.to_string(), }], + ..Default::default() } } diff --git a/guards/github-guard/rust-guard/src/lib.rs b/guards/github-guard/rust-guard/src/lib.rs index 7dd15254..33676456 100644 --- a/guards/github-guard/rust-guard/src/lib.rs +++ b/guards/github-guard/rust-guard/src/lib.rs @@ -267,6 +267,8 @@ fn infer_scope_for_baseline(tool_name: &str, tool_args: &Value, repo_id: &str) - struct LabelAgentInput { #[serde(rename = "allow-only")] allow_only: AllowOnlyPolicy, + #[serde(rename = "trusted-bots", default)] + trusted_bots: Vec, } #[derive(Debug, Deserialize)] @@ -483,6 +485,7 @@ pub extern "C" fn label_agent( }; let policy = input.allow_only; + let trusted_bots = input.trusted_bots; let scopes = match parse_scope(policy.scope) { Ok(v) => v, @@ -502,6 +505,7 @@ pub extern "C" fn label_agent( let ctx = PolicyContext { scopes: scopes.clone(), + trusted_bots, }; set_runtime_policy_context(ctx.clone()); @@ -1050,6 +1054,7 @@ mod tests { scope_repo: Some("git".to_string()), scope_label: "lpcox/git*".to_string(), }], + ..Default::default() }; let tool_args = json!({"query": "repo:lpcox/github-guard README"}); diff --git a/internal/config/config_core.go b/internal/config/config_core.go index 9f75dbb7..1630c4a9 100644 --- a/internal/config/config_core.go +++ b/internal/config/config_core.go @@ -101,6 +101,13 @@ type GatewayConfig struct { // Payloads larger than this threshold are stored to disk, smaller ones are returned inline. // Default: 524288 bytes (512KB) PayloadSizeThreshold int `toml:"payload_size_threshold" json:"payload_size_threshold,omitempty"` + + // TrustedBots is an optional list of additional bot usernames that should be treated + // as trusted. Objects authored by these bots receive "approved" integrity regardless + // of their author_association. This list is merged with the guard's built-in trusted + // bot list and is purely additive (it cannot remove built-in trusted bots). + // Example values: "copilot-swe-agent[bot]", "my-org-bot[bot]" + TrustedBots []string `toml:"trusted_bots" json:"trusted_bots,omitempty"` } // GetAPIKey returns the gateway API key, handling a nil Gateway safely. diff --git a/internal/config/config_stdin.go b/internal/config/config_stdin.go index 3f5ad1c8..70c8c866 100644 --- a/internal/config/config_stdin.go +++ b/internal/config/config_stdin.go @@ -32,12 +32,13 @@ type StdinConfig struct { // StdinGatewayConfig represents gateway configuration in stdin JSON format. // Uses pointers for optional fields to distinguish between unset and zero values. type StdinGatewayConfig struct { - Port *int `json:"port,omitempty"` - APIKey string `json:"apiKey,omitempty"` - Domain string `json:"domain,omitempty"` - StartupTimeout *int `json:"startupTimeout,omitempty"` - ToolTimeout *int `json:"toolTimeout,omitempty"` - PayloadDir string `json:"payloadDir,omitempty"` + Port *int `json:"port,omitempty"` + APIKey string `json:"apiKey,omitempty"` + Domain string `json:"domain,omitempty"` + StartupTimeout *int `json:"startupTimeout,omitempty"` + ToolTimeout *int `json:"toolTimeout,omitempty"` + PayloadDir string `json:"payloadDir,omitempty"` + TrustedBots []string `json:"trustedBots,omitempty"` } // StdinGuardConfig represents a guard configuration in stdin JSON format. @@ -280,6 +281,9 @@ func convertStdinConfig(stdinCfg *StdinConfig) (*Config, error) { if stdinCfg.Gateway.PayloadDir != "" { cfg.Gateway.PayloadDir = stdinCfg.Gateway.PayloadDir } + if len(stdinCfg.Gateway.TrustedBots) > 0 { + cfg.Gateway.TrustedBots = stdinCfg.Gateway.TrustedBots + } } else { logStdin.Print("No gateway config in stdin, applying defaults") cfg.Gateway = &GatewayConfig{} diff --git a/internal/guard/wasm.go b/internal/guard/wasm.go index 913059b3..e1423d52 100644 --- a/internal/guard/wasm.go +++ b/internal/guard/wasm.go @@ -356,8 +356,15 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e return nil, fmt.Errorf("label_agent policy must use top-level allow-only object (received policy.allow-only)") } - if len(payload) != 1 { - return nil, fmt.Errorf("invalid guard policy transport shape: expected {\"allow-only\":{\"repos\":...,\"min-integrity\":...}}") + // Validate that the only allowed top-level keys are "allow-only" (or legacy "allowonly") + // and the optional "trusted-bots" key. + for k := range payload { + switch k { + case "allow-only", "allowonly", "trusted-bots": + // valid top-level keys + default: + return nil, fmt.Errorf("invalid guard policy transport shape: unexpected key %q", k) + } } allowOnly, ok := allowOnlyRaw.(map[string]interface{}) @@ -389,9 +396,54 @@ func buildStrictLabelAgentPayload(policy interface{}) (map[string]interface{}, e return nil, fmt.Errorf("invalid integrity value: expected one of none|unapproved|approved|merged") } + // Validate trusted-bots if present + if trustedBotsRaw, hasTrustedBots := payload["trusted-bots"]; hasTrustedBots { + trustedBots, ok := trustedBotsRaw.([]interface{}) + if !ok { + return nil, fmt.Errorf("invalid trusted-bots value: expected array of strings") + } + for _, entry := range trustedBots { + if s, ok := entry.(string); !ok || strings.TrimSpace(s) == "" { + return nil, fmt.Errorf("invalid trusted-bots 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 { + 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. + policyJSON, err := json.Marshal(policy) + if err != nil { + // If we can't marshal the policy, return it as-is; buildStrictLabelAgentPayload + // will surface the error later. + return policy + } + + var payload map[string]interface{} + if err := json.Unmarshal(policyJSON, &payload); err != nil { + return policy + } + + // Convert []string to []interface{} for JSON compatibility + bots := make([]interface{}, len(trustedBots)) + for i, b := range trustedBots { + bots[i] = b + } + payload["trusted-bots"] = bots + return payload +} + func isValidAllowOnlyRepos(repos interface{}) bool { switch value := repos.(type) { case string: diff --git a/internal/guard/wasm_test.go b/internal/guard/wasm_test.go index 3b4acce6..aefa35ca 100644 --- a/internal/guard/wasm_test.go +++ b/internal/guard/wasm_test.go @@ -401,6 +401,138 @@ func TestBuildStrictLabelAgentPayloadExtended(t *testing.T) { assert.NoError(t, err, "integrity=%q should be valid", integrity) } }) + + t.Run("valid trusted-bots alongside allow-only succeeds", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + }, + "trusted-bots": []interface{}{"copilot-swe-agent[bot]", "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") + }) + + t.Run("unexpected extra key returns error", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + }, + "unknown-key": "value", + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "unexpected key") + }) + + t.Run("trusted-bots 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-bots": []interface{}{"valid-bot", 42}, + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-empty string") + }) + + t.Run("trusted-bots 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-bots": []interface{}{"valid-bot", ""}, + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "non-empty string") + }) + + t.Run("trusted-bots with wrong type returns error", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + }, + "trusted-bots": "not-an-array", + } + + _, err := buildStrictLabelAgentPayload(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "expected array") + }) +} + +func TestBuildLabelAgentPayload(t *testing.T) { + t.Run("nil trusted bots 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) + assert.Equal(t, policy, result) + }) + + t.Run("empty trusted bots 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{}) + assert.Equal(t, policy, result) + }) + + t.Run("non-empty trusted bots injects trusted-bots key", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "none", + }, + } + bots := []string{"copilot-swe-agent[bot]", "my-org-bot[bot]"} + result := BuildLabelAgentPayload(policy, bots) + + resultMap, ok := result.(map[string]interface{}) + require.True(t, ok) + assert.Contains(t, resultMap, "allow-only") + assert.Contains(t, resultMap, "trusted-bots") + + trustedBots, ok := resultMap["trusted-bots"].([]interface{}) + require.True(t, ok) + assert.Len(t, trustedBots, 2) + assert.Equal(t, "copilot-swe-agent[bot]", trustedBots[0]) + assert.Equal(t, "my-org-bot[bot]", trustedBots[1]) + }) + + t.Run("resulting payload is accepted by buildStrictLabelAgentPayload", func(t *testing.T) { + policy := map[string]interface{}{ + "allow-only": map[string]interface{}{ + "repos": "public", + "min-integrity": "unapproved", + }, + } + bots := []string{"copilot-swe-agent[bot]"} + payload := BuildLabelAgentPayload(policy, bots) + + _, err := buildStrictLabelAgentPayload(payload) + assert.NoError(t, err) + }) } func TestWasmGuardClose(t *testing.T) { diff --git a/internal/server/unified.go b/internal/server/unified.go index 2e2edfe2..93eee116 100644 --- a/internal/server/unified.go +++ b/internal/server/unified.go @@ -1160,7 +1160,17 @@ func (us *UnifiedServer) ensureGuardInitialized( if err != nil { return defaultMode, fmt.Errorf("failed to serialize guard policy: %w", err) } - policyHash := string(policyJSON) + + // Build the label_agent payload, merging in any configured trusted bots. + // 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) + payloadJSON, err := json.Marshal(labelAgentPayload) + if err != nil { + return defaultMode, fmt.Errorf("failed to serialize label_agent payload: %w", err) + } + policyHash := string(payloadJSON) us.sessionMu.RLock() session := us.sessions[sessionID] @@ -1175,7 +1185,7 @@ func (us *UnifiedServer) ensureGuardInitialized( log.Printf("[DIFC] Initializing guard session state: server=%s, session=%s, policy_source=%s", serverID, sessionID, source) log.Printf("[DIFC] Calling label_agent: server=%s, session=%s, guard=%s, policy=%s", serverID, sessionID, g.Name(), string(policyJSON)) - labelAgentResult, err := g.LabelAgent(ctx, policy, backendCaller, us.capabilities) + labelAgentResult, err := g.LabelAgent(ctx, labelAgentPayload, backendCaller, us.capabilities) if err != nil { log.Printf("[DIFC] label_agent failed: server=%s, session=%s, guard=%s, error=%v", serverID, sessionID, g.Name(), err) return defaultMode, fmt.Errorf("label_agent failed: %w", err) @@ -1244,6 +1254,15 @@ func (us *UnifiedServer) GetPayloadSizeThreshold() int { return us.payloadSizeThreshold } +// getTrustedBots returns the configured list of additional trusted bot usernames, +// or nil if none are configured. +func (us *UnifiedServer) getTrustedBots() []string { + if us.cfg == nil || us.cfg.Gateway == nil { + return nil + } + return us.cfg.Gateway.TrustedBots +} + // ensureSessionDirectory creates the session subdirectory in the payload directory if it doesn't exist func (us *UnifiedServer) ensureSessionDirectory(sessionID string) error { sessionDir := filepath.Join(us.payloadDir, sessionID)