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
27 changes: 24 additions & 3 deletions guards/github-guard/rust-guard/src/labels/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ pub enum MinIntegrity {
#[derive(Debug, Clone, Default)]
pub struct PolicyContext {
pub scopes: Vec<PolicyScopeEntry>,
/// 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<String>,
}

fn normalize_scope(scope: &str, ctx: &PolicyContext) -> String {
Expand Down Expand Up @@ -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<String> {
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);
}

Expand Down Expand Up @@ -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)
Comment on lines +902 to +903

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

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

is_configured_trusted_bot allocates on every call (username.to_lowercase() and b.to_lowercase() for each entry). This function is used in the integrity floor path and can run frequently, so this may become a hot spot. Consider avoiding allocations by using eq_ignore_ascii_case for comparisons and/or storing a pre-normalized (lowercased) set/vector in PolicyContext at label_agent parse time.

Suggested change
let lower = username.to_lowercase();
ctx.trusted_bots.iter().any(|b| b.to_lowercase() == lower)
ctx.trusted_bots
.iter()
.any(|b| b.eq_ignore_ascii_case(username))

Copilot uses AI. Check for mistakes.
}

/// Check if a user appears to be a bot (broad detection).
///
/// This is a broader check that includes third-party bots.
Expand Down
69 changes: 69 additions & 0 deletions guards/github-guard/rust-guard/src/labels/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -281,6 +282,7 @@ mod tests {
scope_repo: Some("github-".to_string()),
scope_label: "lpcox/github-*".to_string(),
}],
..Default::default()
};

assert_eq!(
Expand Down Expand Up @@ -316,6 +318,7 @@ mod tests {
scope_label: "lpcox/git*".to_string(),
},
],
..Default::default()
};

assert_eq!(
Expand Down Expand Up @@ -344,6 +347,7 @@ mod tests {
scope_repo: None,
scope_label: "public".to_string(),
}],
..Default::default()
};

assert_eq!(
Expand Down Expand Up @@ -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});
Expand Down Expand Up @@ -1059,6 +1127,7 @@ mod tests {
scope_repo: None,
scope_label: owner.to_string(),
}],
..Default::default()
}
}

Expand Down
5 changes: 5 additions & 0 deletions guards/github-guard/rust-guard/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

#[derive(Debug, Deserialize)]
Expand Down Expand Up @@ -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,
Expand All @@ -502,6 +505,7 @@ pub extern "C" fn label_agent(

let ctx = PolicyContext {
scopes: scopes.clone(),
trusted_bots,
};
set_runtime_policy_context(ctx.clone());

Expand Down Expand Up @@ -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"});
Expand Down
7 changes: 7 additions & 0 deletions internal/config/config_core.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 10 additions & 6 deletions internal/config/config_stdin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Comment on lines 282 to +286

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

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

convertStdinConfig copies trustedBots into cfg.Gateway.TrustedBots without any validation/normalization. If stdin contains entries like "" or " ", guard initialization will fail later in buildStrictLabelAgentPayload with a less actionable error at runtime. Consider validating each entry here (trim + require non-empty) and normalizing (dedupe/sort) so config errors fail fast during load.

Copilot uses AI. Check for mistakes.
} else {
logStdin.Print("No gateway config in stdin, applying defaults")
cfg.Gateway = &GatewayConfig{}
Expand Down
56 changes: 54 additions & 2 deletions internal/guard/wasm.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{})
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading