Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1 | //! Run credentials: the least-privilege tokens a sandbox works with. |
| 2 | //! | |
| 3 | //! Every sandbox run gets its own tokens, bound to the run, its repository | |
| 4 | //! (and the pull request's fork), what that kind of run needs to do, and an | |
| 5 | //! expiry no later than the run's timeout. Each carries a composite | |
| 6 | //! identity: an agent acting on behalf of the person who started the work. | |
| 7 | //! What it may do is the intersection of the two: the run's scope, and what | |
| 8 | //! that person may do right now. | |
| 9 | //! | |
| 10 | //! The policy lives here, as pure functions, so that identity (which mints | |
| 11 | //! the tokens), the API (which serves REST and MCP) and repos (which serves | |
| 12 | //! git) all enforce the same rules, and so the rules can be tested. | |
| 13 | ||
| 14 | use serde::{Deserialize, Serialize}; | |
| 15 | ||
| 16 | use crate::identity::AgentScope; | |
| 17 | use crate::repos::RepoPath; | |
| 18 | use crate::access::{BasePermission, RepoGrant, RepoRole}; | |
| 19 | use crate::{Membership, PrincipalKind, Role, User}; | |
| 20 | ||
| 21 | /// What a run does, as far as its credentials are concerned. The same names | |
| 22 | /// as [`crate::agents::RunKind`], plus `deploy`, a build of one commit. | |
| 23 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 24 | #[serde(rename_all = "snake_case")] | |
| 25 | pub enum RunCredentialKind { | |
| 26 | Implement, | |
| 27 | Revise, | |
| 28 | Review, | |
| 29 | Answer, | |
| 30 | Update, | |
| 31 | Plan, | |
| 32 | Checks, | |
| 33 | Queue, | |
| 34 | Mergecheck, | |
| 35 | Deploy, | |
| 36 | /// A security update: raising one package's version in its lockfiles | |
| 37 | /// and pushing that to a branch of its own. Not an agent. | |
| 38 | Bump, | |
| 39 | } | |
| 40 | ||
| 41 | impl RunCredentialKind { | |
| 42 | pub const ALL: [RunCredentialKind; 11] = [ | |
| 43 | RunCredentialKind::Implement, | |
| 44 | RunCredentialKind::Revise, | |
| 45 | RunCredentialKind::Review, | |
| 46 | RunCredentialKind::Answer, | |
| 47 | RunCredentialKind::Update, | |
| 48 | RunCredentialKind::Plan, | |
| 49 | RunCredentialKind::Checks, | |
| 50 | RunCredentialKind::Queue, | |
| 51 | RunCredentialKind::Mergecheck, | |
| 52 | RunCredentialKind::Deploy, | |
| 53 | RunCredentialKind::Bump, | |
| 54 | ]; | |
| 55 | ||
| 56 | pub fn as_str(self) -> &'static str { | |
| 57 | match self { | |
| 58 | RunCredentialKind::Implement => "implement", | |
| 59 | RunCredentialKind::Revise => "revise", | |
| 60 | RunCredentialKind::Review => "review", | |
| 61 | RunCredentialKind::Answer => "answer", | |
| 62 | RunCredentialKind::Update => "update", | |
| 63 | RunCredentialKind::Plan => "plan", | |
| 64 | RunCredentialKind::Checks => "checks", | |
| 65 | RunCredentialKind::Queue => "queue", | |
| 66 | RunCredentialKind::Mergecheck => "mergecheck", | |
| 67 | RunCredentialKind::Deploy => "deploy", | |
| 68 | RunCredentialKind::Bump => "bump", | |
| 69 | } | |
| 70 | } | |
| 71 | ||
| 72 | /// Whether the run works on one pull request, whose session and | |
| 73 | /// readiness it reports. | |
| 74 | fn works_on_a_pull(self) -> bool { | |
| 75 | matches!( | |
| 76 | self, | |
| 77 | RunCredentialKind::Implement | |
| 78 | | RunCredentialKind::Revise | |
| 79 | | RunCredentialKind::Answer | |
| 80 | | RunCredentialKind::Update | |
| 81 | ) | |
| 82 | } | |
| 83 | } | |
| 84 | ||
| 85 | /// Which part of a sandbox a credential is for. | |
| 86 | #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 87 | #[serde(rename_all = "snake_case")] | |
| 88 | pub enum CredentialUse { | |
| 89 | /// g1t's runner: cloning, pushing the result, recording the session. | |
| 90 | /// It acts as the person downstream, so that what it pushes and records | |
| 91 | /// is theirs, within the run's scope. | |
| 92 | Runner, | |
| 93 | /// The agent's own tools, over MCP. It acts as the agent. | |
| 94 | Tools, | |
| 95 | } | |
| 96 | ||
| 97 | impl CredentialUse { | |
| 98 | pub fn as_str(self) -> &'static str { | |
| 99 | match self { | |
| 100 | CredentialUse::Runner => "runner", | |
| 101 | CredentialUse::Tools => "tools", | |
| 102 | } | |
| 103 | } | |
| 104 | } | |
| 105 | ||
| 106 | /// A repository a run may push to, and the one branch, if only one. | |
| 107 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 108 | pub struct GitGrant { | |
| 109 | pub repo: RepoPath, | |
| 110 | /// Null: any branch. A pull request's fork is its own repository, so | |
| 111 | /// the whole of it is the pull request's. | |
| 112 | #[serde(default)] | |
| 113 | pub branch: Option<String>, | |
| 114 | } | |
| 115 | ||
| 116 | /// What binds an agent's token to one run. Absent on agent tokens made | |
| 117 | /// before run credentials, which keep working for the API only. | |
| 118 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 119 | #[serde(rename_all = "camelCase")] | |
| 120 | pub struct RunBinding { | |
| 121 | pub kind: RunCredentialKind, | |
| 122 | #[serde(rename = "use")] | |
| 123 | pub usage: CredentialUse, | |
| 124 | /// The agent run, once the sandbox has recorded it. | |
| 125 | #[serde(default)] | |
| 126 | pub run_id: Option<String>, | |
| 127 | /// The pull request the run works on, for the kinds that work on one. | |
| 128 | #[serde(default)] | |
| 129 | pub number: Option<u32>, | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 130 | /// The agent's name, such as `g1t`. |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 131 | pub agent: String, |
| 132 | /// Repositories it may clone and fetch, besides those it may push to. | |
| 133 | #[serde(default)] | |
| 134 | pub read: Vec<RepoPath>, | |
| 135 | /// Where it may push. | |
| 136 | #[serde(default)] | |
| 137 | pub push: Vec<GitGrant>, | |
| 138 | /// g1t's own run (a security update, an agent g1t put on one): the | |
| 139 | /// credential belongs to the workspace, and acts on behalf of g1t | |
| 140 | /// (`system::ID`), so what it does is g1t's, and the pull request g1t | |
| 141 | /// opened, and its working copy, are its own. | |
| 142 | #[serde(default, skip_serializing_if = "std::ops::Not::not")] | |
| 143 | pub system: bool, | |
| 144 | } | |
| 145 | ||
| 146 | /// A person, by id and name. | |
| 147 | #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] | |
| 148 | pub struct Principal { | |
| 149 | pub id: String, | |
| 150 | pub username: String, | |
| 151 | } | |
| 152 | ||
| g1t is the stored author of what it opens; the person who asked is requested_by and keeps the author's rights | 153 | impl From<&User> for Principal { |
| 154 | fn from(user: &User) -> Self { | |
| 155 | Principal { | |
| 156 | id: user.id.clone(), | |
| 157 | username: user.username.clone(), | |
| 158 | } | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 162 | /// Set on a [`User`] resolved from an agent's token: the composite |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 163 | /// identity, "g1t on behalf of syntaqx", and what it may do. |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 164 | #[derive(Clone, Debug, Serialize, Deserialize)] |
| 165 | #[serde(rename_all = "camelCase")] | |
| 166 | pub struct Acting { | |
| 167 | /// The token's id, as audit entries name it. | |
| 168 | pub credential_id: String, | |
| 169 | pub agent: String, | |
| 170 | pub on_behalf_of: Principal, | |
| 171 | pub scope: AgentScope, | |
| 172 | } | |
| 173 | ||
| 174 | impl Acting { | |
| 175 | pub fn run(&self) -> Option<&RunBinding> { | |
| 176 | self.scope.run.as_ref() | |
| 177 | } | |
| 178 | } | |
| 179 | ||
| 180 | /// `create_run_credential`: a token for one sandbox run. It acts as | |
| 181 | /// `agent` on behalf of `on_behalf_of`, can do only what `kind` and `usage` | |
| 182 | /// allow in `repo`, and expires after `ttl_seconds`, which should be the | |
| 183 | /// run's timeout. Returns `CreatedAccessToken`. | |
| 184 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 185 | #[serde(rename_all = "camelCase")] | |
| 186 | pub struct CreateRunCredentialArgs { | |
| 187 | pub on_behalf_of: User, | |
| 188 | pub repo: RepoPath, | |
| 189 | pub kind: RunCredentialKind, | |
| 190 | #[serde(rename = "use")] | |
| 191 | pub usage: CredentialUse, | |
| 192 | #[serde(default)] | |
| 193 | pub number: Option<u32>, | |
| 194 | #[serde(default)] | |
| 195 | pub read: Vec<RepoPath>, | |
| 196 | #[serde(default)] | |
| 197 | pub push: Vec<GitGrant>, | |
| 198 | pub ttl_seconds: u64, | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 199 | /// Defaults to `g1t`. |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 200 | #[serde(default)] |
| 201 | pub agent: Option<String>, | |
| 202 | } | |
| 203 | ||
| 204 | /// `bind_run_credentials`: ties tokens, named by the SHA-256 of their | |
| 205 | /// text in hex, to the agent run their sandbox recorded. Returns how many. | |
| 206 | #[derive(Clone, Debug, Serialize, Deserialize)] | |
| 207 | #[serde(rename_all = "camelCase")] | |
| 208 | pub struct BindRunCredentialsArgs { | |
| 209 | pub token_hashes: Vec<String>, | |
| 210 | pub run_id: String, | |
| 211 | } | |
| 212 | ||
| 213 | /// `revoke_run_credentials`: ends tokens when their sandbox stops, by hash | |
| 214 | /// or by run. Only run credentials are touched, never a token a person | |
| 215 | /// made. Returns how many. | |
| 216 | #[derive(Clone, Debug, Default, Serialize, Deserialize)] | |
| 217 | #[serde(rename_all = "camelCase")] | |
| 218 | pub struct RevokeRunCredentialsArgs { | |
| 219 | #[serde(default)] | |
| 220 | pub token_hashes: Vec<String>, | |
| 221 | #[serde(default)] | |
| 222 | pub run_id: Option<String>, | |
| 223 | } | |
| 224 | ||
| 225 | // --- Policy -------------------------------------------------------------- | |
| 226 | ||
| 227 | /// Operations that only read. | |
| 228 | pub const READ_OPERATIONS: &[&str] = &[ | |
| 229 | "whoami", | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 230 | "get_usage", |
| 231 | "get_budget", | |
| 232 | "get_ai_credit", | |
| 233 | "list_invoices", | |
| 234 | "get_billing_details", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 235 | "list_repos", |
| 236 | "get_repo", | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 237 | "list_projects", |
| 238 | "get_project", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 239 | "list_deleted_repos", |
| 240 | "list_collaborators", | |
| 241 | "get_collaborator_permission", | |
| 242 | "list_repo_invitations", | |
| 243 | "list_my_repo_invitations", | |
| 244 | "list_outside_collaborators", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 245 | "list_teams", |
| 246 | "get_team", | |
| 247 | "list_team_members", | |
| 248 | "list_child_teams", | |
| 249 | "list_team_repos", | |
| 250 | "list_user_teams", | |
| 251 | "get_codeowners_errors", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 252 | "get_repo_settings", |
| 253 | "list_check_names", | |
| 254 | "get_merge_queue", | |
| 255 | "recall", | |
| 256 | "list_issues", | |
| 257 | "get_issue", | |
| 258 | "get_plan", | |
| 259 | "list_labels", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 260 | "list_issue_labels", |
| 261 | "list_milestones", | |
| 262 | "get_milestone", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 263 | "list_pull_requests", |
| 264 | "get_pull_request", | |
| 265 | "read_session", | |
| 266 | "get_pull_request_changes", | |
| 267 | "list_events", | |
| 268 | "get_context", | |
| 269 | "search_context", | |
| 270 | "get_entity", | |
| 271 | "search", | |
| 272 | "list_workflows", | |
| 273 | "list_workflow_runs", | |
| 274 | "get_workflow_run", | |
| 275 | "get_job_logs", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 276 | "get_pending_deployments", |
| 277 | "get_workflow_permissions", | |
| 278 | "get_fork_pr_approval", | |
| Merge checks: statuses and check runs on every commit | 279 | "list_commit_statuses", |
| 280 | "get_combined_status", | |
| 281 | "list_check_runs_for_ref", | |
| 282 | "get_check_run", | |
| 283 | "list_check_run_annotations", | |
| 284 | "list_check_suites_for_ref", | |
| 285 | "get_check_suite", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 286 | "list_integrations", |
| 287 | "get_model_routes", | |
| 288 | "list_webhooks", | |
| 289 | "list_webhook_deliveries", | |
| 290 | "list_actions_secrets", | |
| 291 | "list_actions_variables", | |
| 292 | "list_security_alerts", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 293 | "list_secret_scanning_alerts", |
| 294 | "get_secret_scanning_alert", | |
| 295 | "list_secret_scanning_locations", | |
| 296 | "list_bypass_requests", | |
| 297 | "list_custom_patterns", | |
| 298 | "list_code_scanning_alerts", | |
| 299 | "get_code_scanning_alert", | |
| 300 | "list_code_scanning_analyses", | |
| 301 | "get_sarif_upload", | |
| 302 | "list_vulnerability_alerts", | |
| 303 | "get_vulnerability_alert", | |
| 304 | "get_dependency_graph", | |
| 305 | "get_sbom", | |
| 306 | "compare_dependencies", | |
| 307 | "get_security_settings", | |
| 308 | "get_workspace_security_settings", | |
| 309 | "get_security_overview", | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 310 | "list_security_advisories", |
| 311 | "get_security_advisory", | |
| 312 | "list_vulnerability_reports", | |
| API: notifications over REST and MCP, with notifications scopes | 313 | "list_notifications", |
| 314 | "get_notification_thread", | |
| 315 | "get_thread_subscription", | |
| 316 | "get_repo_subscription", | |
| 317 | "list_watched_repos", | |
| API: pinned projects over REST and MCP | 318 | "list_pinned_projects", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 319 | ]; |
| 320 | ||
| 321 | /// What no agent's token may ever do, whatever its scope says: workspaces, | |
| 322 | /// repositories' settings, members, tokens, billing, integrations, | |
| 323 | /// webhooks, secrets, workflows' controls, merging, and putting more agents | |
| 324 | /// to work. | |
| 325 | pub const NEVER: &[&str] = &[ | |
| Usage, Billing settings and prepaid AI credit; fixes from the UX audit | 326 | // Billing is people's: agents never spend or change it. |
| 327 | "set_budget", | |
| 328 | "buy_ai_credit", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 329 | "create_workspace", |
| 330 | "delete_workspace", | |
| 331 | "update_workspace", | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 332 | // So is who belongs to a workspace and who owns it. |
| 333 | "list_members", | |
| 334 | "update_member", | |
| 335 | "remove_member", | |
| 336 | "transfer_ownership", | |
| 337 | "leave_workspace", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 338 | "transfer_repo", |
| 339 | "create_repo", | |
| 340 | "update_repo", | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 341 | "update_project", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 342 | "delete_repo", |
| 343 | "list_deleted_repos", | |
| 344 | "restore_repo", | |
| 345 | "purge_repo", | |
| 346 | "rename_repo", | |
| 347 | "archive_repo", | |
| 348 | "unarchive_repo", | |
| 349 | "set_repo_visibility", | |
| 350 | "rename_branch", | |
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 351 | "delete_branch", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 352 | "update_repo_settings", |
| 353 | "merge_pull_request", | |
| 354 | "assign_issue", | |
| 355 | "plan_work", | |
| 356 | "apply_plan", | |
| 357 | "import_issue", | |
| 358 | "list_integrations", | |
| 359 | "connect_integration", | |
| AI Gateway: OpenAI's format, open models, and your own providers | 360 | "update_integration", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 361 | "disconnect_integration", |
| 362 | "test_integration", | |
| 363 | "get_model_routes", | |
| 364 | "set_model_routes", | |
| 365 | "list_webhooks", | |
| 366 | "create_webhook", | |
| 367 | "update_webhook", | |
| 368 | "delete_webhook", | |
| 369 | "ping_webhook", | |
| 370 | "list_webhook_deliveries", | |
| 371 | "redeliver_webhook", | |
| 372 | "dispatch_workflow", | |
| 373 | "cancel_workflow_run", | |
| 374 | "rerun_workflow_run", | |
| 375 | "update_workflow", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 376 | // Nor lets runs or deployments through, or changes what holds them. |
| 377 | "approve_workflow_run", | |
| 378 | "review_pending_deployments", | |
| 379 | "update_environment", | |
| 380 | "delete_environment", | |
| 381 | "set_workflow_permissions", | |
| 382 | "set_fork_pr_approval", | |
| Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts | 383 | "set_actions_access", |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 384 | "create_repository_dispatch", |
| 385 | "set_workspace_workflow_permissions", | |
| Merge checks: statuses and check runs on every commit | 386 | // An agent never reports checks on its own work, nor asks for them |
| 387 | // to run again: what checks say is the integrations' to say. | |
| 388 | "create_commit_status", | |
| 389 | "create_check_run", | |
| 390 | "update_check_run", | |
| 391 | "rerequest_check_run", | |
| 392 | "rerequest_check_suite", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 393 | "list_actions_secrets", |
| 394 | "set_actions_secret", | |
| 395 | "delete_actions_secret", | |
| 396 | "list_actions_variables", | |
| 397 | "set_actions_variable", | |
| 398 | "delete_actions_variable", | |
| 399 | "list_collaborators", | |
| 400 | "get_collaborator_permission", | |
| 401 | "add_collaborator", | |
| 402 | "update_collaborator", | |
| 403 | "remove_collaborator", | |
| 404 | "list_repo_invitations", | |
| 405 | "revoke_repo_invitation", | |
| 406 | "list_my_repo_invitations", | |
| 407 | "accept_repo_invitation", | |
| 408 | "decline_repo_invitation", | |
| Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040) | 409 | // Nor answers a workspace invitation: only the person it is for does. |
| 410 | "accept_invitation", | |
| 411 | "decline_invitation", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 412 | "set_base_permission", |
| 413 | "list_outside_collaborators", | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 414 | // Deploy keys, which let a machine into a repository. |
| 415 | "list_deploy_keys", | |
| 416 | "get_deploy_key", | |
| 417 | "create_deploy_key", | |
| 418 | "delete_deploy_key", | |
| Merge branch 'mirroring' into artifacts-mode | 419 | // Moving a repository to g1t for good, and a remote's token and |
| 420 | // levers, are a person's to decide. | |
| 421 | "move_mirror_to_g1t", | |
| 422 | "add_mirror_remote", | |
| 423 | "update_mirror_remote", | |
| 424 | "remove_mirror_remote", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 425 | // Teams: who is in which, and what they reach, is for people. |
| 426 | "create_team", | |
| 427 | "update_team", | |
| 428 | "delete_team", | |
| 429 | "set_team_member", | |
| 430 | "remove_team_member", | |
| 431 | "set_team_repo", | |
| 432 | "remove_team_repo", | |
| 433 | "set_team_review_assignment", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 434 | // Dismissing a secret lets it through push protection. |
| 435 | "dismiss_security_alert", | |
| 436 | "reopen_security_alert", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 437 | // Nor any other decision about security: closing or reopening an |
| 438 | // alert, pushing past push protection or deciding who may, changing | |
| 439 | // what is looked for or when checks fail, or putting more agents to | |
| 440 | // work. An agent fixes what it finds in its own pull request. | |
| 441 | "update_secret_scanning_alert", | |
| 442 | "bypass_push_protection", | |
| 443 | "review_bypass_request", | |
| 444 | "create_custom_pattern", | |
| 445 | "update_custom_pattern", | |
| 446 | "delete_custom_pattern", | |
| 447 | "update_code_scanning_alert", | |
| 448 | "update_vulnerability_alert", | |
| 449 | "fix_security_alert", | |
| 450 | "update_security_settings", | |
| 451 | "update_workspace_security_settings", | |
| API: notifications over REST and MCP, with notifications scopes | 452 | // A person's own inbox: g1t's agents act as g1t, which has none. |
| 453 | "list_notifications", | |
| 454 | "get_notification_thread", | |
| 455 | "mark_notifications_read", | |
| 456 | "mark_thread_read", | |
| 457 | "mark_thread_done", | |
| 458 | "save_thread", | |
| 459 | "snooze_thread", | |
| 460 | "get_thread_subscription", | |
| 461 | "set_thread_subscription", | |
| 462 | "delete_thread_subscription", | |
| 463 | "get_repo_subscription", | |
| 464 | "set_repo_subscription", | |
| 465 | "delete_repo_subscription", | |
| 466 | "list_watched_repos", | |
| API: pinned projects over REST and MCP | 467 | // Pins are a person's own, as the inbox is. |
| 468 | "list_pinned_projects", | |
| 469 | "pin_project", | |
| 470 | "unpin_project", | |
| 471 | "reorder_pinned_projects", | |
| Merge packages: roles, Actions access, source label, soft delete, API | 472 | // Deleting packages and deciding who may use them is for people: an |
| 473 | // agent publishes its repository's packages and never deletes them. | |
| 474 | "update_package", | |
| 475 | "link_package", | |
| 476 | "unlink_package", | |
| 477 | "set_package_access", | |
| 478 | "remove_package_access", | |
| 479 | "set_package_actions_access", | |
| 480 | "remove_package_actions_access", | |
| 481 | "delete_package", | |
| 482 | "restore_package", | |
| 483 | "delete_package_version", | |
| 484 | "restore_package_version", | |
| Merge main into Artifacts Phase 2 | 485 | // Sharing an artifact and deleting one for good are for people: an |
| 486 | // agent shares only through the agents service, with the people | |
| The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were. | 487 | // already in its conversation. |
| Merge main into Artifacts Phase 2 | 488 | "set_workspace_artifact_access", |
| 489 | "purge_workspace_artifact", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 490 | ]; |
| 491 | ||
| 492 | /// Reading what an agent needs to know about its repository. | |
| 493 | const TOOLS_READ: &[&str] = &[ | |
| 494 | "get_repo", | |
| 495 | "list_issues", | |
| 496 | "get_issue", | |
| 497 | "list_labels", | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 498 | "list_issue_labels", |
| 499 | "list_milestones", | |
| 500 | "get_milestone", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 501 | "list_pull_requests", |
| 502 | "get_pull_request", | |
| 503 | "get_pull_request_changes", | |
| 504 | "read_session", | |
| 505 | "get_merge_queue", | |
| 506 | "list_events", | |
| 507 | "recall", | |
| 508 | "search_context", | |
| 509 | "get_entity", | |
| 510 | "search", | |
| 511 | "list_workflows", | |
| 512 | "list_workflow_runs", | |
| 513 | "get_workflow_run", | |
| 514 | "get_job_logs", | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 515 | "get_pending_deployments", |
| 516 | "get_workflow_permissions", | |
| 517 | "get_fork_pr_approval", | |
| Merge checks: statuses and check runs on every commit | 518 | "list_commit_statuses", |
| 519 | "get_combined_status", | |
| 520 | "list_check_runs_for_ref", | |
| 521 | "get_check_run", | |
| 522 | "list_check_run_annotations", | |
| 523 | "list_check_suites_for_ref", | |
| 524 | "get_check_suite", | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 525 | ]; |
| 526 | ||
| 527 | pub fn is_read(operation: &str) -> bool { | |
| 528 | READ_OPERATIONS.contains(&operation) | |
| 529 | } | |
| 530 | ||
| 531 | /// The API and MCP operations a run of `kind` may use with a credential | |
| 532 | /// for `usage`. Git is separate: see [`decide_git`]. | |
| 533 | pub fn operations_for(kind: RunCredentialKind, usage: CredentialUse) -> Vec<&'static str> { | |
| 534 | use RunCredentialKind as K; | |
| 535 | let mut operations: Vec<&'static str> = Vec::new(); | |
| 536 | match usage { | |
| 537 | CredentialUse::Runner => { | |
| 538 | if kind.works_on_a_pull() { | |
| 539 | operations.extend(["get_repo", "get_pull_request", "record_session"]); | |
| 540 | } | |
| 541 | if kind == K::Implement { | |
| 542 | operations.push("mark_pull_request_ready"); | |
| 543 | } | |
| 544 | } | |
| 545 | CredentialUse::Tools => match kind { | |
| 546 | K::Implement | K::Revise | K::Answer => { | |
| 547 | operations.extend(TOOLS_READ.iter().copied()); | |
| 548 | operations.extend([ | |
| 549 | "create_issue", | |
| 550 | "add_comment", | |
| 551 | "take_messages", | |
| 552 | "remember", | |
| 553 | "message_agent", | |
| 554 | "answer_message", | |
| 555 | "get_context", | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 556 | "customize_bundle", |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 557 | ]); |
| 558 | } | |
| 559 | K::Review => { | |
| 560 | operations.extend(TOOLS_READ.iter().copied()); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 561 | operations.extend(["add_comment", "review_pull_request", "get_context", "customize_bundle"]); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 562 | } |
| 563 | K::Plan => { | |
| 564 | operations.extend(TOOLS_READ.iter().copied()); | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 565 | operations.extend(["create_issue", "get_context", "customize_bundle"]); |
| 566 | } | |
| 567 | K::Update => { | |
| 568 | operations.extend(TOOLS_READ.iter().copied()); | |
| 569 | operations.push("customize_bundle"); | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 570 | } |
| 571 | K::Checks | K::Queue | K::Mergecheck | K::Deploy | K::Bump => {} | |
| 572 | }, | |
| 573 | } | |
| 574 | operations | |
| 575 | } | |
| 576 | ||
| 577 | /// What a run's credential may do, in the scope vocabulary that access | |
| 578 | /// tokens use (see [`crate::scopes`]): the scopes of its operations, and | |
| 579 | /// for a runner, git's. Its operations, its repository and its run still | |
| 580 | /// bound it more tightly than these scopes say. | |
| 581 | pub fn run_scopes(kind: RunCredentialKind, usage: CredentialUse) -> Vec<crate::scopes::Scope> { | |
| 582 | use crate::scopes::{Scope, normalize, scope_for}; | |
| 583 | let mut scopes: Vec<Scope> = operations_for(kind, usage) | |
| 584 | .into_iter() | |
| 585 | .filter_map(scope_for) | |
| 586 | .collect(); | |
| 587 | if usage == CredentialUse::Runner { | |
| 588 | scopes.push(Scope::CodeRead); | |
| 589 | if matches!( | |
| 590 | kind, | |
| 591 | RunCredentialKind::Implement | |
| 592 | | RunCredentialKind::Revise | |
| 593 | | RunCredentialKind::Answer | |
| 594 | | RunCredentialKind::Update | |
| 595 | | RunCredentialKind::Bump | |
| 596 | ) { | |
| 597 | scopes.push(Scope::CodeWrite); | |
| 598 | } | |
| 599 | } | |
| 600 | normalize(&mut scopes); | |
| 601 | scopes | |
| 602 | } | |
| 603 | ||
| 604 | /// Operations that change a pull request, which a runner may do only to | |
| 605 | /// the pull request its run works on. | |
| 606 | const PULL_WRITES: &[&str] = &["record_session", "mark_pull_request_ready"]; | |
| 607 | ||
| 608 | /// Whether something was allowed, and the rule that decided it. | |
| 609 | #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] | |
| 610 | pub struct Decision { | |
| 611 | pub allowed: bool, | |
| 612 | /// A short, stable name: `run:implement/tools`, `never`, | |
| 613 | /// `scope:repository` and so on. Shown in the audit log. | |
| 614 | pub rule: String, | |
| 615 | /// Why it was refused, for the caller. | |
| 616 | #[serde(default, skip_serializing_if = "Option::is_none")] | |
| 617 | pub reason: Option<String>, | |
| 618 | } | |
| 619 | ||
| 620 | impl Decision { | |
| 621 | pub fn allow(rule: impl Into<String>) -> Self { | |
| 622 | Decision { | |
| 623 | allowed: true, | |
| 624 | rule: rule.into(), | |
| 625 | reason: None, | |
| 626 | } | |
| 627 | } | |
| 628 | ||
| 629 | pub fn deny(rule: impl Into<String>, reason: impl Into<String>) -> Self { | |
| 630 | Decision { | |
| 631 | allowed: false, | |
| 632 | rule: rule.into(), | |
| 633 | reason: Some(reason.into()), | |
| 634 | } | |
| 635 | } | |
| 636 | } | |
| 637 | ||
| 638 | fn same_repo(a: &RepoPath, b: &RepoPath) -> bool { | |
| 639 | a.namespace.eq_ignore_ascii_case(&b.namespace) && a.name.eq_ignore_ascii_case(&b.name) | |
| 640 | } | |
| 641 | ||
| 642 | fn scope_rule(scope: &AgentScope) -> String { | |
| 643 | match &scope.run { | |
| 644 | Some(run) => format!("run:{}/{}", run.kind.as_str(), run.usage.as_str()), | |
| 645 | None => "agent-token".to_owned(), | |
| 646 | } | |
| 647 | } | |
| 648 | ||
| 649 | /// Whether `user`, resolved from an agent's token with `scope`, may use | |
| 650 | /// `operation`. `repo` is the repository the call names, if any, and | |
| 651 | /// `needs_repo` whether the operation is about one; `number` the issue or | |
| 652 | /// pull request it names. | |
| 653 | pub fn decide_operation( | |
| 654 | user: &User, | |
| 655 | scope: &AgentScope, | |
| 656 | operation: &str, | |
| 657 | repo: Option<&RepoPath>, | |
| 658 | needs_repo: bool, | |
| 659 | number: Option<u32>, | |
| 660 | ) -> Decision { | |
| 661 | let who = "A g1t agent's token"; | |
| 662 | if NEVER.contains(&operation) { | |
| 663 | return Decision::deny( | |
| 664 | "never", | |
| 665 | format!( | |
| 666 | "{who} can never use {operation}: settings, members, tokens, billing, integrations, webhooks, secrets and merging are for people." | |
| 667 | ), | |
| 668 | ); | |
| 669 | } | |
| 670 | if !scope.operations.iter().any(|name| name == operation) { | |
| 671 | return Decision::deny( | |
| 672 | "scope:operation", | |
| 673 | format!("{who} for this run cannot use {operation}."), | |
| 674 | ); | |
| 675 | } | |
| 676 | if needs_repo && !repo.is_some_and(|asked| same_repo(asked, &scope.repo)) { | |
| 677 | return Decision::deny( | |
| 678 | "scope:repository", | |
| 679 | format!( | |
| 680 | "{who} works in {}/{} only.", | |
| 681 | scope.repo.namespace, scope.repo.name | |
| 682 | ), | |
| 683 | ); | |
| 684 | } | |
| 685 | // The intersection: the person it acts for must still be able to work | |
| 686 | // in the repository's workspace, as a member or with a role on its | |
| 687 | // repositories. What it may do in the repository itself is their | |
| 688 | // role there, which services check (`access::can`). | |
| 689 | if !crate::access::has_access_in(user, &scope.repo.namespace) { | |
| 690 | return Decision::deny( | |
| 691 | "on-behalf-of:membership", | |
| 692 | format!( | |
| 693 | "The person this agent works for is no longer a member of {}.", | |
| 694 | scope.repo.namespace | |
| 695 | ), | |
| 696 | ); | |
| 697 | } | |
| 698 | if let Some(run) = &scope.run | |
| 699 | && run.usage == CredentialUse::Runner | |
| 700 | && PULL_WRITES.contains(&operation) | |
| 701 | && run.number.is_some() | |
| 702 | && number != run.number | |
| 703 | { | |
| 704 | return Decision::deny( | |
| 705 | "scope:pull", | |
| 706 | format!( | |
| 707 | "{who} can change pull request #{} only.", | |
| 708 | run.number.unwrap_or_default() | |
| 709 | ), | |
| 710 | ); | |
| 711 | } | |
| 712 | Decision::allow(scope_rule(scope)) | |
| 713 | } | |
| 714 | ||
| 715 | /// Whether a run credential may clone or fetch (`write` false), or push to | |
| 716 | /// (`write` true), the repository at `repo`. | |
| 717 | pub fn decide_git(scope: &AgentScope, repo: &RepoPath, write: bool) -> Decision { | |
| 718 | let Some(run) = scope | |
| 719 | .run | |
| 720 | .as_ref() | |
| 721 | .filter(|run| run.usage == CredentialUse::Runner) | |
| 722 | else { | |
| 723 | return Decision::deny( | |
| 724 | "git:not-a-run", | |
| 725 | "A g1t agent's tools token cannot be used with git.", | |
| 726 | ); | |
| 727 | }; | |
| 728 | let pushable = run.push.iter().any(|grant| same_repo(&grant.repo, repo)); | |
| 729 | if write { | |
| 730 | return if pushable { | |
| 731 | Decision::allow(format!("{}:push", scope_rule(scope))) | |
| 732 | } else { | |
| 733 | Decision::deny( | |
| 734 | "git:push", | |
| 735 | format!( | |
| 736 | "A {} run cannot push to {}/{}.", | |
| 737 | run.kind.as_str(), | |
| 738 | repo.namespace, | |
| 739 | repo.name | |
| 740 | ), | |
| 741 | ) | |
| 742 | }; | |
| 743 | } | |
| 744 | let readable = pushable | |
| 745 | || same_repo(&scope.repo, repo) | |
| 746 | || run.read.iter().any(|path| same_repo(path, repo)); | |
| 747 | if readable { | |
| 748 | Decision::allow(format!("{}:read", scope_rule(scope))) | |
| 749 | } else { | |
| 750 | Decision::deny( | |
| 751 | "git:read", | |
| 752 | format!( | |
| 753 | "A {} run cannot read {}/{}.", | |
| 754 | run.kind.as_str(), | |
| 755 | repo.namespace, | |
| 756 | repo.name | |
| 757 | ), | |
| 758 | ) | |
| 759 | } | |
| 760 | } | |
| 761 | ||
| 762 | /// Whether a push to `repo` is limited to certain branches, so that the | |
| 763 | /// refs it moves have to be read and checked with [`decide_refs`]. | |
| 764 | pub fn limits_branches(scope: &AgentScope, repo: &RepoPath) -> bool { | |
| 765 | scope | |
| 766 | .run | |
| 767 | .iter() | |
| 768 | .flat_map(|run| run.push.iter()) | |
| 769 | .any(|grant| same_repo(&grant.repo, repo) && grant.branch.is_some()) | |
| 770 | } | |
| 771 | ||
| 772 | /// Whether a push to `repo` may move `refs` (full refs, such as | |
| 773 | /// `refs/heads/main`). Tags are never a run's to move. | |
| 774 | pub fn decide_refs(scope: &AgentScope, repo: &RepoPath, refs: &[String]) -> Decision { | |
| 775 | let repo_decision = decide_git(scope, repo, true); | |
| 776 | if !repo_decision.allowed { | |
| 777 | return repo_decision; | |
| 778 | } | |
| 779 | let grants: Vec<&GitGrant> = scope | |
| 780 | .run | |
| 781 | .iter() | |
| 782 | .flat_map(|run| run.push.iter()) | |
| 783 | .filter(|grant| same_repo(&grant.repo, repo)) | |
| 784 | .collect(); | |
| 785 | for git_ref in refs { | |
| 786 | let Some(branch) = git_ref.strip_prefix("refs/heads/") else { | |
| 787 | return Decision::deny("git:ref", format!("A run cannot push {git_ref}.")); | |
| 788 | }; | |
| 789 | let allowed = grants | |
| 790 | .iter() | |
| 791 | .any(|grant| grant.branch.as_deref().is_none_or(|only| only == branch)); | |
| 792 | if !allowed { | |
| 793 | return Decision::deny( | |
| 794 | "git:ref", | |
| 795 | format!( | |
| 796 | "A run cannot push to {branch} in {}/{}.", | |
| 797 | repo.namespace, repo.name | |
| 798 | ), | |
| 799 | ); | |
| 800 | } | |
| 801 | } | |
| 802 | repo_decision | |
| 803 | } | |
| 804 | ||
| 805 | /// The most an agent may be on a repository, whoever it works for: it | |
| 806 | /// can push, merge and run, never change settings or who has access. | |
| 807 | pub const AGENT_CEILING: RepoRole = RepoRole::Write; | |
| 808 | ||
| 809 | /// The memberships an agent working for `person` has: the run's | |
| 810 | /// workspace, as a member, only if the person is in it now, with the | |
| 811 | /// person's role on its repositories (an owner's Admin included) cut down | |
| 812 | /// to [`AGENT_CEILING`]. | |
| 813 | pub fn intersect(person: &[Membership], namespace: &str) -> Vec<Membership> { | |
| 814 | let namespace = namespace.to_lowercase(); | |
| 815 | person | |
| 816 | .iter() | |
| 817 | .filter(|membership| membership.slug == namespace) | |
| 818 | .map(|membership| { | |
| 819 | let base = match membership.role { | |
| 820 | Role::Owner => BasePermission::Admin, | |
| 821 | Role::Member => membership.base_permission.unwrap_or_default(), | |
| 822 | }; | |
| 823 | Membership { | |
| 824 | role: Role::Member, | |
| 825 | base_permission: Some(match base { | |
| 826 | BasePermission::Admin => BasePermission::Write, | |
| 827 | base => base, | |
| 828 | }), | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 829 | // Billing and security manager are the person's, never |
| 830 | // their agent's. | |
| 831 | org_roles: Vec::new(), | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 832 | ..membership.clone() |
| 833 | } | |
| 834 | }) | |
| 835 | .collect() | |
| 836 | } | |
| 837 | ||
| 838 | /// The repository grants an agent working for `person` has: those in the | |
| 839 | /// run's workspace, each cut down to [`AGENT_CEILING`]. | |
| 840 | pub fn intersect_grants(person: &[RepoGrant], namespace: &str) -> Vec<RepoGrant> { | |
| 841 | let namespace = namespace.to_lowercase(); | |
| 842 | person | |
| 843 | .iter() | |
| 844 | .filter(|grant| grant.workspace == namespace) | |
| 845 | .map(|grant| RepoGrant { | |
| 846 | role: grant.role.min(AGENT_CEILING), | |
| 847 | ..grant.clone() | |
| 848 | }) | |
| 849 | .collect() | |
| 850 | } | |
| 851 | ||
| 852 | /// Who a runner's credential acts as downstream: the person, with only the | |
| 853 | /// agent's (already intersected) memberships. `None` for anything else. | |
| 854 | pub fn as_person(user: &User) -> Option<User> { | |
| 855 | let acting = user.acting.as_ref()?; | |
| 856 | if user.kind != PrincipalKind::Agent { | |
| 857 | return None; | |
| 858 | } | |
| 859 | let run = acting.run()?; | |
| 860 | if run.usage != CredentialUse::Runner { | |
| 861 | return None; | |
| 862 | } | |
| 863 | Some(User { | |
| 864 | id: acting.on_behalf_of.id.clone(), | |
| 865 | username: acting.on_behalf_of.username.clone(), | |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 866 | display_username: None, |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 867 | kind: PrincipalKind::User, |
| 868 | verified: user.verified, | |
| 869 | workspaces: user.workspaces.clone(), | |
| 870 | avatar: None, | |
| 871 | acting: None, | |
| 872 | grants: user.grants.clone(), | |
| 873 | token: None, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 874 | held: Vec::new(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 875 | }) |
| 876 | } | |
| 877 | ||
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 878 | /// How an actor is described: "g1t on behalf of syntaqx". |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 879 | pub fn describe(user: &User) -> String { |
| 880 | match &user.acting { | |
| 881 | Some(acting) => format!( | |
| 882 | "{} on behalf of {}", | |
| 883 | acting.agent, acting.on_behalf_of.username | |
| 884 | ), | |
| 885 | None => user.username.clone(), | |
| 886 | } | |
| 887 | } | |
| 888 | ||
| 889 | #[cfg(test)] | |
| 890 | mod tests { | |
| 891 | use super::*; | |
| 892 | ||
| 893 | #[test] | |
| 894 | fn a_run_s_scopes_are_never_admin() { | |
| 895 | for kind in RunCredentialKind::ALL { | |
| 896 | for usage in [CredentialUse::Runner, CredentialUse::Tools] { | |
| 897 | let scopes = run_scopes(kind, usage); | |
| 898 | assert!(scopes.iter().all(|scope| !scope.dangerous()), "{kind:?} {usage:?}: {scopes:?}"); | |
| 899 | } | |
| 900 | } | |
| 901 | let review = run_scopes(RunCredentialKind::Review, CredentialUse::Tools); | |
| 902 | assert!(review.contains(&crate::scopes::Scope::PullRequestsWrite)); | |
| 903 | assert!(!review.contains(&crate::scopes::Scope::CodeWrite)); | |
| 904 | } | |
| 905 | ||
| 906 | #[test] | |
| 907 | fn agents_can_search_the_context_hub() { | |
| 908 | for kind in [RunCredentialKind::Implement, RunCredentialKind::Review, RunCredentialKind::Plan] { | |
| 909 | let tools = operations_for(kind, CredentialUse::Tools); | |
| 910 | assert!(tools.contains(&"search_context") && tools.contains(&"get_entity")); | |
| 911 | } | |
| 912 | assert!(is_read("search_context") && is_read("get_entity")); | |
| 913 | } | |
| 914 | ||
| 915 | #[test] | |
| 916 | fn agents_can_search_all_of_g1t() { | |
| 917 | // Site-wide search only reads: every run that reads its repository | |
| 918 | // may use it, and nothing that never reads gets it. | |
| 919 | assert!(is_read("search")); | |
| 920 | assert!(!NEVER.contains(&"search")); | |
| 921 | for kind in [ | |
| 922 | RunCredentialKind::Implement, | |
| 923 | RunCredentialKind::Revise, | |
| 924 | RunCredentialKind::Answer, | |
| 925 | RunCredentialKind::Review, | |
| 926 | RunCredentialKind::Plan, | |
| 927 | RunCredentialKind::Update, | |
| 928 | ] { | |
| 929 | let tools = operations_for(kind, CredentialUse::Tools); | |
| 930 | assert!(tools.contains(&"search"), "{kind:?} should search"); | |
| 931 | // The context hub's search stays its own tool beside it. | |
| 932 | assert!(tools.contains(&"search_context"), "{kind:?} keeps search_context"); | |
| 933 | } | |
| 934 | for kind in [RunCredentialKind::Checks, RunCredentialKind::Queue, RunCredentialKind::Mergecheck, RunCredentialKind::Deploy, RunCredentialKind::Bump] { | |
| 935 | assert!(!operations_for(kind, CredentialUse::Tools).contains(&"search")); | |
| 936 | } | |
| 937 | assert!(!operations_for(RunCredentialKind::Implement, CredentialUse::Runner).contains(&"search")); | |
| 938 | } | |
| 939 | ||
| 940 | fn path(namespace: &str, name: &str) -> RepoPath { | |
| 941 | RepoPath { | |
| 942 | namespace: namespace.to_owned(), | |
| 943 | name: name.to_owned(), | |
| 944 | } | |
| 945 | } | |
| 946 | ||
| 947 | fn scope(kind: RunCredentialKind, usage: CredentialUse) -> AgentScope { | |
| 948 | AgentScope { | |
| 949 | repo: path("acme", "rocket"), | |
| 950 | operations: operations_for(kind, usage) | |
| 951 | .into_iter() | |
| 952 | .map(str::to_owned) | |
| 953 | .collect(), | |
| 954 | run: Some(RunBinding { | |
| 955 | kind, | |
| 956 | usage, | |
| 957 | run_id: Some("run_1".to_owned()), | |
| 958 | number: Some(7), | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 959 | agent: "g1t".to_owned(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 960 | system: false, |
| 961 | read: vec![path("acme", "rocket")], | |
| 962 | push: match kind { | |
| 963 | RunCredentialKind::Implement | |
| 964 | | RunCredentialKind::Revise | |
| 965 | | RunCredentialKind::Answer => vec![GitGrant { | |
| 966 | repo: path("pulls", "pul_7"), | |
| 967 | branch: None, | |
| 968 | }], | |
| 969 | RunCredentialKind::Update => vec![GitGrant { | |
| 970 | repo: path("acme", "rocket"), | |
| 971 | branch: Some("fix-login".to_owned()), | |
| 972 | }], | |
| 973 | _ => vec![], | |
| 974 | }, | |
| 975 | }), | |
| 976 | } | |
| 977 | } | |
| 978 | ||
| 979 | fn agent(member_of: &[&str], scope: AgentScope) -> User { | |
| 980 | User { | |
| 981 | id: "usr_g1t_agent".to_owned(), | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 982 | username: "g1t".to_owned(), |
| One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers | 983 | display_username: None, |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 984 | kind: PrincipalKind::Agent, |
| 985 | verified: true, | |
| 986 | workspaces: member_of | |
| 987 | .iter() | |
| 988 | .map(|slug| Membership::member(*slug)) | |
| 989 | .collect(), | |
| 990 | avatar: None, | |
| 991 | grants: Vec::new(), | |
| 992 | token: None, | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 993 | held: Vec::new(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 994 | acting: Some(Box::new(Acting { |
| 995 | credential_id: "tok_1".to_owned(), | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 996 | agent: "g1t".to_owned(), |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 997 | on_behalf_of: Principal { |
| 998 | id: "usr_1".to_owned(), | |
| 999 | username: "syntaqx".to_owned(), | |
| 1000 | }, | |
| 1001 | scope, | |
| 1002 | })), | |
| 1003 | } | |
| 1004 | } | |
| 1005 | ||
| 1006 | fn op(kind: RunCredentialKind, usage: CredentialUse, operation: &str) -> Decision { | |
| 1007 | let scope = scope(kind, usage); | |
| 1008 | let user = agent(&["acme"], scope.clone()); | |
| 1009 | decide_operation( | |
| 1010 | &user, | |
| 1011 | &scope, | |
| 1012 | operation, | |
| 1013 | Some(&path("acme", "rocket")), | |
| 1014 | true, | |
| 1015 | Some(7), | |
| 1016 | ) | |
| 1017 | } | |
| 1018 | ||
| 1019 | use CredentialUse::{Runner, Tools}; | |
| 1020 | use RunCredentialKind as K; | |
| 1021 | ||
| 1022 | /// Which operations each kind of run may use through its tools: the | |
| 1023 | /// allowed and denied matrix. | |
| 1024 | #[test] | |
| 1025 | fn tools_matrix() { | |
| 1026 | let cases: [(&str, [bool; 6]); 12] = [ | |
| 1027 | // implement revise answer review plan checks | |
| 1028 | ("get_issue", [true, true, true, true, true, false]), | |
| 1029 | ("create_issue", [true, true, true, false, true, false]), | |
| 1030 | ("add_comment", [true, true, true, true, false, false]), | |
| 1031 | ( | |
| 1032 | "review_pull_request", | |
| 1033 | [false, false, false, true, false, false], | |
| 1034 | ), | |
| 1035 | ("remember", [true, true, true, false, false, false]), | |
| 1036 | ("take_messages", [true, true, true, false, false, false]), | |
| 1037 | ("record_session", [false, false, false, false, false, false]), | |
| 1038 | ( | |
| 1039 | "merge_pull_request", | |
| 1040 | [false, false, false, false, false, false], | |
| 1041 | ), | |
| 1042 | ( | |
| 1043 | "update_repo_settings", | |
| 1044 | [false, false, false, false, false, false], | |
| 1045 | ), | |
| 1046 | ("create_webhook", [false, false, false, false, false, false]), | |
| 1047 | ( | |
| 1048 | "set_actions_secret", | |
| 1049 | [false, false, false, false, false, false], | |
| 1050 | ), | |
| 1051 | ("assign_issue", [false, false, false, false, false, false]), | |
| 1052 | ]; | |
| 1053 | let kinds = [ | |
| 1054 | K::Implement, | |
| 1055 | K::Revise, | |
| 1056 | K::Answer, | |
| 1057 | K::Review, | |
| 1058 | K::Plan, | |
| 1059 | K::Checks, | |
| 1060 | ]; | |
| 1061 | for (operation, expected) in cases { | |
| 1062 | for (kind, allowed) in kinds.into_iter().zip(expected) { | |
| 1063 | assert_eq!( | |
| 1064 | op(kind, Tools, operation).allowed, | |
| 1065 | allowed, | |
| 1066 | "{operation} by a {} run's tools", | |
| 1067 | kind.as_str() | |
| 1068 | ); | |
| 1069 | } | |
| 1070 | } | |
| 1071 | } | |
| 1072 | ||
| 1073 | #[test] | |
| 1074 | fn runner_matrix() { | |
| 1075 | assert!(op(K::Implement, Runner, "record_session").allowed); | |
| 1076 | assert!(op(K::Implement, Runner, "mark_pull_request_ready").allowed); | |
| 1077 | assert!(op(K::Revise, Runner, "record_session").allowed); | |
| 1078 | assert!(!op(K::Revise, Runner, "mark_pull_request_ready").allowed); | |
| 1079 | assert!(!op(K::Implement, Runner, "create_issue").allowed); | |
| 1080 | assert!(!op(K::Review, Runner, "record_session").allowed); | |
| 1081 | assert!(!op(K::Checks, Runner, "get_issue").allowed); | |
| 1082 | } | |
| 1083 | ||
| 1084 | #[test] | |
| 1085 | fn settings_billing_tokens_and_members_are_never_reachable() { | |
| 1086 | for kind in RunCredentialKind::ALL { | |
| 1087 | for usage in [Runner, Tools] { | |
| 1088 | for operation in NEVER.iter().copied() { | |
| 1089 | let decision = op(kind, usage, operation); | |
| 1090 | assert!(!decision.allowed); | |
| 1091 | assert_eq!(decision.rule, "never"); | |
| 1092 | } | |
| 1093 | } | |
| 1094 | } | |
| 1095 | // Even a scope that lists one is refused. | |
| 1096 | let mut wide = scope(K::Implement, Tools); | |
| 1097 | wide.operations.push("merge_pull_request".to_owned()); | |
| 1098 | let user = agent(&["acme"], wide.clone()); | |
| 1099 | let decision = decide_operation( | |
| 1100 | &user, | |
| 1101 | &wide, | |
| 1102 | "merge_pull_request", | |
| 1103 | Some(&path("acme", "rocket")), | |
| 1104 | true, | |
| 1105 | Some(7), | |
| 1106 | ); | |
| 1107 | assert_eq!(decision.rule, "never"); | |
| 1108 | } | |
| 1109 | ||
| 1110 | #[test] | |
| 1111 | fn another_repository_is_refused() { | |
| 1112 | let scope = scope(K::Implement, Tools); | |
| 1113 | let user = agent(&["acme"], scope.clone()); | |
| 1114 | let decision = decide_operation( | |
| 1115 | &user, | |
| 1116 | &scope, | |
| 1117 | "create_issue", | |
| 1118 | Some(&path("acme", "other")), | |
| 1119 | true, | |
| 1120 | None, | |
| 1121 | ); | |
| 1122 | assert!(!decision.allowed); | |
| 1123 | assert_eq!(decision.rule, "scope:repository"); | |
| 1124 | let decision = decide_operation(&user, &scope, "create_issue", None, true, None); | |
| 1125 | assert_eq!(decision.rule, "scope:repository"); | |
| 1126 | // The repository's name is matched without regard to case. | |
| 1127 | let decision = decide_operation( | |
| 1128 | &user, | |
| 1129 | &scope, | |
| 1130 | "create_issue", | |
| 1131 | Some(&path("Acme", "Rocket")), | |
| 1132 | true, | |
| 1133 | None, | |
| 1134 | ); | |
| 1135 | assert!(decision.allowed); | |
| 1136 | assert_eq!(decision.rule, "run:implement/tools"); | |
| 1137 | } | |
| 1138 | ||
| 1139 | #[test] | |
| 1140 | fn the_permission_is_the_intersection_with_the_person() { | |
| 1141 | let scope = scope(K::Implement, Tools); | |
| 1142 | // The person left the workspace: their agent can do nothing there. | |
| 1143 | let user = agent(&[], scope.clone()); | |
| 1144 | let decision = decide_operation( | |
| 1145 | &user, | |
| 1146 | &scope, | |
| 1147 | "get_issue", | |
| 1148 | Some(&path("acme", "rocket")), | |
| 1149 | true, | |
| 1150 | Some(1), | |
| 1151 | ); | |
| 1152 | assert!(!decision.allowed); | |
| 1153 | assert_eq!(decision.rule, "on-behalf-of:membership"); | |
| 1154 | // And an owner's agent is only ever a member. | |
| 1155 | let owner = vec![ | |
| 1156 | Membership { | |
| 1157 | slug: "acme".to_owned(), | |
| 1158 | role: Role::Owner, | |
| 1159 | name: None, | |
| 1160 | avatar: None, | |
| 1161 | base_permission: Some(BasePermission::None), | |
| Merge branch 'worktree-agent-ad7c6d88d93adc817' | 1162 | team_creation: None, |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1163 | org_roles: vec![crate::OrgRole::SecurityManager], |
| 1164 | privileges: None, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1165 | }, |
| 1166 | Membership::member("elsewhere"), | |
| 1167 | ]; | |
| 1168 | let memberships = intersect(&owner, "Acme"); | |
| 1169 | assert_eq!(memberships.len(), 1); | |
| 1170 | assert_eq!(memberships[0].slug, "acme"); | |
| 1171 | assert_eq!(memberships[0].role, Role::Member); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 1172 | assert!(memberships[0].org_roles.is_empty()); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1173 | assert!(intersect(&owner, "nowhere").is_empty()); |
| 1174 | } | |
| 1175 | ||
| 1176 | /// An agent gets at most the person's role on the repository, and | |
| 1177 | /// never more than Write; nothing outside the run's workspace. | |
| 1178 | #[test] | |
| 1179 | fn an_agent_has_at_most_its_persons_role() { | |
| 1180 | use crate::access::{Capability, RepoRef, can, permission}; | |
| 1181 | let rocket = RepoRef { id: "rep_1", namespace: "acme", private: true }; | |
| 1182 | let other = RepoRef { id: "rep_2", namespace: "acme", private: true }; | |
| 1183 | let elsewhere = RepoRef { id: "rep_3", namespace: "globex", private: true }; | |
| 1184 | let tools = scope(K::Implement, Tools); | |
| 1185 | let scope = scope(K::Implement, Runner); | |
| 1186 | // An owner's agent: Write, never Admin. | |
| 1187 | let owner = [Membership { role: Role::Owner, ..Membership::member("acme") }, Membership::member("globex")]; | |
| 1188 | let mut agent_user = agent(&[], scope.clone()); | |
| 1189 | agent_user.workspaces = intersect(&owner, "acme"); | |
| 1190 | assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write)); | |
| 1191 | assert!(!can(Some(&agent_user), rocket, Capability::ManageSettings)); | |
| 1192 | assert_eq!(permission(Some(&agent_user), elsewhere), None); | |
| 1193 | // A member whose workspace gives Read: Read, so it cannot push. | |
| 1194 | let reader = [Membership { base_permission: Some(BasePermission::Read), ..Membership::member("acme") }]; | |
| 1195 | agent_user.workspaces = intersect(&reader, "acme"); | |
| 1196 | assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Read)); | |
| 1197 | assert!(!can(Some(&agent_user), rocket, Capability::Push)); | |
| 1198 | // An outside collaborator with Maintain on one repository: Write | |
| 1199 | // there, nothing elsewhere, and the run is allowed. | |
| 1200 | let grants = [ | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1201 | RepoGrant { repo_id: "rep_1".into(), workspace: "acme".into(), role: RepoRole::Maintain, team: None }, |
| 1202 | RepoGrant { repo_id: "rep_3".into(), workspace: "globex".into(), role: RepoRole::Admin, team: None }, | |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1203 | ]; |
| 1204 | agent_user.workspaces = intersect(&[], "acme"); | |
| 1205 | agent_user.grants = intersect_grants(&grants, "Acme"); | |
| 1206 | assert_eq!(permission(Some(&agent_user), rocket), Some(RepoRole::Write)); | |
| 1207 | assert_eq!(permission(Some(&agent_user), other), None); | |
| 1208 | assert_eq!(permission(Some(&agent_user), elsewhere), None); | |
| 1209 | let decision = decide_operation(&agent_user, &tools, "get_issue", Some(&path("acme", "rocket")), true, Some(1)); | |
| 1210 | assert!(decision.allowed, "{}", decision.reason.unwrap_or_default()); | |
| 1211 | // The person, downstream of a runner's credential, carries the same. | |
| 1212 | let person = as_person(&agent_user).expect("a runner acts as the person"); | |
| 1213 | assert_eq!(permission(Some(&person), rocket), Some(RepoRole::Write)); | |
| 1214 | } | |
| 1215 | ||
| 1216 | #[test] | |
| 1217 | fn a_runner_changes_only_its_own_pull_request() { | |
| 1218 | let scope = scope(K::Implement, Runner); | |
| 1219 | let user = agent(&["acme"], scope.clone()); | |
| 1220 | let repo = path("acme", "rocket"); | |
| 1221 | let other = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(8)); | |
| 1222 | assert!(!other.allowed); | |
| 1223 | assert_eq!(other.rule, "scope:pull"); | |
| 1224 | let own = decide_operation(&user, &scope, "record_session", Some(&repo), true, Some(7)); | |
| 1225 | assert!(own.allowed); | |
| 1226 | // Reading another is fine. | |
| 1227 | assert!( | |
| 1228 | decide_operation( | |
| 1229 | &user, | |
| 1230 | &scope, | |
| 1231 | "get_pull_request", | |
| 1232 | Some(&repo), | |
| 1233 | true, | |
| 1234 | Some(8) | |
| 1235 | ) | |
| 1236 | .allowed | |
| 1237 | ); | |
| 1238 | } | |
| 1239 | ||
| 1240 | #[test] | |
| 1241 | fn git_matrix() { | |
| 1242 | let fork = path("pulls", "pul_7"); | |
| 1243 | let upstream = path("acme", "rocket"); | |
| 1244 | let elsewhere = path("acme", "billing"); | |
| 1245 | let implement = scope(K::Implement, Runner); | |
| 1246 | assert!(decide_git(&implement, &fork, true).allowed); | |
| 1247 | assert!(decide_git(&implement, &fork, false).allowed); | |
| 1248 | assert!(decide_git(&implement, &upstream, false).allowed); | |
| 1249 | assert_eq!(decide_git(&implement, &upstream, true).rule, "git:push"); | |
| 1250 | assert_eq!(decide_git(&implement, &elsewhere, false).rule, "git:read"); | |
| 1251 | let review = scope(K::Review, Runner); | |
| 1252 | assert!(decide_git(&review, &upstream, false).allowed); | |
| 1253 | assert!(!decide_git(&review, &upstream, true).allowed); | |
| 1254 | assert!(!decide_git(&review, &fork, true).allowed); | |
| 1255 | // A tools token made before run credentials never reaches git. | |
| 1256 | let old = AgentScope { | |
| 1257 | repo: upstream.clone(), | |
| 1258 | operations: vec!["get_issue".to_owned()], | |
| 1259 | run: None, | |
| 1260 | }; | |
| 1261 | assert_eq!(decide_git(&old, &upstream, false).rule, "git:not-a-run"); | |
| 1262 | // Nor does an agent's tools token. | |
| 1263 | assert_eq!( | |
| 1264 | decide_git(&scope(K::Implement, Tools), &upstream, false).rule, | |
| 1265 | "git:not-a-run" | |
| 1266 | ); | |
| 1267 | } | |
| 1268 | ||
| 1269 | #[test] | |
| 1270 | fn a_push_moves_only_granted_branches() { | |
| 1271 | let update = scope(K::Update, Runner); | |
| 1272 | let repo = path("acme", "rocket"); | |
| 1273 | let refs = |names: &[&str]| { | |
| 1274 | names | |
| 1275 | .iter() | |
| 1276 | .map(|name| (*name).to_owned()) | |
| 1277 | .collect::<Vec<_>>() | |
| 1278 | }; | |
| 1279 | assert!(decide_refs(&update, &repo, &refs(&["refs/heads/fix-login"])).allowed); | |
| 1280 | assert_eq!( | |
| 1281 | decide_refs(&update, &repo, &refs(&["refs/heads/main"])).rule, | |
| 1282 | "git:ref" | |
| 1283 | ); | |
| 1284 | assert_eq!( | |
| 1285 | decide_refs( | |
| 1286 | &update, | |
| 1287 | &repo, | |
| 1288 | &refs(&["refs/heads/fix-login", "refs/tags/v1"]) | |
| 1289 | ) | |
| 1290 | .rule, | |
| 1291 | "git:ref" | |
| 1292 | ); | |
| 1293 | let implement = scope(K::Implement, Runner); | |
| 1294 | assert!( | |
| 1295 | decide_refs( | |
| 1296 | &implement, | |
| 1297 | &path("pulls", "pul_7"), | |
| 1298 | &refs(&["refs/heads/main"]) | |
| 1299 | ) | |
| 1300 | .allowed | |
| 1301 | ); | |
| 1302 | } | |
| 1303 | ||
| 1304 | #[test] | |
| 1305 | fn a_runner_acts_downstream_as_the_person() { | |
| 1306 | let user = agent(&["acme"], scope(K::Implement, Runner)); | |
| 1307 | let person = as_person(&user).unwrap(); | |
| 1308 | assert_eq!(person.id, "usr_1"); | |
| 1309 | assert_eq!(person.username, "syntaqx"); | |
| 1310 | assert_eq!(person.kind, PrincipalKind::User); | |
| 1311 | assert!(person.is_member("acme")); | |
| 1312 | assert!(person.acting.is_none()); | |
| g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent | 1313 | assert_eq!(describe(&user), "g1t on behalf of syntaqx"); |
| Spend caps: a monthly budget for comped workspaces and a daily breaker on what g1t pays | 1314 | // The tools act as the agent. |
| 1315 | assert!(as_person(&agent(&["acme"], scope(K::Implement, Tools))).is_none()); | |
| 1316 | } | |
| 1317 | ||
| 1318 | #[test] | |
| 1319 | fn scopes_without_a_run_still_parse() { | |
| 1320 | let old: AgentScope = serde_json::from_str( | |
| 1321 | r#"{"repo":{"namespace":"acme","name":"rocket"},"operations":["get_issue"]}"#, | |
| 1322 | ) | |
| 1323 | .unwrap(); | |
| 1324 | assert!(old.run.is_none()); | |
| 1325 | let written = serde_json::to_string(&scope(K::Review, Tools)).unwrap(); | |
| 1326 | assert!(written.contains(r#""use":"tools""#)); | |
| 1327 | assert!(written.contains(r#""kind":"review""#)); | |
| 1328 | let back: AgentScope = serde_json::from_str(&written).unwrap(); | |
| 1329 | assert_eq!(back.run.unwrap().kind, K::Review); | |
| 1330 | // Only g1t's own runs say so; every other reads as not. | |
| 1331 | assert!(!written.contains("system")); | |
| 1332 | assert!(!back_run(&written).system); | |
| 1333 | let mut own = scope(K::Bump, Runner); | |
| 1334 | own.run.as_mut().unwrap().system = true; | |
| 1335 | let written = serde_json::to_string(&own).unwrap(); | |
| 1336 | assert!(written.contains(r#""system":true"#)); | |
| 1337 | assert!(back_run(&written).system); | |
| 1338 | } | |
| 1339 | ||
| 1340 | fn back_run(written: &str) -> RunBinding { | |
| 1341 | serde_json::from_str::<AgentScope>(written).unwrap().run.unwrap() | |
| 1342 | } | |
| 1343 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.