Skip to content

g1t/crates/contracts/src/scopes.rs

931 lines38,496 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",
291 Scope::AccountRead => "Read your email addresses, invites and invitations",
292 Scope::AccountWrite => "Change your email addresses, make invites and answer invitations",
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: notifications over REST and MCP, with notifications scopes478 // Your inbox: notifications, subscriptions and watching.
479 ("list_notifications", Scope::NotificationsRead),
480 ("get_notification_thread", Scope::NotificationsRead),
481 ("get_thread_subscription", Scope::NotificationsRead),
482 ("get_repo_subscription", Scope::NotificationsRead),
483 ("list_watched_repos", Scope::NotificationsRead),
484 ("mark_notifications_read", Scope::NotificationsWrite),
485 ("mark_thread_read", Scope::NotificationsWrite),
486 ("mark_thread_done", Scope::NotificationsWrite),
487 ("save_thread", Scope::NotificationsWrite),
488 ("snooze_thread", Scope::NotificationsWrite),
489 ("set_thread_subscription", Scope::NotificationsWrite),
490 ("delete_thread_subscription", Scope::NotificationsWrite),
491 ("set_repo_subscription", Scope::NotificationsWrite),
492 ("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 step493 // Workspaces, their invites and integrations.
494 ("create_workspace", Scope::WorkspaceAdmin),
495 ("delete_workspace", Scope::WorkspaceAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily496 ("update_workspace", Scope::WorkspaceAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step497 ("list_workspace_invites", Scope::WorkspaceRead),
498 ("invite_member", Scope::WorkspaceAdmin),
499 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
500 ("list_integrations", Scope::WorkspaceRead),
501 ("connect_integration", Scope::WorkspaceAdmin),
502 ("disconnect_integration", Scope::WorkspaceAdmin),
503 ("test_integration", Scope::WorkspaceAdmin),
504 ("get_model_routes", Scope::WorkspaceRead),
505 ("set_model_routes", Scope::WorkspaceAdmin),
506 // Repositories.
507 ("list_repos", Scope::RepoRead),
508 ("get_repo", Scope::RepoRead),
509 ("search", Scope::RepoRead),
510 ("list_events", Scope::RepoRead),
511 ("list_labels", Scope::RepoRead),
512 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents513 ("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 step514 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily515 ("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 step516 ("create_repo", Scope::RepoWrite),
517 ("update_repo", Scope::RepoWrite),
518 ("update_repo_settings", Scope::RepoWrite),
519 ("rename_branch", Scope::RepoWrite),
520 ("rename_repo", Scope::RepoAdmin),
521 ("transfer_repo", Scope::RepoAdmin),
522 ("archive_repo", Scope::RepoAdmin),
523 ("unarchive_repo", Scope::RepoAdmin),
524 ("set_repo_visibility", Scope::RepoAdmin),
525 ("delete_repo", Scope::RepoAdmin),
526 ("restore_repo", Scope::RepoAdmin),
527 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily528 // A dismissed secret is let through push protection.
529 ("dismiss_security_alert", Scope::RepoAdmin),
530 ("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 step531 // Issues and plans.
532 ("list_issues", Scope::IssuesRead),
533 ("get_issue", Scope::IssuesRead),
534 ("get_plan", Scope::IssuesRead),
535 ("create_issue", Scope::IssuesWrite),
536 ("update_issue", Scope::IssuesWrite),
537 ("close_issue", Scope::IssuesWrite),
538 ("reopen_issue", Scope::IssuesWrite),
539 ("add_comment", Scope::IssuesWrite),
540 ("import_issue", Scope::IssuesWrite),
541 ("apply_plan", Scope::IssuesWrite),
542 // Pull requests.
543 ("list_pull_requests", Scope::PullRequestsRead),
544 ("get_pull_request", Scope::PullRequestsRead),
545 ("get_pull_request_changes", Scope::PullRequestsRead),
546 ("read_session", Scope::PullRequestsRead),
547 ("get_merge_queue", Scope::PullRequestsRead),
548 ("create_pull_request", Scope::PullRequestsWrite),
549 ("record_session", Scope::PullRequestsWrite),
550 ("mark_pull_request_ready", Scope::PullRequestsWrite),
551 ("close_pull_request", Scope::PullRequestsWrite),
552 ("review_pull_request", Scope::PullRequestsWrite),
553 ("merge_pull_request", Scope::PullRequestsWrite),
554 // g1t's agents.
555 ("assign_issue", Scope::AgentsRun),
556 ("delegate", Scope::AgentsRun),
557 ("plan_work", Scope::AgentsRun),
558 ("message_agent", Scope::AgentsRun),
559 ("answer_message", Scope::AgentsRun),
560 ("take_messages", Scope::AgentsRun),
561 // Workflows.
562 ("list_workflows", Scope::WorkflowsRead),
563 ("list_workflow_runs", Scope::WorkflowsRead),
564 ("get_workflow_run", Scope::WorkflowsRead),
565 ("get_job_logs", Scope::WorkflowsRead),
566 ("dispatch_workflow", Scope::WorkflowsWrite),
567 ("cancel_workflow_run", Scope::WorkflowsWrite),
568 ("rerun_workflow_run", Scope::WorkflowsWrite),
569 ("update_workflow", Scope::WorkflowsWrite),
570 // Memory and the context hub.
571 ("recall", Scope::MemoryRead),
572 ("search_context", Scope::MemoryRead),
573 ("get_entity", Scope::MemoryRead),
574 ("get_context", Scope::MemoryRead),
575 ("remember", Scope::MemoryWrite),
576 // Who has access.
577 ("list_collaborators", Scope::AccessRead),
578 ("get_collaborator_permission", Scope::AccessRead),
579 ("list_repo_invitations", Scope::AccessRead),
580 ("list_outside_collaborators", Scope::AccessRead),
581 ("add_collaborator", Scope::AccessAdmin),
582 ("update_collaborator", Scope::AccessAdmin),
583 ("remove_collaborator", Scope::AccessAdmin),
584 ("revoke_repo_invitation", Scope::AccessAdmin),
585 ("set_base_permission", Scope::AccessAdmin),
586 // Webhooks.
587 ("list_webhooks", Scope::WebhooksRead),
588 ("list_webhook_deliveries", Scope::WebhooksRead),
589 ("create_webhook", Scope::WebhooksAdmin),
590 ("update_webhook", Scope::WebhooksAdmin),
591 ("delete_webhook", Scope::WebhooksAdmin),
592 ("ping_webhook", Scope::WebhooksAdmin),
593 ("redeliver_webhook", Scope::WebhooksAdmin),
594 // Secrets and variables.
595 ("list_actions_secrets", Scope::SecretsRead),
596 ("list_actions_variables", Scope::SecretsRead),
597 ("set_actions_secret", Scope::SecretsAdmin),
598 ("delete_actions_secret", Scope::SecretsAdmin),
599 ("set_actions_variable", Scope::SecretsAdmin),
600 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents601 // Self-hosted runners.
602 ("list_runners", Scope::RunnersRead),
603 ("list_runner_groups", Scope::RunnersRead),
604 ("get_runner_settings", Scope::RunnersRead),
605 ("create_runner_registration_token", Scope::RunnersAdmin),
606 ("remove_runner", Scope::RunnersAdmin),
607 ("create_runner_group", Scope::RunnersAdmin),
608 ("update_runner_group", Scope::RunnersAdmin),
609 ("delete_runner_group", Scope::RunnersAdmin),
610 ("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 step611];
612
613/// Operations any token may use: saying who it is.
614pub const NO_SCOPE: &[&str] = &["whoami"];
615
616/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
617/// operation in neither list needs full access.
618pub fn scope_for(operation: &str) -> Option<Scope> {
619 OPERATIONS
620 .iter()
621 .find(|(name, _)| *name == operation)
622 .map(|(_, scope)| *scope)
623}
624
625/// What a token needs for `operation` with this input beyond its own
626/// scope: starting agents from an operation that can, and making a
627/// repository public or private.
628pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
629 let mut extra = Vec::new();
630 let assigns = input["assign"].as_bool() == Some(true)
631 || input["agent"].as_bool() == Some(true)
632 || input["assign_agent"].as_bool() == Some(true);
633 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
634 extra.push(Scope::AgentsRun);
635 }
636 // Opening the issue an agent is put on.
637 if operation == "delegate" {
638 extra.push(Scope::IssuesWrite);
639 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily640 // A workspace's base permission is who has access.
641 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
642 extra.push(Scope::AccessAdmin);
643 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step644 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())) {
645 extra.push(Scope::RepoAdmin);
646 }
647 extra
648}
649
650/// The scopes a call needs, its own first.
651pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
652 scope_for(operation)
653 .into_iter()
654 .chain(extra_scopes(operation, input))
655 .collect()
656}
657
658/// Whether `access` may use `operation` with `input`. The person's (or
659/// workspace's) role is checked after this, by the service that owns what
660/// was asked about.
661pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
662 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
663 if access.scopes.is_some() {
664 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
665 if !known {
666 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
667 }
668 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
669 return Decision::deny(
670 "token:scope",
671 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
672 );
673 }
674 }
675 Decision::allow(rule)
676}
677
678/// Whether a token may clone or fetch (`write` false), or push to (`write`
679/// true), a repository with git. `public` is whether anyone may read it,
680/// which needs no scope.
681pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
682 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
683 if !access.allows(needed) && (write || !public) {
684 return Decision::deny(
685 "token:scope",
686 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
687 );
688 }
689 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
690}
691
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member692/// Whether a token may pull (`Level::Read`), push or publish
693/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
694/// whether anyone may pull the package, which needs no scope.
695pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
696 let (needed, doing) = match level {
697 Level::Read => (Scope::PackagesRead, "pull a private package"),
698 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
699 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
700 };
701 if !access.allows(needed) && !(level == Level::Read && public) {
702 return Decision::deny(
703 "token:scope",
704 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
705 );
706 }
707 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
708}
709
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step710#[cfg(test)]
711mod tests {
712 use super::*;
713 use serde_json::json;
714
715 fn token(scopes: &[Scope]) -> TokenAccess {
716 TokenAccess {
717 token_id: "tok_1".to_owned(),
718 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
719 legacy: false,
720 }
721 }
722
723 #[test]
724 fn every_scope_reads_back_and_belongs_to_a_resource() {
725 for scope in Scope::ALL {
726 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
727 assert!(scope.as_str().starts_with(scope.resource().as_str()));
728 assert!(scope.includes(scope));
729 }
730 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
731 assert_eq!(Scope::parse("issues"), None);
732 }
733
734 #[test]
735 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
736 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
737 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
738 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
739 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
740 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
741 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
742 }
743
744 #[test]
745 fn operations_are_listed_once_and_never_also_free() {
746 let mut seen = std::collections::HashSet::new();
747 for (name, _) in OPERATIONS {
748 assert!(seen.insert(*name), "{name} twice");
749 assert!(!NO_SCOPE.contains(name), "{name}");
750 }
751 }
752
753 #[test]
754 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
755 assert_eq!(
756 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
757 vec![Scope::RepoRead, Scope::IssuesWrite]
758 );
759 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
760 }
761
762 #[test]
763 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
764 let scopes = oauth_default();
765 assert!(scopes.contains(&Scope::IssuesWrite));
766 assert!(scopes.contains(&Scope::PullRequestsWrite));
767 assert!(scopes.contains(&Scope::AgentsRun));
768 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
769 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents770 // Every read but the machines work runs on.
771 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 step772 }
773 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
774 assert_eq!(Preset::Full.scopes(), None);
775 }
776
777 #[test]
778 fn a_legacy_token_can_do_everything() {
779 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
780 for (operation, _) in OPERATIONS {
781 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
782 }
783 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
784 }
785
786 #[test]
787 fn a_missing_scope_is_named() {
788 let read = token(&[Scope::IssuesRead]);
789 assert!(decide(&read, "get_issue", &json!({})).allowed);
790 assert!(decide(&read, "whoami", &json!({})).allowed);
791 let refused = decide(&read, "create_issue", &json!({}));
792 assert!(!refused.allowed);
793 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
794 // An operation the table does not know needs full access.
795 assert!(!decide(&read, "something_new", &json!({})).allowed);
796 }
797
798 #[test]
799 fn starting_agents_from_another_operation_needs_agents_run() {
800 let writer = token(&[Scope::IssuesWrite]);
801 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
802 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
803 assert!(refused.reason.unwrap().contains("agents:run"));
804 let maintainer = token(&[Scope::RepoWrite]);
805 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
806 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
807 }
808
809 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily810 fn a_workspaces_base_permission_needs_access_admin_too() {
811 let admin = token(&[Scope::WorkspaceAdmin]);
812 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
813 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
814 assert!(refused.reason.unwrap().contains("access:admin"));
815 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
816 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
817 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
818 }
819
820 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step821 fn delegating_needs_both_agents_and_issues() {
822 let agents = token(&[Scope::AgentsRun]);
823 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
824 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
825 assert!(decide(&both, "delegate", &json!({})).allowed);
826 }
827
828 #[test]
829 fn git_push_needs_code_write_and_private_reads_need_code_read() {
830 let reader = token(&[Scope::CodeRead]);
831 assert!(decide_git(&reader, false, false).allowed);
832 let refused = decide_git(&reader, true, false);
833 assert!(!refused.allowed);
834 assert!(refused.reason.unwrap().contains("code:write"));
835 let issues = token(&[Scope::IssuesWrite]);
836 assert!(!decide_git(&issues, false, false).allowed);
837 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
838 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
839 let writer = token(&[Scope::CodeWrite]);
840 assert!(decide_git(&writer, true, false).allowed);
841 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
842 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
843 }
844
845 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member846 fn packages_need_their_own_scopes_and_public_pulls_none() {
847 let reader = token(&[Scope::PackagesRead]);
848 assert!(decide_packages(&reader, Level::Read, false).allowed);
849 assert!(!decide_packages(&reader, Level::Write, false).allowed);
850 let code = token(&[Scope::CodeWrite]);
851 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
852 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
853 let writer = token(&[Scope::PackagesWrite]);
854 assert!(decide_packages(&writer, Level::Write, false).allowed);
855 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
856 let refused = decide_packages(&writer, Level::Delete, false);
857 assert!(refused.reason.unwrap().contains("packages:delete"));
858 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
859 assert!(Scope::PackagesDelete.dangerous());
860 // Tokens made before these scopes, and full-access ones, keep working.
861 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
862 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
863 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
864 }
865
866 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step867 fn token_access_travels_as_json() {
868 let access = token(&[Scope::IssuesRead]);
869 let wire = serde_json::to_value(&access).unwrap();
870 assert_eq!(wire["scopes"], json!(["issues:read"]));
871 assert!(wire.get("resources").is_none());
872 let back: TokenAccess = serde_json::from_value(wire).unwrap();
873 assert_eq!(back, access);
874 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
875 assert!(full.is_full());
876 // A reach written by an older version is ignored: a token reaches
877 // whatever its owner can.
878 let older: TokenAccess = serde_json::from_value(json!({
879 "token_id": "tok_1",
880 "scopes": ["issues:read"],
881 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
882 }))
883 .unwrap();
884 assert_eq!(older, access);
885 }
886
887 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
888 /// lists the same scopes in the same order, the same operations with
889 /// the same scopes, and the same presets.
890 #[test]
891 fn the_typescript_mirror_has_the_same_table() {
892 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
893 let section = |start: &str| {
894 ts.split_once(start)
895 .and_then(|(_, rest)| rest.split_once("] as const"))
896 .map(|(table, _)| table)
897 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
898 };
899 let scopes: Vec<&str> = section("export const SCOPES = [")
900 .lines()
901 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
902 .collect();
903 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
904 assert_eq!(scopes, expected);
905 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
906 .lines()
907 .filter_map(|line| {
908 let mut quoted = line.split('"').skip(1).step_by(2);
909 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
910 })
911 .collect();
912 let expected: Vec<(String, String)> = OPERATIONS
913 .iter()
914 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
915 .collect();
916 assert_eq!(operations, expected);
917 for preset in Preset::ALL {
918 let list = section(&format!("{}: [", preset.as_str()));
919 let mirrored: Vec<&str> = list
920 .split(',')
921 .map(|item| item.trim().trim_matches('"'))
922 .filter(|item| !item.is_empty())
923 .collect();
924 let expected: Vec<&str> = preset
925 .scopes()
926 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
927 .unwrap_or_else(|| vec!["*"]);
928 assert_eq!(mirrored, expected, "{}", preset.as_str());
929 }
930 }
931}