Skip to content
1,581 linesCodeBlameRaw

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,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit28 Billing,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step29 Repo,
30 Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar31 Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member32 Packages,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step33 Issues,
34 PullRequests,
35 Agents,
36 Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap37 WorkflowFiles,
Merge checks: statuses and check runs on every commit38 Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9739 Deployments,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step40 Memory,
41 Access,
42 Webhooks,
43 Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents44 Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens45 Models,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step46}
47
48impl Resource {
Token reach: workflow_files scope, fine-grained reach, workspace token cap49 pub const ALL: [Resource; 21] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step50 Resource::Repo,
51 Resource::Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar52 Resource::Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member53 Resource::Packages,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step54 Resource::Issues,
55 Resource::PullRequests,
56 Resource::Agents,
57 Resource::Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap58 Resource::WorkflowFiles,
Merge checks: statuses and check runs on every commit59 Resource::Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9760 Resource::Deployments,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step61 Resource::Memory,
62 Resource::Account,
API: notifications over REST and MCP, with notifications scopes63 Resource::Notifications,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step64 Resource::Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit65 Resource::Billing,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step66 Resource::Access,
67 Resource::Webhooks,
68 Resource::Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents69 Resource::Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens70 Resource::Models,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step71 ];
72
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Resource::Account => "account",
API: notifications over REST and MCP, with notifications scopes76 Resource::Notifications => "notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step77 Resource::Workspace => "workspace",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit78 Resource::Billing => "billing",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step79 Resource::Repo => "repo",
80 Resource::Code => "code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar81 Resource::Security => "security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member82 Resource::Packages => "packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step83 Resource::Issues => "issues",
84 Resource::PullRequests => "pull_requests",
85 Resource::Agents => "agents",
86 Resource::Workflows => "workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap87 Resource::WorkflowFiles => "workflow_files",
Merge checks: statuses and check runs on every commit88 Resource::Checks => "checks",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9789 Resource::Deployments => "deployments",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step90 Resource::Memory => "memory",
91 Resource::Access => "access",
92 Resource::Webhooks => "webhooks",
93 Resource::Secrets => "secrets",
Fast pages, required checks on the branch, self-hosted runners, honest incidents94 Resource::Runners => "runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens95 Resource::Models => "models",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step96 }
97 }
98
99 /// Its name, for people.
100 pub fn label(self) -> &'static str {
101 match self {
102 Resource::Account => "Your account",
API: notifications over REST and MCP, with notifications scopes103 Resource::Notifications => "Notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step104 Resource::Workspace => "Workspaces",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit105 Resource::Billing => "Billing",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step106 Resource::Repo => "Repositories",
107 Resource::Code => "Code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar108 Resource::Security => "Security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member109 Resource::Packages => "Packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step110 Resource::Issues => "Issues",
111 Resource::PullRequests => "Pull requests",
112 Resource::Agents => "g1t agents",
113 Resource::Workflows => "Workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap114 Resource::WorkflowFiles => "Workflow files",
Merge checks: statuses and check runs on every commit115 Resource::Checks => "Checks and statuses",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97116 Resource::Deployments => "Deployments",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step117 Resource::Memory => "Memory and context",
118 Resource::Access => "Who has access",
119 Resource::Webhooks => "Webhooks",
120 Resource::Secrets => "Secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents121 Resource::Runners => "Self-hosted runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens122 Resource::Models => "AI Gateway",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step123 }
124 }
125}
126
127/// How much of a resource.
128#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
129pub enum Level {
130 Read,
131 Write,
132 /// Starting g1t's agents, which spends the workspace's money.
133 Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member134 /// Deleting what cannot be brought back, such as a package's versions.
135 Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step136 Admin,
137}
138
139impl Level {
140 pub fn as_str(self) -> &'static str {
141 match self {
142 Level::Read => "read",
143 Level::Write => "write",
144 Level::Run => "run",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member145 Level::Delete => "delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step146 Level::Admin => "admin",
147 }
148 }
149}
150
151/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
152/// clients ask for, and errors name.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
154pub enum Scope {
155 RepoRead,
156 RepoWrite,
157 RepoAdmin,
158 CodeRead,
159 CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar160 SecurityRead,
161 SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member162 PackagesRead,
163 PackagesWrite,
164 PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step165 IssuesRead,
166 IssuesWrite,
167 PullRequestsRead,
168 PullRequestsWrite,
169 AgentsRun,
170 WorkflowsRead,
171 WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap172 WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit173 ChecksRead,
174 ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97175 DeploymentsRead,
176 DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step177 MemoryRead,
178 MemoryWrite,
179 AccountRead,
180 AccountWrite,
API: notifications over REST and MCP, with notifications scopes181 NotificationsRead,
182 NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step183 WorkspaceRead,
184 WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit185 BillingRead,
186 BillingWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step187 AccessRead,
188 AccessAdmin,
189 WebhooksRead,
190 WebhooksAdmin,
191 SecretsRead,
192 SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents193 RunnersRead,
194 RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens195 ModelsRead,
196 ModelsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step197}
198
199impl Scope {
200 /// Every scope, grouped by resource, least first.
Token reach: workflow_files scope, fine-grained reach, workspace token cap201 pub const ALL: [Scope; 42] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step202 Scope::RepoRead,
203 Scope::RepoWrite,
204 Scope::RepoAdmin,
205 Scope::CodeRead,
206 Scope::CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar207 Scope::SecurityRead,
208 Scope::SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member209 Scope::PackagesRead,
210 Scope::PackagesWrite,
211 Scope::PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step212 Scope::IssuesRead,
213 Scope::IssuesWrite,
214 Scope::PullRequestsRead,
215 Scope::PullRequestsWrite,
216 Scope::AgentsRun,
217 Scope::WorkflowsRead,
218 Scope::WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap219 Scope::WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit220 Scope::ChecksRead,
221 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97222 Scope::DeploymentsRead,
223 Scope::DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step224 Scope::MemoryRead,
225 Scope::MemoryWrite,
226 Scope::AccountRead,
227 Scope::AccountWrite,
API: notifications over REST and MCP, with notifications scopes228 Scope::NotificationsRead,
229 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step230 Scope::WorkspaceRead,
231 Scope::WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit232 Scope::BillingRead,
233 Scope::BillingWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step234 Scope::AccessRead,
235 Scope::AccessAdmin,
236 Scope::WebhooksRead,
237 Scope::WebhooksAdmin,
238 Scope::SecretsRead,
239 Scope::SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents240 Scope::RunnersRead,
241 Scope::RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens242 Scope::ModelsRead,
243 Scope::ModelsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step244 ];
245
246 pub fn as_str(self) -> &'static str {
247 match self {
248 Scope::RepoRead => "repo:read",
249 Scope::RepoWrite => "repo:write",
250 Scope::RepoAdmin => "repo:admin",
251 Scope::CodeRead => "code:read",
252 Scope::CodeWrite => "code:write",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar253 Scope::SecurityRead => "security:read",
254 Scope::SecurityWrite => "security:write",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member255 Scope::PackagesRead => "packages:read",
256 Scope::PackagesWrite => "packages:write",
257 Scope::PackagesDelete => "packages:delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step258 Scope::IssuesRead => "issues:read",
259 Scope::IssuesWrite => "issues:write",
260 Scope::PullRequestsRead => "pull_requests:read",
261 Scope::PullRequestsWrite => "pull_requests:write",
262 Scope::AgentsRun => "agents:run",
263 Scope::WorkflowsRead => "workflows:read",
264 Scope::WorkflowsWrite => "workflows:write",
Token reach: workflow_files scope, fine-grained reach, workspace token cap265 Scope::WorkflowFilesWrite => "workflow_files:write",
Merge checks: statuses and check runs on every commit266 Scope::ChecksRead => "checks:read",
267 Scope::ChecksWrite => "checks:write",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97268 Scope::DeploymentsRead => "deployments:read",
269 Scope::DeploymentsWrite => "deployments:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step270 Scope::MemoryRead => "memory:read",
271 Scope::MemoryWrite => "memory:write",
272 Scope::AccountRead => "account:read",
273 Scope::AccountWrite => "account:write",
API: notifications over REST and MCP, with notifications scopes274 Scope::NotificationsRead => "notifications:read",
275 Scope::NotificationsWrite => "notifications:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step276 Scope::WorkspaceRead => "workspace:read",
277 Scope::WorkspaceAdmin => "workspace:admin",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit278 Scope::BillingRead => "billing:read",
279 Scope::BillingWrite => "billing:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step280 Scope::AccessRead => "access:read",
281 Scope::AccessAdmin => "access:admin",
282 Scope::WebhooksRead => "webhooks:read",
283 Scope::WebhooksAdmin => "webhooks:admin",
284 Scope::SecretsRead => "secrets:read",
285 Scope::SecretsAdmin => "secrets:admin",
Fast pages, required checks on the branch, self-hosted runners, honest incidents286 Scope::RunnersRead => "runners:read",
287 Scope::RunnersAdmin => "runners:admin",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens288 Scope::ModelsRead => "models:read",
289 Scope::ModelsWrite => "models:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step290 }
291 }
292
293 pub fn parse(text: &str) -> Option<Scope> {
294 let text = text.trim().to_ascii_lowercase();
295 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
296 }
297
298 pub fn resource(self) -> Resource {
299 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
300 Resource::ALL
301 .into_iter()
302 .find(|resource| resource.as_str() == name)
303 .unwrap_or(Resource::Account)
304 }
305
306 pub fn level(self) -> Level {
307 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
308 "write" => Level::Write,
309 "run" => Level::Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member310 "delete" => Level::Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step311 "admin" => Level::Admin,
312 _ => Level::Read,
313 }
314 }
315
316 /// Whether holding `self` gives `other`: the same resource, at the same
317 /// level or a lower one.
318 pub fn includes(self, other: Scope) -> bool {
319 self.resource() == other.resource() && self.level() >= other.level()
320 }
321
322 /// Changes that are hard or impossible to undo, or that decide who can
323 /// reach what. Shown behind a warning wherever scopes are chosen.
324 pub fn dangerous(self) -> bool {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member325 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 step326 }
327
328 /// What it lets a token do, in plain words.
329 pub fn describe(self) -> &'static str {
330 match self {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97331 Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search",
332 Scope::RepoWrite => "Create repositories, rename branches, change how pull requests merge and publish releases",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge333 Scope::RepoAdmin => "Rename, archive, transfer, delete or change who can see a repository, change its rulesets, and dismiss security alerts",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step334 Scope::CodeRead => "Clone and fetch private repositories with git",
335 Scope::CodeWrite => "Push commits with git",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar336 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
337 Scope::SecurityWrite => "Dismiss and reopen alerts, bypass push protection, review bypass requests, manage custom patterns, upload SARIF and change security settings",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member338 Scope::PackagesRead => "Pull container images and install private packages",
339 Scope::PackagesWrite => "Push container images and publish packages",
Merge packages: roles, Actions access, source label, soft delete, API340 Scope::PackagesDelete => "Delete and restore packages and their versions",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step341 Scope::IssuesRead => "Read issues, comments and plans",
342 Scope::IssuesWrite => "Open, edit, close and comment on issues",
343 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
344 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
345 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
346 Scope::WorkflowsRead => "Read workflows, runs and logs",
347 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
Token reach: workflow_files scope, fine-grained reach, workspace token cap348 Scope::WorkflowFilesWrite => "Add, change and delete workflow files under .g1t/workflows and .github/workflows, with git or the API",
Merge checks: statuses and check runs on every commit349 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
350 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97351 Scope::DeploymentsRead => "See deployments, their statuses and environments",
352 Scope::DeploymentsWrite => "Report deployments and their statuses, from any CI",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step353 Scope::MemoryRead => "Recall memory and search the workspace's context",
354 Scope::MemoryWrite => "Save memory for the next agent",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97355 Scope::AccountRead => "Read your email addresses, invites, invitations, pinned projects and stars",
356 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
API: notifications over REST and MCP, with notifications scopes357 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
358 Scope::NotificationsWrite => "Mark notifications read, done, saved or snoozed, subscribe to threads and watch repositories",
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge359 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
360 Scope::WorkspaceAdmin => "Create and delete workspaces, invite members, connect integrations, create, change and delete teams, and change the workspace's rulesets",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit361 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
362 Scope::BillingWrite => "Change a workspace's budget and buy AI credit",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step363 Scope::AccessRead => "See who has access to repositories",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar364 Scope::AccessAdmin => "Give and take away access to repositories, a team's included",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step365 Scope::WebhooksRead => "See webhooks and their deliveries",
366 Scope::WebhooksAdmin => "Create, change and delete webhooks",
367 Scope::SecretsRead => "List secrets (never their values) and read variables",
368 Scope::SecretsAdmin => "Set and delete secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents369 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
370 Scope::RunnersAdmin => "Register and remove self-hosted runners, change their groups and settings",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens371 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
372 Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step373 }
374 }
375}
376
377impl Serialize for Scope {
378 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
379 serializer.serialize_str(self.as_str())
380 }
381}
382
383impl<'de> Deserialize<'de> for Scope {
384 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
385 let text = String::deserialize(deserializer)?;
386 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
387 }
388}
389
390/// Scopes as written in a token's row or an OAuth request: separated by
391/// spaces or commas. Unknown names are left out, so a client asking for a
392/// scope from a newer version gets the rest.
393pub fn parse_scopes(text: &str) -> Vec<Scope> {
394 let mut scopes: Vec<Scope> = text
395 .split(|c: char| c.is_whitespace() || c == ',')
396 .filter_map(Scope::parse)
397 .collect();
398 normalize(&mut scopes);
399 scopes
400}
401
402/// In table order, without repeats.
403pub fn normalize(scopes: &mut Vec<Scope>) {
404 let given = std::mem::take(scopes);
405 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
406}
407
408/// Space-separated, as stored and as OAuth writes them.
409pub fn scopes_text(scopes: &[Scope]) -> String {
410 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
411}
412
413/// What a token stores for full access, which is not a scope a client can
414/// ask for by name.
415pub const FULL_ACCESS: &str = "*";
416
417/// Starting points for choosing scopes.
418#[derive(Clone, Copy, Debug, PartialEq, Eq)]
419pub enum Preset {
420 ReadOnly,
421 Agent,
422 Ci,
423 Full,
424}
425
426impl Preset {
427 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
428
429 pub fn as_str(self) -> &'static str {
430 match self {
431 Preset::ReadOnly => "read_only",
432 Preset::Agent => "agent",
433 Preset::Ci => "ci",
434 Preset::Full => "full",
435 }
436 }
437
438 pub fn label(self) -> &'static str {
439 match self {
440 Preset::ReadOnly => "Read only",
441 Preset::Agent => "Agent",
442 Preset::Ci => "CI",
443 Preset::Full => "Full access",
444 }
445 }
446
447 /// Its scopes; `None` for full access.
448 pub fn scopes(self) -> Option<Vec<Scope>> {
449 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
450 match self {
451 Preset::ReadOnly => Some(reads().collect()),
452 Preset::Agent => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents453 // Not the machines work runs on: an agent has no business
454 // knowing a workspace's own runners.
455 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes456 // And answering what needs the person it works for: marking
457 // it done, subscribing, watching.
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step458 scopes.extend([
459 Scope::CodeWrite,
460 Scope::IssuesWrite,
461 Scope::PullRequestsWrite,
462 Scope::AgentsRun,
463 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes464 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step465 ]);
466 normalize(&mut scopes);
467 Some(scopes)
468 }
469 Preset::Ci => Some(vec![
470 Scope::RepoRead,
471 Scope::CodeRead,
472 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member473 Scope::PackagesRead,
474 Scope::PackagesWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step475 Scope::WorkflowsRead,
476 Scope::WorkflowsWrite,
Merge checks: statuses and check runs on every commit477 Scope::ChecksRead,
478 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97479 Scope::DeploymentsRead,
480 Scope::DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step481 ]),
482 Preset::Full => None,
483 }
484 }
485}
486
487/// What an OAuth client gets when it asks for nothing in particular: the
488/// agent preset. Never an admin scope.
489pub fn oauth_default() -> Vec<Scope> {
490 Preset::Agent.scopes().unwrap_or_default()
491}
492
493/// Set on a [`crate::User`] resolved from an access token: what the token
494/// may do. Absent on a signed-in session, which may do whatever its person
495/// can.
496#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
497pub struct TokenAccess {
498 /// The token's id, as audit entries and errors name it.
499 #[serde(default)]
500 pub token_id: String,
501 /// Its scopes, as `resource:level`. Absent: full access, everything the
502 /// person (or workspace) can do.
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub scopes: Option<Vec<String>>,
505 /// Made before tokens had scopes: full access until someone narrows it.
506 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
507 pub legacy: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'508 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
509 /// reaches, as `owner/name`. Every other is refused, whatever its owner
510 /// could reach.
511 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub repo: Option<String>,
513 /// Set on a workflow job's token: the run and job it was made for. The
514 /// audit log records its changes as that job's, and what it changes
515 /// starts no workflows (only `workflow_dispatch` and
516 /// `repository_dispatch` do), so a workflow cannot set itself off.
517 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub job: Option<JobToken>,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens519 /// The token's name, as its owner gave it, so a log can say which
520 /// token made a request. Absent where whoever resolved it did not say.
521 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub name: Option<String>,
Token reach: workflow_files scope, fine-grained reach, workspace token cap523 /// Set on a fine-grained personal access token: whose resources it
524 /// reaches, and which of their repositories. Absent on a classic token,
525 /// which reaches whatever its owner can.
526 #[serde(default, skip_serializing_if = "Option::is_none")]
527 pub fine_grained: Option<FineGrainedReach>,
528 /// Set on a workspace's own token that an owner gave Admin when making
529 /// it. Without it a workspace's token has Write on the workspace's
530 /// repositories, as a member would (see [`crate::access`]).
531 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
532 pub admin: bool,
533 /// Set on what a repository's deploy key resolves to: the key's id. Its
534 /// `repo` is the one repository it reaches.
535 #[serde(default, skip_serializing_if = "Option::is_none")]
536 pub deploy_key: Option<String>,
537}
538
539/// Which of the resource owner's repositories a fine-grained token reaches.
540#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
541#[serde(rename_all = "snake_case")]
542pub enum RepositorySelection {
543 /// Every repository of the workspace, ones made later included.
544 #[default]
545 All,
546 /// The repositories chosen, by id.
547 Selected,
548 /// None of the workspace's private repositories: public repositories,
549 /// read-only, and the workspace's own settings its permissions allow.
550 Public,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step551}
552
Token reach: workflow_files scope, fine-grained reach, workspace token cap553impl RepositorySelection {
554 pub fn as_str(self) -> &'static str {
555 match self {
556 RepositorySelection::All => "all",
557 RepositorySelection::Selected => "selected",
558 RepositorySelection::Public => "public",
559 }
560 }
561
562 pub fn parse(text: &str) -> Option<RepositorySelection> {
563 match text.trim().to_ascii_lowercase().as_str() {
564 "all" => Some(RepositorySelection::All),
565 "selected" => Some(RepositorySelection::Selected),
566 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
567 _ => None,
568 }
569 }
570}
571
572/// What a fine-grained token reaches, as identity resolves it on each use.
573#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
574pub struct FineGrainedReach {
575 /// The resource owner: the workspace whose repositories and settings it
576 /// reaches, by slug as it is now. Absent: the person's own account
577 /// only, with public repositories read-only.
578 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub workspace: Option<String>,
580 #[serde(default)]
581 pub repositories: RepositorySelection,
582 /// With [`RepositorySelection::Selected`]: the repositories' ids.
583 #[serde(default, skip_serializing_if = "Vec::is_empty")]
584 pub repo_ids: Vec<String>,
585}
586
587impl FineGrainedReach {
588 /// Whether it reaches the repository with this id in the workspace
589 /// `namespace` for more than what anyone may do with a public one.
590 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
591 let Some(workspace) = self.workspace.as_deref() else {
592 return false;
593 };
594 if !workspace.eq_ignore_ascii_case(namespace) {
595 return false;
596 }
597 match self.repositories {
598 RepositorySelection::All => true,
599 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
600 RepositorySelection::Public => false,
601 }
602 }
603
604 /// Whether the workspace `slug` is its resource owner.
605 pub fn owned_by(&self, slug: &str) -> bool {
606 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
607 }
608}
609
610/// Where workflow files live. Adding, changing or deleting a file under
611/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
612/// token: what GitHub's `workflow` scope and `workflows` permission do.
613pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
614
615/// Whether `path` is a workflow file, or a file in one's directory.
616pub fn is_workflow_file(path: &str) -> bool {
617 let path = path.trim_start_matches('/');
618 WORKFLOW_DIRS.iter().any(|dir| {
619 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
620 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
621}
622
623/// Whether a token may add, change or delete the files at `paths`: a
624/// refusal naming the first workflow file it may not touch, else `None`.
625/// A signed-in person (no token) is never refused here; their role decides.
626pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
627 let access = access?;
628 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
629 return None;
630 }
631 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
632 let why = if access.job.is_some() {
633 "a workflow job's token can never add or change workflow files".to_owned()
634 } else if access.fine_grained.is_some() {
635 "it needs the Workflows permission (read and write), which maps to the workflow_files:write scope".to_owned()
636 } else {
637 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
638 };
639 Some(Decision::deny(
640 "token:workflows",
641 format!("This access token cannot change the workflow file {path}: {why}."),
642 ))
643}
644
Merge branch 'worktree-agent-a3abfcce648e87dca'645/// The workflow job a token was made for.
646#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
647pub struct JobToken {
648 /// The run, `run_…`.
649 pub run_id: String,
650 /// The job, `job_…`.
651 pub job_id: String,
652 /// Whether it may open pull requests and approve them, by its
653 /// repository's and workspace's choice ("Allow g1t Actions to create and
654 /// approve pull requests"). Off unless chosen.
655 #[serde(default)]
656 pub pull_requests: bool,
657}
658
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step659impl TokenAccess {
660 /// Full access to everything: the access tokens made before scopes had.
661 pub fn full() -> Self {
662 TokenAccess::default()
663 }
664
Merge branch 'worktree-agent-a3abfcce648e87dca'665 /// Whether it may reach the repository `owner/name`: every token but a
666 /// workflow job's, which reaches its own repository only.
667 pub fn reaches(&self, repo: &str) -> bool {
668 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
669 }
670
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step671 pub fn is_full(&self) -> bool {
672 self.scopes.is_none()
673 }
674
675 /// The scopes it holds, or `None` for full access.
676 pub fn granted(&self) -> Option<Vec<Scope>> {
677 self.scopes
678 .as_ref()
679 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
680 }
681
682 pub fn allows(&self, needed: Scope) -> bool {
683 match self.granted() {
684 None => true,
685 Some(granted) => granted.iter().any(|held| held.includes(needed)),
686 }
687 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap688
689 /// Whether it reaches the repository with this id in `namespace` for
690 /// more than reading a public one: every token but a fine-grained one
691 /// outside its resource owner or repository selection. Its owner's role
692 /// still decides; see [`crate::access`].
693 pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
694 self.fine_grained.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
695 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step696}
697
698/// Every operation of the API and MCP server, with the scope it needs. An
699/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
700/// its operations is in exactly one of the two.
701pub const OPERATIONS: &[(&str, Scope)] = &[
702 // Your account.
703 ("list_emails", Scope::AccountRead),
704 ("add_email", Scope::AccountWrite),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)705 ("confirm_email", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step706 ("remove_email", Scope::AccountWrite),
707 ("update_email_settings", Scope::AccountWrite),
708 ("list_invites", Scope::AccountRead),
709 ("create_invite", Scope::AccountWrite),
710 ("revoke_invite", Scope::AccountWrite),
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)711 ("list_invitations", Scope::AccountRead),
712 ("accept_invitation", Scope::AccountWrite),
713 ("decline_invitation", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step714 ("list_my_repo_invitations", Scope::AccountRead),
715 ("accept_repo_invitation", Scope::AccountWrite),
716 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP717 // Your pinned projects: a preference of your account.
718 ("list_pinned_projects", Scope::AccountRead),
719 ("pin_project", Scope::AccountWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97720 // Your stars: a preference of your account.
721 ("list_starred", Scope::AccountRead),
722 ("check_starred", Scope::AccountRead),
723 ("star_repo", Scope::AccountWrite),
724 ("unstar_repo", Scope::AccountWrite),
API: pinned projects over REST and MCP725 ("unpin_project", Scope::AccountWrite),
726 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes727 // Your inbox: notifications, subscriptions and watching.
728 ("list_notifications", Scope::NotificationsRead),
729 ("get_notification_thread", Scope::NotificationsRead),
730 ("get_thread_subscription", Scope::NotificationsRead),
731 ("get_repo_subscription", Scope::NotificationsRead),
732 ("list_watched_repos", Scope::NotificationsRead),
733 ("mark_notifications_read", Scope::NotificationsWrite),
734 ("mark_thread_read", Scope::NotificationsWrite),
735 ("mark_thread_done", Scope::NotificationsWrite),
736 ("save_thread", Scope::NotificationsWrite),
737 ("snooze_thread", Scope::NotificationsWrite),
738 ("set_thread_subscription", Scope::NotificationsWrite),
739 ("delete_thread_subscription", Scope::NotificationsWrite),
740 ("set_repo_subscription", Scope::NotificationsWrite),
741 ("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 step742 // Workspaces, their invites and integrations.
743 ("create_workspace", Scope::WorkspaceAdmin),
744 ("delete_workspace", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-ad7c6d88d93adc817'745 ("get_workspace", Scope::WorkspaceRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily746 ("update_workspace", Scope::WorkspaceAdmin),
Merge main (membership, two-factor, GitHub repo roles) into tokens747 // Its members, and who owns it.
748 ("list_members", Scope::WorkspaceRead),
749 ("update_member", Scope::WorkspaceAdmin),
750 ("remove_member", Scope::WorkspaceAdmin),
751 ("transfer_ownership", Scope::WorkspaceAdmin),
752 ("leave_workspace", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step753 ("list_workspace_invites", Scope::WorkspaceRead),
754 ("invite_member", Scope::WorkspaceAdmin),
755 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
756 ("list_integrations", Scope::WorkspaceRead),
757 ("connect_integration", Scope::WorkspaceAdmin),
AI Gateway: OpenAI's format, open models, and your own providers758 ("update_integration", Scope::WorkspaceAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step759 ("disconnect_integration", Scope::WorkspaceAdmin),
760 ("test_integration", Scope::WorkspaceAdmin),
761 ("get_model_routes", Scope::WorkspaceRead),
762 ("set_model_routes", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar763 // Teams: reading them, and managing them. A team's role on a
764 // repository is who has access.
765 ("list_teams", Scope::WorkspaceRead),
766 ("get_team", Scope::WorkspaceRead),
767 ("list_team_members", Scope::WorkspaceRead),
768 ("list_child_teams", Scope::WorkspaceRead),
769 ("list_team_repos", Scope::WorkspaceRead),
770 ("list_user_teams", Scope::WorkspaceRead),
771 ("create_team", Scope::WorkspaceAdmin),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge772 ("list_workspace_rulesets", Scope::WorkspaceRead),
773 ("get_workspace_ruleset", Scope::WorkspaceRead),
774 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
775 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
776 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
777 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar778 ("update_team", Scope::WorkspaceAdmin),
779 ("delete_team", Scope::WorkspaceAdmin),
780 ("set_team_member", Scope::WorkspaceAdmin),
781 ("remove_team_member", Scope::WorkspaceAdmin),
782 ("set_team_review_assignment", Scope::WorkspaceAdmin),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit783 // A workspace's billing: usage, budget, AI credit and invoices.
784 ("get_usage", Scope::BillingRead),
785 ("get_budget", Scope::BillingRead),
786 ("get_ai_credit", Scope::BillingRead),
787 ("list_invoices", Scope::BillingRead),
788 ("get_billing_details", Scope::BillingRead),
789 ("set_budget", Scope::BillingWrite),
790 ("buy_ai_credit", Scope::BillingWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step791 // Repositories.
792 ("list_repos", Scope::RepoRead),
793 ("get_repo", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97794 // Projects follow their repositories.
795 ("list_projects", Scope::RepoRead),
796 ("get_project", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step797 ("search", Scope::RepoRead),
798 ("list_events", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97799 // What the default branch says about a repository, who starred it, and
800 // its releases.
801 ("get_languages", Scope::RepoRead),
802 ("list_contributors", Scope::RepoRead),
803 ("get_license", Scope::RepoRead),
804 ("list_stargazers", Scope::RepoRead),
805 ("list_releases", Scope::RepoRead),
806 ("get_latest_release", Scope::RepoRead),
807 ("get_release_by_tag", Scope::RepoRead),
808 ("get_release", Scope::RepoRead),
809 ("create_release", Scope::RepoWrite),
810 ("update_release", Scope::RepoWrite),
811 ("delete_release", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step812 ("list_labels", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar813 ("list_milestones", Scope::RepoRead),
814 ("get_milestone", Scope::RepoRead),
815 ("create_label", Scope::IssuesWrite),
816 ("update_label", Scope::IssuesWrite),
817 ("delete_label", Scope::IssuesWrite),
818 ("add_default_labels", Scope::IssuesWrite),
819 ("create_milestone", Scope::IssuesWrite),
820 ("update_milestone", Scope::IssuesWrite),
821 ("delete_milestone", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step822 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents823 ("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 step824 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily825 ("list_security_alerts", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar826 ("get_codeowners_errors", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step827 ("create_repo", Scope::RepoWrite),
828 ("update_repo", Scope::RepoWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97829 ("update_project", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step830 ("update_repo_settings", Scope::RepoWrite),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge831 // Rulesets: reading them is reading the repository; changing them
832 // changes what everyone, agents included, may do, so it is admin.
833 ("list_repo_rulesets", Scope::RepoRead),
834 ("get_repo_ruleset", Scope::RepoRead),
835 ("get_branch_rules", Scope::RepoRead),
836 ("list_rule_evaluations", Scope::RepoRead),
837 ("create_repo_ruleset", Scope::RepoAdmin),
838 ("update_repo_ruleset", Scope::RepoAdmin),
839 ("delete_repo_ruleset", Scope::RepoAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step840 ("rename_branch", Scope::RepoWrite),
841 ("rename_repo", Scope::RepoAdmin),
842 ("transfer_repo", Scope::RepoAdmin),
843 ("archive_repo", Scope::RepoAdmin),
844 ("unarchive_repo", Scope::RepoAdmin),
845 ("set_repo_visibility", Scope::RepoAdmin),
846 ("delete_repo", Scope::RepoAdmin),
847 ("restore_repo", Scope::RepoAdmin),
848 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily849 // A dismissed secret is let through push protection.
850 ("dismiss_security_alert", Scope::RepoAdmin),
851 ("reopen_security_alert", Scope::RepoAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar852 // The security suite: alerts, push protection, patterns, code
853 // scanning, the supply chain and settings.
854 ("list_secret_scanning_alerts", Scope::SecurityRead),
855 ("get_secret_scanning_alert", Scope::SecurityRead),
856 ("list_secret_scanning_locations", Scope::SecurityRead),
857 ("list_bypass_requests", Scope::SecurityRead),
858 ("list_custom_patterns", Scope::SecurityRead),
859 ("list_code_scanning_alerts", Scope::SecurityRead),
860 ("get_code_scanning_alert", Scope::SecurityRead),
861 ("list_code_scanning_analyses", Scope::SecurityRead),
862 ("get_sarif_upload", Scope::SecurityRead),
863 ("list_vulnerability_alerts", Scope::SecurityRead),
864 ("get_vulnerability_alert", Scope::SecurityRead),
865 ("get_dependency_graph", Scope::SecurityRead),
866 ("get_sbom", Scope::SecurityRead),
867 ("compare_dependencies", Scope::SecurityRead),
868 ("get_security_settings", Scope::SecurityRead),
869 ("get_workspace_security_settings", Scope::SecurityRead),
870 ("get_security_overview", Scope::SecurityRead),
871 ("update_secret_scanning_alert", Scope::SecurityWrite),
872 ("bypass_push_protection", Scope::SecurityWrite),
873 ("check_secret_validity", Scope::SecurityWrite),
874 ("review_bypass_request", Scope::SecurityWrite),
875 ("create_custom_pattern", Scope::SecurityWrite),
876 ("update_custom_pattern", Scope::SecurityWrite),
877 ("delete_custom_pattern", Scope::SecurityWrite),
878 ("dry_run_custom_pattern", Scope::SecurityWrite),
879 ("update_code_scanning_alert", Scope::SecurityWrite),
880 ("upload_sarif", Scope::SecurityWrite),
881 ("update_vulnerability_alert", Scope::SecurityWrite),
882 ("fix_security_alert", Scope::SecurityWrite),
883 ("update_security_settings", Scope::SecurityWrite),
884 ("update_workspace_security_settings", Scope::SecurityWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step885 // Issues and plans.
886 ("list_issues", Scope::IssuesRead),
887 ("get_issue", Scope::IssuesRead),
888 ("get_plan", Scope::IssuesRead),
889 ("create_issue", Scope::IssuesWrite),
890 ("update_issue", Scope::IssuesWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar891 ("list_issue_labels", Scope::IssuesRead),
892 ("add_issue_labels", Scope::IssuesWrite),
893 ("set_issue_labels", Scope::IssuesWrite),
894 ("remove_issue_labels", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step895 ("close_issue", Scope::IssuesWrite),
896 ("reopen_issue", Scope::IssuesWrite),
897 ("add_comment", Scope::IssuesWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts898 ("edit_comment", Scope::IssuesWrite),
899 ("delete_comment", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step900 ("import_issue", Scope::IssuesWrite),
901 ("apply_plan", Scope::IssuesWrite),
902 // Pull requests.
903 ("list_pull_requests", Scope::PullRequestsRead),
904 ("get_pull_request", Scope::PullRequestsRead),
905 ("get_pull_request_changes", Scope::PullRequestsRead),
906 ("read_session", Scope::PullRequestsRead),
907 ("get_merge_queue", Scope::PullRequestsRead),
908 ("create_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar909 ("update_pull_request", Scope::PullRequestsWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step910 ("record_session", Scope::PullRequestsWrite),
911 ("mark_pull_request_ready", Scope::PullRequestsWrite),
912 ("close_pull_request", Scope::PullRequestsWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts913 ("reopen_pull_request", Scope::PullRequestsWrite),
914 ("convert_pull_request_to_draft", Scope::PullRequestsWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step915 ("review_pull_request", Scope::PullRequestsWrite),
916 ("merge_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar917 ("request_reviewers", Scope::PullRequestsWrite),
918 ("remove_requested_reviewers", Scope::PullRequestsWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step919 // g1t's agents.
920 ("assign_issue", Scope::AgentsRun),
921 ("delegate", Scope::AgentsRun),
922 ("plan_work", Scope::AgentsRun),
923 ("message_agent", Scope::AgentsRun),
924 ("answer_message", Scope::AgentsRun),
925 ("take_messages", Scope::AgentsRun),
926 // Workflows.
927 ("list_workflows", Scope::WorkflowsRead),
928 ("list_workflow_runs", Scope::WorkflowsRead),
929 ("get_workflow_run", Scope::WorkflowsRead),
930 ("get_job_logs", Scope::WorkflowsRead),
931 ("dispatch_workflow", Scope::WorkflowsWrite),
932 ("cancel_workflow_run", Scope::WorkflowsWrite),
933 ("rerun_workflow_run", Scope::WorkflowsWrite),
934 ("update_workflow", Scope::WorkflowsWrite),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R2935 ("list_artifacts", Scope::WorkflowsRead),
936 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
937 ("get_artifact", Scope::WorkflowsRead),
938 ("download_artifact", Scope::WorkflowsRead),
939 ("get_artifact_retention", Scope::WorkflowsRead),
940 ("delete_artifact", Scope::WorkflowsWrite),
941 ("set_artifact_retention", Scope::WorkflowsWrite),
Merge checks: statuses and check runs on every commit942 // Checks: statuses, check runs and check suites on commits.
943 ("list_commit_statuses", Scope::ChecksRead),
944 ("get_combined_status", Scope::ChecksRead),
945 ("list_check_runs_for_ref", Scope::ChecksRead),
946 ("get_check_run", Scope::ChecksRead),
947 ("list_check_run_annotations", Scope::ChecksRead),
948 ("list_check_suites_for_ref", Scope::ChecksRead),
949 ("get_check_suite", Scope::ChecksRead),
950 ("create_commit_status", Scope::ChecksWrite),
951 ("create_check_run", Scope::ChecksWrite),
952 ("update_check_run", Scope::ChecksWrite),
953 ("rerequest_check_run", Scope::ChecksWrite),
954 ("rerequest_check_suite", Scope::ChecksWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97955 // Deployments, wherever they run: reading them, and reporting them.
956 ("list_deployments", Scope::DeploymentsRead),
957 ("get_deployment", Scope::DeploymentsRead),
958 ("list_deployment_statuses", Scope::DeploymentsRead),
959 ("list_environments", Scope::DeploymentsRead),
960 ("get_environment", Scope::DeploymentsRead),
961 ("create_deployment", Scope::DeploymentsWrite),
962 ("create_deployment_status", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a3abfcce648e87dca'963 // What keeps runs safe: the runs environments hold and reviewing them,
964 // approving a pull request's run, and a repository's own rules for
965 // its environments and tokens, which are an admin's.
966 ("get_pending_deployments", Scope::WorkflowsRead),
967 ("review_pending_deployments", Scope::WorkflowsWrite),
968 ("approve_workflow_run", Scope::WorkflowsWrite),
969 ("get_workflow_permissions", Scope::RepoRead),
970 ("get_fork_pr_approval", Scope::RepoRead),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts971 ("get_actions_access", Scope::RepoRead),
Merge branch 'worktree-agent-a3abfcce648e87dca'972 ("update_environment", Scope::RepoAdmin),
973 ("delete_environment", Scope::RepoAdmin),
974 ("set_workflow_permissions", Scope::RepoAdmin),
975 ("set_fork_pr_approval", Scope::RepoAdmin),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts976 ("set_actions_access", Scope::RepoAdmin),
Merge branch 'worktree-agent-a3abfcce648e87dca'977 // Starting workflows from outside, as a push would.
978 ("create_repository_dispatch", Scope::CodeWrite),
979 // A workspace's policy for its repositories' tokens.
980 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
981 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
API and MCP for a workspace's personal access token rules, members' tokens and approvals982 // A workspace's rules for personal access tokens, and the members'
983 // tokens that reach it: who has access.
984 ("get_token_policy", Scope::WorkspaceRead),
985 ("set_token_policy", Scope::WorkspaceAdmin),
986 ("list_member_tokens", Scope::AccessRead),
987 ("list_token_requests", Scope::AccessRead),
988 ("review_token_request", Scope::AccessAdmin),
989 ("revoke_member_token", Scope::AccessAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step990 // Memory and the context hub.
991 ("recall", Scope::MemoryRead),
992 ("search_context", Scope::MemoryRead),
993 ("get_entity", Scope::MemoryRead),
994 ("get_context", Scope::MemoryRead),
995 ("remember", Scope::MemoryWrite),
996 // Who has access.
997 ("list_collaborators", Scope::AccessRead),
998 ("get_collaborator_permission", Scope::AccessRead),
999 ("list_repo_invitations", Scope::AccessRead),
1000 ("list_outside_collaborators", Scope::AccessRead),
1001 ("add_collaborator", Scope::AccessAdmin),
1002 ("update_collaborator", Scope::AccessAdmin),
1003 ("remove_collaborator", Scope::AccessAdmin),
1004 ("revoke_repo_invitation", Scope::AccessAdmin),
1005 ("set_base_permission", Scope::AccessAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1006 ("set_team_repo", Scope::AccessAdmin),
1007 ("remove_team_repo", Scope::AccessAdmin),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1008 // Deploy keys: each lets a machine reach one repository, so they
1009 // are part of who has access.
1010 ("list_deploy_keys", Scope::AccessRead),
1011 ("get_deploy_key", Scope::AccessRead),
1012 ("create_deploy_key", Scope::AccessAdmin),
1013 ("delete_deploy_key", Scope::AccessAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1014 // Webhooks.
1015 ("list_webhooks", Scope::WebhooksRead),
1016 ("list_webhook_deliveries", Scope::WebhooksRead),
1017 ("create_webhook", Scope::WebhooksAdmin),
1018 ("update_webhook", Scope::WebhooksAdmin),
1019 ("delete_webhook", Scope::WebhooksAdmin),
1020 ("ping_webhook", Scope::WebhooksAdmin),
1021 ("redeliver_webhook", Scope::WebhooksAdmin),
1022 // Secrets and variables.
1023 ("list_actions_secrets", Scope::SecretsRead),
1024 ("list_actions_variables", Scope::SecretsRead),
1025 ("set_actions_secret", Scope::SecretsAdmin),
1026 ("delete_actions_secret", Scope::SecretsAdmin),
1027 ("set_actions_variable", Scope::SecretsAdmin),
1028 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1029 // Self-hosted runners.
1030 ("list_runners", Scope::RunnersRead),
1031 ("list_runner_groups", Scope::RunnersRead),
1032 ("get_runner_settings", Scope::RunnersRead),
1033 ("create_runner_registration_token", Scope::RunnersAdmin),
1034 ("remove_runner", Scope::RunnersAdmin),
1035 ("create_runner_group", Scope::RunnersAdmin),
1036 ("update_runner_group", Scope::RunnersAdmin),
1037 ("delete_runner_group", Scope::RunnersAdmin),
1038 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1039 // Packages: reading them, their versions and who may use them needs
1040 // `packages:read`; changing their settings, access and Manage Actions
1041 // access `packages:write` (and the Admin role on the package, which the
1042 // packages service checks); deleting and restoring packages and
1043 // versions `packages:delete`, as the registries' own deletes do.
1044 ("list_packages", Scope::PackagesRead),
1045 ("get_package", Scope::PackagesRead),
1046 ("list_package_versions", Scope::PackagesRead),
1047 ("get_package_version", Scope::PackagesRead),
1048 ("list_package_access", Scope::PackagesRead),
1049 ("list_package_actions_access", Scope::PackagesRead),
1050 ("update_package", Scope::PackagesWrite),
1051 ("link_package", Scope::PackagesWrite),
1052 ("unlink_package", Scope::PackagesWrite),
1053 ("set_package_access", Scope::PackagesWrite),
1054 ("remove_package_access", Scope::PackagesWrite),
1055 ("set_package_actions_access", Scope::PackagesWrite),
1056 ("remove_package_actions_access", Scope::PackagesWrite),
1057 ("delete_package", Scope::PackagesDelete),
1058 ("restore_package", Scope::PackagesDelete),
1059 ("delete_package_version", Scope::PackagesDelete),
1060 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1061 // The AI Gateway. Sending a request to a model needs `models:write`,
1062 // checked by the model proxy at models.g1t.sh, not here.
1063 ("list_gateway_requests", Scope::ModelsRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1064];
1065
1066/// Operations any token may use: saying who it is.
1067pub const NO_SCOPE: &[&str] = &["whoami"];
1068
1069/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1070/// operation in neither list needs full access.
1071pub fn scope_for(operation: &str) -> Option<Scope> {
1072 OPERATIONS
1073 .iter()
1074 .find(|(name, _)| *name == operation)
1075 .map(|(_, scope)| *scope)
1076}
1077
1078/// What a token needs for `operation` with this input beyond its own
1079/// scope: starting agents from an operation that can, and making a
1080/// repository public or private.
1081pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1082 let mut extra = Vec::new();
1083 let assigns = input["assign"].as_bool() == Some(true)
1084 || input["agent"].as_bool() == Some(true)
1085 || input["assign_agent"].as_bool() == Some(true);
1086 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1087 extra.push(Scope::AgentsRun);
1088 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1089 // Fixing an alert opens an issue and puts g1t on it.
1090 if operation == "fix_security_alert" {
1091 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1092 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1093 // Opening the issue an agent is put on.
1094 if operation == "delegate" {
1095 extra.push(Scope::IssuesWrite);
1096 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1097 // A workspace's base permission is who has access.
1098 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1099 extra.push(Scope::AccessAdmin);
1100 }
Merge checks: statuses and check runs on every commit1101 // Asking a g1t Actions job or run to run again reruns its workflow.
1102 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1103 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1104 {
1105 extra.push(Scope::WorkflowsWrite);
1106 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1107 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())) {
1108 extra.push(Scope::RepoAdmin);
1109 }
1110 extra
1111}
1112
1113/// The scopes a call needs, its own first.
1114pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1115 scope_for(operation)
1116 .into_iter()
1117 .chain(extra_scopes(operation, input))
1118 .collect()
1119}
1120
1121/// Whether `access` may use `operation` with `input`. The person's (or
1122/// workspace's) role is checked after this, by the service that owns what
1123/// was asked about.
1124pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1125 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1126 // A workflow job may open or approve pull requests only where its
1127 // repository and workspace let it, as on GitHub.
1128 if let Some(job) = &access.job
1129 && !job.pull_requests
1130 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1131 {
1132 return Decision::deny(
1133 "token:pull-requests",
1134 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1135 );
1136 }
1137 if let Some(only) = access.repo.as_deref()
1138 && !NO_SCOPE.contains(&operation)
1139 {
1140 match input["repo"].as_str() {
1141 Some(repo) if access.reaches(repo) => {}
1142 Some(repo) => {
1143 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1144 }
1145 None => {
1146 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1147 }
1148 }
1149 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap1150 // A fine-grained token only reads outside its resource owner: public
1151 // repositories, as anyone may. Inside it, its repository selection is
1152 // checked with its owner's role (`access::granted`).
1153 if let Some(reach) = &access.fine_grained
1154 && let Some(repo) = input["repo"].as_str()
1155 && !NO_SCOPE.contains(&operation)
1156 {
1157 let namespace = repo.split('/').next().unwrap_or_default();
1158 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1159 if changes && !reach.owned_by(namespace) {
1160 let owner = reach.workspace.as_deref().map_or_else(|| "your account".to_owned(), |workspace| format!("the workspace {workspace}"));
1161 return Decision::deny(
1162 "token:resource-owner",
1163 format!("This fine-grained token's resource owner is {owner}: it can only read public repositories elsewhere, and {repo} is not its owner's."),
1164 );
1165 }
1166 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1167 if access.scopes.is_some() {
1168 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1169 if !known {
1170 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1171 }
1172 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1173 return Decision::deny(
1174 "token:scope",
1175 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1176 );
1177 }
1178 }
1179 Decision::allow(rule)
1180}
1181
Merge branch 'worktree-agent-a3abfcce648e87dca'1182/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1183/// for a workflow job's token or a deploy key in another repository,
1184/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1185/// the package registries ask this before [`decide_git`] and
1186/// [`decide_packages`].
1187pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1188 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1189 let why = if access.deploy_key.is_some() {
1190 format!("This deploy key is for {only}: it cannot reach {repo}.")
1191 } else {
1192 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1193 };
1194 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1195}
1196
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1197/// Whether a token may clone or fetch (`write` false), or push to (`write`
1198/// true), a repository with git. `public` is whether anyone may read it,
1199/// which needs no scope.
1200pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1201 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1202 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1203 if access.deploy_key.is_some() {
1204 return Decision::deny(
1205 "token:scope",
1206 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1207 );
1208 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1209 return Decision::deny(
1210 "token:scope",
1211 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1212 );
1213 }
1214 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1215}
1216
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1217/// Whether a token may pull (`Level::Read`), push or publish
1218/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1219/// whether anyone may pull the package, which needs no scope.
1220pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1221 let (needed, doing) = match level {
1222 Level::Read => (Scope::PackagesRead, "pull a private package"),
1223 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1224 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1225 };
1226 if !access.allows(needed) && !(level == Level::Read && public) {
1227 return Decision::deny(
1228 "token:scope",
1229 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1230 );
1231 }
1232 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1233}
1234
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238 use serde_json::json;
1239
1240 fn token(scopes: &[Scope]) -> TokenAccess {
1241 TokenAccess {
1242 token_id: "tok_1".to_owned(),
1243 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1244 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1245 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1246 ..TokenAccess::default()
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1247 }
1248 }
1249
1250 #[test]
1251 fn every_scope_reads_back_and_belongs_to_a_resource() {
1252 for scope in Scope::ALL {
1253 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1254 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1255 assert!(scope.includes(scope));
1256 }
1257 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1258 assert_eq!(Scope::parse("issues"), None);
1259 }
1260
1261 #[test]
1262 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1263 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1264 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1265 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1266 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1267 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1268 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1269 }
1270
1271 #[test]
1272 fn operations_are_listed_once_and_never_also_free() {
1273 let mut seen = std::collections::HashSet::new();
1274 for (name, _) in OPERATIONS {
1275 assert!(seen.insert(*name), "{name} twice");
1276 assert!(!NO_SCOPE.contains(name), "{name}");
1277 }
1278 }
1279
1280 #[test]
1281 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1282 assert_eq!(
1283 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1284 vec![Scope::RepoRead, Scope::IssuesWrite]
1285 );
1286 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1287 }
1288
1289 #[test]
1290 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1291 let scopes = oauth_default();
1292 assert!(scopes.contains(&Scope::IssuesWrite));
1293 assert!(scopes.contains(&Scope::PullRequestsWrite));
1294 assert!(scopes.contains(&Scope::AgentsRun));
1295 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1296 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1297 // Every read but the machines work runs on.
1298 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 step1299 }
1300 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1301 assert_eq!(Preset::Full.scopes(), None);
1302 }
1303
1304 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1305 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1306 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1307 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1308 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1309 }
1310 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1311 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1312 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1313 let reader = token(&[Scope::BillingRead]);
1314 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1315 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1316 }
1317
1318 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1319 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1320 // Reading the log is a read like any other.
1321 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1322 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1323 // Sending requests spends the workspace's AI credit: chosen on purpose.
1324 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1325 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1326 }
1327 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1328 assert!(!Scope::ModelsWrite.dangerous());
1329 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1330 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1331 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1332 }
1333
1334 #[test]
Merge checks: statuses and check runs on every commit1335 fn checks_are_reported_with_checks_write_which_ci_gets() {
1336 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1337 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1338 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1339 let ci = Preset::Ci.scopes().unwrap();
1340 assert!(ci.contains(&Scope::ChecksWrite));
1341 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1342 let reporter = token(&[Scope::ChecksWrite]);
1343 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1344 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1345 // A g1t Actions job runs again as its workflow does.
1346 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1347 assert!(refused.reason.unwrap().contains("workflows:write"));
1348 }
1349
1350 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1351 fn a_job_token_reaches_its_repository_only() {
1352 let job = TokenAccess {
1353 repo: Some("acme/web".into()),
1354 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1355 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1356 };
1357 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1358 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1359 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1360 assert!(!elsewhere.allowed);
1361 assert_eq!(elsewhere.rule, "token:repository");
1362 // Nothing beyond the one repository, a workspace's listing included.
1363 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1364 assert!(decide(&job, "whoami", &json!({})).allowed);
1365 // Its scopes still hold inside it.
1366 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1367 assert!(decide_repo(&job, "acme/web").is_none());
1368 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1369 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1370 // Opening and approving pull requests is off unless allowed.
1371 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1372 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1373 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1374 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1375 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1376 }
1377
1378 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1379 fn workflow_files_need_their_own_scope() {
1380 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1381 assert!(is_workflow_file(path), "{path}");
1382 }
1383 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1384 assert!(!is_workflow_file(path), "{path}");
1385 }
1386 let code = token(&[Scope::CodeWrite]);
1387 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1388 assert_eq!(refused.rule, "token:workflows");
1389 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1390 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1391 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1392 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1393 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1394 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1395 // A job's token never may, as GITHUB_TOKEN never may.
1396 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1397 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1398 // Nothing in a preset changes workflow files but full access.
1399 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1400 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1401 }
1402 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1403 }
1404
1405 #[test]
1406 fn a_fine_grained_token_only_reads_outside_its_resource_owner() {
1407 let reach = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1408 let fine = TokenAccess { fine_grained: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
1409 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1410 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1411 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1412 assert_eq!(elsewhere.rule, "token:resource-owner");
1413 assert!(elsewhere.reason.unwrap().contains("acme"));
1414 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1415 assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
1416 let mine = TokenAccess { fine_grained: Some(FineGrainedReach::default()), ..token(&[Scope::IssuesWrite]) };
1417 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1418 assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
1419 let selected = FineGrainedReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
1420 assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
1421 let public = FineGrainedReach { repositories: RepositorySelection::Public, ..selected.clone() };
1422 assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
1423 assert!(token(&[]).covers_repo("rep_1", "anything"), "a classic token's reach is its owner's");
1424 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1425 }
1426
1427 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1428 fn a_legacy_token_can_do_everything() {
1429 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1430 for (operation, _) in OPERATIONS {
1431 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1432 }
1433 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1434 }
1435
1436 #[test]
1437 fn a_missing_scope_is_named() {
1438 let read = token(&[Scope::IssuesRead]);
1439 assert!(decide(&read, "get_issue", &json!({})).allowed);
1440 assert!(decide(&read, "whoami", &json!({})).allowed);
1441 let refused = decide(&read, "create_issue", &json!({}));
1442 assert!(!refused.allowed);
1443 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1444 // An operation the table does not know needs full access.
1445 assert!(!decide(&read, "something_new", &json!({})).allowed);
1446 }
1447
1448 #[test]
1449 fn starting_agents_from_another_operation_needs_agents_run() {
1450 let writer = token(&[Scope::IssuesWrite]);
1451 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1452 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1453 assert!(refused.reason.unwrap().contains("agents:run"));
1454 let maintainer = token(&[Scope::RepoWrite]);
1455 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1456 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1457 }
1458
1459 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1460 fn a_workspaces_base_permission_needs_access_admin_too() {
1461 let admin = token(&[Scope::WorkspaceAdmin]);
1462 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1463 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1464 assert!(refused.reason.unwrap().contains("access:admin"));
1465 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1466 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1467 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1468 }
1469
1470 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1471 fn delegating_needs_both_agents_and_issues() {
1472 let agents = token(&[Scope::AgentsRun]);
1473 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1474 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1475 assert!(decide(&both, "delegate", &json!({})).allowed);
1476 }
1477
1478 #[test]
1479 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1480 let reader = token(&[Scope::CodeRead]);
1481 assert!(decide_git(&reader, false, false).allowed);
1482 let refused = decide_git(&reader, true, false);
1483 assert!(!refused.allowed);
1484 assert!(refused.reason.unwrap().contains("code:write"));
1485 let issues = token(&[Scope::IssuesWrite]);
1486 assert!(!decide_git(&issues, false, false).allowed);
1487 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1488 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1489 let writer = token(&[Scope::CodeWrite]);
1490 assert!(decide_git(&writer, true, false).allowed);
1491 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1492 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1493 }
1494
1495 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1496 fn packages_need_their_own_scopes_and_public_pulls_none() {
1497 let reader = token(&[Scope::PackagesRead]);
1498 assert!(decide_packages(&reader, Level::Read, false).allowed);
1499 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1500 let code = token(&[Scope::CodeWrite]);
1501 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1502 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1503 let writer = token(&[Scope::PackagesWrite]);
1504 assert!(decide_packages(&writer, Level::Write, false).allowed);
1505 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1506 let refused = decide_packages(&writer, Level::Delete, false);
1507 assert!(refused.reason.unwrap().contains("packages:delete"));
1508 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1509 assert!(Scope::PackagesDelete.dangerous());
1510 // Tokens made before these scopes, and full-access ones, keep working.
1511 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1512 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1513 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1514 }
1515
1516 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1517 fn token_access_travels_as_json() {
1518 let access = token(&[Scope::IssuesRead]);
1519 let wire = serde_json::to_value(&access).unwrap();
1520 assert_eq!(wire["scopes"], json!(["issues:read"]));
1521 assert!(wire.get("resources").is_none());
1522 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1523 assert_eq!(back, access);
1524 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1525 assert!(full.is_full());
1526 // A reach written by an older version is ignored: a token reaches
1527 // whatever its owner can.
1528 let older: TokenAccess = serde_json::from_value(json!({
1529 "token_id": "tok_1",
1530 "scopes": ["issues:read"],
1531 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1532 }))
1533 .unwrap();
1534 assert_eq!(older, access);
1535 }
1536
1537 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1538 /// lists the same scopes in the same order, the same operations with
1539 /// the same scopes, and the same presets.
1540 #[test]
1541 fn the_typescript_mirror_has_the_same_table() {
1542 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1543 let section = |start: &str| {
1544 ts.split_once(start)
1545 .and_then(|(_, rest)| rest.split_once("] as const"))
1546 .map(|(table, _)| table)
1547 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1548 };
1549 let scopes: Vec<&str> = section("export const SCOPES = [")
1550 .lines()
1551 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
1552 .collect();
1553 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
1554 assert_eq!(scopes, expected);
1555 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1556 .lines()
1557 .filter_map(|line| {
1558 let mut quoted = line.split('"').skip(1).step_by(2);
1559 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1560 })
1561 .collect();
1562 let expected: Vec<(String, String)> = OPERATIONS
1563 .iter()
1564 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1565 .collect();
1566 assert_eq!(operations, expected);
1567 for preset in Preset::ALL {
1568 let list = section(&format!("{}: [", preset.as_str()));
1569 let mirrored: Vec<&str> = list
1570 .split(',')
1571 .map(|item| item.trim().trim_matches('"'))
1572 .filter(|item| !item.is_empty())
1573 .collect();
1574 let expected: Vec<&str> = preset
1575 .scopes()
1576 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1577 .unwrap_or_else(|| vec!["*"]);
1578 assert_eq!(mirrored, expected, "{}", preset.as_str());
1579 }
1580 }
1581}

This file's history is long; its oldest lines are credited to the oldest commit read.