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 6fc884732..e86451856 100644 --- a/guards/github-guard/rust-guard/src/labels/tool_rules.rs +++ b/guards/github-guard/rust-guard/src/labels/tool_rules.rs @@ -794,6 +794,21 @@ pub fn apply_tool_labels( integrity = writer_integrity(scope_names::USER, ctx); } + // === Codespaces lifecycle management (account-scoped writes) === + // Pre-emptive synthetic guard entries for CLI-only Codespaces lifecycle operations: + // `gh codespace create` → POST /user/codespaces + // `gh codespace edit` → PATCH /user/codespaces/{codespace_name} + // `gh codespace delete` → DELETE /user/codespaces/{name} or /orgs/{org}/members/{user}/codespaces/{name} + // `gh codespace stop` → POST /user|/orgs/.../codespaces/.../stop + // Codespaces expose repository content, dev-environment metadata, and user/org-billed + // compute state. Treat conservatively as private user-scoped writes. + // S = private:user; I = writer(user) + "create_codespace" | "update_codespace" | "delete_codespace" | "stop_codespace" => { + secrecy = private_user_label(); + baseline_scope = Cow::Borrowed(scope_names::USER); + integrity = writer_integrity(scope_names::USER, ctx); + } + _ => { // Default: inherit provided labels } @@ -1670,6 +1685,32 @@ mod tests { } } + #[test] + fn apply_tool_labels_codespace_lifecycle_is_user_private_write() { + let ctx = default_ctx(); + let args = serde_json::json!({}); + let expected_secrecy = private_user_label(); + let expected_integrity = writer_integrity(scope_names::USER, &ctx); + + for tool in &[ + "create_codespace", + "update_codespace", + "delete_codespace", + "stop_codespace", + ] { + let (secrecy, integrity, _) = + super::apply_tool_labels(tool, &args, "", vec![], vec![], String::new(), &ctx); + assert_eq!( + secrecy, expected_secrecy, + "{tool}: must be user-private (secrecy = private:user)", + ); + assert_eq!( + integrity, expected_integrity, + "{tool}: must require writer-level user integrity", + ); + } + } + #[test] fn apply_tool_labels_reaction_operations_are_repo_scoped_write() { let ctx = default_ctx(); diff --git a/guards/github-guard/rust-guard/src/lib.rs b/guards/github-guard/rust-guard/src/lib.rs index 1006ce80b..201b7c182 100644 --- a/guards/github-guard/rust-guard/src/lib.rs +++ b/guards/github-guard/rust-guard/src/lib.rs @@ -463,6 +463,10 @@ fn infer_scope_for_baseline<'a>( | "manage_repository_notification_subscription" | "create_repository" | "fork_repository" => Cow::Borrowed(scope_names::GITHUB), + "create_codespace" + | "update_codespace" + | "delete_codespace" + | "stop_codespace" => Cow::Borrowed(scope_names::USER), "search_code" | "search_issues" | "search_pull_requests" | "search_commits" => { let query = tool_args .get("query") @@ -1362,6 +1366,57 @@ mod tests { } } + #[test] + fn infer_scope_for_baseline_uses_user_scope_for_codespace_lifecycle_tools() { + let tool_args = json!({}); + for tool in &[ + "create_codespace", + "update_codespace", + "delete_codespace", + "stop_codespace", + ] { + let inferred = infer_scope_for_baseline(tool, &tool_args, ""); + assert_eq!( + inferred, + scope_names::USER, + "{} should infer user baseline scope", + tool + ); + } + } + + #[test] + fn codespace_lifecycle_integrity_preserved_after_baseline() { + let ctx = PolicyContext::default(); + let tool_args = json!({}); + for tool in &[ + "create_codespace", + "update_codespace", + "delete_codespace", + "stop_codespace", + ] { + let (_, integrity, _) = labels::apply_tool_labels( + tool, + &tool_args, + "", + vec![], + vec![], + String::new(), + &ctx, + ); + let baseline_scope = infer_scope_for_baseline(tool, &tool_args, ""); + let after_baseline = + labels::ensure_integrity_baseline(&baseline_scope, integrity, &ctx); + + assert_eq!( + after_baseline, + labels::writer_integrity(scope_names::USER, &ctx), + "{} integrity should remain user writer-scoped after baseline enforcement", + tool + ); + } + } + #[test] fn transfer_repository_integrity_is_blocked_after_ensure_baseline() { // Verify that the is_blocked_tool + blocked_integrity override logic produces diff --git a/guards/github-guard/rust-guard/src/tools.rs b/guards/github-guard/rust-guard/src/tools.rs index 74ad62b8e..a838d9ee9 100644 --- a/guards/github-guard/rust-guard/src/tools.rs +++ b/guards/github-guard/rust-guard/src/tools.rs @@ -23,6 +23,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "cancel_workflow_run", // gh run cancel — cancels an in-progress workflow run "copy_project", // gh project copy — creates a new Projects v2 board from an existing one "create_branch", + "create_codespace", // gh codespace create — POST /user/codespaces "create_discussion", // gh discussion create — creates a discussion in a repository "create_gist", "create_issue", @@ -35,6 +36,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "create_release", // POST /repos/.../releases "create_repository", "create_repository_autolink", // gh repo autolink create — POST /repos/.../autolinks + "delete_codespace", // gh codespace delete — DELETE /user/codespaces/{name} or /orgs/{org}/members/{user}/codespaces/{name} "delete_deploy_key", "delete_file", "delete_gist", // DELETE /gists/{gist_id} @@ -90,6 +92,7 @@ pub const WRITE_OPERATIONS: &[&str] = &[ "unmark_project_template", // gh project mark-template --undo — GraphQL unmarkProjectV2AsTemplate "unpin_issue", // gh issue unpin "unstar_repository", + "update_codespace", // gh codespace edit — PATCH /user/codespaces/{codespace_name} "update_issue_comment", // PATCH /repos/.../issues/comments/{id} "update_project", // gh project close/edit/reopen — updates Projects v2 metadata/status "upload_release_asset", // gh release upload @@ -333,6 +336,9 @@ mod tests { "delete_release_asset", "delete_workflow_run", "stop_codespace", + "create_codespace", + "delete_codespace", + "update_codespace", ] { assert!( is_write_operation(op),