diff --git a/guards/github-guard/rust-guard/src/labels/constants.rs b/guards/github-guard/rust-guard/src/labels/constants.rs index c034f7684..7244fc409 100644 --- a/guards/github-guard/rust-guard/src/labels/constants.rs +++ b/guards/github-guard/rust-guard/src/labels/constants.rs @@ -31,6 +31,23 @@ pub mod policy_integrity { pub const ORDER_HIGH_TO_LOW: [&str; 4] = [MERGED, APPROVED, UNAPPROVED, NONE]; } +/// Canonical *reserved* scope token strings used for baseline and integrity scoping. +/// +/// These are the three well-known, fixed scope tokens that represent broad resource +/// categories (org-level, user-level, and cross-repo). Other scopes exist at runtime +/// (e.g. `owner` or `owner/repo` for concrete repositories) — those are constructed +/// dynamically and are not represented here. +/// Using constants avoids silent typos (e.g. "Github") that produce wrong DIFC labels +/// with no compiler error. +pub mod scope_names { + /// Owner-scoped policy (GitHub-org-level resources) + pub const GITHUB: &str = "github"; + /// User-scoped policy (personal resources) + pub const USER: &str = "user"; + /// Global-scoped policy (cross-repo / no specific owner) + pub const GLOBAL: &str = "global"; +} + /// Field name constants for JSON extraction pub mod field_names { pub const OWNER: &str = "owner"; diff --git a/guards/github-guard/rust-guard/src/labels/helpers.rs b/guards/github-guard/rust-guard/src/labels/helpers.rs index 3d346a691..a9802375b 100644 --- a/guards/github-guard/rust-guard/src/labels/helpers.rs +++ b/guards/github-guard/rust-guard/src/labels/helpers.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use serde_json::Value; use super::backend::GithubMcpCallback; -use super::constants::{field_names, label_constants}; +use super::constants::{field_names, label_constants, scope_names}; /// Ensures the endorsement gateway-mode warning is emitted at most once per process lifetime. static ENDORSEMENT_GATEWAY_WARNING_EMITTED: AtomicBool = AtomicBool::new(false); @@ -696,7 +696,7 @@ pub fn private_user_label() -> Vec { /// Returns a vec with the "approved:github" label #[inline] pub fn project_github_label(ctx: &PolicyContext) -> Vec { - writer_integrity("github", ctx) + writer_integrity(scope_names::GITHUB, ctx) } /// Returns a vec with a "private:{scope}" label diff --git a/guards/github-guard/rust-guard/src/labels/mod.rs b/guards/github-guard/rust-guard/src/labels/mod.rs index 564ce3892..ddf5262c2 100644 --- a/guards/github-guard/rust-guard/src/labels/mod.rs +++ b/guards/github-guard/rust-guard/src/labels/mod.rs @@ -152,7 +152,7 @@ pub(crate) fn extract_mcp_response(response: &Value) -> Value { mod tests { use super::*; use super::helpers::{get_bool_or, get_nested_str, get_str_or, has_author_association, make_item_path}; - use crate::labels::constants::label_constants; + use crate::labels::constants::{label_constants, scope_names}; use serde_json::json; fn default_ctx() -> PolicyContext { @@ -2127,7 +2127,7 @@ mod tests { ); assert_eq!(secrecy, private_user_label(), "list_gists must carry private:user secrecy (mix of public/secret gists)"); - assert_eq!(integrity, reader_integrity("user", &ctx), "list_gists must have reader (unapproved) integrity (user content)"); + assert_eq!(integrity, reader_integrity(scope_names::USER, &ctx), "list_gists must have reader (unapproved) integrity (user content)"); } // ------------------------------------------------------------------------- @@ -2150,7 +2150,7 @@ mod tests { ); assert_eq!(secrecy, private_user_label(), "get_gist must carry private:user secrecy"); - assert_eq!(integrity, reader_integrity("user", &ctx), "get_gist must have reader integrity"); + assert_eq!(integrity, reader_integrity(scope_names::USER, &ctx), "get_gist must have reader integrity"); } // ------------------------------------------------------------------------- @@ -2557,7 +2557,7 @@ mod tests { assert_eq!(items.len(), 1, "should label one gist item"); let item = &items[0]; assert_eq!(item.labels.secrecy, vec![] as Vec, "public gist must have empty secrecy"); - assert_eq!(item.labels.integrity, reader_integrity("user", &ctx), "gist must have reader integrity"); + assert_eq!(item.labels.integrity, reader_integrity(scope_names::USER, &ctx), "gist must have reader integrity"); assert_eq!(item.labels.description, "gist:abc123def456"); } @@ -2578,7 +2578,7 @@ mod tests { assert_eq!(items.len(), 1); let item = &items[0]; assert_eq!(item.labels.secrecy, private_user_label(), "secret gist must carry private:user secrecy"); - assert_eq!(item.labels.integrity, reader_integrity("user", &ctx), "secret gist still has reader integrity"); + assert_eq!(item.labels.integrity, reader_integrity(scope_names::USER, &ctx), "secret gist still has reader integrity"); assert_eq!(item.labels.description, "gist:secret789xyz"); } @@ -2604,7 +2604,7 @@ mod tests { assert_eq!(items[2].labels.secrecy, vec![] as Vec, "third item is public → empty secrecy"); // All gists share the same reader integrity level for item in &items { - assert_eq!(item.labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(item.labels.integrity, reader_integrity(scope_names::USER, &ctx)); } } @@ -2622,7 +2622,7 @@ mod tests { assert_eq!(items.len(), 1, "single-object response must produce one labeled item"); assert_eq!(items[0].labels.secrecy, vec![] as Vec); - assert_eq!(items[0].labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(items[0].labels.integrity, reader_integrity(scope_names::USER, &ctx)); } // ------------------------------------------------------------------------- @@ -2639,7 +2639,7 @@ mod tests { assert_eq!(items.len(), 1); assert_eq!(items[0].labels.secrecy, private_user_label()); - assert_eq!(items[0].labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(items[0].labels.integrity, reader_integrity(scope_names::USER, &ctx)); } // ------------------------------------------------------------------------- @@ -2719,7 +2719,7 @@ mod tests { assert_eq!(result.labeled_paths.len(), 1); assert_eq!(result.labeled_paths[0].path, "/0"); assert_eq!(result.labeled_paths[0].labels.secrecy, vec![] as Vec, "public gist path must have empty secrecy"); - assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity(scope_names::USER, &ctx)); assert_eq!(result.labeled_paths[0].labels.description, "gist:pub1"); } @@ -2740,7 +2740,7 @@ mod tests { assert_eq!(result.labeled_paths.len(), 1); assert_eq!(result.labeled_paths[0].labels.secrecy, private_user_label(), "private gist path must carry private:user secrecy"); - assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(result.labeled_paths[0].labels.integrity, reader_integrity(scope_names::USER, &ctx)); } // ------------------------------------------------------------------------- @@ -2765,7 +2765,7 @@ mod tests { // Default labels for the collection use conservative reader integrity let default_labels = result.default_labels.as_ref().expect("should have default labels"); assert_eq!(default_labels.secrecy, vec![] as Vec); - assert_eq!(default_labels.integrity, reader_integrity("user", &ctx)); + assert_eq!(default_labels.integrity, reader_integrity(scope_names::USER, &ctx)); } // ------------------------------------------------------------------------- @@ -4306,7 +4306,7 @@ mod tests { ); assert_eq!(secrecy, private_user_label(), "create_gist must carry private:user secrecy"); - assert_eq!(integrity, reader_integrity("user", &ctx), "create_gist must have reader integrity (user content)"); + assert_eq!(integrity, reader_integrity(scope_names::USER, &ctx), "create_gist must have reader integrity (user content)"); } #[test] @@ -4325,7 +4325,7 @@ mod tests { ); assert_eq!(secrecy, private_user_label(), "update_gist must carry private:user secrecy"); - assert_eq!(integrity, reader_integrity("user", &ctx), "update_gist must have reader integrity"); + assert_eq!(integrity, reader_integrity(scope_names::USER, &ctx), "update_gist must have reader integrity"); } #[test] diff --git a/guards/github-guard/rust-guard/src/labels/response_items.rs b/guards/github-guard/rust-guard/src/labels/response_items.rs index e5c86cdef..d6034e2c9 100644 --- a/guards/github-guard/rust-guard/src/labels/response_items.rs +++ b/guards/github-guard/rust-guard/src/labels/response_items.rs @@ -10,7 +10,7 @@ //! Use path-based labeling (`label_response_paths`) when possible for better //! performance with large result sets. -use super::constants::{field_names, label_constants}; +use super::constants::{field_names, label_constants, scope_names}; use super::extract_mcp_response; use super::helpers::*; use crate::{LabeledItem, ResourceLabels}; @@ -387,7 +387,7 @@ pub fn label_response_items( labels: ResourceLabels { description: format!("gist:{}", id), secrecy, - integrity: reader_integrity("user", ctx), + integrity: reader_integrity(scope_names::USER, ctx), }, }); } diff --git a/guards/github-guard/rust-guard/src/labels/response_paths.rs b/guards/github-guard/rust-guard/src/labels/response_paths.rs index 798c90170..f61090f87 100644 --- a/guards/github-guard/rust-guard/src/labels/response_paths.rs +++ b/guards/github-guard/rust-guard/src/labels/response_paths.rs @@ -7,7 +7,7 @@ //! Returns JSON paths like `/items/0`, `/items/1` pointing to labeled objects //! in the response, rather than cloning the entire data. -use super::constants::{field_names, label_constants}; +use super::constants::{field_names, label_constants, scope_names}; use super::extract_mcp_response; use super::helpers::*; use serde_json::Value; @@ -545,7 +545,7 @@ pub fn label_response_paths( labels: crate::ResourceLabels { description: format!("gist:{}", id), secrecy, - integrity: reader_integrity("user", ctx), + integrity: reader_integrity(scope_names::USER, ctx), }, }); } @@ -555,7 +555,7 @@ pub fn label_response_paths( default_labels: Some(crate::ResourceLabels { description: "gist".to_string(), secrecy: vec![], - integrity: reader_integrity("user", ctx), + integrity: reader_integrity(scope_names::USER, ctx), }), items_path: None, // Root array }); 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 a97f56a81..cbf6afb2a 100644 --- a/guards/github-guard/rust-guard/src/labels/tool_rules.rs +++ b/guards/github-guard/rust-guard/src/labels/tool_rules.rs @@ -5,7 +5,7 @@ use serde_json::Value; -use super::constants::{field_names, SENSITIVE_FILE_KEYWORDS, SENSITIVE_FILE_PATTERNS}; +use super::constants::{field_names, scope_names, SENSITIVE_FILE_KEYWORDS, SENSITIVE_FILE_PATTERNS}; use super::helpers::{ author_association_floor_from_str, elevate_via_collaborator_permission, ensure_integrity_baseline, @@ -461,8 +461,8 @@ pub fn apply_tool_labels( // S = private:user (conservative — some gists may be secret) // I = unapproved (user content, no repo-level trust signal) secrecy = private_user_label(); - baseline_scope = "user".to_string(); - integrity = reader_integrity("user", ctx); + baseline_scope = scope_names::USER.to_string(); + integrity = reader_integrity(scope_names::USER, ctx); } // === Notifications (user-scoped, private) === @@ -482,7 +482,7 @@ pub fn apply_tool_labels( // These operations change notification/subscription state and return minimal metadata. // S = public (empty); I = project:github secrecy = vec![]; - baseline_scope = "github".to_string(); + baseline_scope = scope_names::GITHUB.to_string(); integrity = project_github_label(ctx); } @@ -498,7 +498,7 @@ pub fn apply_tool_labels( // S = private:user // I = project:github (GitHub-controlled metadata) secrecy = private_user_label(); - baseline_scope = "github".to_string(); + baseline_scope = scope_names::GITHUB.to_string(); integrity = project_github_label(ctx); } @@ -512,7 +512,7 @@ pub fn apply_tool_labels( // S = public (empty) // I = project:github (GitHub-controlled metadata) secrecy = vec![]; - baseline_scope = "github".to_string(); + baseline_scope = scope_names::GITHUB.to_string(); integrity = project_github_label(ctx); } @@ -584,8 +584,8 @@ pub fn apply_tool_labels( // Creating/forking repositories is account-scoped and does not return repo content. // S = public (empty); I = writer(github) secrecy = vec![]; - baseline_scope = "github".to_string(); - integrity = writer_integrity("github", ctx); + baseline_scope = scope_names::GITHUB.to_string(); + integrity = writer_integrity(scope_names::GITHUB, ctx); } // === Projects write operations (org-scoped) === @@ -677,9 +677,9 @@ pub fn apply_tool_labels( // Enabling a toolset expands the agent's runtime capability set. // Requires writer-level integrity to prevent low-trust agents from // self-escalating by enabling additional tool groups. - // S = public (empty — no repository-scoped data); I = writer (global) - baseline_scope = "github".to_string(); - integrity = writer_integrity("github", ctx); + // S = public (empty — no repository-scoped data); I = writer (github) + baseline_scope = scope_names::GITHUB.to_string(); + integrity = writer_integrity(scope_names::GITHUB, ctx); } // === Star/unstar operations (public metadata) === @@ -687,7 +687,7 @@ pub fn apply_tool_labels( // Starring is a public action; response is minimal metadata. // S = public (empty); I = project:github secrecy = vec![]; - baseline_scope = "github".to_string(); + baseline_scope = scope_names::GITHUB.to_string(); integrity = project_github_label(ctx); } @@ -714,8 +714,8 @@ pub fn apply_tool_labels( // other gist operations that may target secret gists. // S = private_user; I = writer(user) secrecy = private_user_label(); - baseline_scope = "user".to_string(); - integrity = writer_integrity("user", ctx); + baseline_scope = scope_names::USER.to_string(); + integrity = writer_integrity(scope_names::USER, ctx); } _ => { diff --git a/guards/github-guard/rust-guard/src/lib.rs b/guards/github-guard/rust-guard/src/lib.rs index 2e356fbc6..246a6d370 100644 --- a/guards/github-guard/rust-guard/src/lib.rs +++ b/guards/github-guard/rust-guard/src/lib.rs @@ -13,6 +13,7 @@ mod labels; mod tools; use labels::constants::policy_integrity; +use labels::constants::scope_names; use labels::{ blocked_integrity, extract_repo_info, extract_repo_info_from_search_query, MinIntegrity, PolicyContext, PolicyScopeEntry, ScopeKind, @@ -267,7 +268,7 @@ fn infer_scope_for_baseline(tool_name: &str, tool_args: &Value, repo_id: &str) - | "manage_notification_subscription" | "manage_repository_notification_subscription" | "create_repository" - | "fork_repository" => "github".to_string(), + | "fork_repository" => scope_names::GITHUB.to_string(), "search_code" | "search_issues" | "search_pull_requests" => { let query = tool_args .get("query") @@ -693,7 +694,7 @@ pub extern "C" fn label_resource( " tool '{}' is unconditionally blocked — overriding integrity to blocked", input.tool_name )); - let scope = if repo_id.is_empty() { "global" } else { &repo_id }; + let scope = if repo_id.is_empty() { scope_names::GLOBAL } else { &repo_id }; blocked_integrity(scope, &ctx) } else { final_integrity @@ -894,7 +895,7 @@ pub extern "C" fn label_response( if is_server_metadata { let scope = if baseline_scope.is_empty() { - "github" + scope_names::GITHUB } else { &baseline_scope }; @@ -1176,7 +1177,7 @@ mod tests { "manage_repository_notification_subscription", ] { let inferred = infer_scope_for_baseline(tool, &tool_args, ""); - assert_eq!(inferred, "github", "{} should infer github baseline scope", tool); + assert_eq!(inferred, scope_names::GITHUB, "{} should infer github baseline scope", tool); } } @@ -1185,7 +1186,7 @@ mod tests { let tool_args = json!({ "name": "new-repo" }); for tool in &["create_repository", "fork_repository"] { let inferred = infer_scope_for_baseline(tool, &tool_args, ""); - assert_eq!(inferred, "github", "{} should infer github baseline scope", tool); + assert_eq!(inferred, scope_names::GITHUB, "{} should infer github baseline scope", tool); } } @@ -1227,7 +1228,7 @@ mod tests { assert_eq!( after_baseline, - labels::writer_integrity("github", &ctx), + labels::writer_integrity(scope_names::GITHUB, &ctx), "{} integrity should remain github writer-scoped after baseline enforcement", tool ); @@ -1263,7 +1264,7 @@ mod tests { // Simulate the is_blocked_tool override performed in label_resource let final_integrity = if tools::is_blocked_tool("transfer_repository") { - let scope = if repo_id.is_empty() { "global" } else { repo_id }; + let scope = if repo_id.is_empty() { scope_names::GLOBAL } else { repo_id }; blocked_integrity(scope, &ctx) } else { after_baseline