Skip to content
1,821 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,
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet46 /// Artifacts mode's docs, slides, designs and dashboards (folios in
47 /// code). Not offered yet: see [`Resource::offered`].
48 Artifacts,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step49}
50
51impl Resource {
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet52 pub const ALL: [Resource; 22] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step53 Resource::Repo,
54 Resource::Code,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar55 Resource::Security,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member56 Resource::Packages,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step57 Resource::Issues,
58 Resource::PullRequests,
59 Resource::Agents,
60 Resource::Workflows,
Token reach: workflow_files scope, fine-grained reach, workspace token cap61 Resource::WorkflowFiles,
Merge checks: statuses and check runs on every commit62 Resource::Checks,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9763 Resource::Deployments,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step64 Resource::Memory,
65 Resource::Account,
API: notifications over REST and MCP, with notifications scopes66 Resource::Notifications,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step67 Resource::Workspace,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit68 Resource::Billing,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step69 Resource::Access,
70 Resource::Webhooks,
71 Resource::Secrets,
Fast pages, required checks on the branch, self-hosted runners, honest incidents72 Resource::Runners,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens73 Resource::Models,
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet74 Resource::Artifacts,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step75 ];
76
77 pub fn as_str(self) -> &'static str {
78 match self {
79 Resource::Account => "account",
API: notifications over REST and MCP, with notifications scopes80 Resource::Notifications => "notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step81 Resource::Workspace => "workspace",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit82 Resource::Billing => "billing",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step83 Resource::Repo => "repo",
84 Resource::Code => "code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar85 Resource::Security => "security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member86 Resource::Packages => "packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step87 Resource::Issues => "issues",
88 Resource::PullRequests => "pull_requests",
89 Resource::Agents => "agents",
90 Resource::Workflows => "workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap91 Resource::WorkflowFiles => "workflow_files",
Merge checks: statuses and check runs on every commit92 Resource::Checks => "checks",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb9793 Resource::Deployments => "deployments",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step94 Resource::Memory => "memory",
95 Resource::Access => "access",
96 Resource::Webhooks => "webhooks",
97 Resource::Secrets => "secrets",
Fast pages, required checks on the branch, self-hosted runners, honest incidents98 Resource::Runners => "runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens99 Resource::Models => "models",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet100 Resource::Artifacts => "artifacts",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step101 }
102 }
103
104 /// Its name, for people.
105 pub fn label(self) -> &'static str {
106 match self {
107 Resource::Account => "Your account",
API: notifications over REST and MCP, with notifications scopes108 Resource::Notifications => "Notifications",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step109 Resource::Workspace => "Workspaces",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit110 Resource::Billing => "Billing",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step111 Resource::Repo => "Repositories",
112 Resource::Code => "Code",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar113 Resource::Security => "Security",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member114 Resource::Packages => "Packages",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step115 Resource::Issues => "Issues",
116 Resource::PullRequests => "Pull requests",
117 Resource::Agents => "g1t agents",
118 Resource::Workflows => "Workflows",
Token reach: workflow_files scope, fine-grained reach, workspace token cap119 Resource::WorkflowFiles => "Workflow files",
Merge checks: statuses and check runs on every commit120 Resource::Checks => "Checks and statuses",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97121 Resource::Deployments => "Deployments",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step122 Resource::Memory => "Memory and context",
123 Resource::Access => "Who has access",
124 Resource::Webhooks => "Webhooks",
125 Resource::Secrets => "Secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents126 Resource::Runners => "Self-hosted runners",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens127 Resource::Models => "AI Gateway",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet128 Resource::Artifacts => "Artifacts",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step129 }
130 }
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet131
132 /// Whether tokens are offered it yet. A resource that is not is in the
133 /// table (so its scopes parse, and the TypeScript mirror lists it under
134 /// `UPCOMING_RESOURCES`) but nothing hands it out: presets, full
135 /// access, OAuth and the token form leave it out, and no operation
136 /// needs it. Artifacts is offered once its API ships (Phase 3 of
137 /// docs/ARTIFACTS_MODE.md).
138 pub fn offered(self) -> bool {
139 !matches!(self, Resource::Artifacts)
140 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step141}
142
143/// How much of a resource.
144#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
145pub enum Level {
146 Read,
147 Write,
148 /// Starting g1t's agents, which spends the workspace's money.
149 Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member150 /// Deleting what cannot be brought back, such as a package's versions.
151 Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step152 Admin,
153}
154
155impl Level {
156 pub fn as_str(self) -> &'static str {
157 match self {
158 Level::Read => "read",
159 Level::Write => "write",
160 Level::Run => "run",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member161 Level::Delete => "delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step162 Level::Admin => "admin",
163 }
164 }
165}
166
167/// One scope. Its text form, `resource:level`, is what tokens store, OAuth
168/// clients ask for, and errors name.
169#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
170pub enum Scope {
171 RepoRead,
172 RepoWrite,
173 RepoAdmin,
174 CodeRead,
175 CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar176 SecurityRead,
177 SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member178 PackagesRead,
179 PackagesWrite,
180 PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step181 IssuesRead,
182 IssuesWrite,
183 PullRequestsRead,
184 PullRequestsWrite,
185 AgentsRun,
186 WorkflowsRead,
187 WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap188 WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit189 ChecksRead,
190 ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97191 DeploymentsRead,
192 DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step193 MemoryRead,
194 MemoryWrite,
195 AccountRead,
196 AccountWrite,
API: notifications over REST and MCP, with notifications scopes197 NotificationsRead,
198 NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step199 WorkspaceRead,
200 WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit201 BillingRead,
202 BillingWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step203 AccessRead,
204 AccessAdmin,
205 WebhooksRead,
206 WebhooksAdmin,
207 SecretsRead,
208 SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents209 RunnersRead,
210 RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens211 ModelsRead,
212 ModelsWrite,
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet213 ArtifactsRead,
214 ArtifactsWrite,
215 ArtifactsAdmin,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step216}
217
218impl Scope {
219 /// Every scope, grouped by resource, least first.
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet220 pub const ALL: [Scope; 45] = [
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step221 Scope::RepoRead,
222 Scope::RepoWrite,
223 Scope::RepoAdmin,
224 Scope::CodeRead,
225 Scope::CodeWrite,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar226 Scope::SecurityRead,
227 Scope::SecurityWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member228 Scope::PackagesRead,
229 Scope::PackagesWrite,
230 Scope::PackagesDelete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step231 Scope::IssuesRead,
232 Scope::IssuesWrite,
233 Scope::PullRequestsRead,
234 Scope::PullRequestsWrite,
235 Scope::AgentsRun,
236 Scope::WorkflowsRead,
237 Scope::WorkflowsWrite,
Token reach: workflow_files scope, fine-grained reach, workspace token cap238 Scope::WorkflowFilesWrite,
Merge checks: statuses and check runs on every commit239 Scope::ChecksRead,
240 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97241 Scope::DeploymentsRead,
242 Scope::DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step243 Scope::MemoryRead,
244 Scope::MemoryWrite,
245 Scope::AccountRead,
246 Scope::AccountWrite,
API: notifications over REST and MCP, with notifications scopes247 Scope::NotificationsRead,
248 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step249 Scope::WorkspaceRead,
250 Scope::WorkspaceAdmin,
Usage, Billing settings and prepaid AI credit; fixes from the UX audit251 Scope::BillingRead,
252 Scope::BillingWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step253 Scope::AccessRead,
254 Scope::AccessAdmin,
255 Scope::WebhooksRead,
256 Scope::WebhooksAdmin,
257 Scope::SecretsRead,
258 Scope::SecretsAdmin,
Fast pages, required checks on the branch, self-hosted runners, honest incidents259 Scope::RunnersRead,
260 Scope::RunnersAdmin,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens261 Scope::ModelsRead,
262 Scope::ModelsWrite,
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet263 Scope::ArtifactsRead,
264 Scope::ArtifactsWrite,
265 Scope::ArtifactsAdmin,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step266 ];
267
268 pub fn as_str(self) -> &'static str {
269 match self {
270 Scope::RepoRead => "repo:read",
271 Scope::RepoWrite => "repo:write",
272 Scope::RepoAdmin => "repo:admin",
273 Scope::CodeRead => "code:read",
274 Scope::CodeWrite => "code:write",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar275 Scope::SecurityRead => "security:read",
276 Scope::SecurityWrite => "security:write",
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member277 Scope::PackagesRead => "packages:read",
278 Scope::PackagesWrite => "packages:write",
279 Scope::PackagesDelete => "packages:delete",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step280 Scope::IssuesRead => "issues:read",
281 Scope::IssuesWrite => "issues:write",
282 Scope::PullRequestsRead => "pull_requests:read",
283 Scope::PullRequestsWrite => "pull_requests:write",
284 Scope::AgentsRun => "agents:run",
285 Scope::WorkflowsRead => "workflows:read",
286 Scope::WorkflowsWrite => "workflows:write",
Token reach: workflow_files scope, fine-grained reach, workspace token cap287 Scope::WorkflowFilesWrite => "workflow_files:write",
Merge checks: statuses and check runs on every commit288 Scope::ChecksRead => "checks:read",
289 Scope::ChecksWrite => "checks:write",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97290 Scope::DeploymentsRead => "deployments:read",
291 Scope::DeploymentsWrite => "deployments:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step292 Scope::MemoryRead => "memory:read",
293 Scope::MemoryWrite => "memory:write",
294 Scope::AccountRead => "account:read",
295 Scope::AccountWrite => "account:write",
API: notifications over REST and MCP, with notifications scopes296 Scope::NotificationsRead => "notifications:read",
297 Scope::NotificationsWrite => "notifications:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step298 Scope::WorkspaceRead => "workspace:read",
299 Scope::WorkspaceAdmin => "workspace:admin",
Usage, Billing settings and prepaid AI credit; fixes from the UX audit300 Scope::BillingRead => "billing:read",
301 Scope::BillingWrite => "billing:write",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step302 Scope::AccessRead => "access:read",
303 Scope::AccessAdmin => "access:admin",
304 Scope::WebhooksRead => "webhooks:read",
305 Scope::WebhooksAdmin => "webhooks:admin",
306 Scope::SecretsRead => "secrets:read",
307 Scope::SecretsAdmin => "secrets:admin",
Fast pages, required checks on the branch, self-hosted runners, honest incidents308 Scope::RunnersRead => "runners:read",
309 Scope::RunnersAdmin => "runners:admin",
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens310 Scope::ModelsRead => "models:read",
311 Scope::ModelsWrite => "models:write",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet312 Scope::ArtifactsRead => "artifacts:read",
313 Scope::ArtifactsWrite => "artifacts:write",
314 Scope::ArtifactsAdmin => "artifacts:admin",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step315 }
316 }
317
318 pub fn parse(text: &str) -> Option<Scope> {
319 let text = text.trim().to_ascii_lowercase();
320 Scope::ALL.into_iter().find(|scope| scope.as_str() == text)
321 }
322
323 pub fn resource(self) -> Resource {
324 let name = self.as_str().split_once(':').map_or("", |(resource, _)| resource);
325 Resource::ALL
326 .into_iter()
327 .find(|resource| resource.as_str() == name)
328 .unwrap_or(Resource::Account)
329 }
330
331 pub fn level(self) -> Level {
332 match self.as_str().rsplit_once(':').map_or("", |(_, level)| level) {
333 "write" => Level::Write,
334 "run" => Level::Run,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member335 "delete" => Level::Delete,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step336 "admin" => Level::Admin,
337 _ => Level::Read,
338 }
339 }
340
341 /// Whether holding `self` gives `other`: the same resource, at the same
342 /// level or a lower one.
343 pub fn includes(self, other: Scope) -> bool {
344 self.resource() == other.resource() && self.level() >= other.level()
345 }
346
347 /// Changes that are hard or impossible to undo, or that decide who can
348 /// reach what. Shown behind a warning wherever scopes are chosen.
349 pub fn dangerous(self) -> bool {
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member350 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 step351 }
352
353 /// What it lets a token do, in plain words.
354 pub fn describe(self) -> &'static str {
355 match self {
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97356 Scope::RepoRead => "See repositories, their settings, labels, timelines, releases, languages, contributors and security alerts, and search",
357 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 merge358 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 step359 Scope::CodeRead => "Clone and fetch private repositories with git",
360 Scope::CodeWrite => "Push commits with git",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar361 Scope::SecurityRead => "See secret scanning, code scanning and vulnerability alerts, custom patterns, the dependency graph and SBOM, and security settings",
362 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 member363 Scope::PackagesRead => "Pull container images and install private packages",
364 Scope::PackagesWrite => "Push container images and publish packages",
Merge packages: roles, Actions access, source label, soft delete, API365 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 step366 Scope::IssuesRead => "Read issues, comments and plans",
367 Scope::IssuesWrite => "Open, edit, close and comment on issues",
368 Scope::PullRequestsRead => "Read pull requests, their changes, sessions and merge queues",
369 Scope::PullRequestsWrite => "Open, review, close and merge pull requests",
370 Scope::AgentsRun => "Put g1t agents to work and message them, which uses the workspace's money",
371 Scope::WorkflowsRead => "Read workflows, runs and logs",
372 Scope::WorkflowsWrite => "Run, cancel, rerun and turn workflows on or off",
Token reach: workflow_files scope, fine-grained reach, workspace token cap373 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 commit374 Scope::ChecksRead => "Read commits' statuses, check runs, check suites and annotations",
375 Scope::ChecksWrite => "Report statuses and check runs on commits, and ask for checks to run again",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97376 Scope::DeploymentsRead => "See deployments, their statuses and environments",
377 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 step378 Scope::MemoryRead => "Recall memory and search the workspace's context",
379 Scope::MemoryWrite => "Save memory for the next agent",
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97380 Scope::AccountRead => "Read your email addresses, invites, invitations, pinned projects and stars",
381 Scope::AccountWrite => "Change your email addresses, make invites, answer invitations, pin projects and star repositories",
API: notifications over REST and MCP, with notifications scopes382 Scope::NotificationsRead => "See your inbox, its threads, and what you subscribe to and watch",
383 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 merge384 Scope::WorkspaceRead => "Read workspace settings, invites, integrations, model routes, teams and rulesets",
385 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 audit386 Scope::BillingRead => "See a workspace's usage, budget, AI credit and invoices",
387 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 step388 Scope::AccessRead => "See who has access to repositories",
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar389 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 step390 Scope::WebhooksRead => "See webhooks and their deliveries",
391 Scope::WebhooksAdmin => "Create, change and delete webhooks",
392 Scope::SecretsRead => "List secrets (never their values) and read variables",
393 Scope::SecretsAdmin => "Set and delete secrets and variables",
Fast pages, required checks on the branch, self-hosted runners, honest incidents394 Scope::RunnersRead => "See self-hosted runners, their groups and where agents run",
395 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 tokens396 Scope::ModelsRead => "See the workspace's AI Gateway requests: their models, tokens, cost and status",
397 Scope::ModelsWrite => "Send model requests through the AI Gateway, which uses the workspace's AI credit",
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet398 Scope::ArtifactsRead => "List, read and search artifacts you can see, their versions, and the numbers their dashboards show",
399 Scope::ArtifactsWrite => "Create, rename, move, edit, trash and restore artifacts, and propose changes to them",
400 Scope::ArtifactsAdmin => "Share artifacts, change who can open them, and delete them for good",
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step401 }
402 }
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet403
404 /// Whether tokens are offered it yet: its resource's [`Resource::offered`].
405 pub fn offered(self) -> bool {
406 self.resource().offered()
407 }
408}
409
410/// Every scope tokens are offered, in table order: what OAuth advertises.
411pub fn offered_scopes() -> Vec<Scope> {
412 Scope::ALL.into_iter().filter(|scope| scope.offered()).collect()
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step413}
414
415impl Serialize for Scope {
416 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
417 serializer.serialize_str(self.as_str())
418 }
419}
420
421impl<'de> Deserialize<'de> for Scope {
422 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
423 let text = String::deserialize(deserializer)?;
424 Scope::parse(&text).ok_or_else(|| serde::de::Error::custom(format!("unknown scope {text}")))
425 }
426}
427
428/// Scopes as written in a token's row or an OAuth request: separated by
429/// spaces or commas. Unknown names are left out, so a client asking for a
430/// scope from a newer version gets the rest.
431pub fn parse_scopes(text: &str) -> Vec<Scope> {
432 let mut scopes: Vec<Scope> = text
433 .split(|c: char| c.is_whitespace() || c == ',')
434 .filter_map(Scope::parse)
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet435 .filter(|scope| scope.offered())
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step436 .collect();
437 normalize(&mut scopes);
438 scopes
439}
440
441/// In table order, without repeats.
442pub fn normalize(scopes: &mut Vec<Scope>) {
443 let given = std::mem::take(scopes);
444 scopes.extend(Scope::ALL.into_iter().filter(|scope| given.contains(scope)));
445}
446
447/// Space-separated, as stored and as OAuth writes them.
448pub fn scopes_text(scopes: &[Scope]) -> String {
449 scopes.iter().map(|scope| scope.as_str()).collect::<Vec<_>>().join(" ")
450}
451
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers452/// Where a resource sits on the token form, and which tokens may hold it.
453#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
454#[serde(rename_all = "snake_case")]
455pub enum ResourceGroup {
456 /// About repositories: what they hold and how they are run.
457 Repository,
458 /// About a workspace itself.
459 Workspace,
460 /// About the person: only a personal token may hold these.
461 Account,
462}
463
464impl ResourceGroup {
465 pub const ALL: [ResourceGroup; 3] = [ResourceGroup::Repository, ResourceGroup::Workspace, ResourceGroup::Account];
466
467 pub fn as_str(self) -> &'static str {
468 match self {
469 ResourceGroup::Repository => "repository",
470 ResourceGroup::Workspace => "workspace",
471 ResourceGroup::Account => "account",
472 }
473 }
474}
475
476impl Resource {
477 pub fn group(self) -> ResourceGroup {
478 match self {
479 Resource::Account | Resource::Notifications => ResourceGroup::Account,
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet480 Resource::Workspace | Resource::Billing | Resource::Runners | Resource::Models | Resource::Artifacts => ResourceGroup::Workspace,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers481 _ => ResourceGroup::Repository,
482 }
483 }
484
485 pub fn parse(text: &str) -> Option<Resource> {
486 let text = text.trim().to_ascii_lowercase();
487 Resource::ALL.into_iter().find(|resource| resource.as_str() == text)
488 }
489
490 /// Its scopes, least first.
491 pub fn scopes(self) -> Vec<Scope> {
492 Scope::ALL.into_iter().filter(|scope| scope.resource() == self).collect()
493 }
494}
495
496// --- Permissions --------------------------------------------------------------
497//
498// A token's permissions are its scopes read per resource: each resource
499// at none or one level (`{"issues": "write", "repo": "read"}`). A level
500// includes the ones below it, so the highest scope held of each resource
501// says everything; that is what a token stores. Personal tokens and a
502// workspace's own tokens are made, shown and checked this way alike.
503
504/// The highest scope of each resource held, in table order: the fewest
505/// scopes that give the same access, as tokens store them.
506pub fn top_scopes(scopes: &[Scope]) -> Vec<Scope> {
507 let mut top: Vec<Scope> = Vec::new();
508 for resource in Resource::ALL {
509 if let Some(best) = scopes.iter().filter(|scope| scope.resource() == resource).max_by_key(|scope| scope.level()) {
510 top.push(*best);
511 }
512 }
513 normalize(&mut top);
514 top
515}
516
517/// Every resource at its highest level: all a token can be given.
518pub fn everything() -> Vec<Scope> {
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet519 top_scopes(&offered_scopes())
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers520}
521
522/// Scopes as permissions: each resource held, by name, at its highest
523/// level held.
524pub fn permissions_of(scopes: &[Scope]) -> std::collections::BTreeMap<String, String> {
525 top_scopes(scopes)
526 .into_iter()
527 .map(|scope| (scope.resource().as_str().to_owned(), scope.level().as_str().to_owned()))
528 .collect()
529}
530
531/// Permissions as asked for (`{"issues": "write"}`, `none` or empty left
532/// out) into the scopes a token stores, or why they cannot be. `personal`
533/// is whether the token is a person's: only theirs may hold account ones.
534pub fn resolve_permissions(asked: &std::collections::BTreeMap<String, String>, personal: bool) -> Result<Vec<Scope>, String> {
535 let mut scopes = Vec::new();
536 for (name, level) in asked {
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet537 let Some(resource) = Resource::parse(name).filter(|resource| resource.offered()) else {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers538 return Err(format!("There is no permission called {name}."));
539 };
540 let level = level.trim().to_ascii_lowercase();
541 if level.is_empty() || level == "none" {
542 continue;
543 }
544 let Some(scope) = Scope::parse(&format!("{}:{level}", resource.as_str())) else {
545 let levels: Vec<&str> = resource.scopes().iter().map(|scope| scope.level().as_str()).collect();
546 return Err(format!("{} is none or {}, not {level}.", resource.as_str(), levels.join(", ")));
547 };
548 if resource.group() == ResourceGroup::Account && !personal {
549 return Err(format!("{} is about a person's account: a workspace's token cannot hold it.", resource.as_str()));
550 }
551 scopes.push(scope);
552 }
553 Ok(top_scopes(&scopes))
554}
555
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step556/// What a token stores for full access, which is not a scope a client can
557/// ask for by name.
558pub const FULL_ACCESS: &str = "*";
559
560/// Starting points for choosing scopes.
561#[derive(Clone, Copy, Debug, PartialEq, Eq)]
562pub enum Preset {
563 ReadOnly,
564 Agent,
565 Ci,
566 Full,
567}
568
569impl Preset {
570 pub const ALL: [Preset; 4] = [Preset::ReadOnly, Preset::Agent, Preset::Ci, Preset::Full];
571
572 pub fn as_str(self) -> &'static str {
573 match self {
574 Preset::ReadOnly => "read_only",
575 Preset::Agent => "agent",
576 Preset::Ci => "ci",
577 Preset::Full => "full",
578 }
579 }
580
581 pub fn label(self) -> &'static str {
582 match self {
583 Preset::ReadOnly => "Read only",
584 Preset::Agent => "Agent",
585 Preset::Ci => "CI",
586 Preset::Full => "Full access",
587 }
588 }
589
590 /// Its scopes; `None` for full access.
591 pub fn scopes(self) -> Option<Vec<Scope>> {
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet592 let reads = || Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered());
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step593 match self {
594 Preset::ReadOnly => Some(reads().collect()),
595 Preset::Agent => {
Fast pages, required checks on the branch, self-hosted runners, honest incidents596 // Not the machines work runs on: an agent has no business
597 // knowing a workspace's own runners.
598 let mut scopes: Vec<Scope> = reads().filter(|scope| scope.resource() != Resource::Runners).collect();
API: notifications over REST and MCP, with notifications scopes599 // And answering what needs the person it works for: marking
600 // it done, subscribing, watching.
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step601 scopes.extend([
602 Scope::CodeWrite,
603 Scope::IssuesWrite,
604 Scope::PullRequestsWrite,
605 Scope::AgentsRun,
606 Scope::MemoryWrite,
API: notifications over REST and MCP, with notifications scopes607 Scope::NotificationsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step608 ]);
609 normalize(&mut scopes);
610 Some(scopes)
611 }
612 Preset::Ci => Some(vec![
613 Scope::RepoRead,
614 Scope::CodeRead,
615 Scope::CodeWrite,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member616 Scope::PackagesRead,
617 Scope::PackagesWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step618 Scope::WorkflowsRead,
619 Scope::WorkflowsWrite,
Merge checks: statuses and check runs on every commit620 Scope::ChecksRead,
621 Scope::ChecksWrite,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97622 Scope::DeploymentsRead,
623 Scope::DeploymentsWrite,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step624 ]),
625 Preset::Full => None,
626 }
627 }
628}
629
630/// What an OAuth client gets when it asks for nothing in particular: the
631/// agent preset. Never an admin scope.
632pub fn oauth_default() -> Vec<Scope> {
633 Preset::Agent.scopes().unwrap_or_default()
634}
635
636/// Set on a [`crate::User`] resolved from an access token: what the token
637/// may do. Absent on a signed-in session, which may do whatever its person
638/// can.
639#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
640pub struct TokenAccess {
641 /// The token's id, as audit entries and errors name it.
642 #[serde(default)]
643 pub token_id: String,
644 /// Its scopes, as `resource:level`. Absent: full access, everything the
645 /// person (or workspace) can do.
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub scopes: Option<Vec<String>>,
648 /// Made before tokens had scopes: full access until someone narrows it.
649 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
650 pub legacy: bool,
Merge branch 'worktree-agent-a3abfcce648e87dca'651 /// Set on a workflow job's token (`G1T_TOKEN`): the one repository it
652 /// reaches, as `owner/name`. Every other is refused, whatever its owner
653 /// could reach.
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub repo: Option<String>,
656 /// Set on a workflow job's token: the run and job it was made for. The
657 /// audit log records its changes as that job's, and what it changes
658 /// starts no workflows (only `workflow_dispatch` and
659 /// `repository_dispatch` do), so a workflow cannot set itself off.
660 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub job: Option<JobToken>,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens662 /// The token's name, as its owner gave it, so a log can say which
663 /// token made a request. Absent where whoever resolved it did not say.
664 #[serde(default, skip_serializing_if = "Option::is_none")]
665 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 managers666 /// Set on a token narrowed to less than its owner can reach: one
667 /// workspace (all, selected or none of its private repositories), or
668 /// none at all (its owner's account and public repositories). Absent
669 /// on a token that reaches every workspace its owner can.
670 ///
671 /// The wire key is `fine_grained`, kept from before tokens were one
672 /// kind, so services deployed at different moments agree on it.
673 #[serde(rename = "fine_grained", default, skip_serializing_if = "Option::is_none")]
674 pub reach: Option<TokenReach>,
Token reach: workflow_files scope, fine-grained reach, workspace token cap675 /// Set on a workspace's own token that an owner gave Admin when making
676 /// it. Without it a workspace's token has Write on the workspace's
677 /// repositories, as a member would (see [`crate::access`]).
678 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
679 pub admin: bool,
680 /// Set on what a repository's deploy key resolves to: the key's id. Its
681 /// `repo` is the one repository it reaches.
682 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub deploy_key: Option<String>,
684}
685
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers686/// Which repositories a token reaches in the workspace it is made for.
Token reach: workflow_files scope, fine-grained reach, workspace token cap687#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
688#[serde(rename_all = "snake_case")]
689pub enum RepositorySelection {
690 /// Every repository of the workspace, ones made later included.
691 #[default]
692 All,
693 /// The repositories chosen, by id.
694 Selected,
695 /// None of the workspace's private repositories: public repositories,
696 /// 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 managers697 /// With no workspace: the owner's account and public repositories only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap698 Public,
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step699}
700
Token reach: workflow_files scope, fine-grained reach, workspace token cap701impl RepositorySelection {
702 pub fn as_str(self) -> &'static str {
703 match self {
704 RepositorySelection::All => "all",
705 RepositorySelection::Selected => "selected",
706 RepositorySelection::Public => "public",
707 }
708 }
709
710 pub fn parse(text: &str) -> Option<RepositorySelection> {
711 match text.trim().to_ascii_lowercase().as_str() {
712 "all" => Some(RepositorySelection::All),
713 "selected" => Some(RepositorySelection::Selected),
714 "public" | "public_only" | "none" => Some(RepositorySelection::Public),
715 _ => None,
716 }
717 }
718}
719
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers720/// What a narrowed token reaches, as identity resolves it on each use.
Token reach: workflow_files scope, fine-grained reach, workspace token cap721#[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 managers722pub struct TokenReach {
723 /// The workspace whose repositories and settings it reaches, by slug as
724 /// it is now. Absent: its owner's account only, with public
725 /// repositories read-only.
Token reach: workflow_files scope, fine-grained reach, workspace token cap726 #[serde(default, skip_serializing_if = "Option::is_none")]
727 pub workspace: Option<String>,
728 #[serde(default)]
729 pub repositories: RepositorySelection,
730 /// With [`RepositorySelection::Selected`]: the repositories' ids.
731 #[serde(default, skip_serializing_if = "Vec::is_empty")]
732 pub repo_ids: Vec<String>,
733}
734
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers735impl TokenReach {
Token reach: workflow_files scope, fine-grained reach, workspace token cap736 /// Whether it reaches the repository with this id in the workspace
737 /// `namespace` for more than what anyone may do with a public one.
738 pub fn covers(&self, repo_id: &str, namespace: &str) -> bool {
739 let Some(workspace) = self.workspace.as_deref() else {
740 return false;
741 };
742 if !workspace.eq_ignore_ascii_case(namespace) {
743 return false;
744 }
745 match self.repositories {
746 RepositorySelection::All => true,
747 RepositorySelection::Selected => self.repo_ids.iter().any(|id| id == repo_id),
748 RepositorySelection::Public => false,
749 }
750 }
751
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers752 /// Whether it is made for the workspace `slug`.
Token reach: workflow_files scope, fine-grained reach, workspace token cap753 pub fn owned_by(&self, slug: &str) -> bool {
754 self.workspace.as_deref().is_some_and(|workspace| workspace.eq_ignore_ascii_case(slug))
755 }
756}
757
758/// Where workflow files live. Adding, changing or deleting a file under
759/// one, with git or through g1t, needs [`Scope::WorkflowFilesWrite`] from a
760/// token: what GitHub's `workflow` scope and `workflows` permission do.
761pub const WORKFLOW_DIRS: [&str; 2] = [".g1t/workflows/", ".github/workflows/"];
762
763/// Whether `path` is a workflow file, or a file in one's directory.
764pub fn is_workflow_file(path: &str) -> bool {
765 let path = path.trim_start_matches('/');
766 WORKFLOW_DIRS.iter().any(|dir| {
767 path.len() >= dir.len() && path.is_char_boundary(dir.len()) && path[..dir.len()].eq_ignore_ascii_case(dir)
768 }) || WORKFLOW_DIRS.iter().any(|dir| path.eq_ignore_ascii_case(dir.trim_end_matches('/')))
769}
770
771/// Whether a token may add, change or delete the files at `paths`: a
772/// refusal naming the first workflow file it may not touch, else `None`.
773/// A signed-in person (no token) is never refused here; their role decides.
774pub fn decide_workflow_files<'a>(access: Option<&TokenAccess>, paths: impl IntoIterator<Item = &'a str>) -> Option<Decision> {
775 let access = access?;
776 if access.allows(Scope::WorkflowFilesWrite) && access.job.is_none() {
777 return None;
778 }
779 let path = paths.into_iter().find(|path| is_workflow_file(path))?;
780 let why = if access.job.is_some() {
781 "a workflow job's token can never add or change workflow files".to_owned()
782 } else {
783 format!("it needs the {} scope", Scope::WorkflowFilesWrite.as_str())
784 };
785 Some(Decision::deny(
786 "token:workflows",
787 format!("This access token cannot change the workflow file {path}: {why}."),
788 ))
789}
790
Merge branch 'worktree-agent-a3abfcce648e87dca'791/// The workflow job a token was made for.
792#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
793pub struct JobToken {
794 /// The run, `run_…`.
795 pub run_id: String,
796 /// The job, `job_…`.
797 pub job_id: String,
798 /// Whether it may open pull requests and approve them, by its
799 /// repository's and workspace's choice ("Allow g1t Actions to create and
800 /// approve pull requests"). Off unless chosen.
801 #[serde(default)]
802 pub pull_requests: bool,
803}
804
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step805impl TokenAccess {
806 /// Full access to everything: the access tokens made before scopes had.
807 pub fn full() -> Self {
808 TokenAccess::default()
809 }
810
Merge branch 'worktree-agent-a3abfcce648e87dca'811 /// Whether it may reach the repository `owner/name`: every token but a
812 /// workflow job's, which reaches its own repository only.
813 pub fn reaches(&self, repo: &str) -> bool {
814 self.repo.as_deref().is_none_or(|only| only.eq_ignore_ascii_case(repo))
815 }
816
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step817 pub fn is_full(&self) -> bool {
818 self.scopes.is_none()
819 }
820
821 /// The scopes it holds, or `None` for full access.
822 pub fn granted(&self) -> Option<Vec<Scope>> {
823 self.scopes
824 .as_ref()
825 .map(|scopes| scopes.iter().filter_map(|scope| Scope::parse(scope)).collect())
826 }
827
828 pub fn allows(&self, needed: Scope) -> bool {
829 match self.granted() {
830 None => true,
831 Some(granted) => granted.iter().any(|held| held.includes(needed)),
832 }
833 }
Token reach: workflow_files scope, fine-grained reach, workspace token cap834
835 /// 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 managers836 /// more than reading a public one: every token but a narrowed one
837 /// outside its workspace or repository selection. Its owner's role
Token reach: workflow_files scope, fine-grained reach, workspace token cap838 /// still decides; see [`crate::access`].
839 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 managers840 self.reach.as_ref().is_none_or(|reach| reach.covers(repo_id, namespace))
Token reach: workflow_files scope, fine-grained reach, workspace token cap841 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step842}
843
844/// Every operation of the API and MCP server, with the scope it needs. An
845/// operation in [`NO_SCOPE`] needs none. The API checks that every one of
846/// its operations is in exactly one of the two.
847pub const OPERATIONS: &[(&str, Scope)] = &[
848 // Your account.
849 ("list_emails", Scope::AccountRead),
850 ("add_email", Scope::AccountWrite),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)851 ("confirm_email", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step852 ("remove_email", Scope::AccountWrite),
853 ("update_email_settings", Scope::AccountWrite),
854 ("list_invites", Scope::AccountRead),
855 ("create_invite", Scope::AccountWrite),
856 ("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)857 ("list_invitations", Scope::AccountRead),
858 ("accept_invitation", Scope::AccountWrite),
859 ("decline_invitation", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step860 ("list_my_repo_invitations", Scope::AccountRead),
861 ("accept_repo_invitation", Scope::AccountWrite),
862 ("decline_repo_invitation", Scope::AccountWrite),
API: pinned projects over REST and MCP863 // Your pinned projects: a preference of your account.
864 ("list_pinned_projects", Scope::AccountRead),
865 ("pin_project", Scope::AccountWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97866 // Your stars: a preference of your account.
867 ("list_starred", Scope::AccountRead),
868 ("check_starred", Scope::AccountRead),
869 ("star_repo", Scope::AccountWrite),
870 ("unstar_repo", Scope::AccountWrite),
API: pinned projects over REST and MCP871 ("unpin_project", Scope::AccountWrite),
872 ("reorder_pinned_projects", Scope::AccountWrite),
API: notifications over REST and MCP, with notifications scopes873 // Your inbox: notifications, subscriptions and watching.
874 ("list_notifications", Scope::NotificationsRead),
875 ("get_notification_thread", Scope::NotificationsRead),
876 ("get_thread_subscription", Scope::NotificationsRead),
877 ("get_repo_subscription", Scope::NotificationsRead),
878 ("list_watched_repos", Scope::NotificationsRead),
879 ("mark_notifications_read", Scope::NotificationsWrite),
880 ("mark_thread_read", Scope::NotificationsWrite),
881 ("mark_thread_done", Scope::NotificationsWrite),
882 ("save_thread", Scope::NotificationsWrite),
883 ("snooze_thread", Scope::NotificationsWrite),
884 ("set_thread_subscription", Scope::NotificationsWrite),
885 ("delete_thread_subscription", Scope::NotificationsWrite),
886 ("set_repo_subscription", Scope::NotificationsWrite),
887 ("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 step888 // Workspaces, their invites and integrations.
889 ("create_workspace", Scope::WorkspaceAdmin),
890 ("delete_workspace", Scope::WorkspaceAdmin),
Merge branch 'worktree-agent-ad7c6d88d93adc817'891 ("get_workspace", Scope::WorkspaceRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily892 ("update_workspace", Scope::WorkspaceAdmin),
Merge main (membership, two-factor, GitHub repo roles) into tokens893 // Its members, and who owns it.
894 ("list_members", Scope::WorkspaceRead),
895 ("update_member", Scope::WorkspaceAdmin),
896 ("remove_member", Scope::WorkspaceAdmin),
897 ("transfer_ownership", Scope::WorkspaceAdmin),
898 ("leave_workspace", Scope::AccountWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step899 ("list_workspace_invites", Scope::WorkspaceRead),
900 ("invite_member", Scope::WorkspaceAdmin),
901 ("revoke_workspace_invite", Scope::WorkspaceAdmin),
902 ("list_integrations", Scope::WorkspaceRead),
903 ("connect_integration", Scope::WorkspaceAdmin),
AI Gateway: OpenAI's format, open models, and your own providers904 ("update_integration", Scope::WorkspaceAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step905 ("disconnect_integration", Scope::WorkspaceAdmin),
906 ("test_integration", Scope::WorkspaceAdmin),
907 ("get_model_routes", Scope::WorkspaceRead),
908 ("set_model_routes", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar909 // Teams: reading them, and managing them. A team's role on a
910 // repository is who has access.
911 ("list_teams", Scope::WorkspaceRead),
912 ("get_team", Scope::WorkspaceRead),
913 ("list_team_members", Scope::WorkspaceRead),
914 ("list_child_teams", Scope::WorkspaceRead),
915 ("list_team_repos", Scope::WorkspaceRead),
916 ("list_user_teams", Scope::WorkspaceRead),
917 ("create_team", Scope::WorkspaceAdmin),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge918 ("list_workspace_rulesets", Scope::WorkspaceRead),
919 ("get_workspace_ruleset", Scope::WorkspaceRead),
920 ("list_workspace_rule_evaluations", Scope::WorkspaceRead),
921 ("create_workspace_ruleset", Scope::WorkspaceAdmin),
922 ("update_workspace_ruleset", Scope::WorkspaceAdmin),
923 ("delete_workspace_ruleset", Scope::WorkspaceAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar924 ("update_team", Scope::WorkspaceAdmin),
925 ("delete_team", Scope::WorkspaceAdmin),
926 ("set_team_member", Scope::WorkspaceAdmin),
927 ("remove_team_member", Scope::WorkspaceAdmin),
928 ("set_team_review_assignment", Scope::WorkspaceAdmin),
Usage, Billing settings and prepaid AI credit; fixes from the UX audit929 // A workspace's billing: usage, budget, AI credit and invoices.
930 ("get_usage", Scope::BillingRead),
931 ("get_budget", Scope::BillingRead),
932 ("get_ai_credit", Scope::BillingRead),
933 ("list_invoices", Scope::BillingRead),
934 ("get_billing_details", Scope::BillingRead),
935 ("set_budget", Scope::BillingWrite),
936 ("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 step937 // Repositories.
938 ("list_repos", Scope::RepoRead),
939 ("get_repo", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97940 // Projects follow their repositories.
941 ("list_projects", Scope::RepoRead),
942 ("get_project", Scope::RepoRead),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step943 ("search", Scope::RepoRead),
944 ("list_events", Scope::RepoRead),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97945 // What the default branch says about a repository, who starred it, and
946 // its releases.
947 ("get_languages", Scope::RepoRead),
948 ("list_contributors", Scope::RepoRead),
949 ("get_license", Scope::RepoRead),
950 ("list_stargazers", Scope::RepoRead),
951 ("list_releases", Scope::RepoRead),
952 ("get_latest_release", Scope::RepoRead),
953 ("get_release_by_tag", Scope::RepoRead),
954 ("get_release", Scope::RepoRead),
955 ("create_release", Scope::RepoWrite),
956 ("update_release", Scope::RepoWrite),
957 ("delete_release", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step958 ("list_labels", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar959 ("list_milestones", Scope::RepoRead),
960 ("get_milestone", Scope::RepoRead),
961 ("create_label", Scope::IssuesWrite),
962 ("update_label", Scope::IssuesWrite),
963 ("delete_label", Scope::IssuesWrite),
964 ("add_default_labels", Scope::IssuesWrite),
965 ("create_milestone", Scope::IssuesWrite),
966 ("update_milestone", Scope::IssuesWrite),
967 ("delete_milestone", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step968 ("get_repo_settings", Scope::RepoRead),
Fast pages, required checks on the branch, self-hosted runners, honest incidents969 ("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 step970 ("list_deleted_repos", Scope::RepoRead),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily971 ("list_security_alerts", Scope::RepoRead),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar972 ("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 step973 ("create_repo", Scope::RepoWrite),
974 ("update_repo", Scope::RepoWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97975 ("update_project", Scope::RepoWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step976 ("update_repo_settings", Scope::RepoWrite),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge977 // Rulesets: reading them is reading the repository; changing them
978 // changes what everyone, agents included, may do, so it is admin.
979 ("list_repo_rulesets", Scope::RepoRead),
980 ("get_repo_ruleset", Scope::RepoRead),
981 ("get_branch_rules", Scope::RepoRead),
982 ("list_rule_evaluations", Scope::RepoRead),
983 ("create_repo_ruleset", Scope::RepoAdmin),
984 ("update_repo_ruleset", Scope::RepoAdmin),
985 ("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 step986 ("rename_branch", Scope::RepoWrite),
987 ("rename_repo", Scope::RepoAdmin),
988 ("transfer_repo", Scope::RepoAdmin),
989 ("archive_repo", Scope::RepoAdmin),
990 ("unarchive_repo", Scope::RepoAdmin),
991 ("set_repo_visibility", Scope::RepoAdmin),
992 ("delete_repo", Scope::RepoAdmin),
993 ("restore_repo", Scope::RepoAdmin),
994 ("purge_repo", Scope::RepoAdmin),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily995 // A dismissed secret is let through push protection.
996 ("dismiss_security_alert", Scope::RepoAdmin),
997 ("reopen_security_alert", Scope::RepoAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar998 // The security suite: alerts, push protection, patterns, code
999 // scanning, the supply chain and settings.
1000 ("list_secret_scanning_alerts", Scope::SecurityRead),
1001 ("get_secret_scanning_alert", Scope::SecurityRead),
1002 ("list_secret_scanning_locations", Scope::SecurityRead),
1003 ("list_bypass_requests", Scope::SecurityRead),
1004 ("list_custom_patterns", Scope::SecurityRead),
1005 ("list_code_scanning_alerts", Scope::SecurityRead),
1006 ("get_code_scanning_alert", Scope::SecurityRead),
1007 ("list_code_scanning_analyses", Scope::SecurityRead),
1008 ("get_sarif_upload", Scope::SecurityRead),
1009 ("list_vulnerability_alerts", Scope::SecurityRead),
1010 ("get_vulnerability_alert", Scope::SecurityRead),
1011 ("get_dependency_graph", Scope::SecurityRead),
1012 ("get_sbom", Scope::SecurityRead),
1013 ("compare_dependencies", Scope::SecurityRead),
1014 ("get_security_settings", Scope::SecurityRead),
1015 ("get_workspace_security_settings", Scope::SecurityRead),
1016 ("get_security_overview", Scope::SecurityRead),
1017 ("update_secret_scanning_alert", Scope::SecurityWrite),
1018 ("bypass_push_protection", Scope::SecurityWrite),
1019 ("check_secret_validity", Scope::SecurityWrite),
1020 ("review_bypass_request", Scope::SecurityWrite),
1021 ("create_custom_pattern", Scope::SecurityWrite),
1022 ("update_custom_pattern", Scope::SecurityWrite),
1023 ("delete_custom_pattern", Scope::SecurityWrite),
1024 ("dry_run_custom_pattern", Scope::SecurityWrite),
1025 ("update_code_scanning_alert", Scope::SecurityWrite),
1026 ("upload_sarif", Scope::SecurityWrite),
1027 ("update_vulnerability_alert", Scope::SecurityWrite),
1028 ("fix_security_alert", Scope::SecurityWrite),
1029 ("update_security_settings", Scope::SecurityWrite),
1030 ("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 step1031 // Issues and plans.
1032 ("list_issues", Scope::IssuesRead),
1033 ("get_issue", Scope::IssuesRead),
1034 ("get_plan", Scope::IssuesRead),
1035 ("create_issue", Scope::IssuesWrite),
1036 ("update_issue", Scope::IssuesWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1037 ("list_issue_labels", Scope::IssuesRead),
1038 ("add_issue_labels", Scope::IssuesWrite),
1039 ("set_issue_labels", Scope::IssuesWrite),
1040 ("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 step1041 ("close_issue", Scope::IssuesWrite),
1042 ("reopen_issue", Scope::IssuesWrite),
1043 ("add_comment", Scope::IssuesWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1044 ("edit_comment", Scope::IssuesWrite),
1045 ("delete_comment", Scope::IssuesWrite),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1046 ("import_issue", Scope::IssuesWrite),
1047 ("apply_plan", Scope::IssuesWrite),
1048 // Pull requests.
1049 ("list_pull_requests", Scope::PullRequestsRead),
1050 ("get_pull_request", Scope::PullRequestsRead),
1051 ("get_pull_request_changes", Scope::PullRequestsRead),
1052 ("read_session", Scope::PullRequestsRead),
1053 ("get_merge_queue", Scope::PullRequestsRead),
1054 ("create_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1055 ("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 step1056 ("record_session", Scope::PullRequestsWrite),
1057 ("mark_pull_request_ready", Scope::PullRequestsWrite),
1058 ("close_pull_request", Scope::PullRequestsWrite),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1059 ("reopen_pull_request", Scope::PullRequestsWrite),
1060 ("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 step1061 ("review_pull_request", Scope::PullRequestsWrite),
1062 ("merge_pull_request", Scope::PullRequestsWrite),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1063 ("request_reviewers", Scope::PullRequestsWrite),
1064 ("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 step1065 // g1t's agents.
1066 ("assign_issue", Scope::AgentsRun),
1067 ("delegate", Scope::AgentsRun),
1068 ("plan_work", Scope::AgentsRun),
1069 ("message_agent", Scope::AgentsRun),
1070 ("answer_message", Scope::AgentsRun),
1071 ("take_messages", Scope::AgentsRun),
1072 // Workflows.
1073 ("list_workflows", Scope::WorkflowsRead),
1074 ("list_workflow_runs", Scope::WorkflowsRead),
1075 ("get_workflow_run", Scope::WorkflowsRead),
1076 ("get_job_logs", Scope::WorkflowsRead),
1077 ("dispatch_workflow", Scope::WorkflowsWrite),
1078 ("cancel_workflow_run", Scope::WorkflowsWrite),
1079 ("rerun_workflow_run", Scope::WorkflowsWrite),
1080 ("update_workflow", Scope::WorkflowsWrite),
Actions: OIDC tokens, the toolkit's cache and artifact services, and artifacts in R21081 ("list_artifacts", Scope::WorkflowsRead),
1082 ("list_workflow_run_artifacts", Scope::WorkflowsRead),
1083 ("get_artifact", Scope::WorkflowsRead),
1084 ("download_artifact", Scope::WorkflowsRead),
1085 ("get_artifact_retention", Scope::WorkflowsRead),
1086 ("delete_artifact", Scope::WorkflowsWrite),
1087 ("set_artifact_retention", Scope::WorkflowsWrite),
Merge checks: statuses and check runs on every commit1088 // Checks: statuses, check runs and check suites on commits.
1089 ("list_commit_statuses", Scope::ChecksRead),
1090 ("get_combined_status", Scope::ChecksRead),
1091 ("list_check_runs_for_ref", Scope::ChecksRead),
1092 ("get_check_run", Scope::ChecksRead),
1093 ("list_check_run_annotations", Scope::ChecksRead),
1094 ("list_check_suites_for_ref", Scope::ChecksRead),
1095 ("get_check_suite", Scope::ChecksRead),
1096 ("create_commit_status", Scope::ChecksWrite),
1097 ("create_check_run", Scope::ChecksWrite),
1098 ("update_check_run", Scope::ChecksWrite),
1099 ("rerequest_check_run", Scope::ChecksWrite),
1100 ("rerequest_check_suite", Scope::ChecksWrite),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb971101 // Deployments, wherever they run: reading them, and reporting them.
1102 ("list_deployments", Scope::DeploymentsRead),
1103 ("get_deployment", Scope::DeploymentsRead),
1104 ("list_deployment_statuses", Scope::DeploymentsRead),
1105 ("list_environments", Scope::DeploymentsRead),
1106 ("get_environment", Scope::DeploymentsRead),
1107 ("create_deployment", Scope::DeploymentsWrite),
1108 ("create_deployment_status", Scope::DeploymentsWrite),
Merge branch 'worktree-agent-a3abfcce648e87dca'1109 // What keeps runs safe: the runs environments hold and reviewing them,
1110 // approving a pull request's run, and a repository's own rules for
1111 // its environments and tokens, which are an admin's.
1112 ("get_pending_deployments", Scope::WorkflowsRead),
1113 ("review_pending_deployments", Scope::WorkflowsWrite),
1114 ("approve_workflow_run", Scope::WorkflowsWrite),
1115 ("get_workflow_permissions", Scope::RepoRead),
1116 ("get_fork_pr_approval", Scope::RepoRead),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1117 ("get_actions_access", Scope::RepoRead),
Merge branch 'worktree-agent-a3abfcce648e87dca'1118 ("update_environment", Scope::RepoAdmin),
1119 ("delete_environment", Scope::RepoAdmin),
1120 ("set_workflow_permissions", Scope::RepoAdmin),
1121 ("set_fork_pr_approval", Scope::RepoAdmin),
Merge Actions: cross-repo workflows and actions, release and deployment triggers, step timeouts1122 ("set_actions_access", Scope::RepoAdmin),
Merge branch 'worktree-agent-a3abfcce648e87dca'1123 // Starting workflows from outside, as a push would.
1124 ("create_repository_dispatch", Scope::CodeWrite),
1125 // A workspace's policy for its repositories' tokens.
1126 ("get_workspace_workflow_permissions", Scope::WorkspaceRead),
1127 ("set_workspace_workflow_permissions", Scope::WorkspaceAdmin),
API and MCP for a workspace's personal access token rules, members' tokens and approvals1128 // A workspace's rules for personal access tokens, and the members'
1129 // tokens that reach it: who has access.
1130 ("get_token_policy", Scope::WorkspaceRead),
1131 ("set_token_policy", Scope::WorkspaceAdmin),
1132 ("list_member_tokens", Scope::AccessRead),
1133 ("list_token_requests", Scope::AccessRead),
1134 ("review_token_request", Scope::AccessAdmin),
1135 ("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 step1136 // Memory and the context hub.
1137 ("recall", Scope::MemoryRead),
1138 ("search_context", Scope::MemoryRead),
1139 ("get_entity", Scope::MemoryRead),
1140 ("get_context", Scope::MemoryRead),
1141 ("remember", Scope::MemoryWrite),
1142 // Who has access.
1143 ("list_collaborators", Scope::AccessRead),
1144 ("get_collaborator_permission", Scope::AccessRead),
1145 ("list_repo_invitations", Scope::AccessRead),
1146 ("list_outside_collaborators", Scope::AccessRead),
1147 ("add_collaborator", Scope::AccessAdmin),
1148 ("update_collaborator", Scope::AccessAdmin),
1149 ("remove_collaborator", Scope::AccessAdmin),
1150 ("revoke_repo_invitation", Scope::AccessAdmin),
1151 ("set_base_permission", Scope::AccessAdmin),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1152 ("set_team_repo", Scope::AccessAdmin),
1153 ("remove_team_repo", Scope::AccessAdmin),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1154 // Deploy keys: each lets a machine reach one repository, so they
1155 // are part of who has access.
1156 ("list_deploy_keys", Scope::AccessRead),
1157 ("get_deploy_key", Scope::AccessRead),
1158 ("create_deploy_key", Scope::AccessAdmin),
1159 ("delete_deploy_key", Scope::AccessAdmin),
Merge branch 'mirroring' into artifacts-mode1160 // Mirroring: a repository's links to other hosts. Reading them is
1161 // reading the repository; syncing writes code; the rest is an admin's.
1162 ("get_mirror", Scope::RepoRead),
1163 ("sync_mirror", Scope::CodeWrite),
1164 ("get_hand_back_plan", Scope::RepoAdmin),
1165 ("take_over_mirror", Scope::RepoAdmin),
1166 ("set_ci_failover", Scope::RepoAdmin),
1167 ("hand_back_mirror", Scope::RepoAdmin),
1168 ("move_mirror_to_g1t", Scope::RepoAdmin),
1169 ("add_mirror_remote", Scope::RepoAdmin),
1170 ("update_mirror_remote", Scope::RepoAdmin),
1171 ("remove_mirror_remote", Scope::RepoAdmin),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1172 // Webhooks.
1173 ("list_webhooks", Scope::WebhooksRead),
1174 ("list_webhook_deliveries", Scope::WebhooksRead),
1175 ("create_webhook", Scope::WebhooksAdmin),
1176 ("update_webhook", Scope::WebhooksAdmin),
1177 ("delete_webhook", Scope::WebhooksAdmin),
1178 ("ping_webhook", Scope::WebhooksAdmin),
1179 ("redeliver_webhook", Scope::WebhooksAdmin),
1180 // Secrets and variables.
1181 ("list_actions_secrets", Scope::SecretsRead),
1182 ("list_actions_variables", Scope::SecretsRead),
1183 ("set_actions_secret", Scope::SecretsAdmin),
1184 ("delete_actions_secret", Scope::SecretsAdmin),
1185 ("set_actions_variable", Scope::SecretsAdmin),
1186 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1187 // Self-hosted runners.
1188 ("list_runners", Scope::RunnersRead),
1189 ("list_runner_groups", Scope::RunnersRead),
1190 ("get_runner_settings", Scope::RunnersRead),
1191 ("create_runner_registration_token", Scope::RunnersAdmin),
1192 ("remove_runner", Scope::RunnersAdmin),
1193 ("create_runner_group", Scope::RunnersAdmin),
1194 ("update_runner_group", Scope::RunnersAdmin),
1195 ("delete_runner_group", Scope::RunnersAdmin),
1196 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1197 // Packages: reading them, their versions and who may use them needs
1198 // `packages:read`; changing their settings, access and Manage Actions
1199 // access `packages:write` (and the Admin role on the package, which the
1200 // packages service checks); deleting and restoring packages and
1201 // versions `packages:delete`, as the registries' own deletes do.
1202 ("list_packages", Scope::PackagesRead),
1203 ("get_package", Scope::PackagesRead),
1204 ("list_package_versions", Scope::PackagesRead),
1205 ("get_package_version", Scope::PackagesRead),
1206 ("list_package_access", Scope::PackagesRead),
1207 ("list_package_actions_access", Scope::PackagesRead),
1208 ("update_package", Scope::PackagesWrite),
1209 ("link_package", Scope::PackagesWrite),
1210 ("unlink_package", Scope::PackagesWrite),
1211 ("set_package_access", Scope::PackagesWrite),
1212 ("remove_package_access", Scope::PackagesWrite),
1213 ("set_package_actions_access", Scope::PackagesWrite),
1214 ("remove_package_actions_access", Scope::PackagesWrite),
1215 ("delete_package", Scope::PackagesDelete),
1216 ("restore_package", Scope::PackagesDelete),
1217 ("delete_package_version", Scope::PackagesDelete),
1218 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1219 // The AI Gateway. Sending a request to a model needs `models:write`,
1220 // checked by the model proxy at models.g1t.sh, not here.
1221 ("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 step1222];
1223
1224/// Operations any token may use: saying who it is.
1225pub const NO_SCOPE: &[&str] = &["whoami"];
1226
1227/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1228/// operation in neither list needs full access.
1229pub fn scope_for(operation: &str) -> Option<Scope> {
1230 OPERATIONS
1231 .iter()
1232 .find(|(name, _)| *name == operation)
1233 .map(|(_, scope)| *scope)
1234}
1235
1236/// What a token needs for `operation` with this input beyond its own
1237/// scope: starting agents from an operation that can, and making a
1238/// repository public or private.
1239pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1240 let mut extra = Vec::new();
1241 let assigns = input["assign"].as_bool() == Some(true)
1242 || input["agent"].as_bool() == Some(true)
1243 || input["assign_agent"].as_bool() == Some(true);
1244 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1245 extra.push(Scope::AgentsRun);
1246 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1247 // Fixing an alert opens an issue and puts g1t on it.
1248 if operation == "fix_security_alert" {
1249 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1250 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1251 // Opening the issue an agent is put on.
1252 if operation == "delegate" {
1253 extra.push(Scope::IssuesWrite);
1254 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1255 // A workspace's base permission is who has access.
1256 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1257 extra.push(Scope::AccessAdmin);
1258 }
Merge checks: statuses and check runs on every commit1259 // Asking a g1t Actions job or run to run again reruns its workflow.
1260 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1261 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1262 {
1263 extra.push(Scope::WorkflowsWrite);
1264 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1265 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())) {
1266 extra.push(Scope::RepoAdmin);
1267 }
1268 extra
1269}
1270
1271/// The scopes a call needs, its own first.
1272pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1273 scope_for(operation)
1274 .into_iter()
1275 .chain(extra_scopes(operation, input))
1276 .collect()
1277}
1278
1279/// Whether `access` may use `operation` with `input`. The person's (or
1280/// workspace's) role is checked after this, by the service that owns what
1281/// was asked about.
1282pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1283 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1284 // A workflow job may open or approve pull requests only where its
1285 // repository and workspace let it, as on GitHub.
1286 if let Some(job) = &access.job
1287 && !job.pull_requests
1288 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1289 {
1290 return Decision::deny(
1291 "token:pull-requests",
1292 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1293 );
1294 }
1295 if let Some(only) = access.repo.as_deref()
1296 && !NO_SCOPE.contains(&operation)
1297 {
1298 match input["repo"].as_str() {
1299 Some(repo) if access.reaches(repo) => {}
1300 Some(repo) => {
1301 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1302 }
1303 None => {
1304 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1305 }
1306 }
1307 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1308 // A token made for one workspace (or none) only reads outside it:
1309 // public repositories, as anyone may. Inside it, its repository
1310 // selection is checked with its owner's role (`access::granted`).
1311 if let Some(reach) = &access.reach
Token reach: workflow_files scope, fine-grained reach, workspace token cap1312 && let Some(repo) = input["repo"].as_str()
1313 && !NO_SCOPE.contains(&operation)
1314 {
1315 let namespace = repo.split('/').next().unwrap_or_default();
1316 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1317 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 managers1318 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 cap1319 return Decision::deny(
1320 "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 managers1321 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 cap1322 );
1323 }
1324 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1325 if access.scopes.is_some() {
1326 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1327 if !known {
1328 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1329 }
1330 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1331 return Decision::deny(
1332 "token:scope",
1333 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1334 );
1335 }
1336 }
1337 Decision::allow(rule)
1338}
1339
Merge branch 'worktree-agent-a3abfcce648e87dca'1340/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1341/// for a workflow job's token or a deploy key in another repository,
1342/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1343/// the package registries ask this before [`decide_git`] and
1344/// [`decide_packages`].
1345pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1346 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1347 let why = if access.deploy_key.is_some() {
1348 format!("This deploy key is for {only}: it cannot reach {repo}.")
1349 } else {
1350 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1351 };
1352 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1353}
1354
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1355/// Whether a token may clone or fetch (`write` false), or push to (`write`
1356/// true), a repository with git. `public` is whether anyone may read it,
1357/// which needs no scope.
1358pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1359 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1360 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1361 if access.deploy_key.is_some() {
1362 return Decision::deny(
1363 "token:scope",
1364 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1365 );
1366 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1367 return Decision::deny(
1368 "token:scope",
1369 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1370 );
1371 }
1372 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1373}
1374
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1375/// Whether a token may pull (`Level::Read`), push or publish
1376/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1377/// whether anyone may pull the package, which needs no scope.
1378pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1379 let (needed, doing) = match level {
1380 Level::Read => (Scope::PackagesRead, "pull a private package"),
1381 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1382 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1383 };
1384 if !access.allows(needed) && !(level == Level::Read && public) {
1385 return Decision::deny(
1386 "token:scope",
1387 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1388 );
1389 }
1390 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1391}
1392
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1393#[cfg(test)]
1394mod tests {
1395 use super::*;
1396 use serde_json::json;
1397
1398 fn token(scopes: &[Scope]) -> TokenAccess {
1399 TokenAccess {
1400 token_id: "tok_1".to_owned(),
1401 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1402 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1403 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1404 ..TokenAccess::default()
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1405 }
1406 }
1407
1408 #[test]
1409 fn every_scope_reads_back_and_belongs_to_a_resource() {
1410 for scope in Scope::ALL {
1411 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1412 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1413 assert!(scope.includes(scope));
1414 }
1415 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1416 assert_eq!(Scope::parse("issues"), None);
1417 }
1418
1419 #[test]
1420 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1421 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1422 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1423 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1424 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1425 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1426 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1427 }
1428
1429 #[test]
1430 fn operations_are_listed_once_and_never_also_free() {
1431 let mut seen = std::collections::HashSet::new();
1432 for (name, _) in OPERATIONS {
1433 assert!(seen.insert(*name), "{name} twice");
1434 assert!(!NO_SCOPE.contains(name), "{name}");
1435 }
1436 }
1437
1438 #[test]
1439 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1440 assert_eq!(
1441 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1442 vec![Scope::RepoRead, Scope::IssuesWrite]
1443 );
1444 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1445 }
1446
1447 #[test]
1448 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1449 let scopes = oauth_default();
1450 assert!(scopes.contains(&Scope::IssuesWrite));
1451 assert!(scopes.contains(&Scope::PullRequestsWrite));
1452 assert!(scopes.contains(&Scope::AgentsRun));
1453 assert!(scopes.iter().all(|scope| !scope.dangerous()), "{scopes:?}");
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1454 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1455 // Every read offered but the machines work runs on.
Fast pages, required checks on the branch, self-hosted runners, honest incidents1456 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 step1457 }
1458 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1459 assert_eq!(Preset::Full.scopes(), None);
1460 }
1461
1462 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1463 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1464 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1465 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1466 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1467 }
1468 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1469 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1470 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1471 let reader = token(&[Scope::BillingRead]);
1472 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1473 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1474 }
1475
1476 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1477 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1478 // Reading the log is a read like any other.
1479 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1480 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1481 // Sending requests spends the workspace's AI credit: chosen on purpose.
1482 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1483 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1484 }
1485 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1486 assert!(!Scope::ModelsWrite.dangerous());
1487 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1488 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1489 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1490 }
1491
1492 #[test]
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1493 fn artifacts_scopes_exist_but_are_not_offered_yet() {
1494 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1495 assert_eq!(scope.resource(), Resource::Artifacts);
1496 assert!(!scope.offered());
1497 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1498 // Nothing hands it out: not presets, full access, OAuth or the form.
1499 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1500 assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str());
1501 }
1502 assert!(!everything().contains(&scope));
1503 assert!(!offered_scopes().contains(&scope));
1504 assert!(!oauth_default().contains(&scope));
1505 assert!(parse_scopes(scope.as_str()).is_empty());
1506 // And no operation needs it yet.
1507 assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope));
1508 }
1509 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1510 assert!(Scope::ArtifactsAdmin.dangerous());
1511 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1512 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]);
1513 assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned()));
1514 assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3);
1515 }
1516
1517 #[test]
Merge checks: statuses and check runs on every commit1518 fn checks_are_reported_with_checks_write_which_ci_gets() {
1519 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1520 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1521 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1522 let ci = Preset::Ci.scopes().unwrap();
1523 assert!(ci.contains(&Scope::ChecksWrite));
1524 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1525 let reporter = token(&[Scope::ChecksWrite]);
1526 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1527 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1528 // A g1t Actions job runs again as its workflow does.
1529 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1530 assert!(refused.reason.unwrap().contains("workflows:write"));
1531 }
1532
1533 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1534 fn a_job_token_reaches_its_repository_only() {
1535 let job = TokenAccess {
1536 repo: Some("acme/web".into()),
1537 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1538 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1539 };
1540 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1541 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1542 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1543 assert!(!elsewhere.allowed);
1544 assert_eq!(elsewhere.rule, "token:repository");
1545 // Nothing beyond the one repository, a workspace's listing included.
1546 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1547 assert!(decide(&job, "whoami", &json!({})).allowed);
1548 // Its scopes still hold inside it.
1549 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1550 assert!(decide_repo(&job, "acme/web").is_none());
1551 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1552 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1553 // Opening and approving pull requests is off unless allowed.
1554 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1555 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1556 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1557 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1558 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1559 }
1560
1561 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1562 fn workflow_files_need_their_own_scope() {
1563 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1564 assert!(is_workflow_file(path), "{path}");
1565 }
1566 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1567 assert!(!is_workflow_file(path), "{path}");
1568 }
1569 let code = token(&[Scope::CodeWrite]);
1570 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1571 assert_eq!(refused.rule, "token:workflows");
1572 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1573 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1574 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1575 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1576 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1577 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1578 // A job's token never may, as GITHUB_TOKEN never may.
1579 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1580 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1581 // Nothing in a preset changes workflow files but full access.
1582 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1583 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1584 }
1585 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1586 }
1587
1588 #[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 managers1589 fn a_narrowed_token_only_reads_outside_its_workspace() {
1590 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1591 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 table1592 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1593 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1594 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1595 assert_eq!(elsewhere.rule, "token:resource-owner");
1596 assert!(elsewhere.reason.unwrap().contains("acme"));
1597 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1598 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 managers1599 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1600 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1601 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 managers1602 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 table1603 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 managers1604 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
Workflow files need workflow_files:write from a token; fine-grained permission table1605 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 managers1606 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 table1607 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1608 }
1609
1610 #[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 managers1611 fn permissions_are_scopes_read_per_resource() {
1612 let asked: std::collections::BTreeMap<String, String> =
1613 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1614 let scopes = resolve_permissions(&asked, true).unwrap();
1615 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1616 let back = permissions_of(&scopes);
1617 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1618 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1619 assert!(!back.contains_key("code"));
1620 // Lower levels held beside a higher one say nothing more.
1621 assert_eq!(top_scopes(&[Scope::RepoRead, Scope::RepoAdmin, Scope::RepoWrite]), vec![Scope::RepoAdmin]);
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1622 // Every offered resource's top, and nothing a level can lose.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1623 let all = everything();
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1624 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1625 for scope in offered_scopes() {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1626 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1627 }
1628 }
1629
1630 #[test]
1631 fn permissions_are_checked_by_name_level_and_owner() {
1632 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1633 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1634 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1635 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1636 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1637 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1638 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1639 for resource in Resource::ALL {
1640 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1641 assert!(!resource.scopes().is_empty());
1642 }
1643 }
1644
1645 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1646 fn a_legacy_token_can_do_everything() {
1647 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1648 for (operation, _) in OPERATIONS {
1649 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1650 }
1651 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1652 }
1653
1654 #[test]
1655 fn a_missing_scope_is_named() {
1656 let read = token(&[Scope::IssuesRead]);
1657 assert!(decide(&read, "get_issue", &json!({})).allowed);
1658 assert!(decide(&read, "whoami", &json!({})).allowed);
1659 let refused = decide(&read, "create_issue", &json!({}));
1660 assert!(!refused.allowed);
1661 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1662 // An operation the table does not know needs full access.
1663 assert!(!decide(&read, "something_new", &json!({})).allowed);
1664 }
1665
1666 #[test]
1667 fn starting_agents_from_another_operation_needs_agents_run() {
1668 let writer = token(&[Scope::IssuesWrite]);
1669 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1670 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1671 assert!(refused.reason.unwrap().contains("agents:run"));
1672 let maintainer = token(&[Scope::RepoWrite]);
1673 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1674 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1675 }
1676
1677 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1678 fn a_workspaces_base_permission_needs_access_admin_too() {
1679 let admin = token(&[Scope::WorkspaceAdmin]);
1680 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1681 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1682 assert!(refused.reason.unwrap().contains("access:admin"));
1683 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1684 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1685 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1686 }
1687
1688 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1689 fn delegating_needs_both_agents_and_issues() {
1690 let agents = token(&[Scope::AgentsRun]);
1691 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1692 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1693 assert!(decide(&both, "delegate", &json!({})).allowed);
1694 }
1695
1696 #[test]
1697 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1698 let reader = token(&[Scope::CodeRead]);
1699 assert!(decide_git(&reader, false, false).allowed);
1700 let refused = decide_git(&reader, true, false);
1701 assert!(!refused.allowed);
1702 assert!(refused.reason.unwrap().contains("code:write"));
1703 let issues = token(&[Scope::IssuesWrite]);
1704 assert!(!decide_git(&issues, false, false).allowed);
1705 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1706 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1707 let writer = token(&[Scope::CodeWrite]);
1708 assert!(decide_git(&writer, true, false).allowed);
1709 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1710 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1711 }
1712
1713 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1714 fn packages_need_their_own_scopes_and_public_pulls_none() {
1715 let reader = token(&[Scope::PackagesRead]);
1716 assert!(decide_packages(&reader, Level::Read, false).allowed);
1717 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1718 let code = token(&[Scope::CodeWrite]);
1719 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1720 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1721 let writer = token(&[Scope::PackagesWrite]);
1722 assert!(decide_packages(&writer, Level::Write, false).allowed);
1723 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1724 let refused = decide_packages(&writer, Level::Delete, false);
1725 assert!(refused.reason.unwrap().contains("packages:delete"));
1726 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1727 assert!(Scope::PackagesDelete.dangerous());
1728 // Tokens made before these scopes, and full-access ones, keep working.
1729 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1730 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1731 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1732 }
1733
1734 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1735 fn token_access_travels_as_json() {
1736 let access = token(&[Scope::IssuesRead]);
1737 let wire = serde_json::to_value(&access).unwrap();
1738 assert_eq!(wire["scopes"], json!(["issues:read"]));
1739 assert!(wire.get("resources").is_none());
1740 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1741 assert_eq!(back, access);
1742 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1743 assert!(full.is_full());
1744 // A reach written by an older version is ignored: a token reaches
1745 // whatever its owner can.
1746 let older: TokenAccess = serde_json::from_value(json!({
1747 "token_id": "tok_1",
1748 "scopes": ["issues:read"],
1749 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1750 }))
1751 .unwrap();
1752 assert_eq!(older, access);
1753 }
1754
1755 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1756 /// lists the same scopes in the same order, the same operations with
1757 /// the same scopes, and the same presets.
1758 #[test]
1759 fn the_typescript_mirror_has_the_same_table() {
1760 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1761 let section = |start: &str| {
1762 ts.split_once(start)
1763 .and_then(|(_, rest)| rest.split_once("] as const"))
1764 .map(|(table, _)| table)
1765 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1766 };
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1767 let names = |table: &str| -> Vec<String> {
1768 section(table)
1769 .lines()
1770 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1771 .collect()
1772 };
1773 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1774 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1775 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1776 assert_eq!(names("export const SCOPES = ["), offered);
1777 assert_eq!(names("export const UPCOMING_SCOPES = ["), upcoming);
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1778 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1779 .lines()
1780 .filter_map(|line| {
1781 let mut quoted = line.split('"').skip(1).step_by(2);
1782 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1783 })
1784 .collect();
1785 let expected: Vec<(String, String)> = OPERATIONS
1786 .iter()
1787 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1788 .collect();
1789 assert_eq!(operations, expected);
1790 for preset in Preset::ALL {
1791 let list = section(&format!("{}: [", preset.as_str()));
1792 let mirrored: Vec<&str> = list
1793 .split(',')
1794 .map(|item| item.trim().trim_matches('"'))
1795 .filter(|item| !item.is_empty())
1796 .collect();
1797 let expected: Vec<&str> = preset
1798 .scopes()
1799 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1800 .unwrap_or_else(|| vec!["*"]);
1801 assert_eq!(mirrored, expected, "{}", preset.as_str());
1802 }
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1803 // Each resource with its group, in the same order: offered ones in
1804 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1805 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1806 let resources = ts
1807 .split_once(table)
1808 .and_then(|(_, rest)| rest.split_once("
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1809];"))
Artifacts contracts: folios, their four kinds, dashboard datasets, folio events and the artifacts scopes are typed and validated the same in TypeScript and Rust, with nothing using them yet1810 .map(|(table, _)| table)
1811 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1812 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1813 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1814 assert_eq!(rows.len(), expected.len(), "{table}");
1815 for (row, resource) in rows.iter().zip(expected) {
1816 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1817 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1818 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1819 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1820 }
1821}

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