Skip to content
1,809 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),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1160 // Webhooks.
1161 ("list_webhooks", Scope::WebhooksRead),
1162 ("list_webhook_deliveries", Scope::WebhooksRead),
1163 ("create_webhook", Scope::WebhooksAdmin),
1164 ("update_webhook", Scope::WebhooksAdmin),
1165 ("delete_webhook", Scope::WebhooksAdmin),
1166 ("ping_webhook", Scope::WebhooksAdmin),
1167 ("redeliver_webhook", Scope::WebhooksAdmin),
1168 // Secrets and variables.
1169 ("list_actions_secrets", Scope::SecretsRead),
1170 ("list_actions_variables", Scope::SecretsRead),
1171 ("set_actions_secret", Scope::SecretsAdmin),
1172 ("delete_actions_secret", Scope::SecretsAdmin),
1173 ("set_actions_variable", Scope::SecretsAdmin),
1174 ("delete_actions_variable", Scope::SecretsAdmin),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1175 // Self-hosted runners.
1176 ("list_runners", Scope::RunnersRead),
1177 ("list_runner_groups", Scope::RunnersRead),
1178 ("get_runner_settings", Scope::RunnersRead),
1179 ("create_runner_registration_token", Scope::RunnersAdmin),
1180 ("remove_runner", Scope::RunnersAdmin),
1181 ("create_runner_group", Scope::RunnersAdmin),
1182 ("update_runner_group", Scope::RunnersAdmin),
1183 ("delete_runner_group", Scope::RunnersAdmin),
1184 ("update_runner_settings", Scope::RunnersAdmin),
Merge packages: roles, Actions access, source label, soft delete, API1185 // Packages: reading them, their versions and who may use them needs
1186 // `packages:read`; changing their settings, access and Manage Actions
1187 // access `packages:write` (and the Admin role on the package, which the
1188 // packages service checks); deleting and restoring packages and
1189 // versions `packages:delete`, as the registries' own deletes do.
1190 ("list_packages", Scope::PackagesRead),
1191 ("get_package", Scope::PackagesRead),
1192 ("list_package_versions", Scope::PackagesRead),
1193 ("get_package_version", Scope::PackagesRead),
1194 ("list_package_access", Scope::PackagesRead),
1195 ("list_package_actions_access", Scope::PackagesRead),
1196 ("update_package", Scope::PackagesWrite),
1197 ("link_package", Scope::PackagesWrite),
1198 ("unlink_package", Scope::PackagesWrite),
1199 ("set_package_access", Scope::PackagesWrite),
1200 ("remove_package_access", Scope::PackagesWrite),
1201 ("set_package_actions_access", Scope::PackagesWrite),
1202 ("remove_package_actions_access", Scope::PackagesWrite),
1203 ("delete_package", Scope::PackagesDelete),
1204 ("restore_package", Scope::PackagesDelete),
1205 ("delete_package_version", Scope::PackagesDelete),
1206 ("restore_package_version", Scope::PackagesDelete),
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1207 // The AI Gateway. Sending a request to a model needs `models:write`,
1208 // checked by the model proxy at models.g1t.sh, not here.
1209 ("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 step1210];
1211
1212/// Operations any token may use: saying who it is.
1213pub const NO_SCOPE: &[&str] = &["whoami"];
1214
1215/// The scope `operation` needs. `None` for one in [`NO_SCOPE`]; an
1216/// operation in neither list needs full access.
1217pub fn scope_for(operation: &str) -> Option<Scope> {
1218 OPERATIONS
1219 .iter()
1220 .find(|(name, _)| *name == operation)
1221 .map(|(_, scope)| *scope)
1222}
1223
1224/// What a token needs for `operation` with this input beyond its own
1225/// scope: starting agents from an operation that can, and making a
1226/// repository public or private.
1227pub fn extra_scopes(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1228 let mut extra = Vec::new();
1229 let assigns = input["assign"].as_bool() == Some(true)
1230 || input["agent"].as_bool() == Some(true)
1231 || input["assign_agent"].as_bool() == Some(true);
1232 if assigns && matches!(operation, "apply_plan" | "import_issue" | "create_issue") {
1233 extra.push(Scope::AgentsRun);
1234 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1235 // Fixing an alert opens an issue and puts g1t on it.
1236 if operation == "fix_security_alert" {
1237 extra.extend([Scope::IssuesWrite, Scope::AgentsRun]);
1238 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1239 // Opening the issue an agent is put on.
1240 if operation == "delegate" {
1241 extra.push(Scope::IssuesWrite);
1242 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1243 // A workspace's base permission is who has access.
1244 if operation == "update_workspace" && input.get("base_permission").is_some_and(|v| !v.is_null()) {
1245 extra.push(Scope::AccessAdmin);
1246 }
Merge checks: statuses and check runs on every commit1247 // Asking a g1t Actions job or run to run again reruns its workflow.
1248 if matches!(operation, "rerequest_check_run" | "rerequest_check_suite")
1249 && input["id"].as_str().is_some_and(|id| id.starts_with("job_") || id.starts_with("run_"))
1250 {
1251 extra.push(Scope::WorkflowsWrite);
1252 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1253 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())) {
1254 extra.push(Scope::RepoAdmin);
1255 }
1256 extra
1257}
1258
1259/// The scopes a call needs, its own first.
1260pub fn needed(operation: &str, input: &serde_json::Value) -> Vec<Scope> {
1261 scope_for(operation)
1262 .into_iter()
1263 .chain(extra_scopes(operation, input))
1264 .collect()
1265}
1266
1267/// Whether `access` may use `operation` with `input`. The person's (or
1268/// workspace's) role is checked after this, by the service that owns what
1269/// was asked about.
1270pub fn decide(access: &TokenAccess, operation: &str, input: &serde_json::Value) -> Decision {
1271 let rule = if access.legacy { "token:legacy" } else { "token:scope" };
Merge branch 'worktree-agent-a3abfcce648e87dca'1272 // A workflow job may open or approve pull requests only where its
1273 // repository and workspace let it, as on GitHub.
1274 if let Some(job) = &access.job
1275 && !job.pull_requests
1276 && (operation == "create_pull_request" || (operation == "review_pull_request" && input["verdict"].as_str() == Some("approve")))
1277 {
1278 return Decision::deny(
1279 "token:pull-requests",
1280 "A workflow job cannot open or approve pull requests here: an admin can allow it under Settings, Actions.",
1281 );
1282 }
1283 if let Some(only) = access.repo.as_deref()
1284 && !NO_SCOPE.contains(&operation)
1285 {
1286 match input["repo"].as_str() {
1287 Some(repo) if access.reaches(repo) => {}
1288 Some(repo) => {
1289 return Decision::deny("token:repository", format!("This token is a workflow job's in {only}: it cannot reach {repo}."));
1290 }
1291 None => {
1292 return Decision::deny("token:repository", format!("This token is a workflow job's: it reaches only {only}, and {operation} is not about one repository."));
1293 }
1294 }
1295 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1296 // A token made for one workspace (or none) only reads outside it:
1297 // public repositories, as anyone may. Inside it, its repository
1298 // selection is checked with its owner's role (`access::granted`).
1299 if let Some(reach) = &access.reach
Token reach: workflow_files scope, fine-grained reach, workspace token cap1300 && let Some(repo) = input["repo"].as_str()
1301 && !NO_SCOPE.contains(&operation)
1302 {
1303 let namespace = repo.split('/').next().unwrap_or_default();
1304 let changes = needed(operation, input).iter().any(|scope| scope.level() != Level::Read);
1305 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 managers1306 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 cap1307 return Decision::deny(
1308 "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 managers1309 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 cap1310 );
1311 }
1312 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1313 if access.scopes.is_some() {
1314 let known = NO_SCOPE.contains(&operation) || scope_for(operation).is_some();
1315 if !known {
1316 return Decision::deny("token:scope", format!("This access token cannot use {operation}: it needs full access."));
1317 }
1318 if let Some(missing) = needed(operation, input).into_iter().find(|scope| !access.allows(*scope)) {
1319 return Decision::deny(
1320 "token:scope",
1321 format!("This access token needs the {} scope to use {operation}.", missing.as_str()),
1322 );
1323 }
1324 }
1325 Decision::allow(rule)
1326}
1327
Merge branch 'worktree-agent-a3abfcce648e87dca'1328/// Whether a token may use the repository `owner/name` at all: a refusal
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1329/// for a workflow job's token or a deploy key in another repository,
1330/// else `None`. Git and
Merge branch 'worktree-agent-a3abfcce648e87dca'1331/// the package registries ask this before [`decide_git`] and
1332/// [`decide_packages`].
1333pub fn decide_repo(access: &TokenAccess, repo: &str) -> Option<Decision> {
1334 let only = access.repo.as_deref()?;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1335 let why = if access.deploy_key.is_some() {
1336 format!("This deploy key is for {only}: it cannot reach {repo}.")
1337 } else {
1338 format!("This token is a workflow job's in {only}: it cannot reach {repo}.")
1339 };
1340 (!access.reaches(repo)).then(|| Decision::deny("token:repository", why))
Merge branch 'worktree-agent-a3abfcce648e87dca'1341}
1342
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1343/// Whether a token may clone or fetch (`write` false), or push to (`write`
1344/// true), a repository with git. `public` is whether anyone may read it,
1345/// which needs no scope.
1346pub fn decide_git(access: &TokenAccess, write: bool, public: bool) -> Decision {
1347 let needed = if write { Scope::CodeWrite } else { Scope::CodeRead };
1348 if !access.allows(needed) && (write || !public) {
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1349 if access.deploy_key.is_some() {
1350 return Decision::deny(
1351 "token:scope",
1352 "This deploy key is read-only. An admin of the repository can add it again with write access to push with it.",
1353 );
1354 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1355 return Decision::deny(
1356 "token:scope",
1357 format!("This access token needs the {} scope to {} with git.", needed.as_str(), if write { "push" } else { "clone or fetch a private repository" }),
1358 );
1359 }
1360 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1361}
1362
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1363/// Whether a token may pull (`Level::Read`), push or publish
1364/// (`Level::Write`), or delete (`Level::Delete`) packages. `public` is
1365/// whether anyone may pull the package, which needs no scope.
1366pub fn decide_packages(access: &TokenAccess, level: Level, public: bool) -> Decision {
1367 let (needed, doing) = match level {
1368 Level::Read => (Scope::PackagesRead, "pull a private package"),
1369 Level::Delete | Level::Admin => (Scope::PackagesDelete, "delete packages"),
1370 Level::Write | Level::Run => (Scope::PackagesWrite, "push or publish packages"),
1371 };
1372 if !access.allows(needed) && !(level == Level::Read && public) {
1373 return Decision::deny(
1374 "token:scope",
1375 format!("This access token needs the {} scope to {doing}.", needed.as_str()),
1376 );
1377 }
1378 Decision::allow(if access.legacy { "token:legacy" } else { "token:scope" })
1379}
1380
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1381#[cfg(test)]
1382mod tests {
1383 use super::*;
1384 use serde_json::json;
1385
1386 fn token(scopes: &[Scope]) -> TokenAccess {
1387 TokenAccess {
1388 token_id: "tok_1".to_owned(),
1389 scopes: Some(scopes.iter().map(|scope| scope.as_str().to_owned()).collect()),
1390 legacy: false,
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1391 name: None,
Merge branch 'worktree-agent-a3abfcce648e87dca'1392 ..TokenAccess::default()
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1393 }
1394 }
1395
1396 #[test]
1397 fn every_scope_reads_back_and_belongs_to_a_resource() {
1398 for scope in Scope::ALL {
1399 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1400 assert!(scope.as_str().starts_with(scope.resource().as_str()));
1401 assert!(scope.includes(scope));
1402 }
1403 assert_eq!(Scope::parse(" Issues:Write "), Some(Scope::IssuesWrite));
1404 assert_eq!(Scope::parse("issues"), None);
1405 }
1406
1407 #[test]
1408 fn a_higher_level_includes_the_lower_ones_of_its_resource_only() {
1409 assert!(Scope::RepoAdmin.includes(Scope::RepoRead));
1410 assert!(Scope::RepoAdmin.includes(Scope::RepoWrite));
1411 assert!(Scope::IssuesWrite.includes(Scope::IssuesRead));
1412 assert!(!Scope::IssuesRead.includes(Scope::IssuesWrite));
1413 assert!(!Scope::RepoAdmin.includes(Scope::CodeWrite));
1414 assert!(!Scope::PullRequestsWrite.includes(Scope::IssuesWrite));
1415 }
1416
1417 #[test]
1418 fn operations_are_listed_once_and_never_also_free() {
1419 let mut seen = std::collections::HashSet::new();
1420 for (name, _) in OPERATIONS {
1421 assert!(seen.insert(*name), "{name} twice");
1422 assert!(!NO_SCOPE.contains(name), "{name}");
1423 }
1424 }
1425
1426 #[test]
1427 fn scopes_are_parsed_from_oauth_text_leaving_out_unknown_ones() {
1428 assert_eq!(
1429 parse_scopes("issues:write repo:read,bogus:thing issues:write"),
1430 vec![Scope::RepoRead, Scope::IssuesWrite]
1431 );
1432 assert_eq!(scopes_text(&[Scope::RepoRead, Scope::IssuesWrite]), "repo:read issues:write");
1433 }
1434
1435 #[test]
1436 fn the_oauth_default_is_the_agent_preset_and_never_admin() {
1437 let scopes = oauth_default();
1438 assert!(scopes.contains(&Scope::IssuesWrite));
1439 assert!(scopes.contains(&Scope::PullRequestsWrite));
1440 assert!(scopes.contains(&Scope::AgentsRun));
1441 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 yet1442 for read in Scope::ALL.into_iter().filter(|scope| scope.level() == Level::Read && scope.offered()) {
1443 // Every read offered but the machines work runs on.
Fast pages, required checks on the branch, self-hosted runners, honest incidents1444 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 step1445 }
1446 assert!(Preset::ReadOnly.scopes().unwrap().iter().all(|scope| scope.level() == Level::Read));
1447 assert_eq!(Preset::Full.scopes(), None);
1448 }
1449
1450 #[test]
Usage, Billing settings and prepaid AI credit; fixes from the UX audit1451 fn billing_is_read_by_presets_and_changed_by_none_but_full_access() {
1452 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::BillingRead));
1453 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1454 assert!(!preset.scopes().unwrap().contains(&Scope::BillingWrite), "{}", preset.as_str());
1455 }
1456 assert_eq!(scope_for("set_budget"), Some(Scope::BillingWrite));
1457 assert_eq!(scope_for("buy_ai_credit"), Some(Scope::BillingWrite));
1458 assert_eq!(scope_for("get_usage"), Some(Scope::BillingRead));
1459 let reader = token(&[Scope::BillingRead]);
1460 assert!(decide(&reader, "list_invoices", &json!({})).allowed);
1461 assert!(decide(&reader, "set_budget", &json!({})).reason.unwrap().contains("billing:write"));
1462 }
1463
1464 #[test]
Merge the AI Gateway: Anthropic's Messages API on a workspace's tokens1465 fn the_ai_gateway_spends_only_with_models_write_which_no_preset_gives() {
1466 // Reading the log is a read like any other.
1467 assert_eq!(scope_for("list_gateway_requests"), Some(Scope::ModelsRead));
1468 assert!(Preset::ReadOnly.scopes().unwrap().contains(&Scope::ModelsRead));
1469 // Sending requests spends the workspace's AI credit: chosen on purpose.
1470 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1471 assert!(!preset.scopes().unwrap().contains(&Scope::ModelsWrite), "{}", preset.as_str());
1472 }
1473 assert!(Scope::ModelsWrite.includes(Scope::ModelsRead));
1474 assert!(!Scope::ModelsWrite.dangerous());
1475 assert!(token(&[Scope::ModelsWrite]).allows(Scope::ModelsWrite));
1476 assert!(!token(&[Scope::BillingWrite]).allows(Scope::ModelsWrite));
1477 assert!(TokenAccess::full().allows(Scope::ModelsWrite));
1478 }
1479
1480 #[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 yet1481 fn artifacts_scopes_exist_but_are_not_offered_yet() {
1482 for scope in [Scope::ArtifactsRead, Scope::ArtifactsWrite, Scope::ArtifactsAdmin] {
1483 assert_eq!(scope.resource(), Resource::Artifacts);
1484 assert!(!scope.offered());
1485 assert_eq!(Scope::parse(scope.as_str()), Some(scope));
1486 // Nothing hands it out: not presets, full access, OAuth or the form.
1487 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1488 assert!(!preset.scopes().unwrap().contains(&scope), "{}", preset.as_str());
1489 }
1490 assert!(!everything().contains(&scope));
1491 assert!(!offered_scopes().contains(&scope));
1492 assert!(!oauth_default().contains(&scope));
1493 assert!(parse_scopes(scope.as_str()).is_empty());
1494 // And no operation needs it yet.
1495 assert!(OPERATIONS.iter().all(|(_, needed)| *needed != scope));
1496 }
1497 assert!(Scope::ArtifactsAdmin.includes(Scope::ArtifactsWrite));
1498 assert!(Scope::ArtifactsAdmin.dangerous());
1499 assert_eq!(Resource::Artifacts.group(), ResourceGroup::Workspace);
1500 let asked = std::collections::BTreeMap::from([("artifacts".to_owned(), "read".to_owned())]);
1501 assert_eq!(resolve_permissions(&asked, true), Err("There is no permission called artifacts.".to_owned()));
1502 assert_eq!(offered_scopes().len(), Scope::ALL.len() - 3);
1503 }
1504
1505 #[test]
Merge checks: statuses and check runs on every commit1506 fn checks_are_reported_with_checks_write_which_ci_gets() {
1507 assert_eq!(scope_for("create_check_run"), Some(Scope::ChecksWrite));
1508 assert_eq!(scope_for("create_commit_status"), Some(Scope::ChecksWrite));
1509 assert_eq!(scope_for("list_check_runs_for_ref"), Some(Scope::ChecksRead));
1510 let ci = Preset::Ci.scopes().unwrap();
1511 assert!(ci.contains(&Scope::ChecksWrite));
1512 assert!(!Preset::Agent.scopes().unwrap().contains(&Scope::ChecksWrite));
1513 let reporter = token(&[Scope::ChecksWrite]);
1514 assert!(decide(&reporter, "update_check_run", &json!({ "id": "cr_1" })).allowed);
1515 assert!(decide(&reporter, "rerequest_check_run", &json!({ "id": "cr_1" })).allowed);
1516 // A g1t Actions job runs again as its workflow does.
1517 let refused = decide(&reporter, "rerequest_check_run", &json!({ "id": "job_1" }));
1518 assert!(refused.reason.unwrap().contains("workflows:write"));
1519 }
1520
1521 #[test]
Merge branch 'worktree-agent-a3abfcce648e87dca'1522 fn a_job_token_reaches_its_repository_only() {
1523 let job = TokenAccess {
1524 repo: Some("acme/web".into()),
1525 job: Some(JobToken { run_id: "run_1".into(), job_id: "job_1".into(), pull_requests: false }),
1526 ..token(&[Scope::RepoRead, Scope::IssuesWrite, Scope::IssuesRead, Scope::PullRequestsWrite])
1527 };
1528 assert!(decide(&job, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1529 assert!(decide(&job, "create_issue", &json!({ "repo": "Acme/Web" })).allowed, "names compare without case");
1530 let elsewhere = decide(&job, "create_issue", &json!({ "repo": "acme/api" }));
1531 assert!(!elsewhere.allowed);
1532 assert_eq!(elsewhere.rule, "token:repository");
1533 // Nothing beyond the one repository, a workspace's listing included.
1534 assert!(!decide(&job, "list_repos", &json!({})).allowed);
1535 assert!(decide(&job, "whoami", &json!({})).allowed);
1536 // Its scopes still hold inside it.
1537 assert!(!decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1538 assert!(decide_repo(&job, "acme/web").is_none());
1539 assert!(!decide_repo(&job, "acme/api").unwrap().allowed);
1540 assert!(decide_repo(&token(&[Scope::CodeRead]), "acme/api").is_none(), "other tokens reach what their owner can");
1541 // Opening and approving pull requests is off unless allowed.
1542 assert_eq!(decide(&job, "create_pull_request", &json!({ "repo": "acme/web" })).rule, "token:pull-requests");
1543 assert!(!decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "approve" })).allowed);
1544 assert!(decide(&job, "review_pull_request", &json!({ "repo": "acme/web", "verdict": "request_changes" })).allowed);
1545 let allowed = TokenAccess { job: Some(JobToken { pull_requests: true, ..job.job.clone().unwrap() }), ..job.clone() };
1546 assert!(decide(&allowed, "create_pull_request", &json!({ "repo": "acme/web" })).allowed);
1547 }
1548
1549 #[test]
Workflow files need workflow_files:write from a token; fine-grained permission table1550 fn workflow_files_need_their_own_scope() {
1551 for path in [".g1t/workflows/ci.yml", ".github/workflows/deploy.yaml", "/.github/workflows/x.yml", ".GitHub/Workflows/ci.yml", ".github/workflows"] {
1552 assert!(is_workflow_file(path), "{path}");
1553 }
1554 for path in ["README.md", ".github/CODEOWNERS", ".github/workflowsx/ci.yml", "docs/.github/workflows/ci.yml", ".g1t/actions/ci.yml"] {
1555 assert!(!is_workflow_file(path), "{path}");
1556 }
1557 let code = token(&[Scope::CodeWrite]);
1558 let refused = decide_workflow_files(Some(&code), ["README.md", ".github/workflows/ci.yml"]).unwrap();
1559 assert_eq!(refused.rule, "token:workflows");
1560 assert!(refused.reason.as_deref().unwrap().contains(".github/workflows/ci.yml"));
1561 assert!(refused.reason.as_deref().unwrap().contains("workflow_files:write"));
1562 assert!(decide_workflow_files(Some(&code), ["README.md"]).is_none());
1563 assert!(decide_workflow_files(Some(&token(&[Scope::CodeWrite, Scope::WorkflowFilesWrite])), [".g1t/workflows/ci.yml"]).is_none());
1564 assert!(decide_workflow_files(Some(&TokenAccess::full()), [".g1t/workflows/ci.yml"]).is_none(), "full access");
1565 assert!(decide_workflow_files(None, [".g1t/workflows/ci.yml"]).is_none(), "a signed-in person");
1566 // A job's token never may, as GITHUB_TOKEN never may.
1567 let job = TokenAccess { job: Some(JobToken::default()), ..TokenAccess::full() };
1568 assert!(decide_workflow_files(Some(&job), [".g1t/workflows/ci.yml"]).unwrap().reason.unwrap().contains("job"));
1569 // Nothing in a preset changes workflow files but full access.
1570 for preset in [Preset::ReadOnly, Preset::Agent, Preset::Ci] {
1571 assert!(!preset.scopes().unwrap().contains(&Scope::WorkflowFilesWrite), "{}", preset.as_str());
1572 }
1573 assert!(!Scope::WorkflowFilesWrite.includes(Scope::WorkflowsWrite) && !Scope::WorkflowsWrite.includes(Scope::WorkflowFilesWrite));
1574 }
1575
1576 #[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 managers1577 fn a_narrowed_token_only_reads_outside_its_workspace() {
1578 let reach = TokenReach { workspace: Some("acme".into()), repositories: RepositorySelection::All, repo_ids: Vec::new() };
1579 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 table1580 assert!(decide(&fine, "create_issue", &json!({ "repo": "acme/web" })).allowed);
1581 assert!(decide(&fine, "create_issue", &json!({ "repo": "Acme/web" })).allowed);
1582 let elsewhere = decide(&fine, "create_issue", &json!({ "repo": "globex/site" }));
1583 assert_eq!(elsewhere.rule, "token:resource-owner");
1584 assert!(elsewhere.reason.unwrap().contains("acme"));
1585 assert!(decide(&fine, "get_issue", &json!({ "repo": "globex/site" })).allowed, "public repositories elsewhere read");
1586 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 managers1587 let mine = TokenAccess { reach: Some(TokenReach::default()), ..token(&[Scope::IssuesWrite]) };
Workflow files need workflow_files:write from a token; fine-grained permission table1588 assert!(decide(&mine, "create_issue", &json!({ "repo": "acme/web" })).reason.unwrap().contains("your account"));
1589 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 managers1590 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 table1591 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 managers1592 let public = TokenReach { repositories: RepositorySelection::Public, ..selected.clone() };
Workflow files need workflow_files:write from a token; fine-grained permission table1593 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 managers1594 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 table1595 assert_eq!(RepositorySelection::parse("public_only"), Some(RepositorySelection::Public));
1596 }
1597
1598 #[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 managers1599 fn permissions_are_scopes_read_per_resource() {
1600 let asked: std::collections::BTreeMap<String, String> =
1601 [("issues", "write"), ("repo", "read"), ("code", "none"), ("packages", "delete")].iter().map(|(a, b)| ((*a).to_owned(), (*b).to_owned())).collect();
1602 let scopes = resolve_permissions(&asked, true).unwrap();
1603 assert_eq!(scopes, vec![Scope::RepoRead, Scope::PackagesDelete, Scope::IssuesWrite]);
1604 let back = permissions_of(&scopes);
1605 assert_eq!(back.get("issues").map(String::as_str), Some("write"));
1606 assert_eq!(back.get("packages").map(String::as_str), Some("delete"));
1607 assert!(!back.contains_key("code"));
1608 // Lower levels held beside a higher one say nothing more.
1609 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 yet1610 // 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 managers1611 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 yet1612 assert_eq!(all.len(), Resource::ALL.into_iter().filter(|resource| resource.offered()).count());
1613 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 managers1614 assert!(all.iter().any(|held| held.includes(scope)), "{scope:?}");
1615 }
1616 }
1617
1618 #[test]
1619 fn permissions_are_checked_by_name_level_and_owner() {
1620 let one = |name: &str, level: &str| -> std::collections::BTreeMap<String, String> { [(name.to_owned(), level.to_owned())].into() };
1621 assert!(resolve_permissions(&one("wiki", "read"), true).unwrap_err().contains("wiki"));
1622 assert!(resolve_permissions(&one("issues", "admin"), true).unwrap_err().contains("read, write"));
1623 assert!(resolve_permissions(&one("workflow_files", "read"), true).is_err(), "workflow files are written only");
1624 assert!(resolve_permissions(&one("notifications", "read"), false).unwrap_err().contains("account"));
1625 assert_eq!(resolve_permissions(&one("notifications", "read"), true).unwrap(), vec![Scope::NotificationsRead]);
1626 assert_eq!(resolve_permissions(&one("agents", "run"), false).unwrap(), vec![Scope::AgentsRun]);
1627 for resource in Resource::ALL {
1628 assert_eq!(Resource::parse(resource.as_str()), Some(resource));
1629 assert!(!resource.scopes().is_empty());
1630 }
1631 }
1632
1633 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1634 fn a_legacy_token_can_do_everything() {
1635 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1636 for (operation, _) in OPERATIONS {
1637 assert!(decide(&legacy, operation, &json!({})).allowed, "{operation}");
1638 }
1639 assert_eq!(decide(&legacy, "delete_repo", &json!({})).rule, "token:legacy");
1640 }
1641
1642 #[test]
1643 fn a_missing_scope_is_named() {
1644 let read = token(&[Scope::IssuesRead]);
1645 assert!(decide(&read, "get_issue", &json!({})).allowed);
1646 assert!(decide(&read, "whoami", &json!({})).allowed);
1647 let refused = decide(&read, "create_issue", &json!({}));
1648 assert!(!refused.allowed);
1649 assert_eq!(refused.reason.as_deref(), Some("This access token needs the issues:write scope to use create_issue."));
1650 // An operation the table does not know needs full access.
1651 assert!(!decide(&read, "something_new", &json!({})).allowed);
1652 }
1653
1654 #[test]
1655 fn starting_agents_from_another_operation_needs_agents_run() {
1656 let writer = token(&[Scope::IssuesWrite]);
1657 assert!(decide(&writer, "apply_plan", &json!({})).allowed);
1658 let refused = decide(&writer, "apply_plan", &json!({ "assign": true }));
1659 assert!(refused.reason.unwrap().contains("agents:run"));
1660 let maintainer = token(&[Scope::RepoWrite]);
1661 assert!(decide(&maintainer, "update_repo", &json!({ "description": "x" })).allowed);
1662 assert!(!decide(&maintainer, "update_repo", &json!({ "private": true })).allowed);
1663 }
1664
1665 #[test]
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1666 fn a_workspaces_base_permission_needs_access_admin_too() {
1667 let admin = token(&[Scope::WorkspaceAdmin]);
1668 assert!(decide(&admin, "update_workspace", &json!({ "name": "Acme" })).allowed);
1669 let refused = decide(&admin, "update_workspace", &json!({ "name": "Acme", "base_permission": "read" }));
1670 assert!(refused.reason.unwrap().contains("access:admin"));
1671 let both = token(&[Scope::WorkspaceAdmin, Scope::AccessAdmin]);
1672 assert!(decide(&both, "update_workspace", &json!({ "base_permission": "read" })).allowed);
1673 assert!(!decide(&token(&[Scope::WorkspaceRead]), "update_workspace", &json!({ "name": "Acme" })).allowed);
1674 }
1675
1676 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1677 fn delegating_needs_both_agents_and_issues() {
1678 let agents = token(&[Scope::AgentsRun]);
1679 assert!(decide(&agents, "delegate", &json!({})).reason.unwrap().contains("issues:write"));
1680 let both = token(&[Scope::AgentsRun, Scope::IssuesWrite]);
1681 assert!(decide(&both, "delegate", &json!({})).allowed);
1682 }
1683
1684 #[test]
1685 fn git_push_needs_code_write_and_private_reads_need_code_read() {
1686 let reader = token(&[Scope::CodeRead]);
1687 assert!(decide_git(&reader, false, false).allowed);
1688 let refused = decide_git(&reader, true, false);
1689 assert!(!refused.allowed);
1690 assert!(refused.reason.unwrap().contains("code:write"));
1691 let issues = token(&[Scope::IssuesWrite]);
1692 assert!(!decide_git(&issues, false, false).allowed);
1693 assert!(decide_git(&issues, false, true).allowed, "public code needs no scope");
1694 assert!(!decide_git(&issues, true, true).allowed, "pushing to public code still needs code:write");
1695 let writer = token(&[Scope::CodeWrite]);
1696 assert!(decide_git(&writer, true, false).allowed);
1697 assert!(decide_git(&writer, false, false).allowed, "code:write includes code:read");
1698 assert!(decide_git(&TokenAccess::full(), true, false).allowed);
1699 }
1700
1701 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1702 fn packages_need_their_own_scopes_and_public_pulls_none() {
1703 let reader = token(&[Scope::PackagesRead]);
1704 assert!(decide_packages(&reader, Level::Read, false).allowed);
1705 assert!(!decide_packages(&reader, Level::Write, false).allowed);
1706 let code = token(&[Scope::CodeWrite]);
1707 assert!(!decide_packages(&code, Level::Read, false).allowed, "code scopes are not package scopes");
1708 assert!(decide_packages(&code, Level::Read, true).allowed, "public packages pull with any token");
1709 let writer = token(&[Scope::PackagesWrite]);
1710 assert!(decide_packages(&writer, Level::Write, false).allowed);
1711 assert!(decide_packages(&writer, Level::Read, false).allowed, "packages:write includes packages:read");
1712 let refused = decide_packages(&writer, Level::Delete, false);
1713 assert!(refused.reason.unwrap().contains("packages:delete"));
1714 assert!(decide_packages(&token(&[Scope::PackagesDelete]), Level::Write, false).allowed);
1715 assert!(Scope::PackagesDelete.dangerous());
1716 // Tokens made before these scopes, and full-access ones, keep working.
1717 let legacy = TokenAccess { legacy: true, ..TokenAccess::full() };
1718 assert!(decide_packages(&legacy, Level::Delete, false).allowed);
1719 assert!(decide_packages(&TokenAccess::full(), Level::Write, false).allowed);
1720 }
1721
1722 #[test]
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1723 fn token_access_travels_as_json() {
1724 let access = token(&[Scope::IssuesRead]);
1725 let wire = serde_json::to_value(&access).unwrap();
1726 assert_eq!(wire["scopes"], json!(["issues:read"]));
1727 assert!(wire.get("resources").is_none());
1728 let back: TokenAccess = serde_json::from_value(wire).unwrap();
1729 assert_eq!(back, access);
1730 let full: TokenAccess = serde_json::from_value(json!({})).unwrap();
1731 assert!(full.is_full());
1732 // A reach written by an older version is ignored: a token reaches
1733 // whatever its owner can.
1734 let older: TokenAccess = serde_json::from_value(json!({
1735 "token_id": "tok_1",
1736 "scopes": ["issues:read"],
1737 "resources": { "kind": "repositories", "repositories": ["acme/rocket"] },
1738 }))
1739 .unwrap();
1740 assert_eq!(older, access);
1741 }
1742
1743 /// The site's copy of the table, `packages/contracts/src/scopes.ts`,
1744 /// lists the same scopes in the same order, the same operations with
1745 /// the same scopes, and the same presets.
1746 #[test]
1747 fn the_typescript_mirror_has_the_same_table() {
1748 let ts = include_str!("../../../packages/contracts/src/scopes.ts");
1749 let section = |start: &str| {
1750 ts.split_once(start)
1751 .and_then(|(_, rest)| rest.split_once("] as const"))
1752 .map(|(table, _)| table)
1753 .unwrap_or_else(|| panic!("{start} in scopes.ts"))
1754 };
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 yet1755 let names = |table: &str| -> Vec<String> {
1756 section(table)
1757 .lines()
1758 .filter_map(|line| line.split_once("scope: \"").and_then(|(_, rest)| rest.split_once('"')).map(|(scope, _)| scope.to_owned()))
1759 .collect()
1760 };
1761 // Offered scopes in `SCOPES`, the rest in `UPCOMING_SCOPES`.
1762 let offered: Vec<String> = Scope::ALL.iter().filter(|scope| scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1763 let upcoming: Vec<String> = Scope::ALL.iter().filter(|scope| !scope.offered()).map(|scope| scope.as_str().to_owned()).collect();
1764 assert_eq!(names("export const SCOPES = ["), offered);
1765 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 step1766 let operations: Vec<(String, String)> = section("export const OPERATION_SCOPES = [")
1767 .lines()
1768 .filter_map(|line| {
1769 let mut quoted = line.split('"').skip(1).step_by(2);
1770 Some((quoted.next()?.to_owned(), quoted.next()?.to_owned()))
1771 })
1772 .collect();
1773 let expected: Vec<(String, String)> = OPERATIONS
1774 .iter()
1775 .map(|(name, scope)| ((*name).to_owned(), scope.as_str().to_owned()))
1776 .collect();
1777 assert_eq!(operations, expected);
1778 for preset in Preset::ALL {
1779 let list = section(&format!("{}: [", preset.as_str()));
1780 let mirrored: Vec<&str> = list
1781 .split(',')
1782 .map(|item| item.trim().trim_matches('"'))
1783 .filter(|item| !item.is_empty())
1784 .collect();
1785 let expected: Vec<&str> = preset
1786 .scopes()
1787 .map(|scopes| scopes.iter().map(|scope| scope.as_str()).collect())
1788 .unwrap_or_else(|| vec!["*"]);
1789 assert_eq!(mirrored, expected, "{}", preset.as_str());
1790 }
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 yet1791 // Each resource with its group, in the same order: offered ones in
1792 // `SCOPE_RESOURCES`, the rest in `UPCOMING_RESOURCES`.
1793 for (table, offered) in [("export const SCOPE_RESOURCES", true), ("export const UPCOMING_RESOURCES", false)] {
1794 let resources = ts
1795 .split_once(table)
1796 .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 managers1797];"))
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 yet1798 .map(|(table, _)| table)
1799 .unwrap_or_else(|| panic!("{table} in scopes.ts"));
1800 let rows: Vec<&str> = resources.lines().filter(|line| line.trim_start().starts_with("{ resource:")).collect();
1801 let expected: Vec<Resource> = Resource::ALL.into_iter().filter(|resource| resource.offered() == offered).collect();
1802 assert_eq!(rows.len(), expected.len(), "{table}");
1803 for (row, resource) in rows.iter().zip(expected) {
1804 assert!(row.contains(&format!("resource: \"{}\"", resource.as_str())), "{row}");
1805 assert!(row.contains(&format!("group: \"{}\"", resource.group().as_str())), "{row}");
1806 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1807 }
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step1808 }
1809}

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