Skip to content
1,736 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
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers413/// Where a resource sits on the token form, and which tokens may hold it.
414#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
415#[serde(rename_all = "snake_case")]
416pub enum ResourceGroup {
417 /// About repositories: what they hold and how they are run.
418 Repository,
419 /// About a workspace itself.
420 Workspace,
421 /// About the person: only a personal token may hold these.
422 Account,
423}
424
425impl ResourceGroup {
426 pub const ALL: [ResourceGroup; 3] = [ResourceGroup::Repository, ResourceGroup::Workspace, ResourceGroup::Account];
427
428 pub fn as_str(self) -> &'static str {
429 match self {
430 ResourceGroup::Repository => "repository",
431 ResourceGroup::Workspace => "workspace",
432 ResourceGroup::Account => "account",
433 }
434 }
435}
436
437impl Resource {
438 pub fn group(self) -> ResourceGroup {
439 match self {
440 Resource::Account | Resource::Notifications => ResourceGroup::Account,
441 Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models => ResourceGroup::Workspace,
442 _ => ResourceGroup::Repository,
443 }
444 }
445
446 pub fn parse(text: &str) -> Option<Resource> {
447 let text = text.trim().to_ascii_lowercase();
448 Resource::ALL.into_iter().find(|resource| resource.as_str() == text)
449 }
450
451 /// Its scopes, least first.
452 pub fn scopes(self) -> Vec<Scope> {
453 Scope::ALL.into_iter().filter(|scope| scope.resource() == self).collect()
454 }
455}
456
457// --- Permissions --------------------------------------------------------------
458//
459// A token's permissions are its scopes read per resource: each resource
460// at none or one level (`{"issues": "write", "repo": "read"}`). A level
461// includes the ones below it, so the highest scope held of each resource
462// says everything; that is what a token stores. Personal tokens and a
463// workspace's own tokens are made, shown and checked this way alike.
464
465/// The highest scope of each resource held, in table order: the fewest
466/// scopes that give the same access, as tokens store them.
467pub fn top_scopes(scopes: &[Scope]) -> Vec<Scope> {
468 let mut top: Vec<Scope> = Vec::new();
469 for resource in Resource::ALL {
470 if let Some(best) = scopes.iter().filter(|scope| scope.resource() == resource).max_by_key(|scope| scope.level()) {
471 top.push(*best);
472 }
473 }
474 normalize(&mut top);
475 top
476}
477
478/// Every resource at its highest level: all a token can be given.
479pub fn everything() -> Vec<Scope> {
480 top_scopes(&Scope::ALL)
481}
482
483/// Scopes as permissions: each resource held, by name, at its highest
484/// level held.
485pub fn permissions_of(scopes: &[Scope]) -> std::collections::BTreeMap<String, String> {
486 top_scopes(scopes)
487 .into_iter()
488 .map(|scope| (scope.resource().as_str().to_owned(), scope.level().as_str().to_owned()))
489 .collect()
490}
491
492/// Permissions as asked for (`{"issues": "write"}`, `none` or empty left
493/// out) into the scopes a token stores, or why they cannot be. `personal`
494/// is whether the token is a person's: only theirs may hold account ones.
495pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
496 let mut scopes = Vec::new();
497 for (name, level) in asked {
498 let Some(resource) = Resource::parse(name) else {
499 return Err(format!("There is no permission called {name}."));
500 };
501 let level = level.trim().to_ascii_lowercase();
502 if level.is_empty() || level == "none" {
503 continue;
504 }
505 let Some(scope) = Scope::parse(&format!("{}:{level}", resource.as_str())) else {
506 let levels: Vec<&str> = resource.scopes().iter().map(|scope| scope.level().as_str()).collect();
507 return Err(format!("{} is none or {}, not {level}.", resource.as_str(), levels.join(", ")));
508 };
509 if resource.group() == ResourceGroup::Account && !personal {
510 return Err(format!("{} is about a person's account: a workspace's token cannot hold it.", resource.as_str()));
511 }
512 scopes.push(scope);
513 }
514 Ok(top_scopes(&scopes))
515}
516
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step517/// What a token stores for full access, which is not a scope a client can
518/// ask for by name.
519pub const FULL_ACCESS: &str = "*";
520
521/// Starting points for choosing scopes.
522#[derive(Clone, Copy, Debug, PartialEq, Eq)]
523pub enum Preset {
524 ReadOnly,
525 Agent,
526 Ci,
527 Full,
528}
529
530impl Preset {
531 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
532
533 pub fn as_str(self) -> &'static str {
534 match self {
535 Preset::ReadOnly => "read_only",
536 Preset::Agent => "agent",
537 Preset::Ci => "ci",
538 Preset::Full => "full",
539 }
540 }
541
542 pub fn label(self) -> &'static str {
543 match self {
544 Preset::ReadOnly => "Read only",
545 Preset::Agent => "Agent",
546 Preset::Ci => "CI",
547 Preset::Full => "Full access",
548 }
549 }
550
551 /// Its scopes; `None` for full access.
552 pub fn scopes(self) -> Option<Vec<Scope>> {
553 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read);
554 match self {
555 Preset::ReadOnly => Some(reads().collect()),
556 Preset::Agent => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents557 // Not the machines work runs on: an agent has no business
558 // knowing a workspace's own runners.
559 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes560 // And answering what needs the person it works for: marking
561 // it done, subscribing, watching.
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step562 scopes.extend([
563 Scope::CodeWrite,
564 Scope::IssuesWrite,
565 Scope::PullRequestsWrite,
566 Scope::AgentsRun,
567 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes568 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step569 ]);
570 normalize(&mut scopes);
571 Some(scopes)
572 }
573 Preset::Ci => Some(vec![
574 Scope::RepoRead,
575 Scope::CodeRead,
576 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member577 Scope::PackagesRead,
578 Scope::PackagesWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step579 Scope::WorkflowsRead,
580 Scope::WorkflowsWrite,
Merge checks: statuses and check runs on every commit581 Scope::ChecksRead,
582 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97583 Scope::DeploymentsRead,
584 Scope::DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step585 ]),
586 Preset::Full => None,
587 }
588 }
589}
590
591/// What an OAuth client gets when it asks for nothing in particular: the
592/// agent preset. Never an admin scope.
593pub fn oauth_default() -> Vec<Scope> {
594 Preset::Agent.scopes().unwrap_or_default()
595}
596
597/// Set on a [`crate::User`] resolved from an access token: what the token
598/// may do. Absent on a signed-in session, which may do whatever its person
599/// can.
600#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
601pub struct TokenAccess {
602 /// The token's id, as audit entries and errors name it.
603 #[serde(default)]
604 pub token_id: String,
605 /// Its scopes, as `resource:level`. Absent: full access, everything the
606 /// person (or workspace) can do.
607 #[serde(default, skip_serializing_if = "Option::is_none")]
608 pub scopes: Option<Vec<String>>,
609 /// Made before tokens had scopes: full access until someone narrows it.
610 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
611 pub legacy: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'612 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
613 /// reaches, as `owner/name`. Every other is refused, whatever its owner
614 /// could reach.
615 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub repo: Option<String>,
617 /// Set on a workflow job's token: the run and job it was made for. The
618 /// audit log records its changes as that job's, and what it changes
619 /// starts no workflows (only `workflow_dispatch` and
620 /// `repository_dispatch` do), so a workflow cannot set itself off.
621 #[serde(default, skip_serializing_if = "Option::is_none")]
622 pub job: Option<JobToken>,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens623 /// The token's name, as its owner gave it, so a log can say which
624 /// token made a request. Absent where whoever resolved it did not say.
625 #[serde(default, skip_serializing_if = "Option::is_none")]
626 pub name: Option<String>,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers627 /// Set on a token narrowed to less than its owner can reach: one
628 /// workspace (all, selected or none of its private repositories), or
629 /// none at all (its owner's account and public repositories). Absent
630 /// on a token that reaches every workspace its owner can.
631 ///
632 /// The wire key is `fine_grained`, kept from before tokens were one
633 /// kind, so services deployed at different moments agree on it.
634 #[serde(rename = "fine_grained", default, skip_serializing_if = "Option::is_none")]
635 pub reach: Option<TokenReach>,
Token reach: workflow_files scope, fine-grained reach, workspace token cap636 /// Set on a workspace's own token that an owner gave Admin when making
637 /// it. Without it a workspace's token has Write on the workspace's
638 /// repositories, as a member would (see [`crate::access`]).
639 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
640 pub admin: bool,
641 /// Set on what a repository's deploy key resolves to: the key's id. Its
642 /// `repo` is the one repository it reaches.
643 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub deploy_key: Option<String>,
645}
646
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers647/// Which repositories a token reaches in the workspace it is made for.
Token reach: workflow_files scope, fine-grained reach, workspace token cap648#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
649#[serde(rename_all = "snake_case")]
650pub enum RepositorySelection {
651 /// Every repository of the workspace, ones made later included.
652 #[default]
653 All,
654 /// The repositories chosen, by id.
655 Selected,
656 /// None of the workspace's private repositories: public repositories,
657 /// read-only, and the workspace's own settings its permissions allow.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers658 /// With no workspace: the owner's account and public repositories only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap659 Public,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step660}
661
Token reach: workflow_files scope, fine-grained reach, workspace token cap662impl RepositorySelection {
663 pub fn as_str(self) -> &'static str {
664 match self {
665 RepositorySelection::All => "all",
666 RepositorySelection::Selected => "selected",
667 RepositorySelection::Public => "public",
668 }
669 }
670
671 pub fn parse(text: &str) -> Option<RepositorySelection> {
672 match text.trim().to_ascii_lowercase().as_str() {
673 "all" => Some(RepositorySelection::All),
674 "selected" => Some(RepositorySelection::Selected),
675 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
676 _ => None,
677 }
678 }
679}
680
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers681/// What a narrowed token reaches, as identity resolves it on each use.
Token reach: workflow_files scope, fine-grained reach, workspace token cap682#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers683pub struct TokenReach {
684 /// The workspace whose repositories and settings it reaches, by slug as
685 /// it is now. Absent: its owner's account only, with public
686 /// repositories read-only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub workspace: Option<String>,
689 #[serde(default)]
690 pub repositories: RepositorySelection,
691 /// With [`RepositorySelection::Selected`]: the repositories' ids.
692 #[serde(default, skip_serializing_if = "Vec::is_empty")]
693 pub repo_ids: Vec<String>,
694}
695
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers696impl TokenReach {
Token reach: workflow_files scope, fine-grained reach, workspace token cap697 /// Whether it reaches the repository with this id in the workspace
698 /// `namespace` for more than what anyone may do with a public one.
699 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
700 let Some(workspace) = self.workspace.as_deref() else {
701 return false;
702 };
703 if !workspace.eq_ignore_ascii_case(namespace) {
704 return false;
705 }
706 match self.repositories {
707 RepositorySelection::All => true,
708 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
709 RepositorySelection::Public => false,
710 }
711 }
712
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers713 /// Whether it is made for the workspace `slug`.
Token reach: workflow_files scope, fine-grained reach, workspace token cap714 pub fn owned_by(&self, slug: &str) -> bool {
715 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
716 }
717}
718
719/// Where workflow files live. Adding, changing or deleting a file under
720/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
721/// token: what GitHub's `workflow` scope and `workflows` permission do.
722pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
723
724/// Whether `path` is a workflow file, or a file in one's directory.
725pub fn is_workflow_file(path: &str) -> bool {
726 let path = path.trim_start_matches('/');
727 WORKFLOW_DIRS.iter().any(|dir| {
728 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
729 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
730}
731
732/// Whether a token may add, change or delete the files at `paths`: a
733/// refusal naming the first workflow file it may not touch, else `None`.
734/// A signed-in person (no token) is never refused here; their role decides.
735pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
736 let access = access?;
737 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
738 return None;
739 }
740 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
741 let why = if access.job.is_some() {
742 "a workflow job's token can never add or change workflow files".to_owned()
743 } else {
744 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
745 };
746 Some(Decision::deny(
747 "token:workflows",
748 format!("This access token cannot change the workflow file {path}: {why}."),
749 ))
750}
751
Merge branch 'worktree-agent-a3abfcce648e87dca'752/// The workflow job a token was made for.
753#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
754pub struct JobToken {
755 /// The run, `run_…`.
756 pub run_id: String,
757 /// The job, `job_…`.
758 pub job_id: String,
759 /// Whether it may open pull requests and approve them, by its
760 /// repository's and workspace's choice ("Allow g1t Actions to create and
761 /// approve pull requests"). Off unless chosen.
762 #[serde(default)]
763 pub pull_requests: bool,
764}
765
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step766impl TokenAccess {
767 /// Full access to everything: the access tokens made before scopes had.
768 pub fn full() -> Self {
769 TokenAccess::default()
770 }
771
Merge branch 'worktree-agent-a3abfcce648e87dca'772 /// Whether it may reach the repository `owner/name`: every token but a
773 /// workflow job's, which reaches its own repository only.
774 pub fn reaches(&self, repo: &str) -> bool {
775 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
776 }
777
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step778 pub fn is_full(&self) -> bool {
779 self.scopes.is_none()
780 }
781
782 /// The scopes it holds, or `None` for full access.
783 pub fn granted(&self) -> Option<Vec<Scope>> {
784 self.scopes
785 .as_ref()
786 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
787 }
788
789 pub fn allows(&self, needed: Scope) -> bool {
790 match self.granted() {
791 None => true,
792 Some(granted) => granted.iter().any(|held| held.includes(needed)),
793 }
794 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap795
796 /// Whether it reaches the repository with this id in `namespace` for
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers797 /// more than reading a public one: every token but a narrowed one
798 /// outside its workspace or repository selection. Its owner's role
Token reach: workflow_files scope, fine-grained reach, workspace token cap799 /// still decides; see [`crate::access`].
800 pub fn covers_repo(&self, repo_id: &str, namespace: &str) -> bool {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers801 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
Token reach: workflow_files scope, fine-grained reach, workspace token cap802 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step803}
804
805/// Every operation of the API and MCP server, with the scope it needs. An
806/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
807/// its operations is in exactly one of the two.
808pub const OPERATIONS: &[(&str, Scope)] = &[
809 // Your account.
810 ("list_emails", Scope::AccountRead),
811 ("add_email", Scope::AccountWrite),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)812 ("confirm_email", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step813 ("remove_email", Scope::AccountWrite),
814 ("update_email_settings", Scope::AccountWrite),
815 ("list_invites", Scope::AccountRead),
816 ("create_invite", Scope::AccountWrite),
817 ("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)818 ("list_invitations", Scope::AccountRead),
819 ("accept_invitation", Scope::AccountWrite),
820 ("decline_invitation", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step821 ("list_my_repo_invitations", Scope::AccountRead),
822 ("accept_repo_invitation", Scope::AccountWrite),
823 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP824 // Your pinned projects: a preference of your account.
825 ("list_pinned_projects", Scope::AccountRead),
826 ("pin_project", Scope::AccountWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97827 // Your stars: a preference of your account.
828 ("list_starred", Scope::AccountRead),
829 ("check_starred", Scope::AccountRead),
830 ("star_repo", Scope::AccountWrite),
831 ("unstar_repo", Scope::AccountWrite),
API: pinned projects over REST and MCP832 ("unpin_project", Scope::AccountWrite),
833 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes834 // Your inbox: notifications, subscriptions and watching.
835 ("list_notifications", Scope::NotificationsRead),
836 ("get_notification_thread", Scope::NotificationsRead),
837 ("get_thread_subscription", Scope::NotificationsRead),
838 ("get_repo_subscription", Scope::NotificationsRead),
839 ("list_watched_repos", Scope::NotificationsRead),
840 ("mark_notifications_read", Scope::NotificationsWrite),
841 ("mark_thread_read", Scope::NotificationsWrite),
842 ("mark_thread_done", Scope::NotificationsWrite),
843 ("save_thread", Scope::NotificationsWrite),
844 ("snooze_thread", Scope::NotificationsWrite),
845 ("set_thread_subscription", Scope::NotificationsWrite),
846 ("delete_thread_subscription", Scope::NotificationsWrite),
847 ("set_repo_subscription", Scope::NotificationsWrite),
848 ("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 step849 // Workspaces, their invites and integrations.
850 ("create_workspace", Scope::WorkspaceAdmin),
851 ("delete_workspace", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-ad7c6d88d93adc817'852 ("get_workspace", Scope::WorkspaceRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily853 ("update_workspace", Scope::WorkspaceAdmin),
Merge main (membership, two-factor, GitHub repo roles) into tokens854 // Its members, and who owns it.
855 ("list_members", Scope::WorkspaceRead),
856 ("update_member", Scope::WorkspaceAdmin),
857 ("remove_member", Scope::WorkspaceAdmin),
858 ("transfer_ownership", Scope::WorkspaceAdmin),
859 ("leave_workspace", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step860 ("list_workspace_invites", Scope::WorkspaceRead),
861 ("invite_member", Scope::WorkspaceAdmin),
862 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
863 ("list_integrations", Scope::WorkspaceRead),
864 ("connect_integration", Scope::WorkspaceAdmin),
AI Gateway: OpenAI's format, open models, and your own providers865 ("update_integration", Scope::WorkspaceAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step866 ("disconnect_integration", Scope::WorkspaceAdmin),
867 ("test_integration", Scope::WorkspaceAdmin),
868 ("get_model_routes", Scope::WorkspaceRead),
869 ("set_model_routes", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar870 // Teams: reading them, and managing them. A team's role on a
871 // repository is who has access.
872 ("list_teams", Scope::WorkspaceRead),
873 ("get_team", Scope::WorkspaceRead),
874 ("list_team_members", Scope::WorkspaceRead),
875 ("list_child_teams", Scope::WorkspaceRead),
876 ("list_team_repos", Scope::WorkspaceRead),
877 ("list_user_teams", Scope::WorkspaceRead),
878 ("create_team", Scope::WorkspaceAdmin),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge879 ("list_workspace_rulesets", Scope::WorkspaceRead),
880 ("get_workspace_ruleset", Scope::WorkspaceRead),
881 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
882 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
883 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
884 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar885 ("update_team", Scope::WorkspaceAdmin),
886 ("delete_team", Scope::WorkspaceAdmin),
887 ("set_team_member", Scope::WorkspaceAdmin),
888 ("remove_team_member", Scope::WorkspaceAdmin),
889 ("set_team_review_assignment", Scope::WorkspaceAdmin),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit890 // A workspace's billing: usage, budget, AI credit and invoices.
891 ("get_usage", Scope::BillingRead),
892 ("get_budget", Scope::BillingRead),
893 ("get_ai_credit", Scope::BillingRead),
894 ("list_invoices", Scope::BillingRead),
895 ("get_billing_details", Scope::BillingRead),
896 ("set_budget", Scope::BillingWrite),
897 ("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 step898 // Repositories.
899 ("list_repos", Scope::RepoRead),
900 ("get_repo", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97901 // Projects follow their repositories.
902 ("list_projects", Scope::RepoRead),
903 ("get_project", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step904 ("search", Scope::RepoRead),
905 ("list_events", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97906 // What the default branch says about a repository, who starred it, and
907 // its releases.
908 ("get_languages", Scope::RepoRead),
909 ("list_contributors", Scope::RepoRead),
910 ("get_license", Scope::RepoRead),
911 ("list_stargazers", Scope::RepoRead),
912 ("list_releases", Scope::RepoRead),
913 ("get_latest_release", Scope::RepoRead),
914 ("get_release_by_tag", Scope::RepoRead),
915 ("get_release", Scope::RepoRead),
916 ("create_release", Scope::RepoWrite),
917 ("update_release", Scope::RepoWrite),
918 ("delete_release", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step919 ("list_labels", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar920 ("list_milestones", Scope::RepoRead),
921 ("get_milestone", Scope::RepoRead),
922 ("create_label", Scope::IssuesWrite),
923 ("update_label", Scope::IssuesWrite),
924 ("delete_label", Scope::IssuesWrite),
925 ("add_default_labels", Scope::IssuesWrite),
926 ("create_milestone", Scope::IssuesWrite),
927 ("update_milestone", Scope::IssuesWrite),
928 ("delete_milestone", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step929 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents930 ("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 step931 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily932 ("list_security_alerts", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar933 ("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 step934 ("create_repo", Scope::RepoWrite),
935 ("update_repo", Scope::RepoWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97936 ("update_project", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step937 ("update_repo_settings", Scope::RepoWrite),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge938 // Rulesets: reading them is reading the repository; changing them
939 // changes what everyone, agents included, may do, so it is admin.
940 ("list_repo_rulesets", Scope::RepoRead),
941 ("get_repo_ruleset", Scope::RepoRead),
942 ("get_branch_rules", Scope::RepoRead),
943 ("list_rule_evaluations", Scope::RepoRead),
944 ("create_repo_ruleset", Scope::RepoAdmin),
945 ("update_repo_ruleset", Scope::RepoAdmin),
946 ("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 step947 ("rename_branch", Scope::RepoWrite),
948 ("rename_repo", Scope::RepoAdmin),
949 ("transfer_repo", Scope::RepoAdmin),
950 ("archive_repo", Scope::RepoAdmin),
951 ("unarchive_repo", Scope::RepoAdmin),
952 ("set_repo_visibility", Scope::RepoAdmin),
953 ("delete_repo", Scope::RepoAdmin),
954 ("restore_repo", Scope::RepoAdmin),
955 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily956 // A dismissed secret is let through push protection.
957 ("dismiss_security_alert", Scope::RepoAdmin),
958 ("reopen_security_alert", Scope::RepoAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar959 // The security suite: alerts, push protection, patterns, code
960 // scanning, the supply chain and settings.
961 ("list_secret_scanning_alerts", Scope::SecurityRead),
962 ("get_secret_scanning_alert", Scope::SecurityRead),
963 ("list_secret_scanning_locations", Scope::SecurityRead),
964 ("list_bypass_requests", Scope::SecurityRead),
965 ("list_custom_patterns", Scope::SecurityRead),
966 ("list_code_scanning_alerts", Scope::SecurityRead),
967 ("get_code_scanning_alert", Scope::SecurityRead),
968 ("list_code_scanning_analyses", Scope::SecurityRead),
969 ("get_sarif_upload", Scope::SecurityRead),
970 ("list_vulnerability_alerts", Scope::SecurityRead),
971 ("get_vulnerability_alert", Scope::SecurityRead),
972 ("get_dependency_graph", Scope::SecurityRead),
973 ("get_sbom", Scope::SecurityRead),
974 ("compare_dependencies", Scope::SecurityRead),
975 ("get_security_settings", Scope::SecurityRead),
976 ("get_workspace_security_settings", Scope::SecurityRead),
977 ("get_security_overview", Scope::SecurityRead),
978 ("update_secret_scanning_alert", Scope::SecurityWrite),
979 ("bypass_push_protection", Scope::SecurityWrite),
980 ("check_secret_validity", Scope::SecurityWrite),
981 ("review_bypass_request", Scope::SecurityWrite),
982 ("create_custom_pattern", Scope::SecurityWrite),
983 ("update_custom_pattern", Scope::SecurityWrite),
984 ("delete_custom_pattern", Scope::SecurityWrite),
985 ("dry_run_custom_pattern", Scope::SecurityWrite),
986 ("update_code_scanning_alert", Scope::SecurityWrite),
987 ("upload_sarif", Scope::SecurityWrite),
988 ("update_vulnerability_alert", Scope::SecurityWrite),
989 ("fix_security_alert", Scope::SecurityWrite),
990 ("update_security_settings", Scope::SecurityWrite),
991 ("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 step992 // Issues and plans.
993 ("list_issues", Scope::IssuesRead),
994 ("get_issue", Scope::IssuesRead),
995 ("get_plan", Scope::IssuesRead),
996 ("create_issue", Scope::IssuesWrite),
997 ("update_issue", Scope::IssuesWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar998 ("list_issue_labels", Scope::IssuesRead),
999 ("add_issue_labels", Scope::IssuesWrite),
1000 ("set_issue_labels", Scope::IssuesWrite),
1001 ("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 step1002 ("close_issue", Scope::IssuesWrite),
1003 ("reopen_issue", Scope::IssuesWrite),
1004 ("add_comment", Scope::IssuesWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1005 ("edit_comment", Scope::IssuesWrite),
1006 ("delete_comment", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1007 ("import_issue", Scope::IssuesWrite),
1008 ("apply_plan", Scope::IssuesWrite),
1009 // Pull requests.
1010 ("list_pull_requests", Scope::PullRequestsRead),
1011 ("get_pull_request", Scope::PullRequestsRead),
1012 ("get_pull_request_changes", Scope::PullRequestsRead),
1013 ("read_session", Scope::PullRequestsRead),
1014 ("get_merge_queue", Scope::PullRequestsRead),
1015 ("create_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1016 ("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 step1017 ("record_session", Scope::PullRequestsWrite),
1018 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1019 ("close_pull_request", Scope::PullRequestsWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1020 ("reopen_pull_request", Scope::PullRequestsWrite),
1021 ("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 step1022 ("review_pull_request", Scope::PullRequestsWrite),
1023 ("merge_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1024 ("request_reviewers", Scope::PullRequestsWrite),
1025 ("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 step1026 // g1t's agents.
1027 ("assign_issue", Scope::AgentsRun),
1028 ("delegate", Scope::AgentsRun),
1029 ("plan_work", Scope::AgentsRun),
1030 ("message_agent", Scope::AgentsRun),
1031 ("answer_message", Scope::AgentsRun),
1032 ("take_messages", Scope::AgentsRun),
1033 // Workflows.
1034 ("list_workflows", Scope::WorkflowsRead),
1035 ("list_workflow_runs", Scope::WorkflowsRead),
1036 ("get_workflow_run", Scope::WorkflowsRead),
1037 ("get_job_logs", Scope::WorkflowsRead),
1038 ("dispatch_workflow", Scope::WorkflowsWrite),
1039 ("cancel_workflow_run", Scope::WorkflowsWrite),
1040 ("rerun_workflow_run", Scope::WorkflowsWrite),
1041 ("update_workflow", Scope::WorkflowsWrite),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21042 ("list_artifacts", Scope::WorkflowsRead),
1043 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1044 ("get_artifact", Scope::WorkflowsRead),
1045 ("download_artifact", Scope::WorkflowsRead),
1046 ("get_artifact_retention", Scope::WorkflowsRead),
1047 ("delete_artifact", Scope::WorkflowsWrite),
1048 ("set_artifact_retention", Scope::WorkflowsWrite),
Merge checks: statuses and check runs on every commit1049 // Checks: statuses, check runs and check suites on commits.
1050 ("list_commit_statuses", Scope::ChecksRead),
1051 ("get_combined_status", Scope::ChecksRead),
1052 ("list_check_runs_for_ref", Scope::ChecksRead),
1053 ("get_check_run", Scope::ChecksRead),
1054 ("list_check_run_annotations", Scope::ChecksRead),
1055 ("list_check_suites_for_ref", Scope::ChecksRead),
1056 ("get_check_suite", Scope::ChecksRead),
1057 ("create_commit_status", Scope::ChecksWrite),
1058 ("create_check_run", Scope::ChecksWrite),
1059 ("update_check_run", Scope::ChecksWrite),
1060 ("rerequest_check_run", Scope::ChecksWrite),
1061 ("rerequest_check_suite", Scope::ChecksWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971062 // Deployments, wherever they run: reading them, and reporting them.
1063 ("list_deployments", Scope::DeploymentsRead),
1064 ("get_deployment", Scope::DeploymentsRead),
1065 ("list_deployment_statuses", Scope::DeploymentsRead),
1066 ("list_environments", Scope::DeploymentsRead),
1067 ("get_environment", Scope::DeploymentsRead),
1068 ("create_deployment", Scope::DeploymentsWrite),
1069 ("create_deployment_status", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a3abfcce648e87dca'1070 // What keeps runs safe: the runs environments hold and reviewing them,
1071 // approving a pull request's run, and a repository's own rules for
1072 // its environments and tokens, which are an admin's.
1073 ("get_pending_deployments", Scope::WorkflowsRead),
1074 ("review_pending_deployments", Scope::WorkflowsWrite),
1075 ("approve_workflow_run", Scope::WorkflowsWrite),
1076 ("get_workflow_permissions", Scope::RepoRead),
1077 ("get_fork_pr_approval", Scope::RepoRead),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1078 ("get_actions_access", Scope::RepoRead),
Merge branch 'worktree-agent-a3abfcce648e87dca'1079 ("update_environment", Scope::RepoAdmin),
1080 ("delete_environment", Scope::RepoAdmin),
1081 ("set_workflow_permissions", Scope::RepoAdmin),
1082 ("set_fork_pr_approval", Scope::RepoAdmin),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1083 ("set_actions_access", Scope::RepoAdmin),
Merge branch 'worktree-agent-a3abfcce648e87dca'1084 // Starting workflows from outside, as a push would.
1085 ("create_repository_dispatch", Scope::CodeWrite),
1086 // A workspace's policy for its repositories' tokens.
1087 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1088 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
API and MCP for a workspace's personal access token rules, members' tokens and approvals1089 // A workspace's rules for personal access tokens, and the members'
1090 // tokens that reach it: who has access.
1091 ("get_token_policy", Scope::WorkspaceRead),
1092 ("set_token_policy", Scope::WorkspaceAdmin),
1093 ("list_member_tokens", Scope::AccessRead),
1094 ("list_token_requests", Scope::AccessRead),
1095 ("review_token_request", Scope::AccessAdmin),
1096 ("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 step1097 // Memory and the context hub.
1098 ("recall", Scope::MemoryRead),
1099 ("search_context", Scope::MemoryRead),
1100 ("get_entity", Scope::MemoryRead),
1101 ("get_context", Scope::MemoryRead),
1102 ("remember", Scope::MemoryWrite),
1103 // Who has access.
1104 ("list_collaborators", Scope::AccessRead),
1105 ("get_collaborator_permission", Scope::AccessRead),
1106 ("list_repo_invitations", Scope::AccessRead),
1107 ("list_outside_collaborators", Scope::AccessRead),
1108 ("add_collaborator", Scope::AccessAdmin),
1109 ("update_collaborator", Scope::AccessAdmin),
1110 ("remove_collaborator", Scope::AccessAdmin),
1111 ("revoke_repo_invitation", Scope::AccessAdmin),
1112 ("set_base_permission", Scope::AccessAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1113 ("set_team_repo", Scope::AccessAdmin),
1114 ("remove_team_repo", Scope::AccessAdmin),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1115 // Deploy keys: each lets a machine reach one repository, so they
1116 // are part of who has access.
1117 ("list_deploy_keys", Scope::AccessRead),
1118 ("get_deploy_key", Scope::AccessRead),
1119 ("create_deploy_key", Scope::AccessAdmin),
1120 ("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 step1121 // Webhooks.
1122 ("list_webhooks", Scope::WebhooksRead),
1123 ("list_webhook_deliveries", Scope::WebhooksRead),
1124 ("create_webhook", Scope::WebhooksAdmin),
1125 ("update_webhook", Scope::WebhooksAdmin),
1126 ("delete_webhook", Scope::WebhooksAdmin),
1127 ("ping_webhook", Scope::WebhooksAdmin),
1128 ("redeliver_webhook", Scope::WebhooksAdmin),
1129 // Secrets and variables.
1130 ("list_actions_secrets", Scope::SecretsRead),
1131 ("list_actions_variables", Scope::SecretsRead),
1132 ("set_actions_secret", Scope::SecretsAdmin),
1133 ("delete_actions_secret", Scope::SecretsAdmin),
1134 ("set_actions_variable", Scope::SecretsAdmin),
1135 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1136 // Self-hosted runners.
1137 ("list_runners", Scope::RunnersRead),
1138 ("list_runner_groups", Scope::RunnersRead),
1139 ("get_runner_settings", Scope::RunnersRead),
1140 ("create_runner_registration_token", Scope::RunnersAdmin),
1141 ("remove_runner", Scope::RunnersAdmin),
1142 ("create_runner_group", Scope::RunnersAdmin),
1143 ("update_runner_group", Scope::RunnersAdmin),
1144 ("delete_runner_group", Scope::RunnersAdmin),
1145 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1146 // Packages: reading them, their versions and who may use them needs
1147 // `packages:read`; changing their settings, access and Manage Actions
1148 // access `packages:write` (and the Admin role on the package, which the
1149 // packages service checks); deleting and restoring packages and
1150 // versions `packages:delete`, as the registries' own deletes do.
1151 ("list_packages", Scope::PackagesRead),
1152 ("get_package", Scope::PackagesRead),
1153 ("list_package_versions", Scope::PackagesRead),
1154 ("get_package_version", Scope::PackagesRead),
1155 ("list_package_access", Scope::PackagesRead),
1156 ("list_package_actions_access", Scope::PackagesRead),
1157 ("update_package", Scope::PackagesWrite),
1158 ("link_package", Scope::PackagesWrite),
1159 ("unlink_package", Scope::PackagesWrite),
1160 ("set_package_access", Scope::PackagesWrite),
1161 ("remove_package_access", Scope::PackagesWrite),
1162 ("set_package_actions_access", Scope::PackagesWrite),
1163 ("remove_package_actions_access", Scope::PackagesWrite),
1164 ("delete_package", Scope::PackagesDelete),
1165 ("restore_package", Scope::PackagesDelete),
1166 ("delete_package_version", Scope::PackagesDelete),
1167 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1168 // The AI Gateway. Sending a request to a model needs `models:write`,
1169 // checked by the model proxy at models.g1t.sh, not here.
1170 ("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 step1171];
1172
1173/// Operations any token may use: saying who it is.
1174pub const NO_SCOPE: &[&str] = &["whoami"];
1175
1176/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1177/// operation in neither list needs full access.
1178pub fn scope_for(operation: &str) -> Option<Scope> {
1179 OPERATIONS
1180 .iter()
1181 .find(|(name, _)| *name == operation)
1182 .map(|(_, scope)| *scope)
1183}
1184
1185/// What a token needs for `operation` with this input beyond its own
1186/// scope: starting agents from an operation that can, and making a
1187/// repository public or private.
1188pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1189 let mut extra = Vec::new();
1190 let assigns = input["assign"].as_bool() == Some(true)
1191 || input["agent"].as_bool() == Some(true)
1192 || input["assign_agent"].as_bool() == Some(true);
1193 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1194 extra.push(Scope::AgentsRun);
1195 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1196 // Fixing an alert opens an issue and puts g1t on it.
1197 if operation == "fix_security_alert" {
1198 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1199 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1200 // Opening the issue an agent is put on.
1201 if operation == "delegate" {
1202 extra.push(Scope::IssuesWrite);
1203 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1204 // A workspace's base permission is who has access.
1205 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1206 extra.push(Scope::AccessAdmin);
1207 }
Merge checks: statuses and check runs on every commit1208 // Asking a g1t Actions job or run to run again reruns its workflow.
1209 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1210 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1211 {
1212 extra.push(Scope::WorkflowsWrite);
1213 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1214 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())) {
1215 extra.push(Scope::RepoAdmin);
1216 }
1217 extra
1218}
1219
1220/// The scopes a call needs, its own first.
1221pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1222 scope_for(operation)
1223 .into_iter()
1224 .chain(extra_scopes(operation, input))
1225 .collect()
1226}
1227
1228/// Whether `access` may use `operation` with `input`. The person's (or
1229/// workspace's) role is checked after this, by the service that owns what
1230/// was asked about.
1231pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1232 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1233 // A workflow job may open or approve pull requests only where its
1234 // repository and workspace let it, as on GitHub.
1235 if let Some(job) = &access.job
1236 && !job.pull_requests
1237 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1238 {
1239 return Decision::deny(
1240 "token:pull-requests",
1241 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1242 );
1243 }
1244 if let Some(only) = access.repo.as_deref()
1245 && !NO_SCOPE.contains(&operation)
1246 {
1247 match input["repo"].as_str() {
1248 Some(repo) if access.reaches(repo) => {}
1249 Some(repo) => {
1250 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1251 }
1252 None => {
1253 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1254 }
1255 }
1256 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1257 // A token made for one workspace (or none) only reads outside it:
1258 // public repositories, as anyone may. Inside it, its repository
1259 // selection is checked with its owner's role (`access::granted`).
1260 if let Some(reach) = &access.reach
Token reach: workflow_files scope, fine-grained reach, workspace token cap1261 && let Some(repo) = input["repo"].as_str()
1262 && !NO_SCOPE.contains(&operation)
1263 {
1264 let namespace = repo.split('/').next().unwrap_or_default();
1265 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1266 if changes && !reach.owned_by(namespace) {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1267 let made_for = reach.workspace.as_deref().map_or_else(|| "your account only".to_owned(), |workspace| format!("the workspace {workspace}"));
Token reach: workflow_files scope, fine-grained reach, workspace token cap1268 return Decision::deny(
1269 "token:resource-owner",
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1270 format!("This access token is made for {made_for}: elsewhere it can only read public repositories, and {repo} is not in its reach."),
Token reach: workflow_files scope, fine-grained reach, workspace token cap1271 );
1272 }
1273 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1274 if access.scopes.is_some() {
1275 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1276 if !known {
1277 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1278 }
1279 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1280 return Decision::deny(
1281 "token:scope",
1282 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1283 );
1284 }
1285 }
1286 Decision::allow(rule)
1287}
1288
Merge branch 'worktree-agent-a3abfcce648e87dca'1289/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1290/// for a workflow job's token or a deploy key in another repository,
1291/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1292/// the package registries ask this before [`decide_git`] and
1293/// [`decide_packages`].
1294pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1295 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1296 let why = if access.deploy_key.is_some() {
1297 format!("This deploy key is for {only}: it cannot reach {repo}.")
1298 } else {
1299 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1300 };
1301 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1302}
1303
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1304/// Whether a token may clone or fetch (`write` false), or push to (`write`
1305/// true), a repository with git. `public` is whether anyone may read it,
1306/// which needs no scope.
1307pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1308 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1309 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1310 if access.deploy_key.is_some() {
1311 return Decision::deny(
1312 "token:scope",
1313 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1314 );
1315 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1316 return Decision::deny(
1317 "token:scope",
1318 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1319 );
1320 }
1321 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1322}
1323
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1324/// Whether a token may pull (`Level::Read`), push or publish
1325/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1326/// whether anyone may pull the package, which needs no scope.
1327pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1328 let (needed, doing) = match level {
1329 Level::Read => (Scope::PackagesRead, "pull a private package"),
1330 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1331 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1332 };
1333 if !access.allows(needed) && !(level == Level::Read && public) {
1334 return Decision::deny(
1335 "token:scope",
1336 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1337 );
1338 }
1339 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1340}
1341
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1342#[cfg(test)]
1343mod tests {
1344 use super::*;
1345 use serde_json::json;
1346
1347 fn token(scopes: &[Scope]) -> TokenAccess {
1348 TokenAccess {
1349 token_id: "tok_1".to_owned(),
1350 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1351 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1352 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1353 ..TokenAccess::default()
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1354 }
1355 }
1356
1357 #[test]
1358 fn every_scope_reads_back_and_belongs_to_a_resource() {
1359 for scope in Scope::ALL {
1360 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1361 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1362 assert!(scope.includes(scope));
1363 }
1364 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1365 assert_eq!(Scope::parse("issues"), None);
1366 }
1367
1368 #[test]
1369 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1370 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1371 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1372 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1373 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1374 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1375 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1376 }
1377
1378 #[test]
1379 fn operations_are_listed_once_and_never_also_free() {
1380 let mut seen = std::collections::HashSet::new();
1381 for (name, _) in OPERATIONS {
1382 assert!(seen.insert(*name), "{name} twice");
1383 assert!(!NO_SCOPE.contains(name), "{name}");
1384 }
1385 }
1386
1387 #[test]
1388 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1389 assert_eq!(
1390 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1391 vec![Scope::RepoRead, Scope::IssuesWrite]
1392 );
1393 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1394 }
1395
1396 #[test]
1397 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1398 let scopes = oauth_default();
1399 assert!(scopes.contains(&Scope::IssuesWrite));
1400 assert!(scopes.contains(&Scope::PullRequestsWrite));
1401 assert!(scopes.contains(&Scope::AgentsRun));
1402 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
1403 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read) {
Fast pages, required checks on the branch, self-hosted runners, honest incidents1404 // Every read but the machines work runs on.
1405 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 step1406 }
1407 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1408 assert_eq!(Preset::Full.scopes(), None);
1409 }
1410
1411 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1412 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1413 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1414 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1415 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1416 }
1417 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1418 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1419 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1420 let reader = token(&[Scope::BillingRead]);
1421 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1422 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1423 }
1424
1425 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1426 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1427 // Reading the log is a read like any other.
1428 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1429 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1430 // Sending requests spends the workspace's AI credit: chosen on purpose.
1431 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1432 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1433 }
1434 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1435 assert!(!Scope::ModelsWrite.dangerous());
1436 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1437 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1438 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1439 }
1440
1441 #[test]
Merge checks: statuses and check runs on every commit1442 fn checks_are_reported_with_checks_write_which_ci_gets() {
1443 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1444 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1445 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1446 let ci = Preset::Ci.scopes().unwrap();
1447 assert!(ci.contains(&Scope::ChecksWrite));
1448 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1449 let reporter = token(&[Scope::ChecksWrite]);
1450 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1451 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1452 // A g1t Actions job runs again as its workflow does.
1453 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1454 assert!(refused.reason.unwrap().contains("workflows:write"));
1455 }
1456
1457 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1458 fn a_job_token_reaches_its_repository_only() {
1459 let job = TokenAccess {
1460 repo: Some("acme/web".into()),
1461 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1462 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1463 };
1464 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1465 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1466 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1467 assert!(!elsewhere.allowed);
1468 assert_eq!(elsewhere.rule, "token:repository");
1469 // Nothing beyond the one repository, a workspace's listing included.
1470 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1471 assert!(decide(&job, "whoami", &json!({})).allowed);
1472 // Its scopes still hold inside it.
1473 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1474 assert!(decide_repo(&job, "acme/web").is_none());
1475 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1476 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1477 // Opening and approving pull requests is off unless allowed.
1478 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1479 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1480 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1481 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1482 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1483 }
1484
1485 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1486 fn workflow_files_need_their_own_scope() {
1487 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1488 assert!(is_workflow_file(path), "{path}");
1489 }
1490 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1491 assert!(!is_workflow_file(path), "{path}");
1492 }
1493 let code = token(&[Scope::CodeWrite]);
1494 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1495 assert_eq!(refused.rule, "token:workflows");
1496 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1497 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1498 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1499 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1500 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1501 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1502 // A job's token never may, as GITHUB_TOKEN never may.
1503 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1504 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1505 // Nothing in a preset changes workflow files but full access.
1506 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1507 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1508 }
1509 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1510 }
1511
1512 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1513 fn a_narrowed_token_only_reads_outside_its_workspace() {
1514 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1515 let fine = TokenAccess { reach: Some(reach), ..token(&[Scope::RepoRead, Scope::IssuesRead, Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1516 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1517 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1518 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1519 assert_eq!(elsewhere.rule, "token:resource-owner");
1520 assert!(elsewhere.reason.unwrap().contains("acme"));
1521 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1522 assert!(!decide(&fine, "create_pull_request", &json!({ "repo": "acme/web" })).allowed, "its scopes still hold");
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1523 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1524 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1525 assert!(fine.covers_repo("rep_1", "acme") && !fine.covers_repo("rep_1", "globex"));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1526 let selected = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::Selected, repo_ids: vec!["rep_1".into()] };
Workflow files need workflow_files:write from a token; fine-grained permission table1527 assert!(selected.covers("rep_1", "ACME") && !selected.covers("rep_2", "acme"));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1528 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
Workflow files need workflow_files:write from a token; fine-grained permission table1529 assert!(!public.covers("rep_1", "acme") && public.owned_by("acme"));
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1530 assert!(token(&[]).covers_repo("rep_1", "anything"), "a token for every workspace reaches what its owner can");
Workflow files need workflow_files:write from a token; fine-grained permission table1531 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1532 }
1533
1534 #[test]
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1535 fn permissions_are_scopes_read_per_resource() {
1536 let asked: std::collections::BTreeMap<String, String> =
1537 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1538 let scopes = resolve_permissions(&asked, true).unwrap();
1539 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1540 let back = permissions_of(&scopes);
1541 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1542 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1543 assert!(!back.contains_key("code"));
1544 // Lower levels held beside a higher one say nothing more.
1545 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
1546 // Every resource's top, and nothing a level can lose.
1547 let all = everything();
1548 assert_eq!(all.len(), Resource::ALL.len());
1549 for scope in Scope::ALL {
1550 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1551 }
1552 }
1553
1554 #[test]
1555 fn permissions_are_checked_by_name_level_and_owner() {
1556 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1557 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1558 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1559 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1560 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1561 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1562 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1563 for resource in Resource::ALL {
1564 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1565 assert!(!resource.scopes().is_empty());
1566 }
1567 }
1568
1569 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1570 fn a_legacy_token_can_do_everything() {
1571 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1572 for (operation, _) in OPERATIONS {
1573 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1574 }
1575 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1576 }
1577
1578 #[test]
1579 fn a_missing_scope_is_named() {
1580 let read = token(&[Scope::IssuesRead]);
1581 assert!(decide(&read, "get_issue", &json!({})).allowed);
1582 assert!(decide(&read, "whoami", &json!({})).allowed);
1583 let refused = decide(&read, "create_issue", &json!({}));
1584 assert!(!refused.allowed);
1585 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1586 // An operation the table does not know needs full access.
1587 assert!(!decide(&read, "something_new", &json!({})).allowed);
1588 }
1589
1590 #[test]
1591 fn starting_agents_from_another_operation_needs_agents_run() {
1592 let writer = token(&[Scope::IssuesWrite]);
1593 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1594 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1595 assert!(refused.reason.unwrap().contains("agents:run"));
1596 let maintainer = token(&[Scope::RepoWrite]);
1597 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1598 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1599 }
1600
1601 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1602 fn a_workspaces_base_permission_needs_access_admin_too() {
1603 let admin = token(&[Scope::WorkspaceAdmin]);
1604 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1605 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1606 assert!(refused.reason.unwrap().contains("access:admin"));
1607 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1608 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1609 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1610 }
1611
1612 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1613 fn delegating_needs_both_agents_and_issues() {
1614 let agents = token(&[Scope::AgentsRun]);
1615 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1616 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1617 assert!(decide(&both, "delegate", &json!({})).allowed);
1618 }
1619
1620 #[test]
1621 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1622 let reader = token(&[Scope::CodeRead]);
1623 assert!(decide_git(&reader, false, false).allowed);
1624 let refused = decide_git(&reader, true, false);
1625 assert!(!refused.allowed);
1626 assert!(refused.reason.unwrap().contains("code:write"));
1627 let issues = token(&[Scope::IssuesWrite]);
1628 assert!(!decide_git(&issues, false, false).allowed);
1629 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1630 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1631 let writer = token(&[Scope::CodeWrite]);
1632 assert!(decide_git(&writer, true, false).allowed);
1633 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1634 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1635 }
1636
1637 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1638 fn packages_need_their_own_scopes_and_public_pulls_none() {
1639 let reader = token(&[Scope::PackagesRead]);
1640 assert!(decide_packages(&reader, Level::Read, false).allowed);
1641 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1642 let code = token(&[Scope::CodeWrite]);
1643 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1644 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1645 let writer = token(&[Scope::PackagesWrite]);
1646 assert!(decide_packages(&writer, Level::Write, false).allowed);
1647 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1648 let refused = decide_packages(&writer, Level::Delete, false);
1649 assert!(refused.reason.unwrap().contains("packages:delete"));
1650 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1651 assert!(Scope::PackagesDelete.dangerous());
1652 // Tokens made before these scopes, and full-access ones, keep working.
1653 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1654 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1655 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1656 }
1657
1658 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1659 fn token_access_travels_as_json() {
1660 let access = token(&[Scope::IssuesRead]);
1661 let wire = serde_json::to_value(&access).unwrap();
1662 assert_eq!(wire["scopes"], json!(["issues:read"]));
1663 assert!(wire.get("resources").is_none());
1664 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1665 assert_eq!(back, access);
1666 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1667 assert!(full.is_full());
1668 // A reach written by an older version is ignored: a token reaches
1669 // whatever its owner can.
1670 let older: TokenAccess = serde_json::from_value(json!({
1671 "token_id": "tok_1",
1672 "scopes": ["issues:read"],
1673 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1674 }))
1675 .unwrap();
1676 assert_eq!(older, access);
1677 }
1678
1679 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1680 /// lists the same scopes in the same order, the same operations with
1681 /// the same scopes, and the same presets.
1682 #[test]
1683 fn the_typescript_mirror_has_the_same_table() {
1684 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1685 let section = |start: &str| {
1686 ts.split_once(start)
1687 .and_then(|(_, rest)| rest.split_once("] as const"))
1688 .map(|(table, _)| table)
1689 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1690 };
1691 let scopes: Vec<&str> = section("export const SCOPES = [")
1692 .lines()
1693 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope))
1694 .collect();
1695 let expected: Vec<&str> = Scope::ALL.iter().map(|scope| scope.as_str()).collect();
1696 assert_eq!(scopes, expected);
1697 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1698 .lines()
1699 .filter_map(|line| {
1700 let mut quoted = line.split('"').skip(1).step_by(2);
1701 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1702 })
1703 .collect();
1704 let expected: Vec<(String, String)> = OPERATIONS
1705 .iter()
1706 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1707 .collect();
1708 assert_eq!(operations, expected);
1709 for preset in Preset::ALL {
1710 let list = section(&format!("{}: [", preset.as_str()));
1711 let mirrored: Vec<&str> = list
1712 .split(',')
1713 .map(|item| item.trim().trim_matches('"'))
1714 .filter(|item| !item.is_empty())
1715 .collect();
1716 let expected: Vec<&str> = preset
1717 .scopes()
1718 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1719 .unwrap_or_else(|| vec!["*"]);
1720 assert_eq!(mirrored, expected, "{}", preset.as_str());
1721 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1722 // Each resource with its group, in the same order.
1723 let resources = ts
1724 .split_once("export const SCOPE_RESOURCES")
1725 .and_then(|(_, rest)| rest.split_once("
1726];"))
1727 .map(|(table, _)| table)
1728 .expect("SCOPE_RESOURCES in scopes.ts");
1729 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1730 assert_eq!(rows.len(), Resource::ALL.len());
1731 for (row, resource) in rows.iter().zip(Resource::ALL) {
1732 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1733 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1734 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1735 }
1736}

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