Skip to content

g1t/crates/contracts/src/scopes.rs

936 lines38,776 bytesCodeBlame

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.

Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1//! Scopes: what an access token may do on its owner's behalf.
2//!
3//! A personal access token, a workspace's token and an application signed
4//! in with OAuth each carry a set of scopes. A token reaches whatever the
5//! one it acts as can reach: a person's token, that person's workspaces and
6//! repositories; a workspace's token, that workspace. What a request may do
7//! is the intersection of two things: the role of whoever the token acts as
8//! (see [`crate::access`]) and the token's scopes.
9//!
10//! Each scope is a resource and a level, written `resource:level`, such as
11//! `issues:write`. A higher level of a resource includes the lower ones:
12//! `repo:admin` includes `repo:write`, which includes `repo:read`.
13//!
14//! This module is the one source of truth: the API (REST and MCP) and git
15//! enforce it, and identity stores it. `packages/contracts/src/scopes.ts`
16//! mirrors the table for the site; a test keeps the two the same.
17
18use serde::{Deserialize, Serialize};
19
20use crate::credentials::Decision;
21
22/// Something a token can be given access to.
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
24pub enum Resource {
25 Account,
API: notifications over REST and MCP, with notifications scopes26 Notifications,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step27 Workspace,
28 Repo,
29 Code,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member30 Packages,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step31 Issues,
32 PullRequests,
33 Agents,
34 Workflows,
35 Memory,
36 Access,
37 Webhooks,
38 Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents39 Runners,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step40}
41
42impl Resource {
API: notifications over REST and MCP, with notifications scopes43 pub const ALL: [Resource; 15] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step44 Resource::Repo,
45 Resource::Code,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member46 Resource::Packages,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step47 Resource::Issues,
48 Resource::PullRequests,
49 Resource::Agents,
50 Resource::Workflows,
51 Resource::Memory,
52 Resource::Account,
API: notifications over REST and MCP, with notifications scopes53 Resource::Notifications,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step54 Resource::Workspace,
55 Resource::Access,
56 Resource::Webhooks,
57 Resource::Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents58 Resource::Runners,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step59 ];
60
61 pub fn as_str(self) -> &'static str {
62 match self {
63 Resource::Account => "account",
API: notifications over REST and MCP, with notifications scopes64 Resource::Notifications => "notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step65 Resource::Workspace => "workspace",
66 Resource::Repo => "repo",
67 Resource::Code => "code",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member68 Resource::Packages => "packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step69 Resource::Issues => "issues",
70 Resource::PullRequests => "pull_requests",
71 Resource::Agents => "agents",
72 Resource::Workflows => "workflows",
73 Resource::Memory => "memory",
74 Resource::Access => "access",
75 Resource::Webhooks => "webhooks",
76 Resource::Secrets => "secrets",
Fast pages, required checks on the branch, self-hosted runners, honest incidents77 Resource::Runners => "runners",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step78 }
79 }
80
81 /// Its name, for people.
82 pub fn label(self) -> &'static str {
83 match self {
84 Resource::Account => "Your account",
API: notifications over REST and MCP, with notifications scopes85 Resource::Notifications => "Notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step86 Resource::Workspace => "Workspaces",
87 Resource::Repo => "Repositories",
88 Resource::Code => "Code",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member89 Resource::Packages => "Packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step90 Resource::Issues => "Issues",
91 Resource::PullRequests => "Pull requests",
92 Resource::Agents => "g1t agents",
93 Resource::Workflows => "Workflows",
94 Resource::Memory => "Memory and context",
95 Resource::Access => "Who has access",
96 Resource::Webhooks => "Webhooks",
97 Resource::Secrets => "Secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents98 Resource::Runners => "Self-hosted runners",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step99 }
100 }
101}
102
103/// How much of a resource.
104#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
105pub enum Level {
106 Read,
107 Write,
108 /// Starting g1t's agents, which spends the workspace's money.
109 Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member110 /// Deleting what cannot be brought back, such as a package's versions.
111 Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step112 Admin,
113}
114
115impl Level {
116 pub fn as_str(self) -> &'static str {
117 match self {
118 Level::Read => "read",
119 Level::Write => "write",
120 Level::Run => "run",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member121 Level::Delete => "delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step122 Level::Admin => "admin",
123 }
124 }
125}
126
127/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
128/// clients ask for, and errors name.
129#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
130pub enum Scope {
131 RepoRead,
132 RepoWrite,
133 RepoAdmin,
134 CodeRead,
135 CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member136 PackagesRead,
137 PackagesWrite,
138 PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step139 IssuesRead,
140 IssuesWrite,
141 PullRequestsRead,
142 PullRequestsWrite,
143 AgentsRun,
144 WorkflowsRead,
145 WorkflowsWrite,
146 MemoryRead,
147 MemoryWrite,
148 AccountRead,
149 AccountWrite,
API: notifications over REST and MCP, with notifications scopes150 NotificationsRead,
151 NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step152 WorkspaceRead,
153 WorkspaceAdmin,
154 AccessRead,
155 AccessAdmin,
156 WebhooksRead,
157 WebhooksAdmin,
158 SecretsRead,
159 SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents160 RunnersRead,
161 RunnersAdmin,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step162}
163
164impl Scope {
165 /// Every scope, grouped by resource, least first.
API: notifications over REST and MCP, with notifications scopes166 pub const ALL: [Scope; 31] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step167 Scope::RepoRead,
168 Scope::RepoWrite,
169 Scope::RepoAdmin,
170 Scope::CodeRead,
171 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member172 Scope::PackagesRead,
173 Scope::PackagesWrite,
174 Scope::PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step175 Scope::IssuesRead,
176 Scope::IssuesWrite,
177 Scope::PullRequestsRead,
178 Scope::PullRequestsWrite,
179 Scope::AgentsRun,
180 Scope::WorkflowsRead,
181 Scope::WorkflowsWrite,
182 Scope::MemoryRead,
183 Scope::MemoryWrite,
184 Scope::AccountRead,
185 Scope::AccountWrite,
API: notifications over REST and MCP, with notifications scopes186 Scope::NotificationsRead,
187 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step188 Scope::WorkspaceRead,
189 Scope::WorkspaceAdmin,
190 Scope::AccessRead,
191 Scope::AccessAdmin,
192 Scope::WebhooksRead,
193 Scope::WebhooksAdmin,
194 Scope::SecretsRead,
195 Scope::SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents196 Scope::RunnersRead,
197 Scope::RunnersAdmin,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step198 ];
199
200 pub fn as_str(self) -> &'static str {
201 match self {
202 Scope::RepoRead => "repo:read",
203 Scope::RepoWrite => "repo:write",
204 Scope::RepoAdmin => "repo:admin",
205 Scope::CodeRead => "code:read",
206 Scope::CodeWrite => "code:write",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member207 Scope::PackagesRead => "packages:read",
208 Scope::PackagesWrite => "packages:write",
209 Scope::PackagesDelete => "packages:delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step210 Scope::IssuesRead => "issues:read",
211 Scope::IssuesWrite => "issues:write",
212 Scope::PullRequestsRead => "pull_requests:read",
213 Scope::PullRequestsWrite => "pull_requests:write",
214 Scope::AgentsRun => "agents:run",
215 Scope::WorkflowsRead => "workflows:read",
216 Scope::WorkflowsWrite => "workflows:write",
217 Scope::MemoryRead => "memory:read",
218 Scope::MemoryWrite => "memory:write",
219 Scope::AccountRead => "account:read",
220 Scope::AccountWrite => "account:write",
API: notifications over REST and MCP, with notifications scopes221 Scope::NotificationsRead => "notifications:read",
222 Scope::NotificationsWrite => "notifications:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step223 Scope::WorkspaceRead => "workspace:read",
224 Scope::WorkspaceAdmin => "workspace:admin",
225 Scope::AccessRead => "access:read",
226 Scope::AccessAdmin => "access:admin",
227 Scope::WebhooksRead => "webhooks:read",
228 Scope::WebhooksAdmin => "webhooks:admin",
229 Scope::SecretsRead => "secrets:read",
230 Scope::SecretsAdmin => "secrets:admin",
Fast pages, required checks on the branch, self-hosted runners, honest incidents231 Scope::RunnersRead => "runners:read",
232 Scope::RunnersAdmin => "runners:admin",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step233 }
234 }
235
236 pub fn parse(text: &str) -> Option<Scope> {
237 let text = text.trim().to_ascii_lowercase();
238 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
239 }
240
241 pub fn resource(self) -> Resource {
242 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
243 Resource::ALL
244 .into_iter()
245 .find(|resource| resource.as_str() == name)
246 .unwrap_or(Resource::Account)
247 }
248
249 pub fn level(self) -> Level {
250 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
251 "write" => Level::Write,
252 "run" => Level::Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member253 "delete" => Level::Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step254 "admin" => Level::Admin,
255 _ => Level::Read,
256 }
257 }
258
259 /// Whether holding `self` gives `other`: the same resource, at the same
260 /// level or a lower one.
261 pub fn includes(self, other: Scope) -> bool {
262 self.resource() == other.resource() && self.level() >= other.level()
263 }
264
265 /// Changes that are hard or impossible to undo, or that decide who can
266 /// reach what. Shown behind a warning wherever scopes are chosen.
267 pub fn dangerous(self) -> bool {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member268 matches!(self.level(), Level::Admin | Level::Delete)
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step269 }
270
271 /// What it lets a token do, in plain words.
272 pub fn describe(self) -> &'static str {
273 match self {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily274 Scope::RepoRead => "See repositories, their settings, labels, timelines and security alerts, and search",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step275 Scope::RepoWrite => "Create repositories, rename branches and change how pull requests merge",
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily276 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, and dismiss security alerts",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step277 Scope::CodeRead => "Clone and fetch private repositories with git",
278 Scope::CodeWrite => "Push commits with git",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member279 Scope::PackagesRead => "Pull container images and install private packages",
280 Scope::PackagesWrite => "Push container images and publish packages",
281 Scope::PackagesDelete => "Delete packages and their versions",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step282 Scope::IssuesRead => "Read issues, comments and plans",
283 Scope::IssuesWrite => "Open, edit, close and comment on issues",
284 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
285 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
286 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
287 Scope::WorkflowsRead => "Read workflows, runs and logs",
288 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
289 Scope::MemoryRead => "Recall memory and search the workspace's context",
290 Scope::MemoryWrite => "Save memory for the next agent",
API: pinned projects over REST and MCP291 Scope::AccountRead => "Read your email addresses, invites, invitations and pinned projects",
292 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations and pin projects",
API: notifications over REST and MCP, with notifications scopes293 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
294 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step295 Scope::WorkspaceRead => "Read workspace invites, integrations and model routes",
296 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations",
297 Scope::AccessRead => "See who has access to repositories",
298 Scope::AccessAdmin => "Give and take away access to repositories",
299 Scope::WebhooksRead => "See webhooks and their deliveries",
300 Scope::WebhooksAdmin => "Create, change and delete webhooks",
301 Scope::SecretsRead => "List secrets (never their values) and read variables",
302 Scope::SecretsAdmin => "Set and delete secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents303 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
304 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step305 }
306 }
307}
308
309impl Serialize for Scope {
310 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
311 serializer.serialize_str(self.as_str())
312 }
313}
314
315impl<'de> Deserialize<'de> for Scope {
316 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
317 let text = String::deserialize(deserializer)?;
318 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
319 }
320}
321
322/// Scopes as written in a token's row or an OAuth request: separated by
323/// spaces or commas. Unknown names are left out, so a client asking for a
324/// scope from a newer version gets the rest.
325pub fn parse_scopes(text: &str) -> Vec<Scope> {
326 let mut scopes: Vec<Scope> = text
327 .split(|c: char| c.is_whitespace() || c == ',')
328 .filter_map(Scope::parse)
329 .collect();
330 normalize(&mut scopes);
331 scopes
332}
333
334/// In table order, without repeats.
335pub fn normalize(scopes: &mut Vec<Scope>) {
336 let given = std::mem::take(scopes);
337 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
338}
339
340/// Space-separated, as stored and as OAuth writes them.
341pub fn scopes_text(scopes: &[Scope]) -> String {
342 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
343}
344
345/// What a token stores for full access, which is not a scope a client can
346/// ask for by name.
347pub const FULL_ACCESS: &str = "*";
348
349/// Starting points for choosing scopes.
350#[derive(Clone, Copy, Debug, PartialEq, Eq)]
351pub enum Preset {
352 ReadOnly,
353 Agent,
354 Ci,
355 Full,
356}
357
358impl Preset {
359 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
360
361 pub fn as_str(self) -> &'static str {
362 match self {
363 Preset::ReadOnly => "read_only",
364 Preset::Agent => "agent",
365 Preset::Ci => "ci",
366 Preset::Full => "full",
367 }
368 }
369
370 pub fn label(self) -> &'static str {
371 match self {
372 Preset::ReadOnly => "Read only",
373 Preset::Agent => "Agent",
374 Preset::Ci => "CI",
375 Preset::Full => "Full access",
376 }
377 }
378
379 /// Its scopes; `None` for full access.
380 pub fn scopes(self) -> Option<Vec<Scope>> {
381 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
382 match self {
383 Preset::ReadOnly => Some(reads().collect()),
384 Preset::Agent => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents385 // Not the machines work runs on: an agent has no business
386 // knowing a workspace's own runners.
387 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes388 // And answering what needs the person it works for: marking
389 // it done, subscribing, watching.
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step390 scopes.extend([
391 Scope::CodeWrite,
392 Scope::IssuesWrite,
393 Scope::PullRequestsWrite,
394 Scope::AgentsRun,
395 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes396 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step397 ]);
398 normalize(&mut scopes);
399 Some(scopes)
400 }
401 Preset::Ci => Some(vec![
402 Scope::RepoRead,
403 Scope::CodeRead,
404 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member405 Scope::PackagesRead,
406 Scope::PackagesWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step407 Scope::WorkflowsRead,
408 Scope::WorkflowsWrite,
409 ]),
410 Preset::Full => None,
411 }
412 }
413}
414
415/// What an OAuth client gets when it asks for nothing in particular: the
416/// agent preset. Never an admin scope.
417pub fn oauth_default() -> Vec<Scope> {
418 Preset::Agent.scopes().unwrap_or_default()
419}
420
421/// Set on a [`crate::User`] resolved from an access token: what the token
422/// may do. Absent on a signed-in session, which may do whatever its person
423/// can.
424#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
425pub struct TokenAccess {
426 /// The token's id, as audit entries and errors name it.
427 #[serde(default)]
428 pub token_id: String,
429 /// Its scopes, as `resource:level`. Absent: full access, everything the
430 /// person (or workspace) can do.
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub scopes: Option<Vec<String>>,
433 /// Made before tokens had scopes: full access until someone narrows it.
434 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
435 pub legacy: bool,
436}
437
438impl TokenAccess {
439 /// Full access to everything: the access tokens made before scopes had.
440 pub fn full() -> Self {
441 TokenAccess::default()
442 }
443
444 pub fn is_full(&self) -> bool {
445 self.scopes.is_none()
446 }
447
448 /// The scopes it holds, or `None` for full access.
449 pub fn granted(&self) -> Option<Vec<Scope>> {
450 self.scopes
451 .as_ref()
452 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
453 }
454
455 pub fn allows(&self, needed: Scope) -> bool {
456 match self.granted() {
457 None => true,
458 Some(granted) => granted.iter().any(|held| held.includes(needed)),
459 }
460 }
461}
462
463/// Every operation of the API and MCP server, with the scope it needs. An
464/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
465/// its operations is in exactly one of the two.
466pub const OPERATIONS: &[(&str, Scope)] = &[
467 // Your account.
468 ("list_emails", Scope::AccountRead),
469 ("add_email", Scope::AccountWrite),
470 ("remove_email", Scope::AccountWrite),
471 ("update_email_settings", Scope::AccountWrite),
472 ("list_invites", Scope::AccountRead),
473 ("create_invite", Scope::AccountWrite),
474 ("revoke_invite", Scope::AccountWrite),
475 ("list_my_repo_invitations", Scope::AccountRead),
476 ("accept_repo_invitation", Scope::AccountWrite),
477 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP478 // Your pinned projects: a preference of your account.
479 ("list_pinned_projects", Scope::AccountRead),
480 ("pin_project", Scope::AccountWrite),
481 ("unpin_project", Scope::AccountWrite),
482 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes483 // Your inbox: notifications, subscriptions and watching.
484 ("list_notifications", Scope::NotificationsRead),
485 ("get_notification_thread", Scope::NotificationsRead),
486 ("get_thread_subscription", Scope::NotificationsRead),
487 ("get_repo_subscription", Scope::NotificationsRead),
488 ("list_watched_repos", Scope::NotificationsRead),
489 ("mark_notifications_read", Scope::NotificationsWrite),
490 ("mark_thread_read", Scope::NotificationsWrite),
491 ("mark_thread_done", Scope::NotificationsWrite),
492 ("save_thread", Scope::NotificationsWrite),
493 ("snooze_thread", Scope::NotificationsWrite),
494 ("set_thread_subscription", Scope::NotificationsWrite),
495 ("delete_thread_subscription", Scope::NotificationsWrite),
496 ("set_repo_subscription", Scope::NotificationsWrite),
497 ("delete_repo_subscription", Scope::NotificationsWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step498 // Workspaces, their invites and integrations.
499 ("create_workspace", Scope::WorkspaceAdmin),
500 ("delete_workspace", Scope::WorkspaceAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily501 ("update_workspace", Scope::WorkspaceAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step502 ("list_workspace_invites", Scope::WorkspaceRead),
503 ("invite_member", Scope::WorkspaceAdmin),
504 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
505 ("list_integrations", Scope::WorkspaceRead),
506 ("connect_integration", Scope::WorkspaceAdmin),
507 ("disconnect_integration", Scope::WorkspaceAdmin),
508 ("test_integration", Scope::WorkspaceAdmin),
509 ("get_model_routes", Scope::WorkspaceRead),
510 ("set_model_routes", Scope::WorkspaceAdmin),
511 // Repositories.
512 ("list_repos", Scope::RepoRead),
513 ("get_repo", Scope::RepoRead),
514 ("search", Scope::RepoRead),
515 ("list_events", Scope::RepoRead),
516 ("list_labels", Scope::RepoRead),
517 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents518 ("list_check_names", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step519 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily520 ("list_security_alerts", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step521 ("create_repo", Scope::RepoWrite),
522 ("update_repo", Scope::RepoWrite),
523 ("update_repo_settings", Scope::RepoWrite),
524 ("rename_branch", Scope::RepoWrite),
525 ("rename_repo", Scope::RepoAdmin),
526 ("transfer_repo", Scope::RepoAdmin),
527 ("archive_repo", Scope::RepoAdmin),
528 ("unarchive_repo", Scope::RepoAdmin),
529 ("set_repo_visibility", Scope::RepoAdmin),
530 ("delete_repo", Scope::RepoAdmin),
531 ("restore_repo", Scope::RepoAdmin),
532 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily533 // A dismissed secret is let through push protection.
534 ("dismiss_security_alert", Scope::RepoAdmin),
535 ("reopen_security_alert", Scope::RepoAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step536 // Issues and plans.
537 ("list_issues", Scope::IssuesRead),
538 ("get_issue", Scope::IssuesRead),
539 ("get_plan", Scope::IssuesRead),
540 ("create_issue", Scope::IssuesWrite),
541 ("update_issue", Scope::IssuesWrite),
542 ("close_issue", Scope::IssuesWrite),
543 ("reopen_issue", Scope::IssuesWrite),
544 ("add_comment", Scope::IssuesWrite),
545 ("import_issue", Scope::IssuesWrite),
546 ("apply_plan", Scope::IssuesWrite),
547 // Pull requests.
548 ("list_pull_requests", Scope::PullRequestsRead),
549 ("get_pull_request", Scope::PullRequestsRead),
550 ("get_pull_request_changes", Scope::PullRequestsRead),
551 ("read_session", Scope::PullRequestsRead),
552 ("get_merge_queue", Scope::PullRequestsRead),
553 ("create_pull_request", Scope::PullRequestsWrite),
554 ("record_session", Scope::PullRequestsWrite),
555 ("mark_pull_request_ready", Scope::PullRequestsWrite),
556 ("close_pull_request", Scope::PullRequestsWrite),
557 ("review_pull_request", Scope::PullRequestsWrite),
558 ("merge_pull_request", Scope::PullRequestsWrite),
559 // g1t's agents.
560 ("assign_issue", Scope::AgentsRun),
561 ("delegate", Scope::AgentsRun),
562 ("plan_work", Scope::AgentsRun),
563 ("message_agent", Scope::AgentsRun),
564 ("answer_message", Scope::AgentsRun),
565 ("take_messages", Scope::AgentsRun),
566 // Workflows.
567 ("list_workflows", Scope::WorkflowsRead),
568 ("list_workflow_runs", Scope::WorkflowsRead),
569 ("get_workflow_run", Scope::WorkflowsRead),
570 ("get_job_logs", Scope::WorkflowsRead),
571 ("dispatch_workflow", Scope::WorkflowsWrite),
572 ("cancel_workflow_run", Scope::WorkflowsWrite),
573 ("rerun_workflow_run", Scope::WorkflowsWrite),
574 ("update_workflow", Scope::WorkflowsWrite),
575 // Memory and the context hub.
576 ("recall", Scope::MemoryRead),
577 ("search_context", Scope::MemoryRead),
578 ("get_entity", Scope::MemoryRead),
579 ("get_context", Scope::MemoryRead),
580 ("remember", Scope::MemoryWrite),
581 // Who has access.
582 ("list_collaborators", Scope::AccessRead),
583 ("get_collaborator_permission", Scope::AccessRead),
584 ("list_repo_invitations", Scope::AccessRead),
585 ("list_outside_collaborators", Scope::AccessRead),
586 ("add_collaborator", Scope::AccessAdmin),
587 ("update_collaborator", Scope::AccessAdmin),
588 ("remove_collaborator", Scope::AccessAdmin),
589 ("revoke_repo_invitation", Scope::AccessAdmin),
590 ("set_base_permission", Scope::AccessAdmin),
591 // Webhooks.
592 ("list_webhooks", Scope::WebhooksRead),
593 ("list_webhook_deliveries", Scope::WebhooksRead),
594 ("create_webhook", Scope::WebhooksAdmin),
595 ("update_webhook", Scope::WebhooksAdmin),
596 ("delete_webhook", Scope::WebhooksAdmin),
597 ("ping_webhook", Scope::WebhooksAdmin),
598 ("redeliver_webhook", Scope::WebhooksAdmin),
599 // Secrets and variables.
600 ("list_actions_secrets", Scope::SecretsRead),
601 ("list_actions_variables", Scope::SecretsRead),
602 ("set_actions_secret", Scope::SecretsAdmin),
603 ("delete_actions_secret", Scope::SecretsAdmin),
604 ("set_actions_variable", Scope::SecretsAdmin),
605 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents606 // Self-hosted runners.
607 ("list_runners", Scope::RunnersRead),
608 ("list_runner_groups", Scope::RunnersRead),
609 ("get_runner_settings", Scope::RunnersRead),
610 ("create_runner_registration_token", Scope::RunnersAdmin),
611 ("remove_runner", Scope::RunnersAdmin),
612 ("create_runner_group", Scope::RunnersAdmin),
613 ("update_runner_group", Scope::RunnersAdmin),
614 ("delete_runner_group", Scope::RunnersAdmin),
615 ("update_runner_settings", Scope::RunnersAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step616];
617
618/// Operations any token may use: saying who it is.
619pub const NO_SCOPE: &[&str] = &["whoami"];
620
621/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
622/// operation in neither list needs full access.
623pub fn scope_for(operation: &str) -> Option<Scope> {
624 OPERATIONS
625 .iter()
626 .find(|(name, _)| *name == operation)
627 .map(|(_, scope)| *scope)
628}
629
630/// What a token needs for `operation` with this input beyond its own
631/// scope: starting agents from an operation that can, and making a
632/// repository public or private.
633pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
634 let mut extra = Vec::new();
635 let assigns = input["assign"].as_bool() == Some(true)
636 || input["agent"].as_bool() == Some(true)
637 || input["assign_agent"].as_bool() == Some(true);
638 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
639 extra.push(Scope::AgentsRun);
640 }
641 // Opening the issue an agent is put on.
642 if operation == "delegate" {
643 extra.push(Scope::IssuesWrite);
644 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily645 // A workspace's base permission is who has access.
646 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
647 extra.push(Scope::AccessAdmin);
648 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step649 if operation == "update_repo" && (input.get("private").is_some_and(|v| !v.is_null()) || input.get("default_branch").is_some_and(|v| !v.is_null())) {
650 extra.push(Scope::RepoAdmin);
651 }
652 extra
653}
654
655/// The scopes a call needs, its own first.
656pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
657 scope_for(operation)
658 .into_iter()
659 .chain(extra_scopes(operation, input))
660 .collect()
661}
662
663/// Whether `access` may use `operation` with `input`. The person's (or
664/// workspace's) role is checked after this, by the service that owns what
665/// was asked about.
666pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
667 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
668 if access.scopes.is_some() {
669 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
670 if !known {
671 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
672 }
673 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
674 return Decision::deny(
675 "token:scope",
676 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
677 );
678 }
679 }
680 Decision::allow(rule)
681}
682
683/// Whether a token may clone or fetch (`write` false), or push to (`write`
684/// true), a repository with git. `public` is whether anyone may read it,
685/// which needs no scope.
686pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
687 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
688 if !access.allows(needed) && (write || !public) {
689 return Decision::deny(
690 "token:scope",
691 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
692 );
693 }
694 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
695}
696
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member697/// Whether a token may pull (`Level::Read`), push or publish
698/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
699/// whether anyone may pull the package, which needs no scope.
700pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
701 let (needed, doing) = match level {
702 Level::Read => (Scope::PackagesRead, "pull a private package"),
703 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
704 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
705 };
706 if !access.allows(needed) && !(level == Level::Read && public) {
707 return Decision::deny(
708 "token:scope",
709 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
710 );
711 }
712 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
713}
714
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step715#[cfg(test)]
716mod tests {
717 use super::*;
718 use serde_json::json;
719
720 fn token(scopes: &[Scope]) -> TokenAccess {
721 TokenAccess {
722 token_id: "tok_1".to_owned(),
723 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
724 legacy: false,
725 }
726 }
727
728 #[test]
729 fn every_scope_reads_back_and_belongs_to_a_resource() {
730 for scope in Scope::ALL {
731 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
732 assert!(scope.as_str().starts_with(scope.resource().as_str()));
733 assert!(scope.includes(scope));
734 }
735 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
736 assert_eq!(Scope::parse("issues"), None);
737 }
738
739 #[test]
740 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
741 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
742 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
743 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
744 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
745 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
746 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
747 }
748
749 #[test]
750 fn operations_are_listed_once_and_never_also_free() {
751 let mut seen = std::collections::HashSet::new();
752 for (name, _) in OPERATIONS {
753 assert!(seen.insert(*name), "{name} twice");
754 assert!(!NO_SCOPE.contains(name), "{name}");
755 }
756 }
757
758 #[test]
759 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
760 assert_eq!(
761 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
762 vec![Scope::RepoRead, Scope::IssuesWrite]
763 );
764 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
765 }
766
767 #[test]
768 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
769 let scopes = oauth_default();
770 assert!(scopes.contains(&Scope::IssuesWrite));
771 assert!(scopes.contains(&Scope::PullRequestsWrite));
772 assert!(scopes.contains(&Scope::AgentsRun));
773 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
774 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents775 // Every read but the machines work runs on.
776 assert_eq!(scopes.contains(&read), read != Scope::RunnersRead, "{read:?}");
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step777 }
778 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
779 assert_eq!(Preset::Full.scopes(), None);
780 }
781
782 #[test]
783 fn a_legacy_token_can_do_everything() {
784 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
785 for (operation, _) in OPERATIONS {
786 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
787 }
788 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
789 }
790
791 #[test]
792 fn a_missing_scope_is_named() {
793 let read = token(&[Scope::IssuesRead]);
794 assert!(decide(&read, "get_issue", &json!({})).allowed);
795 assert!(decide(&read, "whoami", &json!({})).allowed);
796 let refused = decide(&read, "create_issue", &json!({}));
797 assert!(!refused.allowed);
798 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
799 // An operation the table does not know needs full access.
800 assert!(!decide(&read, "something_new", &json!({})).allowed);
801 }
802
803 #[test]
804 fn starting_agents_from_another_operation_needs_agents_run() {
805 let writer = token(&[Scope::IssuesWrite]);
806 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
807 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
808 assert!(refused.reason.unwrap().contains("agents:run"));
809 let maintainer = token(&[Scope::RepoWrite]);
810 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
811 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
812 }
813
814 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily815 fn a_workspaces_base_permission_needs_access_admin_too() {
816 let admin = token(&[Scope::WorkspaceAdmin]);
817 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
818 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
819 assert!(refused.reason.unwrap().contains("access:admin"));
820 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
821 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
822 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
823 }
824
825 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step826 fn delegating_needs_both_agents_and_issues() {
827 let agents = token(&[Scope::AgentsRun]);
828 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
829 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
830 assert!(decide(&both, "delegate", &json!({})).allowed);
831 }
832
833 #[test]
834 fn git_push_needs_code_write_and_private_reads_need_code_read() {
835 let reader = token(&[Scope::CodeRead]);
836 assert!(decide_git(&reader, false, false).allowed);
837 let refused = decide_git(&reader, true, false);
838 assert!(!refused.allowed);
839 assert!(refused.reason.unwrap().contains("code:write"));
840 let issues = token(&[Scope::IssuesWrite]);
841 assert!(!decide_git(&issues, false, false).allowed);
842 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
843 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
844 let writer = token(&[Scope::CodeWrite]);
845 assert!(decide_git(&writer, true, false).allowed);
846 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
847 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
848 }
849
850 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member851 fn packages_need_their_own_scopes_and_public_pulls_none() {
852 let reader = token(&[Scope::PackagesRead]);
853 assert!(decide_packages(&reader, Level::Read, false).allowed);
854 assert!(!decide_packages(&reader, Level::Write, false).allowed);
855 let code = token(&[Scope::CodeWrite]);
856 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
857 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
858 let writer = token(&[Scope::PackagesWrite]);
859 assert!(decide_packages(&writer, Level::Write, false).allowed);
860 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
861 let refused = decide_packages(&writer, Level::Delete, false);
862 assert!(refused.reason.unwrap().contains("packages:delete"));
863 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
864 assert!(Scope::PackagesDelete.dangerous());
865 // Tokens made before these scopes, and full-access ones, keep working.
866 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
867 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
868 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
869 }
870
871 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step872 fn token_access_travels_as_json() {
873 let access = token(&[Scope::IssuesRead]);
874 let wire = serde_json::to_value(&access).unwrap();
875 assert_eq!(wire["scopes"], json!(["issues:read"]));
876 assert!(wire.get("resources").is_none());
877 let back: TokenAccess = serde_json::from_value(wire).unwrap();
878 assert_eq!(back, access);
879 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
880 assert!(full.is_full());
881 // A reach written by an older version is ignored: a token reaches
882 // whatever its owner can.
883 let older: TokenAccess = serde_json::from_value(json!({
884 "token_id": "tok_1",
885 "scopes": ["issues:read"],
886 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
887 }))
888 .unwrap();
889 assert_eq!(older, access);
890 }
891
892 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
893 /// lists the same scopes in the same order, the same operations with
894 /// the same scopes, and the same presets.
895 #[test]
896 fn the_typescript_mirror_has_the_same_table() {
897 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
898 let section = |start: &str| {
899 ts.split_once(start)
900 .and_then(|(_, rest)| rest.split_once("] as const"))
901 .map(|(table, _)| table)
902 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
903 };
904 let scopes: Vec<&str> = section("export const SCOPES = [")
905 .lines()
906 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
907 .collect();
908 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
909 assert_eq!(scopes, expected);
910 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
911 .lines()
912 .filter_map(|line| {
913 let mut quoted = line.split('"').skip(1).step_by(2);
914 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
915 })
916 .collect();
917 let expected: Vec<(String, String)> = OPERATIONS
918 .iter()
919 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
920 .collect();
921 assert_eq!(operations, expected);
922 for preset in Preset::ALL {
923 let list = section(&format!("{}: [", preset.as_str()));
924 let mirrored: Vec<&str> = list
925 .split(',')
926 .map(|item| item.trim().trim_matches('"'))
927 .filter(|item| !item.is_empty())
928 .collect();
929 let expected: Vec<&str> = preset
930 .scopes()
931 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
932 .unwrap_or_else(|| vec!["*"]);
933 assert_eq!(mirrored, expected, "{}", preset.as_str());
934 }
935 }
936}